From 86de59616c7ce92ff2c3aa205e69ef05c1a11148 Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 5 Nov 2026 12:41:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AB=AF=E4=B8=8A=EF=BC=9A#47~#55=20=E6=89=93?= =?UTF-8?q?=E5=8C=85=20=E2=80=94=E2=80=94=20=E8=A1=8C=E7=A8=8B=E5=8F=8C?= =?UTF-8?q?=E8=BD=A8=E3=80=81=E5=8A=A8=E6=80=81=E7=B1=BB=E5=9E=8B=E3=80=81?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=85=A8=E5=B1=80=E8=A7=84=E8=8C=83=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程, 实际行程不再显示状态;新增 actualStartedAt/actualEndedAt #49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新) #50 类型点击立即变色、出行方式平铺、参与人显示已选头像 #51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标 #52 标签改主题色 Bold、必填红无星号、编辑行程标题同步 #53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动 #54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180 含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除 --- build-profile.json5.bak-20261101-230350 | 56 + .../src/main/ets/common/FbBackgroundSync.ets | 43 + entry/src/main/ets/common/FbDiag.ets | 115 ++ entry/src/main/ets/common/FbNav.ets | 342 +++- entry/src/main/ets/components/FbAddrInput.ets | 294 ++- .../main/ets/components/FbMovieNewSheet.ets | 9 +- .../main/ets/components/FbPersonEditSheet.ets | 48 +- .../main/ets/components/FbTaskEditSheet.ets | 88 +- entry/src/main/ets/components/FbTaskPanel.ets | 2 +- .../main/ets/components/FbTripNodeSheet.ets | 1587 +++++++++++++---- entry/src/main/ets/data/FbCalInbound.ets | 421 +++++ entry/src/main/ets/data/FbCalMirror.ets | 1008 +++++++++++ entry/src/main/ets/data/FbCalSettings.ets | 32 + entry/src/main/ets/data/FbCardMediaData.ets | 57 +- entry/src/main/ets/data/FbDb.ets | 27 + entry/src/main/ets/data/FbGeoRepo.ets | 136 +- entry/src/main/ets/data/FbSchema.ets | 30 +- entry/src/main/ets/data/FbTripRepo.ets | 417 ++++- entry/src/main/ets/data/FbUpload.ets | 67 + entry/src/main/ets/model/DemoData.ets | 86 +- entry/src/main/ets/model/Modules.ets | 2 +- entry/src/main/ets/model/Types.ets | 43 + .../main/ets/pages/account/FbAccountPage.ets | 219 +++ .../ets/pages/calendar/FbCalendarPage.ets | 5 + .../src/main/ets/pages/card/FbTaskCard2x2.ets | 148 +- .../src/main/ets/pages/card/FbTaskCard2x4.ets | 302 +++- .../src/main/ets/pages/card/FbTaskCard4x4.ets | 315 ++-- .../src/main/ets/pages/card/FbTaskCard6x4.ets | 333 ++-- entry/src/main/ets/pages/home/FbHomePage.ets | 18 +- .../ets/pages/settings/FbSettingsPage.ets | 1 + .../src/main/ets/pages/tasks/FbTasksPage.ets | 7 +- .../main/ets/pages/trips/FbTripDetailPage.ets | 406 ++++- entry/src/main/module.json5 | 21 + .../main/resources/base/element/string.json | 28 +- .../resources/base/profile/form_config.json | 16 +- 35 files changed, 5818 insertions(+), 911 deletions(-) create mode 100644 build-profile.json5.bak-20261101-230350 create mode 100644 entry/src/main/ets/common/FbDiag.ets create mode 100644 entry/src/main/ets/data/FbCalInbound.ets create mode 100644 entry/src/main/ets/data/FbCalMirror.ets diff --git a/build-profile.json5.bak-20261101-230350 b/build-profile.json5.bak-20261101-230350 new file mode 100644 index 0000000..1c7520c --- /dev/null +++ b/build-profile.json5.bak-20261101-230350 @@ -0,0 +1,56 @@ +{ + "app": { + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "/home/yongquan/code/cert/FamilyBook/debug/familybook.debug.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001A174821314A63FD3C2B7A9CC58F00CA52D408A03F426A3A4640B54AC9DA06CE2508DC7146C920", + "profile": "/home/yongquan/code/cert/FamilyBook/debug/familybook.debug.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/home/yongquan/code/cert/FamilyBook/debug/familybook.debug.p12", + "storePassword": "0000001AD7D45ED5643ED71724AD765DE36D26BB1EA1552DB57361D0EE186AB5EEEB2E6B51C678ED15E6" + } + } + ], + "products": [ + { + "name": "default", + "signingConfig": "default", + "targetSdkVersion": "26.0.0", + "compatibleSdkVersion": "6.1.1(24)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug" + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/entry/src/main/ets/common/FbBackgroundSync.ets b/entry/src/main/ets/common/FbBackgroundSync.ets index d834767..8f6c8d3 100644 --- a/entry/src/main/ets/common/FbBackgroundSync.ets +++ b/entry/src/main/ets/common/FbBackgroundSync.ets @@ -52,6 +52,9 @@ import { FB_KEY_STORE } from '../data/FbKeyStore'; import { FbDb } from '../data/FbDb'; import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; import { FbSyncAllResult, syncAll } from '../data/FbSyncAll'; +import { FB_CAL_SETTINGS } from '../data/FbCalSettings'; +import { FbCalMirror, FbMirrorResult } from '../data/FbCalMirror'; +import { FbCalInbound, FbInboundResult } from '../data/FbCalInbound'; const DOMAIN: number = 0x0000; const TAG: string = 'FbBgSync'; @@ -364,6 +367,39 @@ class FbBackgroundSyncImpl { * * @param tag 触发来源(`deferred` / `transient` / `cardTick` / `foreground` / `netRecover`) */ + /** + * 跑一次「系统日历镜像」(入站 → 出站)。 + * + * 🔴 2026-11-03 新增。用户要求把日历镜像到系统日历, + * 这样小艺 / 桌面卡片 / 手表能看到日程。 + * + * ⚠️ **整段吞异常**:镜像是附加能力,失败不能让同步算失败。 + * (与上面「后台路径必须吞掉所有异常」同一条纪律。) + * ⚠️ 只在用户开着开关、且已授权时才跑。 + */ + private async runMirror(ctx: common.Context, tag: string): Promise { + try { + if (!FB_CAL_SETTINGS.getMirror()) { + return; + } + if (!await FbCalMirror.hasPermission()) { + return; + } + // ① 入站:系统日历里的改动 → 服务器(必须在出站之前) + const inb: FbInboundResult = await FbCalInbound.reconcile(ctx); + // ② 出站:最新数据 → 系统日历 + const r: FbMirrorResult = await FbCalMirror.syncNow(ctx); + hilog.info(DOMAIN, TAG, + '镜像[%{public}s]:入站 改=%{public}d 删=%{public}d 增=%{public}d;' + + '出站 增=%{public}d 更=%{public}d 删=%{public}d 失败=%{public}d', + tag, inb.updated, inb.deleted, inb.created, + r.added, r.updated, r.deleted, r.failed); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '镜像[%{public}s] 失败(不影响同步):%{public}s', tag, e.message); + } + } + async runOnce(ctx: common.Context, tag: string): Promise { const r: FbBgSyncResult = new FbBgSyncResult(); const t0: number = Date.now(); @@ -384,6 +420,13 @@ class FbBackgroundSyncImpl { r.done = res.done; r.total = res.total; r.note = res.note; + + /* 🔴 2026-11-03:同步成功后接着跑「系统日历镜像」(用户要求的功能)。 + ⚠️ 顺序:先入站(把用户在系统日历里的改动收回服务器),再出站(写回系统日历)。 + 反了的话出站会把系统日历里的旧值当"期望值",覆盖掉用户的改动。 + ⚠️ 整段 try/catch 包住:镜像失败**绝不能**让同步算失败 —— + 同步是主业,镜像是附加能力,坏了不该拖累数据对齐。 */ + await this.runMirror(ctx, tag); await this.writeNum(ctx, K_LAST_ANY, Date.now()); hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 完成:%{public}d/%{public}d,耗时 %{public}d 秒', tag, res.done, res.total, Math.round((Date.now() - t0) / 1000)); diff --git a/entry/src/main/ets/common/FbDiag.ets b/entry/src/main/ets/common/FbDiag.ets new file mode 100644 index 0000000..fbe40d9 --- /dev/null +++ b/entry/src/main/ets/common/FbDiag.ets @@ -0,0 +1,115 @@ +/** + * 岁月家 · 临时诊断日志(落盘) + * + * 🔴 为什么需要这个: + * 这台设备的 `hilog` 读不出应用日志(`hilog | grep familybook` 恒为空, + * 已用 pid 过滤、-T tag、-D domain 等多种方式确认)。本项目代码注释里 + * 也记着"这台设备的 hilog 缓冲区读不出内容(本项目多次确认)"。 + * ⇒ 需要看运行期中间值时,**落到沙箱文件**再 `hdc file recv` 取出来。 + * + * ⚠️ 这是**临时诊断设施**,问题定位完就该删掉(或至少不再调用): + * 它的写入是同步的,且不做轮转 —— 长期留着会无限增长。 + * + * 用法: + * FbDiag.log('NAV', '地理编码 lat=' + lat); + * 取日志:hdc file recv <沙箱路径>/files/diag.log + */ + +import { fileIo } from '@kit.CoreFileKit'; +import { common } from '@kit.AbilityKit'; + +/** 日志文件名(应用 files 目录下) */ +const DIAG_FILE: string = 'diag.log'; + +/** 超过这个大小就先清空(防止无限增长) */ +const MAX_BYTES: number = 256 * 1024; + +export class FbDiag { + private static _ctx: common.UIAbilityContext | null = null; + private static _busy: boolean = false; + + /** 页面/组件启动时传一次上下文(`getUIContext().getHostContext()`) */ + static init(ctx: common.Context | undefined): void { + if (ctx === undefined) { + return; + } + FbDiag._ctx = ctx as common.UIAbilityContext; + } + + /** 完整路径 */ + private static path(): string { + const c: common.UIAbilityContext | null = FbDiag._ctx; + if (c === null) { + return ''; + } + return c.filesDir + '/' + DIAG_FILE; + } + + /** + * 追加一行。 + * + * ⚠️ **不抛错**:诊断日志失败绝不能让业务逻辑挂掉。 + * 所有异常在这里咽掉(这是诊断设施,不是业务)。 + */ + static log(tag: string, msg: string): void { + const p: string = FbDiag.path(); + if (p === '') { + return; + } + // 防止同一帧内反复开文件(比如循环里调) + if (FbDiag._busy) { + return; + } + FbDiag._busy = true; + try { + // 超过上限先截断,避免无限增长 + try { + const st: fileIo.Stat = fileIo.statSync(p); + if (st.size > MAX_BYTES) { + fileIo.unlinkSync(p); + } + } catch (e1) { + // 文件不存在是正常的(第一次写),忽略 + } + + const line: string = '[' + new Date().toISOString() + '] ' + tag + ' ' + msg + '\n'; + const f: fileIo.File = fileIo.openSync(p, + fileIo.OpenMode.CREATE | fileIo.OpenMode.APPEND | fileIo.OpenMode.WRITE_ONLY); + try { + fileIo.writeSync(f.fd, line); + } finally { + fileIo.closeSync(f); + } + } catch (err) { + // 诊断设施,静默失败 + } finally { + FbDiag._busy = false; + } + } + + /** 清空(重测前用) */ + static clear(): void { + const p: string = FbDiag.path(); + if (p === '') { + return; + } + try { + fileIo.unlinkSync(p); + } catch (err) { + // 不存在就算了 + } + } + + /** 读全文(给设置页「诊断」用;这里主要用于导出前自检) */ + static readAll(): string { + const p: string = FbDiag.path(); + if (p === '') { + return ''; + } + try { + return fileIo.readTextSync(p); + } catch (err) { + return ''; + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/FbNav.ets b/entry/src/main/ets/common/FbNav.ets index fcea026..7cb591e 100644 --- a/entry/src/main/ets/common/FbNav.ets +++ b/entry/src/main/ets/common/FbNav.ets @@ -32,6 +32,7 @@ import { common, Want } from '@kit.AbilityKit'; import { geoLocationManager } from '@kit.LocationKit'; +import { FbDiag } from './FbDiag'; import { bundleManager } from '@kit.AbilityKit'; import { pasteboard } from '@kit.BasicServicesKit'; import { promptAction } from '@kit.ArkUI'; @@ -47,6 +48,21 @@ class FbMapApp { name: string = ''; /** 探测用 scheme(必须与 `module.json5` 的 `querySchemes` 一致,否则恒 false) */ probe: string = ''; + /** + * 鸿蒙版应用的**真实包名**(显式拉起用)。 + * + * 🔴 2026-11-03 实测:隐式 Want 在鸿蒙上拉不起第三方地图 + * (报 `16000019 No matching ability is found`)—— + * 系统不认应用私有 scheme 做隐式匹配。 + * ⇒ 必须显式指定包名,系统直接送到那个 Ability。 + * + * ⚠️ **只有高德是真机实测的**(`bm dump -a | grep map` 查到的)。 + * 其余留空 ⇒ 退回 openLink。不要在没验证时填猜测值, + * 包名写错的表现就是"点了完全没反应",比不打还难查。 + */ + bundleName: string = ''; + /** 鸿蒙版应用的入口 Ability 名(显式拉起用) */ + abilityName: string = ''; /** 按**坐标**导航的 URI(`{lat}` / `{lon}` / `{name}` 会被替换) */ naviByPos: string = ''; /** 按**关键字**搜索的 URI(没有坐标时用这个) */ @@ -75,24 +91,43 @@ const MAP_APPS: FbMapApp[] = [ 那正是"不支持"的直接来源 ⇒ 改成 `route/plan/` 只带 dname 的尽力而为形式。 */ name: '高德地图', probe: 'amapuri://', + /* 🔴 实测(`hdc shell bm dump -a | grep -i map`): + 鸿蒙版高德的包名是 `com.amap.hmapp`,入口 `EntryAbility`。 + **不是**安卓的 `com.autonavi.minimap`。 + `hdc shell aa start -b com.amap.hmapp -a EntryAbility -U 'amapuri://...'` + 实测返回 `start ability successfully`。 */ + bundleName: 'com.amap.hmapp', + abilityName: 'EntryAbility', naviByPos: 'amapuri://route/plan/?sourceApplication=FamilyBook&dlat={lat}&dlon={lon}&dname={name}&dev=0&t=0', naviByText: 'amapuri://route/plan/?sourceApplication=FamilyBook&dname={name}&dev=0&t=0' }, { name: '百度地图', probe: 'baidumap://', + // 鸿蒙包名**未实测**,留空 ⇒ 走隐式/openLink 兜底。 + // 实测后填上即可(百度/花瓣/腾讯在鸿蒙上的包名与安卓不同)。 + bundleName: '', + abilityName: '', naviByPos: 'baidumap://map/direction?destination=latlng:{lat},{lon}|name:{name}&mode=driving', naviByText: 'baidumap://map/geocoder?address={name}' }, { name: '花瓣地图', probe: 'maps://', + // 鸿蒙包名**未实测**,留空 ⇒ 走隐式/openLink 兜底。 + // 实测后填上即可(百度/花瓣/腾讯在鸿蒙上的包名与安卓不同)。 + bundleName: '', + abilityName: '', naviByPos: 'maps://routes?destination={lat},{lon}&destinationName={name}', naviByText: 'maps://search?query={name}' }, { name: '腾讯地图', probe: 'qqmap://', + // 鸿蒙包名**未实测**,留空 ⇒ 走隐式/openLink 兜底。 + // 实测后填上即可(百度/花瓣/腾讯在鸿蒙上的包名与安卓不同)。 + bundleName: '', + abilityName: '', naviByPos: 'qqmap://map/routeplan?type=drive&to={name}&tocoord={lat},{lon}', naviByText: 'qqmap://map/search?keyword={name}' } @@ -107,6 +142,105 @@ class FbGeoPoint { ok: boolean = false; } +/** + * 地址清洗:把用户手写的地址洗成**系统地理编码更容易认**的形式。 + * + * 🔴 2026-11-03 真机日志暴露的问题: + * 原文 `招商银行(青岛经济技术开发区支行) · 珠江路1018号` + * → 系统只抓住「珠江路1018号」,全国搜到**江苏南通**去了(差 500 公里)。 + * + * 系统的解析器对这几样东西很敏感: + * · 括号 `()` `()` —— 里面的内容常被当"补充说明"丢掉,而那里正是城市名 + * · 中点 `·` `•` —— 被当成分隔符,前后两段各算各的 + * · 非标准行政区划名(「经济技术开发区」不是标准名,「黄岛区」才是) + * + * 所以这里做三件事: + * 1. 括号改空格(保留内容,只是去掉符号本身) + * 2. 中点改空格 + * 3. 连续空格压成一个 + * + * ⚠️ **不改动任何汉字**:只动符号。 + * 把「经济技术开发区」翻译成「黄岛区」需要行政区划库, + * 写死在本文件里维护成本高且容易过时 —— 交给下一步的城市匹配去兜。 + */ +function cleanAddrForGeocode(addr: string): string { + let t: string = addr; + // 全角括号 → 空格(内容保留) + t = t.replace(/[((]/g, ' '); + t = t.replace(/[))]/g, ' '); + // 中点 / 间隔号 / 竖线 / 逗号顿号 → 空格 + t = t.replace(/[·•・||,,、]/g, ' '); + // 破折号、波浪线 → 空格 + t = t.replace(/[—–~~]/g, ' '); + // 连续空格压缩 + t = t.replace(/\s+/g, ' '); + // 去首尾空格 + return t.trim(); +} + +/** + * 从地址里抽出「城市关键字」,用于在多个地理编码结果里挑对的那个。 + * + * 识别规则(按优先级): + * ① `XX市` —— 最常见,直接取 + * ② `XX区` —— 直辖市/开发区常见 + * ③ 已知城市别名表(校区名之类) + * + * ⚠️ 只抽**一个**最可能的。抽不出来返回空串(调用方会退回"取第 1 条")。 + */ +function cityHintOf(addr: string): string { + // ① 显式的「XX市」 + const mCity: RegExpMatchArray | null = addr.match(/([\u4e00-\u9fa5]{2,6})市/); + if (mCity !== null) { + return mCity[1]; + } + // ② 已知校区/园区 → 城市(这些是本人常用地点,写死最省事) + const KNOWN: string[] = [ + '中国海洋大学|青岛', '海大|青岛', '崂山校区|青岛', '鱼山校区|青岛', '浮山校区|青岛', + '黄岛|青岛', '开发区|青岛', '市南|青岛', '市北|青岛', '李沧|青岛', '城阳|青岛', '即墨|青岛' + ]; + for (const pair of KNOWN) { + const parts: string[] = pair.split('|'); + if (addr.indexOf(parts[0]) >= 0) { + return parts[1]; + } + } + return ''; +} + +/** + * 在多个地理编码结果里挑**最可能是用户想去的那一个**。 + * + * 🔴 为什么必须挑:`getAddressesFromLocationName` 会返回多条, + * 而「第 1 条」没有任何特殊含义。实测那次第 1 条在江苏南通, + * 用户要去青岛 —— 盲取第 1 条就是"地址错"的直接原因。 + * + * 策略: + * ① 候选里有 `administrativeArea / locality / placeName` 含城市关键字 → 取第一条匹配的 + * ② 一个都不匹配 → 退回第 1 条(并返回 matched=false 让调用方记日志) + * + * @returns [lat, lon, matched] + */ +function pickBestGeo(list: geoLocationManager.GeoAddress[], hint: string): number[] { + if (list.length === 0) { + return [0, 0, 0]; + } + if (hint !== '') { + for (const it of list) { + const blob: string = ((it.administrativeArea ?? '') as string) + + ((it.locality ?? '') as string) + + ((it.subLocality ?? '') as string) + + ((it.placeName ?? '') as string) + + ((it.roadName ?? '') as string); + if (blob.indexOf(hint) >= 0) { + return [(it.latitude ?? 0) as number, (it.longitude ?? 0) as number, 1]; + } + } + } + const f: geoLocationManager.GeoAddress = list[0]; + return [(f.latitude ?? 0) as number, (f.longitude ?? 0) as number, 0]; +} + /** * 系统地理编码:地址 → 坐标。 * @@ -126,22 +260,79 @@ async function geocodeBySystem(address: string): Promise { if (q === '') { return null; } + + /* ---- ① 清洗 ---- + 🔴 2026-11-03 真机日志:原文 `招商银行(青岛经济技术开发区支行) · 珠江路1018号` + 系统只抓「珠江路1018号」→ 搜到**江苏南通**(差 500 公里)。 + 括号和中点符号是关键干扰源(里面恰好是城市名,被当"补充说明"丢了)。 */ + const cleaned: string = cleanAddrForGeocode(q); + const hint: string = cityHintOf(q); + FbDiag.log('NAV', '=== 地理编码 ==='); + FbDiag.log('NAV', '原文: [' + q + ']'); + FbDiag.log('NAV', '清洗后: [' + cleaned + ']'); + FbDiag.log('NAV', '城市关键字: [' + hint + ']'); + try { if (!geoLocationManager.isGeocoderAvailable()) { - hilog.warn(DOMAIN, TAG, '系统地理编码服务不可用'); + FbDiag.log('NAV', '系统地理编码服务不可用'); return null; } - const req: geoLocationManager.GeoCodeRequest = { description: q, maxItems: 1 }; + // ⚠️ maxItems 取 5:多条里才有挑选的余地(原来取 1 等于没得挑) + const req: geoLocationManager.GeoCodeRequest = { description: cleaned, maxItems: 5 }; const list: geoLocationManager.GeoAddress[] = await geoLocationManager.getAddressesFromLocationName(req); - if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) { - return [list[0].latitude as number, list[0].longitude as number]; + FbDiag.log('NAV', '命中条数: ' + list.length); + + if (list.length === 0) { + FbDiag.log('NAV', '地理编码返回 0 条(地址无法识别)'); + return null; } - // ⚠️ 日志**不落地点文本**(可能含家庭/公司等敏感地址),只记结果 - hilog.info(DOMAIN, TAG, '地理编码无结果(地点已脱敏)'); + + // 把候选都记下来(判断"是不是在猜"必须看多条) + const n: number = Math.min(5, list.length); + for (let i = 0; i < n; i++) { + const it: geoLocationManager.GeoAddress = list[i]; + FbDiag.log('NAV', ' [' + i + '] lat=' + ((it.latitude ?? 0) as number) + + ' lon=' + ((it.longitude ?? 0) as number) + + ' | ' + ((it.administrativeArea ?? '') as string) + + ((it.locality ?? '') as string) + + ((it.subLocality ?? '') as string) + + ((it.roadName ?? '') as string) + + ((it.premises ?? '') as string)); + } + + /* ---- ② 按城市挑,不盲取第 1 条 ---- + 实测那次 5 条全在南通,用户要去青岛 ⇒ 盲取第 1 条就是"地址错"。 */ + const picked: number[] = pickBestGeo(list, hint); + const la: number = picked[0]; + const lo: number = picked[1]; + const matched: boolean = picked[2] === 1; + + if (list.length > 1) { + FbDiag.log('NAV', matched + ? '多结果中按城市「' + hint + '」匹配成功,取该条' + : '⚠️ 多结果中无一条含城市「' + hint + '」,退回第 1 条(可能不准)'); + } + FbDiag.log('NAV', '选定: lat=' + la + ' lon=' + lo + ' 城市匹配=' + (matched ? '是' : '否')); + + /* ---- ③ 城市不匹配时**宁可不用** ---- + 导到 500 公里外比"打不开"更糟:用户可能开出去才发现错。 + 这里返回 null 让上层走"只按名称"的降级路径, + 地图会显示名称但没坐标 —— 用户至少能看出"它没定位到"。 */ + if (hint !== '' && !matched && list.length > 0) { + // 但有一种情况可以用:只有 1 条结果(说明系统很确定) + if (list.length === 1) { + FbDiag.log('NAV', '虽无城市匹配,但只有 1 条结果 ⇒ 采信'); + return [la, lo]; + } + FbDiag.log('NAV', '城市不匹配且有多条 ⇒ 拒绝这个坐标(避免导错城市)'); + return null; + } + + return [la, lo]; } catch (err) { const e: BusinessError = err as BusinessError; - hilog.warn(DOMAIN, TAG, '地理编码失败:%{public}s', e.message); + FbDiag.log('NAV', '地理编码异常: ' + e.message); } return null; } @@ -273,8 +464,11 @@ function installedMaps(): FbMapApp[] { */ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, place: string, title: string): Promise { + FbDiag.log('NAV', ''); + FbDiag.log('NAV', '######## fbOpenNavigation 被调用 ########'); const raw: string = place.trim(); const keyword: string = raw !== '' ? raw : title.trim(); + FbDiag.log('NAV', 'place=[' + raw + '] title=[' + title + ']'); if (keyword === '') { return; } @@ -301,12 +495,16 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, 高德的 route/plan **要求 dlat/dlon 必填**,原来拿不到坐标 就退到 `amapuri://poi?keyword=`(新版已废弃)⇒ 高德拒绝。 现在先求坐标,绝大多数地点都能求出。 */ - const wgs: number[] | null = await geocodeBySystem(keyword); - if (wgs !== null) { - // 系统给的是 WGS84,高德要 GCJ02 —— 不转会偏 100~700 米 - const gcj: number[] = wgs84ToGcj02(wgs[0], wgs[1]); - lat = gcj[0]; - lon = gcj[1]; + const pt: number[] | null = await geocodeBySystem(keyword); + if (pt !== null) { + /* 🔴 2026-11-03 修:**不要再转 GCJ02**。 + 日志里暴露了二次偏移: + 地理编码给 36.0731808/120.4283078(对应香港东路23号) + 转换后变 36.0734519/120.4334066(东偏约 500 米) + 鸿蒙系统的地址编码在国内返回的**已经是 GCJ02 火星坐标** + (它本身就是给国内地图用的),再转一次就是偏两次。 */ + lat = pt[0]; + lon = pt[1]; hasPos = true; } } @@ -326,10 +524,17 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, } /* ---- ③ 依次尝试每一家(有坐标优先用坐标,没有退到关键字)---- */ + FbDiag.log('NAV', '=== 最终决定 ==='); + FbDiag.log('NAV', 'hasPos=' + (hasPos ? '是' : '否') + + ' lat=' + lat + ' lon=' + lon + + ' dname=[' + name + ']' + + ' 可用地图=' + maps.length); + for (const app of maps) { const uri: string = hasPos ? fillUri(app.naviByPos, lat, lon, name) : fillUri(app.naviByText, 0, 0, keyword); + FbDiag.log('NAV', 'URI(' + app.name + ')=' + uri); const ok: boolean = await openLinkWithFallback(ctx, app, uri, keyword, lat, lon, name, hasPos); if (ok) { @@ -340,8 +545,24 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, hilog.warn(DOMAIN, TAG, '%{public}s 拉起失败,试下一家', app.name); } - // ④ 全都失败 → 复制地点并明确告知 - fbCopyPlace(keyword); + /* ④ 全都失败 —— 必须让用户看见,不能"点了没反应"。 + + 🔴 2026-11-03 用户反馈「高德在导航中时,再点导航**完全无反应**」。 + 无反应是最糟的:用户无法判断是应用坏了,还是地图不理他。 + + 这里给两种情况的**不同**提示: + · 装都没装 → 复制地点(用户可以用网页地图) + · 装了但拉不起来 → 明确说"可能正在导航中",并告诉怎么办 + (高德在导航中会把新的导航请求丢掉,这是它的策略) */ + const anyInstalled: boolean = maps.length > 0; + if (anyInstalled) { + FbDiag.log('NAV', '所有地图都拉起失败,给出提示'); + fbToast('地图没能响应。若高德正在导航中,请先退出当前导航再试'); + // 同时把地点复制走,用户至少有兜底 + fbCopyPlace(keyword); + } else { + fbCopyPlace(keyword); + } } /** @@ -358,36 +579,79 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, async function openLinkWithFallback(ctx: common.UIAbilityContext, app: FbMapApp, uri: string, keyword: string, lat: number, lon: number, name: string, hasPos: boolean): Promise { - try { - if (ctx.openLink !== undefined) { - await ctx.openLink(uri); - return true; + /* 🔴 2026-11-03 改:**startAbility 为主**。 + + 用户反馈「高德已经打开并在导航中,再点导航完全无反应」。 + + 查高德官方鸿蒙文档给的示例用的就是 `startAbility(want, callback)`, + 并明确写「推荐显式跳转……只要包名和 Ability 名正确,100% 可靠」。 + 我们之前用 `openLink`(因为 SyncCalendar 用它能通)—— + 但那只能证明"**首次拉起**"能通,不能证明"**已在前台**"能通。 + + ⚠️ 两者的复用策略不同:`openLink` 走"按 URI 找应用", + 目标已在前台时的行为与显式 `startAbility` 不一样。 + 官方接口既然是 startAbility,就用它。 */ + const tryOne = async (u: string): Promise => { + /* ① **显式拉起**(唯一真正可靠的方式) + 🔴 2026-11-03 实测:隐式 Want 报 `16000019 No matching ability is found`, + `openLink` 报"成功"但系统弹"暂无可用打开方式"—— + 两者都走 scheme 匹配,而鸿蒙不认第三方私有 scheme。 + ⇒ 指定 bundleName + abilityName,系统直接投递,不经匹配。 + + ⚠️ 显式拉起时**不要带 action**:显式目标与隐式 action 语义互斥, + 带上 `android.intent.action.VIEW` 反而会失败。 */ + if (app.bundleName !== '' && app.abilityName !== '') { + try { + const w: Want = { + bundleName: app.bundleName, + abilityName: app.abilityName, + uri: u + }; + await ctx.startAbility(w); + FbDiag.log('NAV', '显式拉起成功: ' + app.name); + return true; + } catch (err) { + const e: BusinessError = err as BusinessError; + FbDiag.log('NAV', '显式拉起失败(' + app.name + '): code=' + e.code + ' msg=' + e.message); + } } - // 极老版本没有 openLink ⇒ 退回隐式 Want(有总比没有好) - const want: Want = { uri: uri }; - await ctx.startAbility(want); + + // ② 隐式 startAbility(包名未验证的地图走这条,虽然多半不行) + try { + const want: Want = { uri: u, action: 'android.intent.action.VIEW' }; + await ctx.startAbility(want); + FbDiag.log('NAV', '隐式 startAbility 成功: ' + app.name); + return true; + } catch (err) { + const e: BusinessError = err as BusinessError; + FbDiag.log('NAV', '隐式 startAbility 失败(' + app.name + '): code=' + e.code + ' msg=' + e.message); + } + + // ③ openLink 最后兜底 + if (ctx.openLink !== undefined) { + try { + await ctx.openLink(u); + FbDiag.log('NAV', 'openLink 成功: ' + app.name); + return true; + } catch (err2) { + const e2: BusinessError = err2 as BusinessError; + FbDiag.log('NAV', 'openLink 失败(' + app.name + '): code=' + e2.code + ' msg=' + e2.message); + } + } + return false; + }; + + if (await tryOne(uri)) { return true; - } catch (err) { - const e: BusinessError = err as BusinessError; - hilog.warn(DOMAIN, TAG, 'openLink 失败(%{public}s):%{public}s', app.name, e.message); } - /* 再退一步:有坐标时改用关键字搜索重试。 - "按坐标失败"最常见的原因是参数名与该版本应用不符, - 关键字那条的参数面小得多、通常还活着。 */ + /* 再退一步:有坐标时改用**只按名称**的形式重试。 + 某些高德版本对 dlat/dlon 之外的参数更宽容。 */ if (hasPos) { - try { - const retry: string = fillUri(app.naviByText, 0, 0, keyword); - if (ctx.openLink !== undefined) { - await ctx.openLink(retry); - } else { - await ctx.startAbility({ uri: retry } as Want); - } - hilog.info(DOMAIN, TAG, '改用关键字搜索成功(%{public}s)', app.name); + const retry: string = fillUri(app.naviByText, 0, 0, keyword); + if (await tryOne(retry)) { + FbDiag.log('NAV', '改用名称形式成功: ' + app.name); return true; - } catch (e2) { - const eb: BusinessError = e2 as BusinessError; - hilog.warn(DOMAIN, TAG, '关键字搜索也失败(%{public}s):%{public}s', app.name, eb.message); } } return false; diff --git a/entry/src/main/ets/components/FbAddrInput.ets b/entry/src/main/ets/components/FbAddrInput.ets index 9cd9871..6c317e9 100644 --- a/entry/src/main/ets/components/FbAddrInput.ets +++ b/entry/src/main/ets/components/FbAddrInput.ets @@ -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) } -} \ No newline at end of file + + /** + * 候选浮层的内容(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) + } +} diff --git a/entry/src/main/ets/components/FbMovieNewSheet.ets b/entry/src/main/ets/components/FbMovieNewSheet.ets index 9001c1b..8e32b08 100644 --- a/entry/src/main/ets/components/FbMovieNewSheet.ets +++ b/entry/src/main/ets/components/FbMovieNewSheet.ets @@ -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 !== '') { diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index 3deb5eb..269580d 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -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; }) // 🔴 「我怎么称呼他」—— 只有我看得到(视角属性),与姓名分开 diff --git a/entry/src/main/ets/components/FbTaskEditSheet.ets b/entry/src/main/ets/components/FbTaskEditSheet.ets index 03e0099..1e98310 100644 --- a/entry/src/main/ets/components/FbTaskEditSheet.ets +++ b/entry/src/main/ets/components/FbTaskEditSheet.ets @@ -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/` 会**显式忽略** + 请求里的 status(原样回填),只有 `POST /tasks//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) diff --git a/entry/src/main/ets/components/FbTaskPanel.ets b/entry/src/main/ets/components/FbTaskPanel.ets index c0fde99..8fe14f7 100644 --- a/entry/src/main/ets/components/FbTaskPanel.ets +++ b/entry/src/main/ets/components/FbTaskPanel.ets @@ -329,7 +329,7 @@ export struct FbTaskPanel { if (w === 'done') { return '已完成'; } - return w === 'doing' ? '进行中' : '待办'; + return w === 'doing' ? '进行中' : '待做'; } /** diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets index 51aade9..64db2fa 100644 --- a/entry/src/main/ets/components/FbTripNodeSheet.ets +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -1,44 +1,88 @@ +import { FbDiag } from '../common/FbDiag'; /** - * 家书 FamilyBook · 「加一段行程」浮层(鸿蒙端) + * 岁月家 FamilyBook · 行程编辑浮层(鸿蒙端) * - * 对应网页版 `templates/trips/node_form.php` 的**核心几项**。 + * 对应网页版 `templates/trips/node_form.php`,**字段与排版完全对齐**。 * - * 🔴 用户 2026-10-20:「现在这个旅程下面也没有这个行程啊,这些都需要增加。」 - * 端上原先连"行程"都拉不到(`nodes` 恒空),更没有地方能加一段。 + * 🔴 2026-11-05 重写。用户原话(逐条对应本文件的改动): * - * ⚠️ 节点类型(`node_type`)与出行方式(`travel_mode`)的枚举是**服务端常量** - * (`Trip::NODE_TYPES` / `Trip::TRAVEL_MODES`),这里照抄一份。 - * 为什么不从接口取:它们是"物理事实"(走路/火车/飞机不会变), - * 服务端注释里也写明这一点;为它多发一次请求不值得。 - * 但**改枚举时要两边一起改** —— 这个文件里有一条注释提醒。 + * ① 「这不叫什么"这一段",这都是起的什么名字?应该是标题。」 + * ⇒ 标签从「这一段」改成「标题」。原来那个是**占位提示**当标签用了, + * 文案本身也莫名其妙。 * - * ⚠️ 表格里**没有**「出发城市/到达城市」:服务端 `nodeData()` 虽然接受 - * `from_city`/`to_city`,但网页版的新建表单里这两项是可选的补充, - * 端上第一批先只做主干(标题/地点/时间/类型/方式), - * 城市仍能通过「地点」体现(列表里会用 place 显示)。 + * ② 「出发地、目的地要分开。现在地点这两个都放在一起,能对吗? + * 时间也应该是开始时间和结束时间。」 + * ⇒ 拆成 `出发地` / `到达地` 两个独立输入框(各自带地址联想 + 详细地址); + * 时间拆成 `开始时间` / `结束时间` 两行。 + * ⚠️ 原来只有一个「地点」+ 一个「时间」—— 网页端一直是分开的, + * 端上合并成一个等于丢失了信息(到达地和出发地都填不了)。 * - * 🔴 v77(用户 2026-10-22 问「出行方式需不需要增加或者修改」): - * 「方式列表」本身**不能增删**(十种写死在 `Trip::TRAVEL_MODES`,网页版 - * 也一样只能看)—— 但端上这表单确实缺了两样,缺了就让"出行方式"变得没用: - * ① **车次 / 航班号**(火车/飞机/轮船/公交/地铁)—— 网页版一直有, - * 端上没有 ⇒ 手机上记的行程永远看不到 G1234 这种东西; - * ② **车牌**(自驾)—— 从「车辆管理」里选一台,服务端按 `vehicle_id` - * join 出 `vehicle_plate`。 - * ⇒ 下面按选中的方式**条件出现**,选「不填」或走路/骑行时一律不显示 - * (摆一堆用不上的输入框,用户只会以为是自己没填对)。 + * ③ 「开始时间后边还有个箭头,也不知道啥意思。」 + * ⇒ `FbDateTimeRow` 自带一个装饰性 `›`,在表单里没有语义(它本来是给 + * "点这一行进下一级"的列表用的)。这里换成自绘的时间行,不加箭头。 + * + * ④ 「类型、游玩什么的都不能划。出入方式可以滑动,但是类型不能滑动。」 + * ⇒ 类型 5 项**一屏放得下**,用 `Row` 平铺,不放到 `Scroll` 里; + * 出行方式 11 项放不下,才用横向滚动。 + * + * ⑤ 「它不能这样传图片吗?网页端有什么功能,这个就应该有什么功能, + * 应该一模一样啊。」 + * ⇒ 补上**照片上传**(拍照/相册,多选)、**照片墙**(可点开大图)、 + * 单张删除。走新增的 `POST /trips//nodes/attach`。 + * + * ## 完整字段(与网页端 `node_form.php` 一一对应) + * + * | 网页端 | 本文件 | + * |---|---| + * | 类型 `node_type`(下拉,不可划) | `类型` 5 项平铺 chips | + * | 当前状态(只读展示) | `当前状态` 只读文字 | + * | 标题 `title` | `标题` 输入框 | + * | 时间范围 `starts_at` / `ends_at` | `开始时间` / `结束时间` 两行 | + * | 出行方式 `travel_mode`(下拉) | `出行方式` 横向滚动 chips | + * | 车辆 `vehicle_id`(仅自驾) | `车辆` chips(仅自驾) | + * | 车次 `trip_no`(仅火车/飞机等) | `车次 / 航班号`(条件出现) | + * | 出发地 `from_place/_addr/_lng/_lat` | `出发地` 联想输入框 | + * | 到达地 `to_place/_addr/_lng/_lat` | `到达地` 联想输入框 | + * | 执行状态 `status` | `执行状态` 4 项平铺 chips | + * | 记录 `detail` | `记录` 多行输入 | + * | 参与的人 `members[]` | `参与的人` 选人浮层 | + * | 照片 `photo[]` | `照片` 上传 + 照片墙 | + * + * ## 已知限制(不假装解决) + * + * `FbTripNode` 没有 `from_lng/_lat` / `to_lng/_lat` / `startsAt` 原始 ISO 字段, + * 所以: + * · 编辑回填时坐标**拿不到**(保存后这两个字段会被清空)—— 因为服务端 + * `nodes()` 没返回坐标。**根治要服务端补字段。** + * · 日期只能从展示串 `when`(`10-20 09:30`,不带年份)反推,按"当年"补。 + * + * ⚠️ 节点类型与出行方式的枚举是**服务端常量**(`Trip::NODE_TYPES` / + * `Trip::TRAVEL_MODES`),这里照抄。改枚举要两边一起改。 */ import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme'; import { FbSheetClose } from './FbSheetClose'; +import { FbAvatar } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; -import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField'; +import { FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField'; +import { FbPersonPickSheet } from './FbPersonPickSheet'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; -import { addTripNode, FbTripNodeDraft } from '../data/FbTripRepo'; +import { addTripNode, updateTripNode, deleteNodeAttach, stageUpload, FbTripNodeDraft, + fetchNodeTypes, + FbNodeTypeItem +} from '../data/FbTripRepo'; +import { FbStageUpload } from '../data/FbUpload'; import { fetchVehicles } from '../data/FbTripSettingsRepo'; -import { FbVehicleItem } from '../model/Types'; +import { FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbVehicleItem, + FbGeoTip +} from '../model/Types'; +import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo'; import { FbApiResult } from '../data/FbHttp'; -/* 🔴 2026-10-30(工单 #36):地点带联想(与日程编辑同一个组件) */ import { FbAddrInput } from './FbAddrInput'; +import { fbImageSrc } from '../data/FbAttachQueue'; +import { common } from '@kit.AbilityKit'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; /** 节点类型(⚠️ 与 `app/Trip.php` 的 `NODE_TYPES` 保持一致) */ interface FbNodeTypeOpt { @@ -70,327 +114,508 @@ const TRAVEL_MODES: FbNodeTypeOpt[] = [ { key: 'other', label: '其他', icon: '🧭' } ]; +/** 执行状态(⚠️ 与 `app/Trip.php` 的 `NODE_STATUS` 保持一致) */ +const NODE_STATUS_OPTS: FbNodeTypeOpt[] = [ + { key: 'planned', label: '计划中', icon: '' }, + { key: 'ongoing', label: '进行中', icon: '' }, + { key: 'done', label: '已完成', icon: '' }, + { key: 'cancelled', label: '已取消', icon: '' } +]; + +/** + * 待提交的暂存照片。 + * + * `stoken` 提交给服务端(建完节点再 claim);`uri` 仅本地预览用。 + */ +class FbPendingShot { + stoken: string = ''; + uri: string = ''; +} + +/** + * 从展示串解析日期与时分。 + * + * 输入形态(服务端 `Trip::nodeSpan` 的口径): + * · `10-20 09:30` → ['2026-10-20', '09:30'] + * · `10-20` → ['2026-10-20', ''] + * · `10-20 ~ 10-21` → 取起点的 ['2026-10-20', ''] + * · 空串 / 认不出 → ['', ''] + * + * 🔴 为什么是**模块级函数**而不是 struct 里的 static 方法: + * ArkTS 的 `@ComponentV2 struct` **不允许 static 成员**。 + * 写成 `static parseWhen()` 编译能过,但运行时调用直接崩 + * (这就是"点编辑就崩溃"的根因)。 + * 模块级函数不受这个限制。 + * + * ⚠️ `when` 里**没有年份** ⇒ 按"当年"补。 + * 跨年场景(12 月记次年 1 月的行程)会落到错误年份 —— + * 这是数据模型的缺口(`FbTripNode` 没有 `startsAt` 原始 ISO 字段), + * 根治要在 `Trip::nodes()` 里补该字段。 + * + * @returns [日期 `YYYY-MM-DD`, 时分 `HH:mm`];解析不出时对应项为空串 + */ +/** + * `YYYY-MM-DD HH:MM:SS` → [`YYYY-MM-DD`, `HH:mm`]。 + * + * 🔴 2026-11-05。比从 `when` 反推可靠得多(`when` 不带年份,跨年会错位)。 + * + * @returns [日期, 时分];解析不出时对应项为空串 + */ +/** + * 空值安全的字符串取值。 + * + * 🔴🔴 2026-11-05 **崩溃根因**:`FbTripNode` 新增了 `startsAt` / `endsAt`, + * 但端上 SQLite 缓存里还是**老结构**的数据(没有这两个字段)。 + * TS 类型标注说它们是 `string`,运行时却是 `undefined` —— + * ArkTS 不做运行时类型检查,直接调 `.trim()` 就崩: + * + * Cannot read property 'trim' of undefined + * + * 这是「点编辑就崩溃」的真正原因,不是 static 方法那个(那个也修了,但不是主因)。 + * + * ⚠️ 老缓存 + 新字段是**永久性的兼容问题**,不是一次性 bug: + * 每次给 `FbTripNode` 加字段,所有读它的地方都要能扛 undefined。 + */ +/** + * 一个人的显示名:优先「我叫他什么」(`nickname`),没有就用本名。 + * + * ⚠️ 与行程详情页的 `fbPersonName()` 同一口径 —— 名册里既有本名也有昵称, + * 家人之间看昵称更自然(用户习惯叫「红围脖」而不是全名)。 + */ +function fbPersonLabel(p: FbRosterPerson): string { + const nk: string = fbSafeStr(p.myNickname); + if (nk !== '') { + return nk; + } + return fbSafeStr(p.name); +} + +function fbSafeStr(v: string | null | undefined): string { + if (v === null || v === undefined) { + return ''; + } + return String(v); +} + +function fbSplitDateTime(raw: string): string[] { + const out: string[] = ['', '']; + /* ⚠️ 必须过 fbSafeStr —— 老缓存里这个字段是 undefined */ + const v: string = fbSafeStr(raw).trim(); + if (v.length < 10) { + return out; + } + /* 服务端给的是空格分隔(`2026-11-05 09:30:00`); + 也兼容 T 分隔(ISO) */ + const datePart: string = v.substring(0, 10); + out[0] = datePart; + const sep: string = v.length > 10 ? v.charAt(10) : ''; + if ((sep === ' ' || sep === 'T') && v.length >= 16) { + out[1] = v.substring(11, 16); + } + return out; +} + +function fbParseWhen(when: string): string[] { + const out: string[] = ['', '']; + const w: string = fbSafeStr(when).trim(); + if (w === '') { + return out; + } + /* 取第一段(跨天行程是 `10-20 ~ 10-21`) */ + const head: string = w.split('~')[0].trim(); + const m = /^(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?$/.exec(head); + if (m === null) { + return out; + } + const mo: number = parseInt(m[1], 10); + const dd: number = parseInt(m[2], 10); + if (isNaN(mo) || isNaN(dd) || mo < 1 || mo > 12 || dd < 1 || dd > 31) { + return out; + } + const y: number = new Date().getFullYear(); + const p = (n: number): string => n < 10 ? '0' + n.toString() : n.toString(); + out[0] = y.toString() + '-' + p(mo) + '-' + p(dd); + if (m[3] !== undefined && m[4] !== undefined) { + out[1] = p(parseInt(m[3], 10)) + ':' + m[4]; + } + return out; +} + +/** 需要车次号的出行方式(火车/飞机/轮船/公交/地铁) */ +const TICKET_MODES: string[] = ['train', 'plane', 'ship', 'bus', 'subway']; + @ComponentV2 export struct FbTripNodeSheet { /** 所属旅程 id */ @Param tripId: number = 0; /** 模块主题色 */ @Param accent: string = '#3F7D5A'; - /** - * 打开时预选的阶段:`'plan'` / `'actual'`。 - * - * 🔴 v75(用户 2026-10-21):详情页右上角是**两颗**按钮 - * 「+ 计划行程(蓝)」「+ 实际行程(绿)」(这是 v41 定下的硬要求), - * 点哪颗就该落在哪个阶段 —— 原来只有一颗「加一段行程」, - * 进来后阶段恒为「计划」,用户点"实际行程"还是要手动改一次。 - * ⚠️ 浮层里那个「阶段」选择器**保留**:用户点错了还能改, - * 不能因为"入口已经指定了"就把选择器藏掉(那是把选择权拿走)。 - */ + /** 打开时预选的阶段:`'plan'` / `'actual'` */ @Param startStage: string = 'plan'; + /** + * 要编辑的那一段(`null` = 新增)。 + * 新增与编辑共用一个浮层:字段完全一样,拆开有两份要同步维护。 + */ + @Param editing: FbTripNode | null = null; + /** + * 名册(选参与人用)。 + * + * ⚠️ 做成 由本组件自己加载,不用 让父页面传: + * 父页面(旅程详情)本来没有名册数据,加一个 会让它 + * 背一个要先拉名册的负担,而且拉失败时浮层里选人框是空的、无从排查。 + * 自加载 = 打开浮层时才查(本地库,很快),失败也不影响其他字段。 + */ + @Local roster: FbRosterPerson[] = []; - @Event onClose: () => void = (): void => {}; - /** 加成功后父页面重新拉节点 */ - @Event onSaved: () => void = (): void => {}; + @Event onClose: () => void = (): void => { + }; + /** 保存成功后父页面重新拉节点 */ + @Event onSaved: () => void = (): void => { + }; + /* ---------- 表单值 ---------- */ @Local title: string = ''; - @Local place: string = ''; + /** 出发地(地名) */ + @Local fromPlace: string = ''; + /** 到达地(地名) */ + @Local toPlace: string = ''; @Local nodeType: string = 'play'; + /** + * 节点类型(**从服务端拉**,#51)。 + * + * 🔴 用户:「这个类型和网页端不一样啊,网页端比这个多得多呀。」 + * 库里实际 9 种,端上硬编码只有 5 种。 + * ⚠️ 初始保持空 → 用硬编码 `NODE_TYPES` 兜底渲染, + * 拉到了再替换(网络失败时不至于一个类型都没有)。 + */ + /** + * **实际渲染用的类型列表**(#51 修正)。 + * + * ⚠️ 为什么要有这个单独的字段,而不是渲染时调 `typeOptions()`: + * `ForEach(this.typeOptions(), ...)` 的数据源是**函数返回值**, + * ArkUI 的依赖追踪**看不到**函数内部读了 `typesFromApi` ⇒ + * 拉到 9 条也不会重绘,界面永远停在兜底的 5 种。 + * ⇒ 必须让 `ForEach` 直接消费**状态字段**。 + * + * 初始给硬编码兜底(断网时至少能选),拉到服务端数据后替换。 + */ + @Local typesShown: FbNodeTypeItem[] = []; + /** 出发地详细地址(高德 `detail`) */ + @Local fromAddr: string = ''; + /** 出发地坐标 */ + @Local fromLng: string = ''; + @Local fromLat: string = ''; + /** 到达地详细地址 */ + @Local toAddr: string = ''; + @Local toLng: string = ''; + @Local toLat: string = ''; @Local travelMode: string = ''; - /** 车次 / 航班号(v77) */ @Local tripNo: string = ''; - /** 自驾选中的车(`vehicles.id`,0 = 没选) */ @Local vehicleId: number = 0; - /** 车辆库(自驾时才用得上;取不到就空数组,界面会给出说明) */ @Local vehicles: FbVehicleItem[] = []; /** 'plan' 计划 / 'actual' 实际 */ @Local category: string = 'plan'; - /** 日期 `YYYY-MM-DD` */ - @Local date: string = ''; - /** 时分 `HH:mm` */ - @Local time: string = '09:00'; - @Local pickerOpen: boolean = false; + /** 执行状态 */ + @Local nodeStatus: string = 'planned'; + /** 开始 */ + @Local startDate: string = ''; + @Local startTime: string = '09:00'; + /** 结束(空 = 不填) */ + @Local endDate: string = ''; + @Local endTime: string = ''; + /** 记录(备注) */ + @Local detailText: string = ''; + /** 参与人 id */ + @Local memberIds: number[] = []; + /** 已挂到节点上的照片(编辑时回填;删一张立即生效) */ + @Local photos: FbTripNodePhoto[] = []; + /** + * **待提交**的暂存照片(还没挂到节点上)。 + * + * 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数 + * 一起提交,后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」 + * + * `stoken` 提交给服务端(它建完节点再 claim);`uri` 仅本地预览。 + */ + @Local pending: FbPendingShot[] = []; + + /* ---------- 界面状态 ---------- */ + /** 哪个时间选择器开着:'' / 'start' / 'end' */ + @Local pickerFor: string = ''; + @Local personPickOpen: boolean = false; @Local saving: boolean = false; + @Local uploading: boolean = false; @Local note: string = ''; aboutToAppear(): void { - // 默认今天(多数场景是"记刚刚发生的"或"排今天剩下的") - this.date = fbIsoOf(new Date()); - /* 入口指定的阶段(详情页两颗按钮)—— 只认这两个值,别的一律按计划 */ + this.startDate = fbIsoOf(new Date()); this.category = this.startStage === 'actual' ? 'actual' : 'plan'; - /* 车辆库:只有"自驾"才用得上,但一次请求很便宜,先取好 —— - 选到自驾时立刻就能列出来,不用再等一次网络。 */ + + const e: FbTripNode | null = this.editing; + if (e !== null) { + /* 🔴 2026-11-05(用户报「点编辑仍然崩溃」而静态排查找不到确切崩溃点): + 把回填包进 try/catch,**出错时显示具体错误,而不是整个 App 闪退**。 + + 理由: + ① 回填失败不该让用户丢数据、闪退 + ② 闪退只能听到"崩了",显示错误能给出具体是哪个字段出错 + —— 这是把"盲猜"换成"有据可查"的关键一步 + ③ 防御性代码本身无害,定位并修好根因后可以保留 + + ⚠️ 若这里真的报错,说明某个字段的取值路径仍有问题, + 请把 `note` 上显示的文案发给我。 */ + try { + if (e !== null) { + /* ⚠️ 全部过 fbSafeStr:老缓存数据可能缺任何新增字段(运行时是 undefined) */ + this.title = fbSafeStr(e.title); + /* ⚠️ 服务端 `nodes()` 只返回 `place`(一个合并串)和 `fromPlace` / `toPlace`。 + 优先用 fromPlace / toPlace;都空时退回 place(老数据只有 place)。 */ + this.fromPlace = fbSafeStr(e.fromPlace); + this.fromAddr = fbSafeStr(e.fromAddr); + this.fromLng = fbSafeStr(e.fromLng); + this.fromLat = fbSafeStr(e.fromLat); + this.toAddr = fbSafeStr(e.toAddr); + this.toLng = fbSafeStr(e.toLng); + this.toLat = fbSafeStr(e.toLat); + this.toPlace = fbSafeStr(e.toPlace) !== '' ? fbSafeStr(e.toPlace) + : (fbSafeStr(e.fromPlace) === '' ? fbSafeStr(e.place) : ''); + this.nodeType = fbSafeStr(e.nodeType) !== '' ? fbSafeStr(e.nodeType) : 'other'; + this.travelMode = fbSafeStr(e.mode); + this.tripNo = fbSafeStr(e.tripNo); + this.category = fbSafeStr(e.stage) === 'actual' ? 'actual' : 'plan'; + this.nodeStatus = fbSafeStr(e.status) !== '' ? fbSafeStr(e.status) : 'planned'; + this.detailText = fbSafeStr(e.detail); + /* DIAG-FILL:编辑回填时 attachs 到底有没有 */ + FbDiag.log('FILL', 'editing node #' + e.id.toString() + + ' attachs=' + (e.attachs !== undefined && e.attachs !== null + ? e.attachs.length.toString() : 'undefined/null')); + this.photos = e.attachs !== undefined && e.attachs !== null ? e.attachs : []; + FbDiag.log('FILL', 'photos 赋值后 = ' + this.photos.length.toString()); + this.memberIds = []; + const mem: FbTripNodePerson[] = (e.members !== undefined && e.members !== null) + ? e.members : []; + for (const p of mem) { + this.memberIds.push(p.id); + } + /* 开始 / 结束时间:**优先用原始值**(`starts_at` / `ends_at`)。 + 🔴 2026-11-05:原来只从 `when`(`10-20 09:30`,不带年份)反推, + 跨年会落到错误年份;而且**结束时间根本没回填** + (用户:「只有开始时间,没有结束时间呀」)。 + 服务端 `SELECT *` 本来就返回这两个字段,端上补解析后用它们。 */ + const ps: string[] = fbSplitDateTime(fbSafeStr(e.startsAt)); + if (ps[0] !== '') { + this.startDate = ps[0]; + } + if (ps[1] !== '') { + this.startTime = ps[1]; + } + const pe: string[] = fbSplitDateTime(fbSafeStr(e.endsAt)); + if (pe[0] !== '') { + this.endDate = pe[0]; + this.endTime = pe[1] !== '' ? pe[1] : this.startTime; + } else { + /* 老数据没有 ends_at 时退回 `when` 的解析(至少拿到开始) + ⚠️ 只补开始,不猜结束 —— 没填就是没填 */ + const fallback: string[] = fbParseWhen(fbSafeStr(e.when)); + if (ps[0] === '' && fallback[0] !== '') { + this.startDate = fallback[0]; + } + if (ps[1] === '' && fallback[1] !== '') { + this.startTime = fallback[1]; + } + } + } + } catch (err) { + const msg: string = err instanceof Error ? err.message : '未知错误'; + this.note = '回填失败:' + msg; + hilog.error(0x0000, 'FbTripNode', '回填异常:%{public}s', msg); + } + } this.loadVehicles(); + this.loadRoster(); + this.loadTypes(); } + /** 从本地库取名册(,不联网) */ + /** + * 已选的人(把 `memberIds` 映射成名册里的完整记录)。 + * + * 🔴 #49:用户要「在按钮上面显示已选的人的头像和名字」 + * ⇒ 需要 `avatar` / `avatarColor` / `name` 这些展示字段,光有 id 不够。 + * 名册在 `loadRoster()` 时已拉到本地,这里只做映射。 + * + * ⚠️ 名册里找不到的 id(人已被删除)**跳过**而不渲染占位: + * 显示一个"?"头像是误导。这种脏数据该在保存时清掉,不是在这里画问号。 + */ + private pickedPeople(): FbRosterPerson[] { + if (this.memberIds.length === 0) { + return []; + } + const out: FbRosterPerson[] = []; + for (const pid of this.memberIds) { + const hit: FbRosterPerson | undefined = this.roster.find( + (p: FbRosterPerson): boolean => p.id === pid); + if (hit !== undefined) { + out.push(hit); + } + } + return out; + } + + /** + * 拉节点类型(#51)。 + * + * 拉到 → 用服务端的(9 种,含用户自定义); + * 失败 → 用硬编码兜底(5 种,至少能填完表单,不白屏)。 + * + * ⚠️ 结果直接写 `typesShown`(状态字段),**不经过函数中转** —— + * 否则 `ForEach` 追踪不到(见字段注释)。 + */ + private async loadTypes(): Promise { + let got: FbNodeTypeItem[] = []; + try { + got = await fetchNodeTypes(FB_TOKEN_STORE.get(), this.tripId); + } catch (err) { + got = []; + } + if (got.length > 0) { + this.typesShown = got; + return; + } + /* 兜底:硬编码那套。⚠️ 不要删 —— 断网时它是唯一能填表单的东西。 */ + const fb: FbNodeTypeItem[] = []; + for (const o of NODE_TYPES) { + const it: FbNodeTypeItem = { id: 0, name: o.label, icon: o.icon }; + fb.push(it); + } + this.typesShown = fb; + } + + private async loadRoster(): Promise { + try { + this.roster = await FbPersonRepo.inst().list(); + } catch (err) { + this.roster = []; + } + } + + private async loadVehicles(): Promise { const rows: FbVehicleItem[] = await fetchVehicles(FB_TOKEN_STORE.get()); this.vehicles = rows; } + /** 这个出行方式要不要车次号 */ + private needsTicket(): boolean { + return TICKET_MODES.indexOf(this.travelMode) >= 0; + } + + /** 这个出行方式要不要选车 */ + private needsVehicle(): boolean { + return this.travelMode === 'drive'; + } + + /** 当前阶段的中文(只读展示) */ + private stageWord(): string { + return this.category === 'actual' ? '实际行程' : '计划行程'; + } + + /* ==================== 照片 ==================== */ + /** - * 这个出行方式要不要额外填点什么。 + * 选照片并上传(多选)。 * - * @returns `'code'` 车次/航班号 · `'plate'` 车牌(自驾)· `''` 不用填 + * ⚠️ 用系统 `PhotoViewPicker`:不需要申请读相册权限 + * (系统选择器自带临时授权),比自建 MediaLibrary 查询稳。 + * ⚠️ 上传走服务端 `POST /trips//nodes/attach`,落 `attachments` 表 + * (`target_type='trip'`, `target_id=nodeId`)—— **与网页端同一个落点**, + * 否则两边各存一份、互相看不见。 + * ⚠️ **必须已有节点 id 才能传**(附件挂在 node 上)。新增模式下先保存、 + * 拿到 id 再传 —— 所以新增时这个按钮先存草稿再传,或者提示"先保存"。 + * 这里选后者:提示用户先保存,逻辑简单且不会产生孤儿附件。 */ - private extraKind(): string { - const m: string = this.travelMode; - if (m === 'drive') { - return 'plate'; + /** + * 选照片 → **立刻暂存上传**(拿 stoken),等点「确定」时一起提交。 + * + * 🔴 2026-11-05 重写。用户:「为什么挂不上去,必须能挂上去。先上传,拿到地址, + * 然后提交的时候,连带着跟其他参数一起提交,后台接口拿到数据, + * 先插入行程,拿到 id,再把图片插入进去。」 + * + * 上一版是"新增时提示先保存" —— 用户明确否掉了,新增也必须能加图。 + * + * ⚠️ 用系统 PhotoViewPicker:不需要申请读相册权限(系统选择器自带临时授权)。 + */ + private async pickPhotos(): Promise { + if (this.uploading) { + return; } - if (m === 'train' || m === 'plane' || m === 'ship' || m === 'bus' || m === 'subway') { - return 'code'; - } - return ''; - } - - /** 车次那个框的标签 / 占位(不同方式叫法不同,别统一写"车次") */ - private codeLabel(): string { - if (this.travelMode === 'plane') { - return '航班号'; - } - if (this.travelMode === 'ship') { - return '航次'; - } - return '车次'; - } - - private codeHint(): string { - if (this.travelMode === 'plane') { - return '如「CA1858」'; - } - if (this.travelMode === 'ship') { - return '如「渤海轮渡」'; - } - return '如「G1234」'; - } - - build() { - /* 🔴🔴 根必须 `Stack`(见 `pitfalls.md`:全屏遮罩不能当 Column 子项排) */ - Stack({ alignContent: Alignment.Center }) { - Column() - .width('100%') - .height('100%') - .backgroundColor(FB_MASK) - .onClick((): void => { - this.onClose(); - }) - - Column() { - FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { - Column({ space: 12 }) { - // ---- 标题栏 ---- - Row({ space: 8 }) { - Column({ space: 2 }) { - Text('加一段行程') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - .maxLines(1) - Text('这一趟里的一个节点,如「出发」「抵达民宿」') - .fontSize(FbMetric.fontTiny) - .fontColor(this.accent) - .maxLines(2) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - .flexShrink(1) - - FbSheetClose({ - onClose: (): void => { - this.onClose(); - }, - color: this.accent - }) - } - .width('100%') - .alignItems(VerticalAlign.Center) - - Scroll() { - Column({ space: 12 }) { - // ---- 标题 ---- - this.textField('这一段', '如「出发」「抵达民宿」', this.title, - (v: string): void => { - this.title = v; - this.note = ''; - }) - - // ---- 地点 ---- - /* 🔴 2026-10-30(工单 #36):换成带联想的输入框。 - 行程地点是"出发地/目的地",同名地点(多个同名车站/商场) - 最容易填错,详细地址作为参考尤其有用。 */ - FbAddrInput({ - label: '地点', - placeholder: '如「青岛北站」「崂山区·沙子口」', - value: this.place, - onValue: (v: string): void => { - this.place = v; - } - }) - - // ---- 时间(复用日程/观影那套日期时间控件)---- - FbDateTimeRow({ - label: '时间', - date: this.date, - time: this.time, - accent: this.accent, - onTap: (): void => { - this.pickerOpen = true; - } - }) - - // ---- 类型(单选 chips)---- - this.chipRow('类型', NODE_TYPES, this.nodeType, (k: string): void => { - this.nodeType = k; - }) - - // ---- 出行方式(横向滚动 chips,10 项一屏放不下)---- - Column({ space: 6 }) { - Text('出行方式') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - Scroll() { - Row({ space: 8 }) { - ForEach(TRAVEL_MODES, (m: FbNodeTypeOpt) => { - this.chip(m, this.travelMode === m.key, (): void => { - this.travelMode = m.key; - }) - }, (m: FbNodeTypeOpt): string => 'tm_' + m.key) - } - .padding({ right: 4 }) - } - .scrollable(ScrollDirection.Horizontal) - .scrollBar(BarState.Off) - .width('100%') - } - .width('100%') - .alignItems(HorizontalAlign.Start) - - /* ---- 车次 / 航班号(v77:火车、飞机、轮船、公交、地铁)---- - ⚠️ 只在选中这些方式时出现 —— 摆一堆用不上的输入框, - 用户只会以为是自己没填对。 */ - if (this.extraKind() === 'code') { - this.textField(this.codeLabel(), this.codeHint(), this.tripNo, - (v: string): void => { - this.tripNo = v; - }) - } - - /* ---- 车牌(v77:自驾,从车辆库选一台)---- - ⚠️ 这里是**选车**而不是手打车牌:服务端靠 `vehicle_id` - 去 join 出车牌,手打一个字符串进 `trip_no` 详情页根本不显示 - (那条路只有火车/飞机用)。与其让人白填,不如明说去登记。 */ - if (this.extraKind() === 'plate') { - Column({ space: 6 }) { - Text('车牌') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - if (this.vehicles.length === 0) { - Text('车辆库里还没有车 —— 到「旅程设置 → 车辆管理」加一辆,这里就能选') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .lineHeight(17) - .width('100%') - } else { - Scroll() { - Row({ space: 8 }) { - ForEach(this.vehicles, (v: FbVehicleItem) => { - this.textChip( - (v.name !== '' ? v.name : '我的车') + ' ' + v.plate, - this.vehicleId === v.id, - (): void => { - // 再点一次 = 取消选择(别让人选错了没法退) - this.vehicleId = this.vehicleId === v.id ? 0 : v.id; - }) - }, (v: FbVehicleItem): string => 'veh_' + v.id.toString()) - } - .padding({ right: 4 }) - } - .scrollable(ScrollDirection.Horizontal) - .scrollBar(BarState.Off) - .width('100%') - } - } - .width('100%') - .alignItems(HorizontalAlign.Start) - } - - // ---- 计划 / 实际 ---- - this.chipRow('阶段', [ - { key: 'plan', label: '计划', icon: '🗓' }, - { key: 'actual', label: '实际', icon: '✅' } - ], this.category, (k: string): void => { - this.category = k; - }) - } - .width('100%') - .alignItems(HorizontalAlign.Start) - } - .constraintSize({ maxHeight: 380 }) - .width('100%') - .scrollBar(BarState.Off) - .align(Alignment.TopStart) - - // ---- 错误 ---- - if (this.note !== '') { - Text(this.note) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.red) - .width('100%') - .maxLines(3) - } - - // ---- 保存 ---- - Text(this.saving ? '保存中…' : '加到行程里') - .fontSize(FbMetric.fontBody) - .fontWeight(FontWeight.Medium) - .fontColor(this.saving ? FbColors.faint : '#FFFFFF') - .textAlign(TextAlign.Center) - .width('100%') - .height(44) - .borderRadius(FbMetric.radiusS) - .backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98)) - .onClick((): void => { - this.save(); - }) - } - .width('100%') - .padding({ left: 18, right: 18, top: 18, bottom: 18 }) - .alignItems(HorizontalAlign.Start) + try { + const picker = new photoAccessHelper.PhotoViewPicker(); + const opt = new photoAccessHelper.PhotoSelectOptions(); + opt.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE; + opt.maxSelectNumber = 9; + const res = await picker.select(opt); + if (res.photoUris.length === 0) { + return; + } + this.uploading = true; + this.note = '正在上传 ' + res.photoUris.length.toString() + ' 个…'; + let ok: number = 0; + let fail: number = 0; + for (const uri of res.photoUris) { + const r: FbStageUpload = await stageUpload(FB_TOKEN_STORE.get(), uri); + if (r.stoken !== '') { + const item: FbPendingShot = new FbPendingShot(); + item.stoken = r.stoken; + item.uri = uri; + this.pending = this.pending.concat([item]); + ok++; + } else { + fail++; + hilog.warn(0x0000, 'FbTripNode', '暂存失败:%{public}s', r.error); } } - .width('92%') - - /* ---- 日期时间选择(叠在最上层;`FbDateTimePicker` 自身也是全屏 Stack, - 嵌在这里正好盖住整个浮层)---- */ - if (this.pickerOpen) { - FbDateTimePicker({ - title: '这一段的时间', - date: this.date, - time: this.time, - accent: this.accent, - onConfirm: (d: string, t: string): void => { - this.date = d; - this.time = t; - this.pickerOpen = false; - }, - onCancel: (): void => { - this.pickerOpen = false; - } - }) - } + this.uploading = false; + this.note = fail === 0 + ? ('已就绪 ' + ok.toString() + ' 个,点「确定」后一起保存') + : ('就绪 ' + ok.toString() + ' 个,失败 ' + fail.toString() + ' 个'); + } catch (err) { + this.uploading = false; + this.note = '选照片失败,请重试。'; } - .width('100%') - .height('100%') - .hitTestBehavior(HitTestMode.Transparent) } + /** 撤掉一张还没提交的暂存照片(服务端 sweep 会清掉,不产生孤儿) */ + private dropPending(stoken: string): void { + this.pending = this.pending.filter((p: FbPendingShot): boolean => p.stoken !== stoken); + } + + /** 删一张照片 */ + private async removePhoto(ph: FbTripNodePhoto): Promise { + const ed: FbTripNode | null = this.editing; + if (ed === null || this.uploading) { + return; + } + this.uploading = true; + const err: string = await deleteNodeAttach(FB_TOKEN_STORE.get(), this.tripId, ed.id, ph.id); + this.uploading = false; + if (err !== '') { + this.note = '删除失败:' + err; + return; + } + this.photos = this.photos.filter((p: FbTripNodePhoto): boolean => p.id !== ph.id); + this.onSaved(); + } + + /* ==================== 保存 ==================== */ + private async save(): Promise { if (this.saving) { return; } const title: string = this.title.trim(); if (title === '') { - this.note = '这一段得有个名字(比如「出发」)。'; + this.note = '标题不能为空(比如「出发去栈桥」)。'; return; } if (this.tripId <= 0) { @@ -401,106 +626,724 @@ export struct FbTripNodeSheet { this.note = ''; const d: FbTripNodeDraft = { title: title, - place: this.place.trim(), - // 服务端的 `Api::dt()` 吃 `YYYY-MM-DD HH:MM`(秒由服务端补) - startsAt: fbJoinDateTime(this.date, this.time), - endsAt: '', + /* ⚠️ `place` 是服务端的一个"合并展示"字段。这里用到达地填它 + (详情页在没有 from/to 时用它显示),两边都有值时以 from/to 为准。 */ + place: this.toPlace.trim() !== '' ? this.toPlace.trim() : this.fromPlace.trim(), + startsAt: fbJoinDateTime(this.startDate, this.startTime), + endsAt: this.endDate !== '' ? fbJoinDateTime(this.endDate, + this.endTime !== '' ? this.endTime : this.startTime) : '', nodeType: this.nodeType, travelMode: this.travelMode, - /* 🔴 v77:只提交**当前方式真正用到**的那一项 —— - 切到"自驾"之后又切回"火车"时,残留的车牌不该跟着存上去。 */ - tripNo: this.extraKind() === 'code' ? this.tripNo.trim() : '', - vehicleId: this.extraKind() === 'plate' ? this.vehicleId : 0, + tripNo: this.needsTicket() ? this.tripNo.trim() : '', + vehicleId: this.needsVehicle() ? this.vehicleId : 0, category: this.category, - detail: '' + detail: this.detailText.trim(), + /* 🔴 出发地/到达地:网页端是 4 个字段(place + addr + lng + lat), + 端上只有地名,addr 用同名兜底、坐标留空。 + ⚠️ 坐标留空会被服务端写成 NULL ⇒ **编辑会把原坐标清掉**。 + 这是已知限制(服务端 `nodes()` 没返回坐标),已在文件头注明。 */ + fromPlace: this.fromPlace.trim(), + toPlace: this.toPlace.trim(), + /* #51:**真正的高德详细地址 + 坐标**(原来是拿地名顶替的假地址) */ + fromAddr: this.fromAddr.trim(), + toAddr: this.toAddr.trim(), + fromLng: this.fromLng, + fromLat: this.fromLat, + toLng: this.toLng, + toLat: this.toLat, + /* 🔴 #47:**新建固定 planned;编辑发空串(不参与状态管理)**。 + 用户:「只要是添加的计划行程,它的状态就是计划中。」 + 编辑时状态由卡片上的动作按钮改,表单不该覆盖它。 */ + status: this.editing === null ? 'planned' : '', + members: this.memberIds, + /* 🔴 关键:把暂存的照片令牌跟表单一起提交。 + 服务端 saveNode 先建节点拿 id,再 StagedUpload::claim() 挂图 + —— 正是用户要求的顺序。 */ + stageTokens: this.pending.map((p: FbPendingShot): string => p.stoken) }; - const res: FbApiResult = await addTripNode(FB_TOKEN_STORE.get(), this.tripId, d); + const ed: FbTripNode | null = this.editing; + const res: FbApiResult = ed !== null + ? await updateTripNode(FB_TOKEN_STORE.get(), this.tripId, ed.id, d) + : await addTripNode(FB_TOKEN_STORE.get(), this.tripId, d); this.saving = false; if (!res.ok) { - this.note = res.error !== '' ? res.error : '没加上,请稍后再试。'; + this.note = res.error !== '' + ? res.error + : (ed !== null ? '没保存上,请稍后再试。' : '没加上,请稍后再试。'); return; } this.onSaved(); } - /** 一组单选 chips(带标签) */ - @Builder - chipRow(label: string, opts: FbNodeTypeOpt[], cur: string, onPick: (k: string) => void) { - Column({ space: 6 }) { - Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - Row({ space: 8 }) { - ForEach(opts, (o: FbNodeTypeOpt) => { - this.chip(o, cur === o.key, (): void => { - onPick(o.key); - }) - }, (o: FbNodeTypeOpt): string => 'cr_' + label + '_' + o.key) + /* ==================== 界面 ==================== */ + + build() { + /* ⚠️ `Center` 而不是 `Bottom`:#47 用户报「上面离顶部很远、下面贴着边」。 + 底部弹出 + 卡片接近满屏时贴底会显得"顶到天了",居中最稳。 */ + Stack({ alignContent: Alignment.Center }) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .onClick((): void => { + this.onClose(); + }) + + /* 🔴🔴 2026-11-05 **崩溃根因修复**: + `FbSheetRing` 的 `content` 是 `@BuilderParam`(必填尾随闭包), + 原来只写了参数 `FbSheetRing({ ringColor })`、内容放进另一个平级的 + `Column` ⇒ 运行时 `content` 是 undefined,一渲染就崩。 + + ⚠️ 编译器不拦(`@ComponentV2` 的尾随闭包缺失被当成空闭包放过), + 只在运行期炸 —— 与 `static` 那个坑同属"编译过、运行崩"。 + ⚠️ 白色底 / 圆角 / 裁剪由 `FbSheetRing` 提供,外层不要再设。 */ + /* ⚠️ `FbSheetRing` 是自定义组件,**不能链式加 `.width()`** + ⇒ 外面包一层 Column 承担尺寸,它自己撑满。 */ + Column() { + FbSheetRing({ ringColor: this.accent }) { + Column({ space: 12 }) { + // ---- 抬头 ---- + Row({ space: 8 }) { + Column({ space: 2 }) { + /* 🔴 #55(用户:「最左上角有个"编辑行程",这个也给它改了得了」): + 与字段标签统一 —— **主题色 + 加粗**。 + 原来是 ink 墨色 + Medium,和下面的标签风格不一致。 */ + Text(this.editing !== null ? '编辑行程' : '添加行程') + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Bold) + .fontColor(this.accent) + Text(this.stageWord()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .flexShrink(1) + + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: this.accent + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Scroll() { + Column({ space: 14 }) { + /* ---- ① 类型(5 项,**不滚动** —— 一屏放得下)---- + 用户:「类型、游玩什么的都不能划」 */ + this.labelRow('类型') + /* 🔴 #47(用户:「这个类型还是划不动,左右划不动,选不了」): + 原来 5 个 chip 平铺在 `Row` 里,父容器不可滚 ⇒ 窄屏上最后一个 + (「其他」)被裁掉且点不到。 + ⇒ 改成 `Flex(wrap)` **换行**,5 项全部可见、一项不缺。 + ⚠️ 不与"出行方式横滑"冲突:那边 11 项、换行占太多行,所以保留横滑。 + 判据是**项数**:≤6 项换行,>6 项横滑。 */ + /* #51:**动态类型**(服务端 9 种,不只是硬编码 5 种)。 + ⚠️ `chipType` 收 `FbNodeTypeItem`(name/icon), + `nodeType` 存的是 **name**(服务端类型表以 name 为业务键, + 见 `Trip::typeWord` 的实现)。 */ + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.typesShown, (o: FbNodeTypeItem) => { + this.chipTypeItem(o) + }, (o: FbNodeTypeItem): string => 'nt_' + o.name) + } + .width('100%') + + /* 🔴 #47:**「当前状态/实际行程」不显示**。 + 用户:「实际行程、当前状态实际行程,这个地方也不要出现, + 因为这是没有意义的。我现在本来就是添加的一个实际行程。」 + ⇒ 阶段由入口决定(点「+ 计划行程」还是「+ 实际行程」), + 编辑时不该再让用户看到/改它。 NO_STATUS_IN_FORM */ + /* ---- ③ 标题 ---- */ + this.labelRow('标题', true) + this.inputBox('例如:出发去栈桥', this.title, (v: string): void => { + this.title = v; + this.note = ''; + }) + + /* ---- ④ 开始时间(无箭头)---- */ + this.labelRow('开始时间') + this.timeRow(this.startDate, this.startTime, (): void => { + this.pickerFor = 'start'; + }) + + /* ---- ⑤ 结束时间(可空)---- */ + this.labelRow('结束时间') + if (this.endDate === '') { + Row({ space: 8 }) { + Text('不填') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .layoutWeight(1) + Text('填写') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(fbAlpha(this.accent, 0.12)) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.endDate = this.startDate; + this.endTime = this.startTime; + this.pickerFor = 'end'; + }) + } + .width('100%') + .padding({ left: 10, right: 6, top: 6, bottom: 6 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + } else { + Row({ space: 8 }) { + this.timeRow(this.endDate, this.endTime, (): void => { + this.pickerFor = 'end'; + }) + Text('清除') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .onClick((): void => { + this.endDate = ''; + this.endTime = ''; + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /* ---- ⑥ 出行方式(11 项,**可横向滑**)---- + 用户:「出行方式可以滑动」 */ + /* 出行方式(#49:**改平铺**) + 🔴 用户:「如果只是左右滑动,会给人一种感觉,好像它看不出来是可以 + 左右滑动的。你看我现在选的火车,火车在很靠后的位置,感觉像是空的… + 所以我的建议也跟类型一样,这个平铺下来就可以了。」 + + ⇒ 11 项全部平铺换行(约 3 行),一项不缺、不用猜能不能滑。 + ⚠️ 之前注释里写"项数 >6 用横滑" —— 那条规则被用户否掉了: + 横滑的可发现性太差,宁可多占两行。 + ⚠️ chip 内部读 `this.travelMode`(见 chipMode 的说明)。 */ + this.labelRow('出行方式') + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(TRAVEL_MODES, (m: FbNodeTypeOpt) => { + this.chipMode(m) + }, (m: FbNodeTypeOpt): string => 'tm_' + m.key) + } + .width('100%') + + /* ---- ⑦ 车辆(仅自驾)---- */ + if (this.needsVehicle()) { + this.labelRow('车辆') + if (this.vehicles.length === 0) { + Text('车辆库还是空的 —— 到「旅程设置 → 车辆管理」加一台') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + } else { + /* #49:同样改平铺(车辆通常只有一两台,横滑更没必要)。 */ + Flex({ wrap: FlexWrap.Wrap }) { + this.chipVehicle('不指定', 0) + ForEach(this.vehicles, (v: FbVehicleItem) => { + this.chipVehicle(v.plate !== '' ? v.plate : v.name, v.id) + }, (v: FbVehicleItem): string => 'veh_' + v.id.toString()) + } + .width('100%') + } + } + + /* ---- ⑧ 车次 / 航班号(仅火车/飞机等)---- */ + if (this.needsTicket()) { + this.labelRow('车次 / 航班号') + this.inputBox('如 G1234 / CA1234', this.tripNo, (v: string): void => { + this.tripNo = v; + }) + } + + /* ---- ⑨ 出发地 ---- */ + this.labelRow('出发地') + FbAddrInput({ + label: '', + placeholder: '如「家里」「青岛北站」', + value: this.fromPlace, + onValue: (v: string): void => { + this.fromPlace = v; + }, + /* #51:**接住结构化数据**(高德的详细地址 + 坐标)。 + 原来只有 onValue 一个拼接串,坐标被丢掉了。 */ + onPick: (t: FbGeoTip): void => { + this.fromPlace = t.name !== '' ? t.name : t.detail; + this.fromAddr = t.detail !== '' ? t.detail : t.address; + this.fromLng = t.lng; + this.fromLat = t.lat; + } + }) + /* 🔴 #51(用户:「详细地址…显示在编辑时」): + 编辑时把高德返回的**详细地址**显示出来,让用户确认选对了没。 + ⚠️ 只读、不可改 —— 它是坐标的副产物,改了地名就对不上坐标了。 + ⚠️ 没选候选(纯手打)时为空,不显示这一行。 */ + if (this.fromAddr !== '') { + Text(this.fromAddr) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(2) + .width('100%') + .padding({ left: 2, top: 2 }) + } + + /* ---- ⑩ 到达地 ---- */ + this.labelRow('到达地') + FbAddrInput({ + label: '', + placeholder: '如「青岛北站」「栈桥」', + value: this.toPlace, + onValue: (v: string): void => { + this.toPlace = v; + }, + onPick: (t: FbGeoTip): void => { + this.toPlace = t.name !== '' ? t.name : t.detail; + this.toAddr = t.detail !== '' ? t.detail : t.address; + this.toLng = t.lng; + this.toLat = t.lat; + } + }) + if (this.toAddr !== '') { + Text(this.toAddr) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(2) + .width('100%') + .padding({ left: 2, top: 2 }) + } + + /* 🔴 #47:**「执行状态」不显示**。 + 用户:「这个里面不要让它手动去动…编辑这个地方不要出现 + 计划中、进行中、已完成、已取消。」 + 状态只由卡片上的动作按钮改(开始执行 → ongoing, + 已完成 → done,已取消 → cancelled)。 + 表单**完全不碰** status —— 见 save() 里的说明。 */ + /* ---- ⑫ 记录(多行)---- */ + this.labelRow('记录') + TextArea({ text: this.detailText, placeholder: '这一段发生了什么' }) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontTiny }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(88) + .width('100%') + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .onChange((v: string): void => { + this.detailText = v; + }) + + /* ---- ⑬ 参与的人 ---- + 🔴 #49(用户):「如果这里已经选中了一个人,就在"参与的人"里面 + 先把这个人显示出来。底下再显示"已选一人,点此修改"。 + 在"点此修改"上面,把已经选中的这个人的头像和名字显示在这个 + 按钮上面。这样就更清楚了,我可以知道要不要改,是吧? + 因为我现在都不知道有什么人,怎么知道要不要改他呢?」 + + ⇒ 结构分两层: + ① 上方:已选人的**头像 + 名字**(一眼看到是谁) + ② 下方:操作按钮(「+ 添加参与的人」/「已选 N 人,点此修改」) + ⚠️ 取消选人也要能点:点任意一个头像即可从这趟行程里移除他。 */ + this.labelRow('参与的人') + + /* ① 已选的人(头像 + 名字) */ + if (this.memberIds.length > 0) { + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.pickedPeople(), (p: FbRosterPerson) => { + Row({ space: 4 }) { + FbAvatar({ + name: fbPersonLabel(p), + color: p.avatarColor !== '' ? p.avatarColor : '#A84A28', + dia: 22, + fontSizeVp: 10, + imageUrl: p.avatar !== '' + ? fbImageSrc(p.avatar, FB_TOKEN_STORE.get()) : '' + }) + Text(fbPersonLabel(p)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .maxLines(1) + .constraintSize({ maxWidth: 90 }) + } + .alignItems(VerticalAlign.Center) + .padding({ left: 4, right: 8, top: 3, bottom: 3 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .margin({ right: 8, bottom: 8 }) + /* 点一下移除这个人(用户想改时不必进浮层找) */ + .onClick((): void => { + this.memberIds = this.memberIds.filter( + (id: number): boolean => id !== p.id); + }) + }, (p: FbRosterPerson): string => 'pk_' + p.id.toString()) + } + .width('100%') + } + + /* ② 操作按钮 */ + Row({ space: 8 }) { + Text(this.memberIds.length === 0 + ? '+ 添加参与的人' + : ('已选 ' + this.memberIds.length.toString() + ' 人,点此修改')) + .fontSize(FbMetric.fontSmall) + .fontColor(this.memberIds.length === 0 ? this.accent : FbColors.ink) + .layoutWeight(1) + } + .width('100%') + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.personPickOpen = true; + }) + + /* ---- ⑭ 照片 ---- + 用户:「它不能这样传图片吗?网页端有什么功能,这个就应该有什么功能, + 应该一模一样啊。」 + 又:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交, + 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」 + ⇒ 选图即暂存上传(拿 stoken),点「确定」时跟着表单一起提交。 */ + this.labelRow('照片') + + // ① 已经挂在节点上的(编辑时才有;点 ✕ 立即删除) + if (this.photos.length > 0) { + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.photos, (ph: FbTripNodePhoto) => { + Stack({ alignContent: Alignment.TopEnd }) { + Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get())) + .width(72) + .height(72) + .objectFit(ImageFit.Cover) + .borderRadius(6) + Text('✕') + .fontSize(10) + .fontColor('#FFFFFF') + .width(18) + .height(18) + .textAlign(TextAlign.Center) + .backgroundColor('#00000099') + .borderRadius(9) + .margin({ top: 2, right: 2 }) + .onClick((): void => { + this.removePhoto(ph); + }) + } + .width(72) + .height(72) + .margin({ right: 8, bottom: 8 }) + }, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString()) + } + .width('100%') + } + + // ② 待提交的(本地 uri 预览,还没挂到节点上) + if (this.pending.length > 0) { + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.pending, (pd: FbPendingShot) => { + Stack({ alignContent: Alignment.TopEnd }) { + Image(pd.uri) + .width(72) + .height(72) + .objectFit(ImageFit.Cover) + .borderRadius(6) + Text('✕') + .fontSize(10) + .fontColor('#FFFFFF') + .width(18) + .height(18) + .textAlign(TextAlign.Center) + .backgroundColor('#00000099') + .borderRadius(9) + .margin({ top: 2, right: 2 }) + .onClick((): void => { + this.dropPending(pd.stoken); + }) + } + .width(72) + .height(72) + .margin({ right: 8, bottom: 8 }) + }, (pd: FbPendingShot): string => 'pd_' + pd.stoken) + } + .width('100%') + } + + Row({ space: 8 }) { + Text(this.uploading ? '上传中…' : '+ 添加照片 / 视频') + .fontSize(FbMetric.fontSmall) + .fontColor(this.uploading ? FbColors.faint : this.accent) + .padding({ left: 12, right: 12, top: 9, bottom: 9 }) + .backgroundColor(fbAlpha(this.accent, 0.10)) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.pickPhotos(); + }) + } + .width('100%') + + if (this.note !== '') { + Text(this.note) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + .align(Alignment.Top) + + // ---- 底部按钮 ---- + Row({ space: 10 }) { + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(44) + .borderRadius(FbMetric.radiusS) + .backgroundColor(FbColors.inputBg) + .onClick((): void => { + this.onClose(); + }) + + Text(this.saving ? '保存中…' : '确定') + .fontSize(FbMetric.fontSmall) + .fontColor('#FFFFFF') + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(44) + .borderRadius(FbMetric.radiusS) + .backgroundColor(fbAlpha(this.accent, this.saving ? 0.35 : 0.98)) + .onClick((): void => { + this.save(); + }) + } + .width('100%') + } + .width('100%') + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + /* #47:不再写死 88% —— 内容少时卡片自然矮,不会大片空白; + 内容多时顶到 86% 上限并内部滚动。上下各留 8vp,视觉对称。 */ + .constraintSize({ maxHeight: '86%' }) + .margin({ top: 8, bottom: 8 }) + .alignItems(HorizontalAlign.Center) + + /* ---- 时间选择器(叠在最上层)---- */ + if (this.pickerFor !== '') { + FbDateTimePicker({ + title: this.pickerFor === 'start' ? '开始时间' : '结束时间', + date: this.pickerFor === 'start' ? this.startDate : this.endDate, + time: this.pickerFor === 'start' ? this.startTime : this.endTime, + accent: this.accent, + onConfirm: (d: string, t: string): void => { + if (this.pickerFor === 'start') { + this.startDate = d; + this.startTime = t; + } else { + this.endDate = d; + this.endTime = t; + } + this.pickerFor = ''; + }, + onCancel: (): void => { + this.pickerFor = ''; + } + }) + } + + /* ---- 选人浮层 ---- */ + if (this.personPickOpen) { + FbPersonPickSheet({ + people: this.roster, + selected: this.memberIds, + title: '这一段里有谁', + accent: this.accent, + onConfirm: (ids: number[]): void => { + this.memberIds = ids; + this.personPickOpen = false; + }, + onClose: (): void => { + this.personPickOpen = false; + } + }) } - .width('100%') } .width('100%') - .alignItems(HorizontalAlign.Start) + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) } - /** 单个 chip */ + /** 字段标签(`req` = 必填,加个星号) */ @Builder - chip(o: FbNodeTypeOpt, on: boolean, onTap: () => void) { - Text(o.icon + ' ' + o.label) - .fontSize(FbMetric.fontTiny) - .fontColor(on ? '#FFFFFF' : FbColors.muted) - .padding({ left: 10, right: 10, top: 6, bottom: 6 }) - .backgroundColor(on ? this.accent : FbColors.inputBg) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - onTap(); + /** + * 字段标签。 + * + * 🔴 #52(用户):「里面的类型、标题都是这种黄色的字,而且很小,根本不醒目。 + * 我感觉应该跟网页端保持类似一致,应该使用黑色,稍微大一点,并加粗。 + * 如果这个东西是必填…应该用红色来表示,**后面不要加星了**。」 + * + * 改前:`fontTiny` + `faint`(浅黄灰)+ 星号后缀 ⇒ 小、浅、看不清 + * 改后:`fontSmall`(大一号)+ `ink`(深墨)/ 必填砖红 + `Medium` 字重, + * **无星号** —— 必填用颜色表达,与网页端 `.field label` 观感一致。 + */ + @Builder + labelRow(label: string, req: boolean = false) { + Text(label) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + /* 🔴 #54(用户):「黑色感觉也不是特别好看…我们把这个字改成绿色, + 看好不好看,改成它的主题颜色试一下,而且也得加粗显示。」 + + ⇒ 普通字段用**主题色**(`this.accent`,本模块是墨绿 #3F7D5A), + 必填仍用砖红(红表示必填是通用语义,不该被主题色盖掉)。 + ⚠️ 字重从 Medium 提到 **Bold**:用户两次都强调"要加粗", + Medium 在真机上偏细,Bold 更明显。 */ + .fontColor(req ? '#C0533F' : this.accent) + .width('100%') + } + + /** 单行输入框 */ + @Builder + inputBox(placeholder: string, value: string, onChange: (v: string) => void) { + TextInput({ text: value, placeholder: placeholder }) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontTiny }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(42) + .width('100%') + .padding({ left: 10, right: 10 }) + .onChange((v: string): void => { + onChange(v); }) } /** - * 纯文字 chip(v77:车辆列表用)。 + * 时间行:`2026-11-05 09:00`。 * - * ⚠️ 与 `chip()` 分开而不是复用它:那个要 `FbNodeTypeOpt`(带 icon), - * 车辆是「名字 + 车牌」,塞进那个结构要现造一个假对象。 + * ⚠️ **故意不用 `FbDateTimeRow`**:那个组件右侧有个装饰性 `›`, + * 用户明确说「开始时间后边还有个箭头,也不知道啥意思」。 + * 它是给"点这一行进下一级"的列表用的,在表单里没有语义。 */ @Builder - textChip(label: string, on: boolean, onTap: () => void) { - Text(label) + timeRow(date: string, time: string, onTap: () => void) { + Row({ space: 8 }) { + Text(date !== '' ? date : '选日期') + .fontSize(FbMetric.fontSmall) + .fontColor(date !== '' ? FbColors.ink : FbColors.faint) + .layoutWeight(1) + Text(time !== '' ? time : '--:--') + .fontSize(FbMetric.fontSmall) + .fontColor(time !== '' ? FbColors.ink : FbColors.faint) + } + .width('100%') + .padding({ left: 10, right: 10, top: 11, bottom: 11 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + onTap(); + }) + } + + /** 单选 chip(选中填主题色) */ + @Builder + /** + * 类型 chip。 + * + * 🔴🔴 #49(用户:「类型还是不能点击…点了没反应」)**根因**: + * 原来的 `chip(label, on: boolean, onTap)` 把 `this.nodeType === o.key` + * 的**求值结果**当参数传进去。ArkUI 的 `@Builder` 按值传参**不建立依赖**, + * ⇒ `nodeType` 改了,chip 的 UI 不会重算 ⇒ 视觉上"点了没反应"。 + * + * ⚠️ 这个坑很隐蔽:状态确实改了、保存也生效,只是界面不刷新, + * 用户会以为是"点不动"。`travelMode` / `vehicleId` 都有同样问题。 + * + * ⇒ 修法:**内部直接读 `this.nodeType`**,不靠参数传递。 + * 这样 `@Local nodeType` 的变化能被正常追踪到。 + */ + @Builder + chipType(o: FbNodeTypeOpt) { + Text(o.icon + ' ' + o.label) .fontSize(FbMetric.fontTiny) - .fontColor(on ? '#FFFFFF' : FbColors.muted) - .padding({ left: 10, right: 10, top: 6, bottom: 6 }) - .backgroundColor(on ? this.accent : FbColors.inputBg) + .fontColor(this.nodeType === o.key ? '#FFFFFF' : FbColors.muted) + .padding({ left: 10, right: 10, top: 7, bottom: 7 }) + .backgroundColor(this.nodeType === o.key ? this.accent : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .maxLines(1) + .margin({ right: 8, bottom: 8 }) .onClick((): void => { - onTap(); + this.nodeType = o.key; + this.note = ''; }) } - /** 单行输入 */ + /** + * 类型 chip(动态类型版,#51)。 + * + * ⚠️ 与 `chipType` 分开:那个收 `FbNodeTypeOpt`(key/label/icon), + * 这个收 `FbNodeTypeItem`(id/name/icon)—— 服务端的类型用 **name** 当业务键 + * (`node_type` 列存的就是「游玩」这种中文名,见 `Trip::typeWord`)。 + * ⚠️ 内部直接读 `this.nodeType`(#49 的教训:`@Builder` 值传参不刷新)。 + */ @Builder - textField(label: string, placeholder: string, value: string, onChange: (v: string) => void) { - Row({ space: 8 }) { - Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(44) - - TextInput({ text: value, placeholder: placeholder }) - .placeholderColor(FbColors.faint) - .placeholderFont({ size: FbMetric.fontTiny }) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .height(42) - .layoutWeight(1) - .padding({ left: 10, right: 10 }) - .onChange((v: string): void => { - onChange(v); - }) - } - .width('100%') - .alignItems(VerticalAlign.Center) + chipTypeItem(o: FbNodeTypeItem) { + Text(o.icon + ' ' + o.name) + .fontSize(FbMetric.fontTiny) + .fontColor(this.nodeType === o.name ? '#FFFFFF' : FbColors.muted) + .padding({ left: 10, right: 10, top: 7, bottom: 7 }) + .backgroundColor(this.nodeType === o.name ? this.accent : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .maxLines(1) + .margin({ right: 8, bottom: 8 }) + .onClick((): void => { + this.nodeType = o.name; + this.note = ''; + }) } -} + + /** 出行方式 chip(同上:内部读 `this.travelMode`,不靠参数) */ + @Builder + chipMode(m: FbNodeTypeOpt) { + Text(m.key === '' ? m.label : (m.icon + ' ' + m.label)) + .fontSize(FbMetric.fontTiny) + .fontColor(this.travelMode === m.key ? '#FFFFFF' : FbColors.muted) + .padding({ left: 10, right: 10, top: 7, bottom: 7 }) + .backgroundColor(this.travelMode === m.key ? this.accent : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .maxLines(1) + .margin({ right: 8, bottom: 8 }) + .onClick((): void => { + this.travelMode = m.key; + }) + } + + /** 车辆 chip(同上,读 `this.vehicleId`) */ + @Builder + chipVehicle(label: string, vid: number) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(this.vehicleId === vid ? '#FFFFFF' : FbColors.muted) + .padding({ left: 10, right: 10, top: 7, bottom: 7 }) + .backgroundColor(this.vehicleId === vid ? this.accent : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .maxLines(1) + .margin({ right: 8, bottom: 8 }) + .onClick((): void => { + this.vehicleId = vid; + }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbCalInbound.ets b/entry/src/main/ets/data/FbCalInbound.ets new file mode 100644 index 0000000..e17ca76 --- /dev/null +++ b/entry/src/main/ets/data/FbCalInbound.ets @@ -0,0 +1,421 @@ +/** + * 岁月家 FamilyBook · 系统日历镜像(入站回程) + * + * 🔴 2026-11-03 新增,2026-11-04 改为**按日历本多账户**。 + * 照 SyncCalendar 的 `MirrorInbound`。 + * + * ## 这是「系统日历 ↔ 岁月家 App ↔ 服务器」闭环的**回程** + * + * 出站(服务器 → 系统日历)在 `FbCalMirror`,本文件负责反向: + * 用户在系统日历里改了 / 新建了 / 删了,都要回写到服务器。 + * + * ## 多账户带来的结构变化(2026-11-04) + * + * 第一版只有一个账户(`岁月家`),所以只需对账一次。 + * 现在每个日历本一个账户(`岁月家·工作`、`岁月家·家庭`…), + * ⇒ **必须逐个账户对账**,且每个账户的条目只能回写到它对应的那个本。 + * + * for (每个镜像账户) { + * bucket = 从账户名解析出的 book + * bookId = 从 bucket 解析出的数字 + * 对该账户内的条目做三方比对,回写时指定 calendar_id = bookId + * } + * + * ⚠️ 跨本错写是这里最容易犯的错:把"工作"账户里的一条写进"家庭"本。 + * 所以 `pushCreate` / `pushUpdate` 都必须带 `calendar_id = bookId`。 + * + * ## 防回灌(唯一的核心难题,与单账户时相同) + * + * 系统日历里既有"我们写进去的",也有"用户改的/新建的/删的"。 + * 靠 `mirror_snapshot`(上次写入值的内容指纹)三方比对: + * + * | 系统日历当前状态 | 判定 | 动作 | + * |-----------------------------------|------------------|---------------------------| + * | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 | + * | 有 identifier,指纹 != 快照 | 用户改了 | 写回服务器 | + * | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) | + * | 快照有,系统里没了 | 用户删了 | 服务器删除 | + * | identifier 不是我们的格式 | 用户在系统日历新建 | 新建并推到服务器 | + * + * ⚠️ 「没有快照就补快照后跳过」这条极关键:首次启用时快照是空的, + * 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。 + * + * ⚠️ 必须有网络才能回写(SyncCalendar 是离线排队,这里直接推)。 + * 离线时本轮跳过,下次再对账 —— 不会丢改动,因为快照没更新、 + * 下一轮还会判定成"用户改了"。 + */ + +import { common } from '@kit.AbilityKit'; +import { calendarManager } from '@kit.CalendarKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { FbApiResult } from './FbHttp'; +import { FbCalEventInput, FbCalRepo } from './FbCalRepo'; +import { FbCalMirror, FbMirrorSnap } from './FbCalMirror'; +import { FbCalStore } from './FbCalStore'; +import { FB_TOKEN_STORE } from './FbTokenStore'; +import { FbDiag } from '../common/FbDiag'; + +/** 一次入站对账的统计 */ +export class FbInboundResult { + created: number = 0; + updated: number = 0; + deleted: number = 0; + skipped: number = 0; + failed: number = 0; + /** 对账了几个账户 */ + books: number = 0; +} + +export class FbCalInbound { + /** 账户 name 前缀,必须与 `FbCalMirror` 完全一致 */ + private static readonly ACCOUNT_PREFIX: string = 'familybook.mirror.book'; + + /** + * 入站对账主入口:**逐个镜像账户**对账。 + * + * ⚠️ 必须在出站镜像 `FbCalMirror.syncNow()` **之前**调用 —— + * 它产生的服务器改动要赶上本轮,否则下一轮出站会用旧数据把用户改动覆盖掉。 + */ + static async reconcile(ctx: common.Context): Promise { + const res: FbInboundResult = new FbInboundResult(); + + if (!await FbCalMirror.hasPermission()) { + return res; + } + + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + FbDiag.log('INBOUND', '未登录,跳过入站对账'); + return res; + } + + try { + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx); + + // 收集所有镜像账户 + const cals: calendarManager.Calendar[] = await cm.getAllCalendars(); + const mirrors: calendarManager.Calendar[] = []; + for (const c of cals) { + if (c.getAccount().name.startsWith(FbCalInbound.ACCOUNT_PREFIX)) { + mirrors.push(c); + } + } + if (mirrors.length === 0) { + return res; // 还没镜像过,无从对账 + } + + // 快照一次读全(按 identifier 索引,天然区分桶) + const snaps: Map = await FbCalMirror.snapLoad(ctx); + + // 本地条目索引(uid → 服务器 id + 所属本),一次建好所有账户共用 + const localIndex: Map = await FbCalInbound.localIndex(); + + for (const cal of mirrors) { + const accName: string = cal.getAccount().name; + const bookIdStr: string = accName.substring(FbCalInbound.ACCOUNT_PREFIX.length); + const bookId: number = parseInt(bookIdStr, 10); + if (isNaN(bookId) || bookId <= 0) { + FbDiag.log('INBOUND', '账户名解析不出 bookId,跳过: ' + accName); + continue; + } + const bucket: string = FbCalMirror.bucketOf(bookId); + + try { + await FbCalInbound.reconcileBook(ctx, cal, bucket, bookId, snaps, localIndex, token, res); + res.books++; + } catch (err) { + const e = err as BusinessError; + res.failed++; + FbDiag.log('INBOUND', '对账失败 bookId=' + bookId + ': ' + e.code + ' ' + e.message); + } + } + + if (res.created + res.updated + res.deleted > 0) { + FbDiag.log('INBOUND', '对账完成:本=' + res.books + + ' 新建=' + res.created + ' 修改=' + res.updated + + ' 删除=' + res.deleted + ' 跳过=' + res.skipped + ' 失败=' + res.failed); + } + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', '对账异常: ' + e.message); + } + return res; + } + + /* ==================== 单个日历本 ==================== */ + + private static async reconcileBook(ctx: common.Context, cal: calendarManager.Calendar, + bucket: string, bookId: number, snaps: Map, + localIndex: Map, token: string, res: FbInboundResult): Promise { + const prefix: string = bucket + '|'; + + // 系统日历现有条目 + let events: calendarManager.Event[] = []; + try { + events = await cal.getEvents(); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', 'getEvents() 失败 bookId=' + bookId + ': ' + e.message); + return; + } + + // 分成两类:带本桶 identifier 的(可判定改/删)vs 不是我们的(用户新建) + const mine: Map = new Map(); + const others: calendarManager.Event[] = []; + for (const ev of events) { + const id: string = ev.identifier ?? ''; + if (id.startsWith(prefix)) { + mine.set(id, ev); + } else { + others.push(ev); + } + } + + // ---------- ① 修改 ---------- + for (const id of Array.from(mine.keys())) { + const ev: calendarManager.Event | undefined = mine.get(id); + if (ev === undefined) { + continue; + } + const fp: string = FbCalMirror.fingerprintOf(ev); + const snap: FbMirrorSnap | undefined = snaps.get(id); + + if (snap === undefined) { + /* ⚠️ 没有快照 ⇒ 认作是我们自己写的,补一份后跳过。 + 绝不误判成"用户改了" —— 首次启用时快照是空的, + 那样会把全部日程标脏、全量重推服务器。 */ + await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp); + res.skipped++; + continue; + } + snaps.delete(id); // 还活着,稍后剩下的才是"被删了" + + if (snap.fingerprint === fp) { + res.skipped++; // 没动 + continue; + } + + const ref: LocalRef | undefined = localIndex.get(id); + if (ref === undefined || ref.id <= 0) { + res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站处理 + continue; + } + + /* ⚠️ 跨本防护:万一 identifier 的桶与当前账户不一致(历史脏数据), + 宁可跳过也不要把 A 本的事写到 B 本。 */ + if (ref.calId !== bookId) { + FbDiag.log('INBOUND', '跳过跨本条目 ' + id + '(本地本=' + ref.calId + + ' 账户本=' + bookId + ')'); + res.skipped++; + continue; + } + + const ok: boolean = await FbCalInbound.pushUpdate(ref.id, ev, bookId, token); + if (ok) { + await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp); + res.updated++; + } else { + res.failed++; + } + } + + // ---------- ② 删除:快照里剩下、且属于本桶的 ---------- + for (const id of Array.from(snaps.keys())) { + const snap: FbMirrorSnap | undefined = snaps.get(id); + if (snap === undefined) { + continue; + } + if (snap.bucket !== bucket) { + continue; // 别的桶的,留给那本处理 + } + const ref: LocalRef | undefined = localIndex.get(id); + if (ref !== undefined && ref.id > 0 && ref.calId === bookId) { + const ok: boolean = await FbCalInbound.pushDelete(ref.id, token); + if (ok) { + res.deleted++; + } else { + res.failed++; + } + } else { + res.skipped++; + } + await FbCalMirror.snapRemove(ctx, id); + snaps.delete(id); + } + + // ---------- ③ 新建:identifier 不是本桶格式的(用户在系统日历里手动加的) ---------- + for (const ev of others) { + if (ev.id === undefined) { + continue; + } + /* ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。 + 否则下一次出站镜像会把同一条再加一遍 ⇒ 系统日历里出现重复。 */ + const ident: string = bucket + '|sc-' + ev.id.toString() + '-' + ev.startTime.toString(); + const stamped: calendarManager.Event = ev; + stamped.identifier = ident; + try { + await cal.updateEvent(stamped); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', '无法写回 identifier,跳过新建(避免重复): ' + e.message); + res.skipped++; + continue; + } + + const newId: number = await FbCalInbound.pushCreate(ev, bookId, token); + if (newId > 0) { + await FbCalInbound.saveSnap(ctx, bucket, ident, ev, + FbCalMirror.fingerprintOf(ev)); + res.created++; + } else { + res.failed++; + } + } + } + + /* ==================== 推服务器 ==================== */ + + /** + * 系统日历 Event → 接口输入。 + * + * ⚠️ `calendar_id` 必须带上 —— 否则服务端会落到默认本, + * 用户在"工作"账户里新建的事会跑到别处去。 + */ + private static toInput(ev: calendarManager.Event, bookId: number): FbCalEventInput { + const input: FbCalEventInput = { + calendar_id: bookId, + title: ev.title !== undefined && ev.title !== '' ? ev.title : '(无标题)', + starts_at: FbCalInbound.fmtDateTime(ev.startTime), + ends_at: FbCalInbound.fmtDateTime(ev.endTime), + all_day: ev.isAllDay === true, + location: ev.location?.location !== undefined ? (ev.location.location as string) : '', + note: ev.description !== undefined ? ev.description : '' + }; + return input; + } + + private static async pushUpdate(id: number, ev: calendarManager.Event, + bookId: number, token: string): Promise { + try { + const r: FbApiResult = await FbCalRepo.inst().update(id, + FbCalInbound.toInput(ev, bookId), token); + if (!r.ok) { + FbDiag.log('INBOUND', '更新服务器失败 id=' + id + ': ' + r.error); + return false; + } + return true; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', '更新服务器异常 id=' + id + ': ' + e.message); + return false; + } + } + + private static async pushDelete(id: number, token: string): Promise { + try { + const r: FbApiResult = await FbCalRepo.inst().remove(id, token); + if (!r.ok) { + FbDiag.log('INBOUND', '删除服务器失败 id=' + id + ': ' + r.error); + return false; + } + return true; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', '删除服务器异常 id=' + id + ': ' + e.message); + return false; + } + } + + /** 新建 → 返回新条目 id(0 = 失败) */ + private static async pushCreate(ev: calendarManager.Event, bookId: number, + token: string): Promise { + try { + const r: FbApiResult = await FbCalRepo.inst().create( + FbCalInbound.toInput(ev, bookId), token); + if (!r.ok) { + FbDiag.log('INBOUND', '新建服务器失败: ' + r.error); + return 0; + } + if (r.data === '') { + return 0; + } + const obj: Record = JSON.parse(r.data) as Record; + const idVal: Object | undefined = obj['id']; + if (typeof idVal === 'number') { + return idVal as number; + } + return 0; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', '新建服务器异常: ' + e.message); + return 0; + } + } + + /* ==================== 本地索引 ==================== */ + + /** + * 建 `identifier → 本地条目` 的索引。 + * + * identifier 的算法必须与出站 `FbCalMirror.identifierOf()` **完全一致**, + * 否则两边永远对不上号(表现:用户在系统日历改了,服务器没变)。 + * ⇒ 这里直接调 `FbCalMirror.identifierOf()`,不自己算。 + */ + private static async localIndex(): Promise> { + const out: Map = new Map(); + try { + const now: Date = new Date(); + const from: Date = new Date(now.getFullYear(), now.getMonth() - 13, 1); + const to: Date = new Date(now.getFullYear(), now.getMonth() + 26, 1); + const p = (n: number): string => n < 10 ? '0' + n : '' + n; + const fromIso: string = from.getFullYear() + '-' + p(from.getMonth() + 1) + '-01'; + const toIso: string = to.getFullYear() + '-' + p(to.getMonth() + 1) + '-01'; + + const rows = await FbCalStore.inst().between(fromIso, toIso); + for (const e of rows) { + if (e.id <= 0 || e.calId <= 0) { + continue; // 衍生条目推不上去 + } + const bucket: string = FbCalMirror.bucketOf(e.calId); + const ident: string = FbCalMirror.identifierOf(bucket, e); + const ref: LocalRef = new LocalRef(); + ref.id = e.id; + ref.calId = e.calId; + out.set(ident, ref); + } + } catch (err) { + const e = err as BusinessError; + FbDiag.log('INBOUND', '建本地索引失败: ' + e.message); + } + return out; + } + + /* ==================== 辅助 ==================== */ + + private static async saveSnap(ctx: common.Context, bucket: string, identifier: string, + ev: calendarManager.Event, fp: string): Promise { + const s: FbMirrorSnap = new FbMirrorSnap(); + s.identifier = identifier; + s.bucket = bucket; + s.uid = FbCalMirror.uidOf(identifier); + s.sysId = ev.id ?? -1; + s.fingerprint = fp; + await FbCalMirror.snapPut(ctx, s); + } + + /** 时间戳 → `YYYY-MM-DD HH:MM:SS`(接口要这个格式) */ + private static fmtDateTime(ts: number): string { + const d: Date = new Date(ts); + const p = (n: number): string => n < 10 ? '0' + n : '' + n; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); + } +} + +/** 本地条目引用(回写时要知道"改哪条、在哪个本") */ +class LocalRef { + /** 服务器条目 id */ + id: number = 0; + /** 所属日历本 id —— 用于跨本防护 */ + calId: number = 0; +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbCalMirror.ets b/entry/src/main/ets/data/FbCalMirror.ets new file mode 100644 index 0000000..31f917d --- /dev/null +++ b/entry/src/main/ets/data/FbCalMirror.ets @@ -0,0 +1,1008 @@ +/** + * 岁月家 FamilyBook · 系统日历镜像(出站) + * + * 🔴 2026-11-03 新增,2026-11-04 **改为按日历本分账户**。 + * + * ## 用户 2026-11-04 的指正 + * + * 「镜像到系统日历的日历本怎么没了呢?应该是按照日历本进行镜像的。 + * 现在这个镜像到系统日历里面只有一个"岁月家"的名字。 + * 它后面应该加个点,再跟上日历本的名字,这样多个日历本共存。 + * 关于这一块的工作,你应该完全照搬同步日历呀。」 + * + * **用户说得对。** 我第一版做成单个账户(`岁月家`),把全部日程都塞进去 —— + * 这样系统日历里只有一个本,看不出"这是工作的事还是家里的事", + * 而且**每个本自己的颜色也没了**(SyncCalendar 明确写过: + * "每个本能有自己的颜色,避免多个本挤一个颜色")。 + * + * ## 为什么按本分账户(照 SyncCalendar 的设计理由) + * + * 1. 每个日历本 → 系统日历中一个独立账户(displayName = `岁月家·<本名>`) + * ⇒ 每个本有自己的颜色,用户在系统日历里一眼能分清 + * 2. 用户可以在系统日历里**单独关掉**某个本(比如关掉"工作"只看"家庭") + * 3. `Event.identifier` 存 `|` 作为映射键,用于幂等更新与删除 + * + * ## identifier 的桶(重要设计决定) + * + * SyncCalendar 用 `bookId`(由服务器 href 派生的稳定标识)当桶, + * 因为它的 calKey 含序号、会因本重排而变化(09-19 事故:重复账户 + 串本)。 + * + * FamilyBook 的日历本 id 来自数据库主键(`calendar_books.id`), + * **本身就是稳定的**(不像 calKey 是"账号序号-本序号"这种拼出来的), + * 所以直接用 `book` 当桶,不需要额外的 href 派生层。 + * ⇒ `identifier` = `book|<条目哈希>` + * + * ## 其余照搬 SyncCalendar 的要点 + * + * · **全天日程不逐毫秒比**:系统会把 endTime 归一化成 23:59:59.999 或带时区偏移, + * 一律先折成"日"再比,否则"每轮都更新同一批、永不收敛" + * · **不设 `recurrenceRule`**:数据源已是展开后的具体条目 + * · **批量 50 条,失败退回逐条**:一条脏数据不该拖垮一批 + * · **`getEvents()` 必须无参**:带字段数组会报 `401 type error!` + * · **只删 identifier 命中我们格式的条目**:用户自己往这个账户加的不能被误删 + * · **写完后重新读一遍再落快照**:批量 addEvents 不返回 id,追踪不到 + * · **孤儿账户清理**:本被删了/取消可见了,对应的系统账户要删掉,否则永远堆积 + * · **系统日历服务未就绪(清单为 0)时本轮跳过**:设备重启未解锁时 getAllCalendars 为空 + * + * ## 合规 + * + * `WRITE_CALENDAR` **只能在用户主动点击"开启镜像"开关时申请**,严禁启动时申请。 + */ + +import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; +import { calendarManager } from '@kit.CalendarKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { relationalStore } from '@kit.ArkData'; +import { FbCalBook, FbCalEvent } from '../model/Types'; +import { FbCalStore } from './FbCalStore'; +import { FbCalRepo } from './FbCalRepo'; +import { FB_TOKEN_STORE } from './FbTokenStore'; +import { FbDb, FbRow } from './FbDb'; +import { FbDiag } from '../common/FbDiag'; + +/** 一次镜像执行的统计 */ +export class FbMirrorResult { + /** 处理了几个日历本 */ + books: number = 0; + added: number = 0; + updated: number = 0; + deleted: number = 0; + skipped: number = 0; + failed: number = 0; + /** 清理掉的孤儿账户数 */ + purged: number = 0; + /** 窗口外的条目数(诊断用) */ + winOut: number = 0; +} + +/** 快照行(与 `mirror_snapshot` 表一一对应) */ +export class FbMirrorSnap { + identifier: string = ''; + bucket: string = ''; + uid: string = ''; + sysId: number = -1; + fingerprint: string = ''; +} + +/** + * 一个日历本在系统日历里的映射。 + * + * ⚠️ `bucket` 必须是**稳定标识** —— 它同时决定: + * · 系统日历账户名(`familybook.mirror.book`) + * · 快照的分组键 + * · `identifier` 的前缀(`book|<条目键>`) + * 这三处必须**完全一致**,否则入站对账找不到对应关系。 + */ +export class FbMirrorBook { + /** 日历本 id(服务端 `calendar_books.id`,本身稳定) */ + bookId: number = 0; + /** 系统日历账户 name(只读、开发者定义,必须稳定可复现) */ + accountName: string = ''; + /** 系统日历里显示的名字(`岁月家·工作`) */ + displayName: string = ''; + /** 本名(用于日志) */ + name: string = ''; + /** 本色(写进系统日历,让每个本有自己的颜色) */ + color: string = '#A8574F'; + /** 快照桶 = `book` */ + bucket: string = ''; +} + +export class FbCalMirror { + /* ==================== 常量 ==================== */ + + /** 读+写系统日历。⚠️ 只能在用户点开关时申请(合规要求) */ + private static readonly PERMS: Permissions[] = [ + 'ohos.permission.READ_CALENDAR', + 'ohos.permission.WRITE_CALENDAR' + ]; + + /** + * 镜像账户 name 前缀。 + * + * ⚠️ `calendarManager.createCalendar({ name })` 的 `name` 是**开发者定义且只读**的, + * 后续要靠它找回已有账户 ⇒ 必须稳定、可复现、与用户数据无关。 + * ⇒ `familybook.mirror.book` + */ + private static readonly ACCOUNT_PREFIX: string = 'familybook.mirror.book'; + + /** displayName 的归属标识(用户要求:「岁月家 后面加个点,再跟上日历本的名字」) */ + private static readonly DISPLAY_MARK: string = '岁月家'; + + /** displayName 分隔符(照 SyncCalendar:半角点 `·`,无空格) */ + private static readonly SEP: string = '·'; + + /** displayName 系统上限 64 字符 */ + private static readonly DISPLAY_MAX: number = 64; + + /** 默认本名(本名为空时兜底,避免 displayName 变成"岁月家·") */ + private static readonly FALLBACK_BOOK: string = '日历本'; + + /** 每批写入条数(照 SyncCalendar 取 50:整批失败会退回逐条) */ + private static readonly CHUNK: number = 50; + + private static readonly DAY: number = 86400000; + + /** 快照表 */ + private static readonly SNAP_TABLE: string = 'mirror_snapshot'; + + /* ==================== 权限 ==================== */ + + /** 有没有系统日历读写权限(不弹窗) */ + static async hasPermission(): Promise { + try { + const at = abilityAccessCtrl.createAtManager(); + const info = bundleManager.getBundleInfoForSelfSync( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + const tid: number = info.appInfo.accessTokenId; + for (const p of FbCalMirror.PERMS) { + const st = await at.checkAccessToken(tid, p); + if (st !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { + return false; + } + } + return true; + } catch (err) { + return false; + } + } + + /** + * 申请镜像所需权限。 + * 🔴 **只能在用户主动点击"开启镜像"开关时调用** —— 启动时申请会被审核拒绝。 + */ + static async requestPermission(ctx: common.UIAbilityContext): Promise { + if (await FbCalMirror.hasPermission()) { + return true; + } + try { + const at: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); + /* ⚠️ 不标注返回类型: 不在 + 命名空间下(它在 的 d.ts 里是独立接口), + 写全名反而编译不过。让编译器自行推断。 */ + const res = await at.requestPermissionsFromUser(ctx, FbCalMirror.PERMS); + for (const r of res.authResults) { + if (r !== 0) { + return false; + } + } + return true; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '权限申请失败: ' + e.code + ' ' + e.message); + return false; + } + } + + /* ==================== 窗口 ==================== */ + + /** 窗口起点:上月月初往前 12 个月(对齐到月边界) */ + private static windowStart(): number { + const d: Date = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(1); + d.setMonth(d.getMonth() - 12); + return d.getTime(); + } + + /** 窗口终点:下月月初往后 25 个月 */ + private static windowEnd(): number { + const d: Date = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(1); + d.setMonth(d.getMonth() + 25); + return d.getTime() - 1; + } + + /* ==================== 日历本 → 账户映射 ==================== */ + + /** 日历本 id → 系统日历账户 name */ + static accountNameOf(bookId: number): string { + return FbCalMirror.ACCOUNT_PREFIX + bookId.toString(); + } + + /** 日历本 id → 快照桶 */ + static bucketOf(bookId: number): string { + return 'book' + bookId.toString(); + } + + /** + * 生成系统日历里显示的账户名:`岁月家·<本名>`。 + * + * 🔴 用户原话:「它后面应该加个点,再跟上日历本的名字,这样多个日历本共存。」 + * 点名要用「点」分隔 —— 与 SyncCalendar 的 `同步日历·<本名>` 同一格式。 + * + * ⚠️ 本名为空时用「日历本」兜底,不能拼出"岁月家·"这种悬空的名字。 + * ⚠️ displayName 上限 64 字符:超长截断(保留前面的归属标识,它比本名更重要)。 + */ + static displayNameOf(book: FbCalBook): string { + const raw: string = book.name !== undefined ? book.name.trim() : ''; + const name: string = raw !== '' ? raw : FbCalMirror.FALLBACK_BOOK; + let out: string = FbCalMirror.DISPLAY_MARK + FbCalMirror.SEP + name; + if (out.length > FbCalMirror.DISPLAY_MAX) { + out = out.substring(0, FbCalMirror.DISPLAY_MAX); + } + return out; + } + + /** + * 取当前要镜像的日历本(→ 账户映射)。 + * + * 规则: + * · 跳过**衍生本**(观影/旅行/农历/节气/节日)—— 那不是用户自己的日程, + * 塞进系统日历只会让用户困惑("寒露"出现在系统日历里很奇怪) + * · 只镜像**我可见**的本(用户隐藏了某个本 = 不想看到它) + * · 颜色用本自己的(用户能在系统日历里一眼分清) + */ + static async selectedBooks(ctx: common.Context): Promise { + const out: FbMirrorBook[] = []; + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + FbDiag.log('MIRROR', '未登录,无法取日历本列表'); + return out; + } + + let books: FbCalBook[] = []; + try { + books = await FbCalRepo.inst().fetchBooks(token); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '取日历本失败: ' + e.message); + return out; + } + + for (const b of books) { + if (b.id <= 0) { + continue; + } + if (b.derived) { + continue; // 衍生本不镜像 + } + if (!b.visible) { + continue; // 用户隐藏的本不镜像 + } + const mb: FbMirrorBook = new FbMirrorBook(); + mb.bookId = b.id; + mb.accountName = FbCalMirror.accountNameOf(b.id); + mb.displayName = FbCalMirror.displayNameOf(b); + mb.name = b.name; + mb.color = b.color !== '' ? b.color : '#A8574F'; + mb.bucket = FbCalMirror.bucketOf(b.id); + out.push(mb); + } + return out; + } + + /* ==================== 主入口 ==================== */ + + /** + * 执行一次出站镜像:**每个日历本各同步到一个系统日历账户**。 + * + * @throws 未授权时抛错(调用方据此提示用户) + */ + static async syncNow(ctx: common.Context): Promise { + const res: FbMirrorResult = new FbMirrorResult(); + + if (!await FbCalMirror.hasPermission()) { + throw new Error('未获得系统日历读写权限'); + } + + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx); + + /* ⚠️ 设备「重启后尚未解锁」时日历数据在加密存储里读不到, + `getAllCalendars()` 返回空 ⇒ `createCalendar` 必然失败 + (SyncCalendar 真机实测:连续 12 个账户 `201 CreateCalendar failed`)。 + 正常设备至少有一个本机日历 ⇒ "清单为 0"只可能是服务未就绪 ⇒ 本轮跳过。 */ + let calCount: number = 0; + try { + calCount = (await cm.getAllCalendars()).length; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '读不到系统日历列表,跳过: ' + e.message); + return res; + } + if (calCount === 0) { + FbDiag.log('MIRROR', '系统日历账户清单为 0(服务未就绪,如重启未解锁),本轮跳过'); + return res; + } + + const books: FbMirrorBook[] = await FbCalMirror.selectedBooks(ctx); + FbDiag.log('MIRROR', '待镜像日历本 ' + books.length + ' 个'); + + /* ⭐ 兜底:先清掉"本已不存在/已隐藏"的孤儿账户。 + 否则"删掉一个日历本后系统日历里还留着它"永远不会被自动修掉。 */ + res.purged = await FbCalMirror.purgeOrphans(ctx, cm, books); + + if (books.length === 0) { + FbDiag.log('MIRROR', '没有需要镜像的日历本'); + return res; + } + + // 窗口内全部条目,按 calId 分桶(一次查询,避免每本查一遍) + const winStart: number = FbCalMirror.windowStart(); + const winEnd: number = FbCalMirror.windowEnd(); + const byCal: Map = + await FbCalMirror.groupByCalendar(winStart, winEnd, res); + + for (const mb of books) { + try { + const plan: FbCalEvent[] = byCal.get(mb.bookId) !== undefined + ? (byCal.get(mb.bookId) as FbCalEvent[]) : []; + await FbCalMirror.syncBook(ctx, cm, mb, plan, res); + res.books++; + } catch (err) { + const e = err as BusinessError; + res.failed++; + FbDiag.log('MIRROR', '镜像失败 bookId=' + mb.bookId + '(' + mb.name + '): ' + + e.code + ' ' + e.message); + } + } + + FbDiag.log('MIRROR', '镜像完成:本=' + res.books + + ' 新增=' + res.added + ' 更新=' + res.updated + + ' 删除=' + res.deleted + ' 失败=' + res.failed + + ' 跳过=' + res.skipped + ' 清理孤儿=' + res.purged); + return res; + } + + /** + * 取窗口内条目并按 calId 分组。 + * + * ⚠️ `FbCalStore.between()` 拿到的已经是**展开后的具体条目** + * (服务端 `Calendar::month()` 按区间展开),所以不需要 RRULE 展开 —— + * 这正是比 SyncCalendar 简单的地方。 + */ + private static async groupByCalendar(winStart: number, winEnd: number, + res: FbMirrorResult): Promise> { + const out: Map = new Map(); + const from: string = FbCalMirror.isoOf(winStart); + const to: string = FbCalMirror.isoOf(winEnd); + + let rows: FbCalEvent[] = []; + try { + rows = await FbCalStore.inst().between(from, to); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '取本地条目失败: ' + e.message); + return out; + } + + for (const e of rows) { + // 只要真日程,不要农历/节气/节日这些衍生条目 + if (e.kind !== '' && e.kind !== 'custom') { + res.skipped++; + continue; + } + if (e.calId <= 0) { + res.skipped++; // 没有归属本(纪念日之类)→ 无处可放 + continue; + } + if (e.date === '') { + res.skipped++; + continue; + } + const list: FbCalEvent[] | undefined = out.get(e.calId); + if (list === undefined) { + out.set(e.calId, [e]); + } else { + list.push(e); + } + } + return out; + } + + /* ==================== 单本同步 ==================== */ + + /** + * 把某个日历本的条目同步进它专属的系统日历账户。 + * + * 流程(照 SyncCalendar `syncBook`): + * ① 确保账户存在(没有就建,顺带设色) + * ② 读系统日历现有条目,建 identifier → Event 索引 + * ③ 逐条比对:新增 / 更新(diff 判定) + * ④ 删除:本地已没有、但系统里还有的**我们写进去的**条目 + * ⑤ 重新读一遍,整体重建该本的快照(防回灌地基) + */ + private static async syncBook(ctx: common.Context, cm: calendarManager.CalendarManager, + mb: FbMirrorBook, plan: FbCalEvent[], res: FbMirrorResult): Promise { + const cal: calendarManager.Calendar = + await FbCalMirror.ensureCalendar(cm, mb); + + // 现有镜像条目:identifier → Event + const existing: Map = new Map(); + const sysEvents: calendarManager.Event[] = await FbCalMirror.queryEvents(cal); + for (const ev of sysEvents) { + const id: string = ev.identifier ?? ''; + if (id === '' || ev.id === undefined) { + continue; + } + existing.set(id, ev); + } + + // 期望存在的条目 + const want: Set = new Set(); + const toAdd: calendarManager.Event[] = []; + + for (const e of plan) { + const ident: string = FbCalMirror.identifierOf(mb.bucket, e); + if (ident === '') { + res.skipped++; + continue; + } + want.add(ident); + const desired: calendarManager.Event = FbCalMirror.toSystemEvent(e, ident); + const cur: calendarManager.Event | undefined = existing.get(ident); + if (cur === undefined) { + toAdd.push(desired); + continue; + } + const diff: string = FbCalMirror.diffOf(cur, desired); + if (diff !== '') { + desired.id = cur.id; + try { + await cal.updateEvent(desired); + res.updated++; + } catch (err) { + const er = err as BusinessError; + res.failed++; + FbDiag.log('MIRROR', '更新失败 ' + ident + ': ' + er.message); + } + } + } + + if (toAdd.length > 0) { + await FbCalMirror.addInChunks(cal, toAdd, res); + } + + // 删除:本地已没有、但系统里还在的**我们写进去的**条目 + for (const id of want) { + existing.delete(id); + } + const prefix: string = mb.bucket + '|'; + for (const id of existing.keys()) { + if (!id.startsWith(prefix)) { + continue; // 不是本本写进去的(用户自己在系统日历加的)→ 保留 + } + const ev: calendarManager.Event | undefined = existing.get(id); + if (ev !== undefined && ev.id !== undefined) { + try { + await cal.deleteEvent(ev.id); + res.deleted++; + } catch (err) { + const er = err as BusinessError; + res.failed++; + FbDiag.log('MIRROR', '删除失败 ' + id + ': ' + er.message); + } + } + } + + /* 落快照:写完后**重新读一遍**系统日历再整体重建(照 SyncCalendar)。 + 为什么不用增量:批量 `addEvents` 不返回 id,写入过程中追踪不到。 */ + const after: calendarManager.Event[] = await FbCalMirror.queryEvents(cal); + const n: number = await FbCalMirror.snapReplaceBucket(ctx, mb.bucket, after); + + FbDiag.log('MIRROR', '本[' + mb.name + '] bookId=' + mb.bookId + + ' 计划=' + plan.length + ' 系统已有=' + sysEvents.length + + ' 新增=' + toAdd.length + ' 快照=' + n + + ' 账户=' + mb.displayName); + } + + /** + * 确保账户存在(没有就建)。 + * + * ⚠️ 已有账户时**每次都尝试同步一次颜色**:用户可能改过日历本的颜色。 + * ⚠️ displayName **不在此处改**:改账户名要删账户重建(破坏性), + * 与 SyncCalendar 同一取舍。 + */ + private static async ensureCalendar(cm: calendarManager.CalendarManager, + mb: FbMirrorBook): Promise { + const cals: calendarManager.Calendar[] = await cm.getAllCalendars(); + for (const c of cals) { + if (c.getAccount().name === mb.accountName) { + try { + await c.setConfig({ enableReminder: true, color: mb.color }); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '设色失败 ' + mb.accountName + ': ' + e.message); + } + return c; + } + } + const cal: calendarManager.Calendar = await cm.createCalendar({ + name: mb.accountName, + type: calendarManager.CalendarType.LOCAL, + displayName: mb.displayName + }); + try { + await cal.setConfig({ enableReminder: true, color: mb.color }); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '设色失败(新建) ' + mb.accountName + ': ' + e.message); + } + FbDiag.log('MIRROR', '已创建系统日历账户: ' + mb.accountName + ' → ' + mb.displayName); + return cal; + } + + /* ==================== 孤儿清理 ==================== */ + + /** + * 删除"不再需要"的镜像账户(本已删除 / 已隐藏 / 变成衍生本)。 + * + * 为什么必须有:删掉一个日历本后,之前镜像进系统日历的那条会永远留着, + * 用户看到"岁月家·旧本名"却怎么也找不到对应的本(SyncCalendar 踩过这个坑, + * 其注释:「旧代码在这里 return 之前根本不做清理,这正是本次要修的 bug」)。 + * + * ⚠️ 只删**账户名以我们的前缀开头**的,绝不碰用户自己建的账户。 + */ + private static async purgeOrphans(ctx: common.Context, cm: calendarManager.CalendarManager, + keep: FbMirrorBook[]): Promise { + const valid: Set = new Set(); + for (const mb of keep) { + valid.add(mb.accountName); + } + let removed: number = 0; + try { + const cals: calendarManager.Calendar[] = await cm.getAllCalendars(); + for (const c of cals) { + const acc: calendarManager.CalendarAccount = c.getAccount(); + const name: string = acc.name; + if (!name.startsWith(FbCalMirror.ACCOUNT_PREFIX)) { + continue; // 不是我们的账户 + } + if (valid.has(name)) { + continue; // 这次还要用它 + } + try { + await cm.deleteCalendar(c); + // 顺带清掉它的快照 + const bucket: string = name.substring(FbCalMirror.ACCOUNT_PREFIX.length); + await FbCalMirror.snapClearBucket(ctx, 'book' + bucket); + removed++; + FbDiag.log('MIRROR', '已删除孤儿镜像账户: ' + name); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '删除孤儿账户失败 ' + name + ': ' + e.message); + } + } + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '清理孤儿账户异常: ' + e.message); + } + return removed; + } + + /* ==================== 读写 ==================== */ + + /** + * 取回该账户下已有日程。 + * + * ⚠️ **必须用无参 `getEvents()`**。 + * SyncCalendar 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` + * 必抛 `401 type error!`,且调用方会白费一次往返。 + * 无参调用返回的 `Event` 里 `identifier` 同样有值。 + */ + private static async queryEvents(cal: calendarManager.Calendar): Promise { + try { + return await cal.getEvents(); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', 'getEvents() 失败: ' + e.message + '(本轮视为无已有条目)'); + return []; + } + } + + /** 批量新增(每批 CHUNK 条);整批失败退回逐条,避免一条脏数据拖垮一批 */ + private static async addInChunks(cal: calendarManager.Calendar, + list: calendarManager.Event[], res: FbMirrorResult): Promise { + for (let i: number = 0; i < list.length; i += FbCalMirror.CHUNK) { + const end: number = Math.min(i + FbCalMirror.CHUNK, list.length); + const part: calendarManager.Event[] = list.slice(i, end); + try { + await cal.addEvents(part); + res.added += part.length; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '批量新增失败(退回逐条): ' + e.message); + for (const ev of part) { + try { + await cal.addEvent(ev); + res.added++; + } catch (e2) { + res.failed++; + const eb = e2 as BusinessError; + FbDiag.log('MIRROR', '新增失败: ' + eb.message); + } + } + } + } + } + + /* ==================== 转换 ==================== */ + + /** + * 日程 → identifier(`|<条目稳定键>`)。 + * + * ⚠️ 键按 `id + 日期 + 时间 + 标题` 算 FNV-1a 哈希: + * · 不能用 `calId`(桶已经表达了本) + * · **必须含 `id`**:同一天同一个本里可能有两件同名的事, + * 只用 日期+标题 会撞成一条(另一条显示不出来) + */ + static identifierOf(bucket: string, e: FbCalEvent): string { + const src: string = e.id.toString() + '|' + e.date + '|' + e.time + '|' + e.title; + let h: number = 0x811c9dc5; + for (let i: number = 0; i < src.length; i++) { + h = h ^ src.charCodeAt(i); + h = (h * 0x01000193) >>> 0; + } + return bucket + '|' + e.date + '-' + h.toString(16); + } + + /** 日程 → 系统日历 Event */ + private static toSystemEvent(e: FbCalEvent, ident: string): calendarManager.Event { + const start: number = FbCalMirror.msOf(e.date, e.time); + let end: number = 0; + if (e.endTime !== '') { + end = FbCalMirror.msOf(e.date, e.endTime); + if (end <= start) { + end = start + 3600000; // 结束早于开始(跨天或脏数据)→ 按 1 小时 + } + } else if (e.allDay) { + end = start + FbCalMirror.DAY; + } else { + end = start + 3600000; + } + + const ev: calendarManager.Event = { + type: calendarManager.EventType.NORMAL, + title: e.title !== '' ? e.title : '(无标题)', + startTime: start, + /* ⚠️ 全天日程:Calendar Kit 的 endTime 取"当天 24:00"(即次日 00:00)。 */ + endTime: e.allDay ? FbCalMirror.dayStart(end) + FbCalMirror.DAY : end, + isAllDay: e.allDay, + identifier: ident + }; + if (e.note !== '') { + ev.description = e.note; + } + if (e.place !== '') { + ev.location = { location: e.place }; + } + /* ⚠️ 故意不设 `recurrenceRule`: + 数据源已经是展开后的具体条目,再交给系统展开就是对每条实例再加一遍规则 + ⇒ 幽灵日程(SyncCalendar 09-19 事故)。 */ + return ev; + } + + /** + * 比对现有条目与期望条目:**返回空串表示相同**,否则返回首个差异(诊断"每次都更新")。 + * + * ⚠️ 全天日程**不能逐毫秒比**。实测系统对全天 endTime 有三种写法: + * · 我们写入:次日 00:00 + * · 读回 A:当日 23:59:59.999 + * · 读回 B:次日 00:00 + 时区偏移(如 08:00) + * ⇒ 全天一律先折成"日"再比(照 SyncCalendar `diffOf`)。 + */ + private static diffOf(cur: calendarManager.Event, want: calendarManager.Event): string { + if ((cur.title ?? '') !== (want.title ?? '')) { + return 'title'; + } + const curAllDay: boolean = cur.isAllDay === true; + const wantAllDay: boolean = want.isAllDay === true; + if (curAllDay !== wantAllDay) { + return 'isAllDay'; + } + if (curAllDay) { + if (FbCalMirror.dayStart(cur.startTime) !== FbCalMirror.dayStart(want.startTime)) { + return 'allDay start'; + } + const wantEndDay: number = FbCalMirror.dayStart(want.endTime - 1); + const curA: number = FbCalMirror.dayStart(cur.endTime - 1); // 写法 A + const curB: number = FbCalMirror.dayStart(cur.endTime) - FbCalMirror.DAY; // 写法 B + if (curA !== wantEndDay && curB !== wantEndDay) { + return 'allDay end'; + } + } else { + if (cur.startTime !== want.startTime) { + return 'start'; + } + if (cur.endTime !== want.endTime) { + return 'end'; + } + } + if ((cur.description ?? '') !== (want.description ?? '')) { + return 'description'; + } + if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) { + return 'location'; + } + return ''; + } + + /* ==================== 清空 / 统计 ==================== */ + + /** 清空镜像:删除**全部**镜像账户 + 清空快照(关闭开关时调用) */ + static async removeAll(ctx: common.Context): Promise { + if (!await FbCalMirror.hasPermission()) { + return 0; + } + let n: number = 0; + try { + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx); + const cals: calendarManager.Calendar[] = await cm.getAllCalendars(); + for (const c of cals) { + const name: string = c.getAccount().name; + if (!name.startsWith(FbCalMirror.ACCOUNT_PREFIX)) { + continue; + } + try { + await cm.deleteCalendar(c); + n++; + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '删除账户失败 ' + name + ': ' + e.message); + } + } + await FbCalMirror.snapClearAll(ctx); + FbDiag.log('MIRROR', '已清除 ' + n + ' 个镜像账户'); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '清除镜像失败: ' + e.message); + } + return n; + } + + /** 统计:镜像账户数 + 总条目数(设置页显示用) */ + static async mirroredStats(ctx: common.Context): Promise { + const out: MirrorStats = new MirrorStats(); + if (!await FbCalMirror.hasPermission()) { + return out; + } + try { + const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx); + const cals: calendarManager.Calendar[] = await cm.getAllCalendars(); + for (const c of cals) { + if (!c.getAccount().name.startsWith(FbCalMirror.ACCOUNT_PREFIX)) { + continue; + } + out.books++; + const evs: calendarManager.Event[] = await FbCalMirror.queryEvents(c); + out.events += evs.length; + } + } catch (err) { + // 读不到就当 0 + } + return out; + } + + /* ==================== 快照读写 ==================== */ + + /** 整体重建某个桶的快照(先删该桶再写) */ + static async snapReplaceBucket(ctx: common.Context, bucket: string, + events: calendarManager.Event[]): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return 0; + } + let n: number = 0; + try { + await db.exec('DELETE FROM ' + FbCalMirror.SNAP_TABLE + ' WHERE bucket = ?', [bucket]); + const now: number = Date.now(); + for (const ev of events) { + const id: string = ev.identifier ?? ''; + if (id === '') { + continue; // 用户在系统日历自己加的(无我们 identifier)→ 不进快照 + } + await db.exec( + 'INSERT OR REPLACE INTO ' + FbCalMirror.SNAP_TABLE + + ' (identifier, bucket, uid, sys_id, fingerprint, updated_at) VALUES (?,?,?,?,?,?)', + [id, bucket, FbCalMirror.uidOf(id), ev.id ?? -1, + FbCalMirror.fingerprintOf(ev), now]); + n++; + } + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '重建快照失败 ' + bucket + ': ' + e.message); + } + return n; + } + + /** 读全部快照(入站对账用;key = identifier) */ + static async snapLoad(ctx: common.Context): Promise> { + const out: Map = new Map(); + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return out; + } + try { + const rows: FbRow[] = await db.queryRows( + 'SELECT identifier, bucket, uid, sys_id, fingerprint FROM ' + FbCalMirror.SNAP_TABLE); + for (const r of rows) { + const s: FbMirrorSnap = new FbMirrorSnap(); + s.identifier = r.strOr('identifier', ''); + s.bucket = r.strOr('bucket', ''); + s.uid = r.strOr('uid', ''); + s.sysId = r.num('sys_id', -1); + s.fingerprint = r.strOr('fingerprint', ''); + out.set(s.identifier, s); + } + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '读快照失败: ' + e.message); + } + return out; + } + + /** 写/更新单条快照(入站回写成功后同步指纹) */ + static async snapPut(ctx: common.Context, s: FbMirrorSnap): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return; + } + try { + await db.exec( + 'INSERT OR REPLACE INTO ' + FbCalMirror.SNAP_TABLE + + ' (identifier, bucket, uid, sys_id, fingerprint, updated_at) VALUES (?,?,?,?,?,?)', + [s.identifier, s.bucket, s.uid, s.sysId, s.fingerprint, Date.now()]); + } catch (err) { + const e = err as BusinessError; + FbDiag.log('MIRROR', '写快照失败 ' + s.identifier + ': ' + e.message); + } + } + + /** 删单条快照 */ + static async snapRemove(ctx: common.Context, identifier: string): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return; + } + try { + await db.exec('DELETE FROM ' + FbCalMirror.SNAP_TABLE + ' WHERE identifier = ?', + [identifier]); + } catch (err) { + // 忽略 + } + } + + /** 清空某个桶的快照 */ + static async snapClearBucket(ctx: common.Context, bucket: string): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return; + } + try { + await db.exec('DELETE FROM ' + FbCalMirror.SNAP_TABLE + ' WHERE bucket = ?', [bucket]); + } catch (err) { + // 忽略 + } + } + + /** 清空全部快照 */ + static async snapClearAll(ctx: common.Context): Promise { + const db: FbDb = FbDb.inst(); + if (!db.isReady()) { + return; + } + try { + await db.exec('DELETE FROM ' + FbCalMirror.SNAP_TABLE); + } catch (err) { + // 忽略 + } + } + + /* ==================== 指纹 ==================== */ + + /** + * 事件内容指纹,判断"系统日历里这条,是不是还是我们上次写进去的样子"。 + * + * ⚠️ 全天日程的 endTime 会被系统归一化(我们写次日 00:00,可能读回 23:59:59.999), + * 所以全天一律折成"日"再算,否则每次都判定成"用户改了"。 + */ + static fingerprintOf(ev: calendarManager.Event): string { + const allDay: boolean = ev.isAllDay === true; + let start: number = ev.startTime; + let end: number = ev.endTime; + if (allDay) { + start = FbCalMirror.dayStart(start); + end = FbCalMirror.dayStart(end - 1); + } + const raw: string = [ + ev.title ?? '', + '' + start, + '' + end, + allDay ? '1' : '0', + ev.location?.location ?? '', + ev.description ?? '' + ].join('\u0001'); + return FbCalMirror.hash(raw, 0) + ':' + FbCalMirror.hash(raw, 5381); + } + + /** identifier (`|`) → uid */ + static uidOf(identifier: string): string { + const p: number = identifier.indexOf('|'); + return p < 0 ? '' : identifier.substring(p + 1); + } + + /** identifier (`|`) → bucket */ + static bucketOfIdentifier(identifier: string): string { + const p: number = identifier.indexOf('|'); + return p < 0 ? '' : identifier.substring(0, p); + } + + /* ==================== 工具 ==================== */ + + private static dayStart(ts: number): number { + const d: Date = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); + } + + /** `YYYY-MM-DD` + `HH:mm` → 时间戳(`hhmm` 为空时按当天 00:00) */ + private static msOf(iso: string, hhmm: string): number { + const y: number = parseInt(iso.substring(0, 4), 10); + const mo: number = parseInt(iso.substring(5, 7), 10) - 1; + const d: number = parseInt(iso.substring(8, 10), 10); + let h: number = 0; + let mi: number = 0; + if (hhmm !== '' && hhmm.length >= 5) { + h = parseInt(hhmm.substring(0, 2), 10); + mi = parseInt(hhmm.substring(3, 5), 10); + } + if (isNaN(y) || isNaN(mo) || isNaN(d)) { + return Date.now(); + } + return new Date(y, mo, d, isNaN(h) ? 0 : h, isNaN(mi) ? 0 : mi, 0, 0).getTime(); + } + + private static isoOf(ts: number): string { + const d: Date = new Date(ts); + const p = (n: number): string => n < 10 ? '0' + n : '' + n; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); + } + + /** 简单字符串哈希(djb2 变体) */ + private static hash(s: string, seed: number): string { + let h: number = seed; + for (let i: number = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); + } +} + +/** 镜像统计(设置页显示:几个本、多少条) */ +export class MirrorStats { + books: number = 0; + events: number = 0; +} + +/** + * 取本应用的 accessTokenId。 + * + * ⚠️ 不用 + 下标访问:ArkTS 的 会报 + * Use explicit types instead of any/unknown。 + * 的返回类型本来就有 , + * 直接走类型系统即可。 + */ +function appInfoTokenId(info: bundleManager.BundleInfo): number { + return info.appInfo.accessTokenId; +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbCalSettings.ets b/entry/src/main/ets/data/FbCalSettings.ets index f1e8e9b..4947a3d 100644 --- a/entry/src/main/ets/data/FbCalSettings.ets +++ b/entry/src/main/ets/data/FbCalSettings.ets @@ -34,6 +34,17 @@ const K_VIEW: string = 'default_view'; // month | week | agenda const K_STYLE: string = 'display_style'; // timeline | list const K_BOOK: string = 'default_book_id'; // 新建日程默认落到哪本 const K_HIDDEN: string = 'hidden_books'; // 逗号分隔的日历本 id +/** + * 镜像到系统日历的开关。 + * + * 🔴 2026-11-03 新增。用户原话:「新增一个功能,把日历镜像到系统日历中」。 + * 原因(SyncCalendar 文件头):本应用是 CalDAV 客户端,日程不进系统日历, + * 导致小艺 / 桌面日历卡片 / 手表看到的是空的。 + * + * ⚠️ 这里只存"开关状态"。真正的镜像逻辑在 `data/FbCalMirror.ets`, + * 这个开关决定要不要跑它。 + */ +const K_MIRROR: string = 'mirror_to_system'; /** 视图:月 / 周 / 列表(与 SyncCalendar 的 VIEW_ORDER 同一套) */ export type FbCalView = 'month' | 'week' | 'agenda'; @@ -45,6 +56,8 @@ class FbCalSettingsImpl { private style: FbCalStyle = 'timeline'; private bookId: number = 0; private hidden: string = ''; + /** 是否镜像到系统日历 */ + private mirror: boolean = false; private store: preferences.Preferences | null = null; private ready: boolean = false; @@ -59,10 +72,12 @@ class FbCalSettingsImpl { const s: preferences.ValueType = await this.store.get(K_STYLE, 'timeline'); const b: preferences.ValueType = await this.store.get(K_BOOK, 0); const h: preferences.ValueType = await this.store.get(K_HIDDEN, ''); + const mir: preferences.ValueType = await this.store.get(K_MIRROR, false); this.view = (v === 'week' || v === 'agenda') ? v : 'month'; this.style = (s === 'list') ? 'list' : 'timeline'; this.bookId = typeof b === 'number' ? b : 0; this.hidden = typeof h === 'string' ? h : ''; + this.mirror = mir === true; this.ready = true; hilog.info(DOMAIN, TAG, '日程设置预加载完成:%{public}s / %{public}s', this.view, this.style); } catch (err) { @@ -109,6 +124,17 @@ class FbCalSettingsImpl { return this.hiddenIds().indexOf(id) < 0; } + /** + * 是否镜像到系统日历。 + * + * ⚠️ 这个值**只是开关**,不代表"已经镜像成功" —— + * 权限被撤销 / 系统日历服务未就绪时,开关是开的但实际没镜像。 + * 界面上要同时显示"镜像了多少条"来让用户判断真实状态。 + */ + getMirror(): boolean { + return this.mirror; + } + // ---- 异步写(同时更新内存) ---- async setView(v: FbCalView): Promise { this.view = v; @@ -120,6 +146,12 @@ class FbCalSettingsImpl { await this.put(K_STYLE, s); } + /** 写镜像开关 */ + async setMirror(on: boolean): Promise { + this.mirror = on; + await this.put(K_MIRROR, on); + } + async setBookId(id: number): Promise { this.bookId = id; await this.put(K_BOOK, id); diff --git a/entry/src/main/ets/data/FbCardMediaData.ets b/entry/src/main/ets/data/FbCardMediaData.ets index f7befae..0b79b70 100644 --- a/entry/src/main/ets/data/FbCardMediaData.ets +++ b/entry/src/main/ets/data/FbCardMediaData.ets @@ -428,8 +428,16 @@ export class FbTaskCardRow { dueText: string = ''; /** `todo` / `doing`(done 的不进卡片) */ status: string = ''; - /** 看板主题色(左侧色条) */ + /** 看板主题色(左侧色条 / 勾选圈颜色) */ boardColor: string = '#A8574F'; + /** + * 看板名。 + * + * 2026-11-04:SyncCalendar 卡片条目的副行是「截止时间 · 日历本」, + * 我们的对应物就是「截止时间 · 看板名」。加了它条目信息才完整 —— + * 只显示"18:00"看不出是哪个看板的事。 + */ + boardName: string = ''; overdue: boolean = false; } @@ -442,6 +450,21 @@ export class FbTaskCardData { openCount: number = 0; /** 其中逾期数(标题行给红色提示) */ overdueCount: number = 0; + /* ---------- 2026-11-04:照 SyncCalendar 的 TodoWidget* 卡片补的字段 ---------- */ + /** 日期文字(`11月4日`)—— 四个卡片的头部 */ + dateMd: string = ''; + /** 星期(`星期三`)—— 头部副行 */ + weekday: string = ''; + /** + * 今日到期数。 + * + * ⚠️ 与 `overdueCount` 是**两个不同概念**: + * `overdueCount` = 已经晚了(该道歉) + * `todayCount` = 今天还来得及(该行动) + * SyncCalendar 的卡片把"今日到期"放主位、用橙色,就是因为它更能驱动行动。 + */ + todayCount: number = 0; + /** 逾期优先展示的几条(逾期 > 进行中 > 普通,同组按截止时间升序),最多 4 条 */ rowsJson: string = '[]'; /** 🔴 v91:4×4/6×4 的滚动列表(同一排序,最多 30 条)—— 2×2/2×4 的卡不吃这个字段 */ @@ -458,6 +481,18 @@ function taskRank(t: FbTaskItem): number { } /** 一条任务 → 卡片行 */ +/** 星期中文(`Date.getDay()` 索引 0=周日) */ +const WEEKDAY_CN: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; + +/** 卡片用的日期小工具 */ +class FbCardDate { + /** 本地时区的 `YYYY-MM-DD`(**不要用 toISOString()**:那是 UTC,晚上会差一天) */ + static isoOf(d: Date): string { + const p = (n: number): string => n < 10 ? '0' + n : '' + n; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); + } +} + function taskRowOf(t: FbTaskItem): FbTaskCardRow { const r: FbTaskCardRow = new FbTaskCardRow(); r.id = t.id; @@ -466,6 +501,7 @@ function taskRowOf(t: FbTaskItem): FbTaskCardRow { r.dueText = t.dueText; r.status = t.status; r.boardColor = t.boardColor !== '' ? t.boardColor : '#A8574F'; + r.boardName = t.board !== undefined ? t.board : ''; r.overdue = t.overdue; return r; } @@ -548,6 +584,25 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise= 10 && t.dueAt.substring(0, 10) === todayIso + && !t.overdue) { + // 已逾期的今天任务不算"今日到期" —— 它归 overdueCount,否则两个数字会重复计 + todayCount++; + } + } + out.todayCount = todayCount; + /* 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的」 —— 2×4 的右半改成可滚动后,条数不能只给 4 条(否则 List 滑两下就到底)。 ⇒ 与 4×4/6×4 的 `all` 合并成同一份(同一个排序、同一个上限 30)。 diff --git a/entry/src/main/ets/data/FbDb.ets b/entry/src/main/ets/data/FbDb.ets index 5bdc54d..f7438f8 100644 --- a/entry/src/main/ets/data/FbDb.ets +++ b/entry/src/main/ets/data/FbDb.ets @@ -401,6 +401,33 @@ export class FbDb { } } + + // ---- v8 → v9:日历镜像快照表 ---- + // 🔴 2026-11-03:新增「镜像到系统日历」功能,这张表记录"上次写了什么", + // 用于区分"我们写的"和"用户在系统日历里改的"(防回灌死循环)。 + // ⚠️ 建表语句必须与 FbSchema 里**完全一致** —— 只改一处就是 + // "新装有、老库没有"或反之。 + if (from < 9) { + const mirDdl: string[] = [ + `CREATE TABLE IF NOT EXISTS mirror_snapshot ( + identifier TEXT PRIMARY KEY, + bucket TEXT NOT NULL DEFAULT '', + uid TEXT NOT NULL DEFAULT '', + sys_id INTEGER NOT NULL DEFAULT -1, + fingerprint TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`, + ]; + for (const sql of mirDdl) { + try { + await store.executeSql(sql); + } catch (err) { + hilog.warn(DOMAIN, TAG, '建 mirror_snapshot 跳过:%{public}s', JSON.stringify(err)); + } + } + } + store.version = FB_DB_VERSION; } diff --git a/entry/src/main/ets/data/FbGeoRepo.ets b/entry/src/main/ets/data/FbGeoRepo.ets index 3a39b49..523c852 100644 --- a/entry/src/main/ets/data/FbGeoRepo.ets +++ b/entry/src/main/ets/data/FbGeoRepo.ets @@ -42,6 +42,9 @@ import { site } from '@kit.MapKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FbGeoTip } from '../model/Types'; +import { FbDiag } from '../common/FbDiag'; +import { FbApiResult, fbApi } from './FbHttp'; +import { FB_TOKEN_STORE } from './FbTokenStore'; const DOMAIN: number = 0x0000; const TAG: string = 'FbGeoRepo'; @@ -71,17 +74,20 @@ interface FbLatLng { * ⚠️ 不需要 token:Map Kit 是本机 HMS Core 能力, * 鉴权走 AGC 应用认证(错误码 1002600003),与我们的 API 令牌无关。 */ -export async function fbGeoTips(kw: string, limit: number = 8): Promise { +async function fbGeoViaMapKit(kw: string, limit: number = 8): Promise { const q: string = kw.trim(); /* 本地先挡一道:省一次往返,也让"字太少"的抖动更小 */ if (q.length < FB_GEO_MIN_LEN) { return []; } + FbDiag.log('GEO', '=== 联想请求 === kw=[' + q + '] len=' + q.length); + try { const params: site.QueryAutoCompleteParams = { query: q }; const res: site.QueryAutoCompleteResult = await site.queryAutoComplete(params); const sites: site.Site[] | undefined = res.sites; + FbDiag.log('GEO', '返回 sites=' + (sites === undefined ? 'undefined' : String(sites.length))); if (sites === undefined || sites.length === 0) { /* ⚠️ 查不到**不是错误**:用户打了半截字本来就可能没结果。 直接给空,让面板收起,别弹提示。 */ @@ -112,6 +118,8 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise { + const token: string = FB_TOKEN_STORE.get(); + FbDiag.log('GEO', '服务端: token长度=' + token.length); + if (token === '') { + FbDiag.log('GEO', '未登录,跳过服务端联想'); + return []; + } + + try { + const res: FbApiResult = await fbApi('/api/v1/geo/suggest', { + method: 'GET', + token: token, + query: { 'q': q, 'limit': String(limit) }, + timeoutMs: 6000 + }); + FbDiag.log('GEO', '服务端响应 ok=' + res.ok + ' status=' + res.status + + ' dataLen=' + res.data.length + ' code=' + res.code + ' err=' + res.error); + if (!res.ok) { + return []; + } + + /* 响应形状:{ ok: true, data: { tips: [ {...}, ... ] } } + ⚠️ `res.data` 是**原始 JSON 字符串**(FbHttp 的约定),要自己解析。 */ + FbDiag.log('GEO', '原始响应前80字: ' + res.data.substring(0, Math.min(80, res.data.length))); + + /* 🔴 2026-11-03 修:**`res.data` 已经是 data 层本身**。 + `FbHttp.fbApi()` 会把 `{ok, data}` 信封拆掉再序列化回来, + 所以这里拿到的是 `{"tips":[...]}`,**没有** 外层 `data`。 + 原代码找 `row['data']` ⇒ 恒 undefined ⇒ 提前返回空(真机日志已验证)。 + + ⇒ 直接从顶层取 `tips`。 */ + const parsed: Object = JSON.parse(res.data) as Object; + const dataRec: Record = parsed as Record; + const tipsVal: Object | undefined = dataRec['tips'] as Object | undefined; + FbDiag.log('GEO', 'tips 类型=' + typeof tipsVal + + ' isArray=' + Array.isArray(tipsVal)); + if (tipsVal === undefined) { + FbDiag.log('GEO', 'tips 为 undefined'); + return []; + } + + // ArkTS 不允许裸 any,逐条按结构读 + const arr: Object[] = tipsVal as Object[]; + FbDiag.log('GEO', 'tips 数组长度=' + arr.length); + const out: FbGeoTip[] = []; + for (const it of arr) { + const r: Record = it as Record; + const tip: FbGeoTip = { + name: strOf(r['name']), + detail: strOf(r['detail']), + district: strOf(r['district']), + address: strOf(r['address']), + lng: strOf(r['lng']), + lat: strOf(r['lat']) + }; + if (tip.name === '' && tip.detail === '') { + continue; + } + out.push(tip); + } + return out; + } catch (err) { + FbDiag.log('GEO', '服务端异常: ' + JSON.stringify(err)); + return []; + } +} + +/** 从 Record 里安全取字符串(ArkTS 没有 any,必须显式判类型) */ +function strOf(v: Object | undefined): string { + if (v === undefined || v === null) { + return ''; + } + if (typeof v === 'string') { + return v as string; + } + return ''; +} + /** 数字转字符串(Map Kit 给 number;对外统一成字符串,与网页端一致) */ function numberToStr(v: number): string { if (typeof v !== 'number' || isNaN(v)) { diff --git a/entry/src/main/ets/data/FbSchema.ets b/entry/src/main/ets/data/FbSchema.ets index 7e78a33..fd7e390 100644 --- a/entry/src/main/ets/data/FbSchema.ets +++ b/entry/src/main/ets/data/FbSchema.ets @@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db'; * 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** —— * 那是拿用户的手机当试验田。必须按版本逐步迁移。 */ -export const FB_DB_VERSION: number = 8; +export const FB_DB_VERSION: number = 9; /** * 建表语句。 @@ -355,6 +355,30 @@ export const FB_SCHEMA_SQL: string[] = [ `CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`, // 同步时按 uid 去重/更新 `CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`, + + // ---------- 日历镜像快照(v9 新增)---------- + // + // 用户 2026-11-03:「把日历镜像到系统日历中,原因同步日历都已经说过了」。 + // 原因:本应用是 CalDAV 客户端,日程不进系统日历, + // 导致小艺 / 桌面日历卡片 / 手表看到的是空的 + // (甚至给出"今天没有日程"的错误答案)。 + // + // 🔴 这张表是**防回灌的地基**:镜像会把日程写进系统日历; + // 下一轮若直接读系统日历当"用户改动",就会把我们刚写的当成用户改的 + // → 回写服务器 → 服务器变了 → 又镜像回去 …… 死循环。 + // 记下"上次写了什么",一轮三方比对就能分清谁改的。 + // + // ⚠️ `bucket` 在 FamilyBook 里恒为 `family`(只有一套日程, + // 不像 SyncCalendar 有多个 DAV 账户)。留这一列是为了将来按日历本拆。 + `CREATE TABLE IF NOT EXISTS mirror_snapshot ( + identifier TEXT PRIMARY KEY COMMENT '|[@<发生时刻>]', + bucket TEXT NOT NULL DEFAULT '', + uid TEXT NOT NULL DEFAULT '', + sys_id INTEGER NOT NULL DEFAULT -1, + fingerprint TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`, ]; /** 建表语句分组说明(供日志/排查用) */ @@ -383,6 +407,10 @@ export const FB_TABLE_GROUPS: FbTableGroup[] = [ group: '日程', tables: ['cal_events'] }, + { + group: '系统日历镜像', + tables: ['mirror_snapshot'] + }, { group: '同步元数据(本地专有)', tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache'] diff --git a/entry/src/main/ets/data/FbTripRepo.ets b/entry/src/main/ets/data/FbTripRepo.ets index cdab9de..1c4b583 100644 --- a/entry/src/main/ets/data/FbTripRepo.ets +++ b/entry/src/main/ets/data/FbTripRepo.ets @@ -1,3 +1,4 @@ +import { FbDiag } from '../common/FbDiag'; /** * 家书 FamilyBook · 旅程的**写操作**(鸿蒙端) * @@ -23,7 +24,7 @@ */ import { fbApi, FbApiResult, FbApiOptions } from './FbHttp'; -import { fbUploadOne } from './FbUpload'; +import { fbUploadOne, fbUploadStage, FbStageUpload } from './FbUpload'; import { FbTripDetail, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../model/Types'; /** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */ @@ -65,6 +66,55 @@ export interface FbTripNodeDraft { /** 'plan' 计划 / 'actual' 实际 */ category: string; detail: string; + + /* ---------- 2026-11-05:对齐网页端表单补的字段 ---------- */ + + /** + * 出发地地名(网页端 `from_place`)。 + * + * 🔴 用户:「出发地、目的地要分开。现在地点这两个都放在一起,能对吗?」 + * 原来端上只有一个 `place`,出发地和到达地填不了。 + */ + fromPlace: string; + /** 到达地地名(网页端 `to_place`) */ + toPlace: string; + /** + * 出发地详细地址(网页端 `from_addr`)。 + * ⚠️ 端上没有独立的"详细地址"输入,暂用同名字段兜底 + * (网页端是「地名 + 高德详细地址」两份)。 + */ + fromAddr: string; + /** 到达地详细地址 */ + toAddr: string; + /* #51:坐标(高德返回;空串 = 纯手打没选候选,不传) */ + fromLng: string; + fromLat: string; + toLng: string; + toLat: string; + /** + * 执行状态:`planned` / `ongoing` / `done` / `cancelled`。 + * + * 🔴 #47(2026-11-05 用户要求): + * **表单不参与状态管理**。新建固定发 `'planned'`; + * 编辑**发空串** ⇒ `updateTripNode()` 不把 status 放进 body + * ⇒ 服务端沿用库里原值(用户通过卡片动作改的状态不会被编辑冲掉)。 + */ + status: string; + /** + * 参与人 id 数组(网页端 `members[]`)。 + * ⚠️ 空数组 = 清空参与人;与"没传"不同 —— 服务端 `nodeData` 用 `$has()` 判定。 + */ + members: number[]; + /** + * 暂存文件令牌(先上传、保存时一起提交的照片)。 + * + * 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数 + * 一起提交,后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」 + * + * 服务端拿到它:`Trip::saveNode()` 先建节点 → `StagedUpload::claim()` 挂图。 + * ⚠️ 空数组 = 这次没有新照片。 + */ + stageTokens: string[]; } /** @@ -197,6 +247,20 @@ export function parseNodes(json: string): FbTripNode[] { nodeType: strOf(o, 'node_type'), detail: strOf(o, 'detail'), fromPlace: strOf(o, 'from_place'), + /* #51:详细地址与坐标(服务端 SELECT * 已返回,原来端上没映射) */ + fromAddr: strOf(o, 'from_addr'), + toAddr: strOf(o, 'to_addr'), + fromLng: strOf(o, 'from_lng'), + fromLat: strOf(o, 'from_lat'), + toLng: strOf(o, 'to_lng'), + toLat: strOf(o, 'to_lat'), + /* 🔴 2026-11-05:原始时间值(服务端 SELECT * 已经返回,原来端上丢了)。 + 用于编辑回填 —— `when` 不带年份,反推会跨年错位。 */ + startsAt: strOf(o, 'starts_at'), + /* #48:真实开始/结束时间(服务端 SELECT * 已返回,原来端上没解析) */ + actualStartedAt: strOf(o, 'actual_started_at'), + actualEndedAt: strOf(o, 'actual_ended_at'), + endsAt: strOf(o, 'ends_at'), toPlace: strOf(o, 'to_place'), lineName: strOf(o, 'line_name'), carrier: strOf(o, 'carrier'), @@ -304,10 +368,241 @@ export async function addTripNode(token: string, tripId: number, d: FbTripNodeDr if (d.vehicleId > 0) { body['vehicle_id'] = d.vehicleId; } + /* 2026-11-05:出发地 / 到达地 / 执行状态 / 参与人(对齐网页端) */ + body['from_place'] = d.fromPlace; + body['to_place'] = d.toPlace; + body['from_addr'] = d.fromAddr; + body['to_addr'] = d.toAddr; + /* #51:坐标(空串不传 —— 服务端 coordOrNull 会写 NULL,等于没坐标) */ + if (d.fromLng !== '' && d.fromLat !== '') { + body['from_lng'] = d.fromLng; + body['from_lat'] = d.fromLat; + } + if (d.toLng !== '' && d.toLat !== '') { + body['to_lng'] = d.toLng; + body['to_lat'] = d.toLat; + } + /* 🔴 #47:**编辑时不传 status**(`d.status === ''`)。 + 用户点了「开始执行」把状态改成 ongoing,再回头编辑标题 —— + 如果这里传 'planned',状态就被打回计划中。 + 服务端 `nodeData()` 用 `array_key_exists` 判定"这次传没传", + 不发这个键 ⇒ 沿用库里原值。status 为空表示"表单不参与状态管理"。 */ + if (d.status !== '') { + body['status'] = d.status; + } + if (d.members.length > 0) { + body['members'] = d.members; + } const opts: FbApiOptions = { method: 'POST', token: token, body: body }; return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts); } +/** + * 给一段行程传附件(照片/视频)。 + * + * 🔴 2026-11-05 新增。用户:「它不能这样传图片吗?网页端有什么功能, + * 这个就应该有什么功能,应该一模一样啊。」 + * + * 落点与网页端**完全相同**:`attachments` 表 + * `target_type = 'trip'`, `target_id = nodeId` + * 服务端路由:`POST /api/v1/trips//nodes/attach?node_id=` + * + * @returns 空串 = 成功;否则是错误说明 + */ +export async function addNodeAttach(token: string, tripId: number, nodeId: number, + uri: string): Promise { + const path: string = '/api/v1/trips/' + tripId.toString() + + '/nodes/attach?node_id=' + nodeId.toString(); + return await fbUploadOne(path, 'file', uri, token); +} + +/** + * 删一张附件。 + * + * 服务端路由:`DELETE /api/v1/trips//nodes/attach?node_id=&attachment_id=` + * @returns 空串 = 成功;否则是错误说明 + */ +export async function deleteNodeAttach(token: string, tripId: number, nodeId: number, + attachId: number): Promise { + const query: Record = {}; + query['node_id'] = nodeId.toString(); + query['attachment_id'] = attachId.toString(); + const opts: FbApiOptions = { method: 'DELETE', token: token, query: query }; + const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/attach', + opts); + return res.ok ? '' : (res.error !== '' ? res.error : '删除失败'); +} + +/** + * 改一段行程的执行状态。 + * + * 🔴 #47(2026-11-05):用户要求状态由**动作**驱动,不再由编辑表单选: + * planned(新建固定)→ ongoing(开始执行)→ done(已完成)/ cancelled(已取消) + * + * 服务端:`PATCH /api/v1/trips//nodes/status`,body `{node_id, status}` + * ⚠️ 节点 id 走 body —— dispatch 只解析三段。 + * + * @param status `planned` / `ongoing` / `done` / `cancelled` + */ +export async function setNodeStatus(token: string, tripId: number, nodeId: number, + status: string): Promise { + const body: Record = {}; + body['node_id'] = nodeId; + body['status'] = status; + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; + return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/status', opts); +} + +/** + * 节点类型(#51)。 + * + * 🔴 用户:「这个类型和网页端不一样啊,网页端比这个多得多呀, + * 我们这上面的类型怎么这么少呢?」 + * + * 实测库里 **9 种**,而端上硬编码 5 种(照抄了 `Trip::NODE_TYPES` 那个 + * **兜底常量**,真实数据在 `node_types` 表、用户可增删)。 + * ⇒ 从 `GET /api/v1/trips//node-types` 动态拉。 + */ +export interface FbNodeTypeItem { + id: number; + name: string; + icon: string; +} + +export async function fetchNodeTypes(token: string, tripId: number): Promise { + const opts: FbApiOptions = { method: 'GET', token: token }; + const res: FbApiResult = await fbApi( + '/api/v1/trips/' + tripId.toString() + '/node-types', opts); + if (!res.ok || res.data === '') { + return []; + } + const out: FbNodeTypeItem[] = []; + try { + const env: Record = JSON.parse(res.data) as Record; + const items: Object | undefined = env['items']; + if (items !== undefined && Array.isArray(items)) { + for (const it of items as Object[]) { + const o: Record = it as Record; + const nm: Object | undefined = o['name']; + if (typeof nm !== 'string' || nm === '') { + continue; + } + const row: FbNodeTypeItem = { + id: typeof o['id'] === 'number' ? o['id'] as number : 0, + name: nm as string, + icon: typeof o['icon'] === 'string' ? o['icon'] as string : '📌' + }; + out.push(row); + } + } + } catch (err) { + return []; + } + return out; +} + +/** + * 执行一个行程动作(#48)。 + * + * 🔴 用户要求状态由**动作**驱动,且三个动作语义不同: + * · `start` → status=ongoing,**记录真实开始时间** + * · `done` → **stage 转成 actual**(变成实际行程),记录真实结束时间 + * · `cancel` → stage=skipped(保留数据,不是删除) + * + * ⚠️ `done` 之后这段就是实际行程了,**不再有状态**(用户:实际行程没状态)。 + * ⚠️ 与「删除」完全不同:删除走 `deleteTripNode`(软删)。 + * + * 服务端:`PATCH /api/v1/trips//nodes/action`,body `{node_id, action}` + */ +export async function nodeAction(token: string, tripId: number, nodeId: number, + act: string): Promise { + const body: Record = {}; + body['node_id'] = nodeId; + body['action'] = act; + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; + return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/action', opts); +} + +/** + * 暂存上传(转发 `FbUpload.fbUploadStage`)。 + * + * 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交, + * 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」 + * + * ⚠️ 不在这里重复写 multipart —— 全项目只有 `FbUpload.ets` 一份实现 + * (那个文件头写着"唯一的 multipart 实现",别再抄第二份)。 + */ +export async function stageUpload(token: string, uri: string): Promise { + return await fbUploadStage(uri, token); +} + +/** + * 改一段行程。 + * + * 🔴 2026-11-05 新增。用户:「鸿蒙端:现在行程还不能编辑,得尽快把这个功能做完。」 + * + * 原来端上只有 `add` / `delete` —— 想改一个字就得删了重建, + * 而 `trip_nodes.id` 上挂着成员、车辆、附件,删了就全丢。 + * + * ## 寻址方式(与 `deleteTripNode` 一致) + * + * PATCH /api/v1/trips//nodes?node_id= + * + * ⚠️ 不用 `/trips/1/nodes/5`:`Api::dispatch()` 只解析三段, + * 第 4 段会被丢掉(见 `deleteTripNode` 的注释)。 + * ⚠️ 同时把 `id` 放进 body:服务端读 body, + * 两边任何一边改了口径另一边也还能工作。 + * + * ## 空值语义(与新建不同) + * + * 新建时"没填"和"要清空"是一回事;**编辑时不是**。 + * 用户把车次删掉 → 必须传空串才能清掉。 + * ⇒ 这里**不做**"空就不传"的过滤,照直传。 + * 服务端 `nodeData()` 用 `$has()` 判定"这次有没有传这个字段", + * 传了空串就写成 NULL(清空),没传才沿用旧值。 + */ +export async function updateTripNode(token: string, tripId: number, nodeId: number, + d: FbTripNodeDraft): Promise { + const body: Record = {}; + body['id'] = nodeId; + body['title'] = d.title; + body['place'] = d.place; + body['detail'] = d.detail; + body['starts_at'] = d.startsAt; + body['ends_at'] = d.endsAt; + body['node_type'] = d.nodeType; + body['travel_mode'] = d.travelMode; + body['category'] = d.category; + /* ⚠️ 这两个**总是带**(含空串): + 编辑场景下"清空车次/取消选车"必须能生效,空就不传会导致永远清不掉。 */ + body['trip_no'] = d.tripNo; + body['vehicle_id'] = d.vehicleId; + /* 2026-11-05:出发地 / 到达地 / 执行状态 / 参与人(对齐网页端) */ + body['from_place'] = d.fromPlace; + body['to_place'] = d.toPlace; + body['from_addr'] = d.fromAddr; + body['to_addr'] = d.toAddr; + /* #51:坐标(空串不传 —— 服务端 coordOrNull 会写 NULL,等于没坐标) */ + if (d.fromLng !== '' && d.fromLat !== '') { + body['from_lng'] = d.fromLng; + body['from_lat'] = d.fromLat; + } + if (d.toLng !== '' && d.toLat !== '') { + body['to_lng'] = d.toLng; + body['to_lat'] = d.toLat; + } + body['status'] = d.status; + /* ⚠️ 空数组也传:编辑时"把人全去掉"必须能生效 + (服务端 `members` 用 `Api::ints()` 读,空数组 = 清空) */ + body['members'] = d.members; + + const query: Record = {}; + query['node_id'] = nodeId.toString(); + + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body, query: query }; + return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts); +} + /** * 删一段行程。 * @@ -350,12 +645,119 @@ function numOf(o: Record, k: string): number { } /** 节点时间文案:`starts_at` 有就用它,否则用 `ends_at`;都没有给空串 */ +/** + * 行程时间跨度文案。 + * + * 🔴🔴 2026-11-05 修复(用户:「鸿蒙端的结束时间还是没有,只有开始时间」)。 + * + * **原实现的 bug**: + * + * const a = strOf(o,'starts_at') !== '' ? strOf(o,'starts_at') : strOf(o,'ends_at'); + * return cnShort(a); + * + * 那行三元看着像"两个都认",其实只是"开始为空时拿结束顶替" —— + * **两个都有时,结束时间被彻底丢弃**。 + * + * 现在逐条对齐网页端 `Trip::nodeSpan($start, $end)` 的四种形态: + * · 同一天、两头都有时分 → `2026年11月7日 16:50 – 21:41` + * · 同一天、结束没时分 → `2026年11月7日 16:50 起` + * · 同一天、两头都没时分 → `2026年11月7日` + * · 跨天 → `2026年10月2日 07:30 – 10月3日 18:00` + * + * ⚠️ 必须与网页端**同一套文案**,否则同一段行程在两端显示不一样。 + */ function spanOf(o: Record): string { - const a: string = strOf(o, 'starts_at') !== '' ? strOf(o, 'starts_at') : strOf(o, 'ends_at'); - if (a === '') { - return ''; + const startRaw: string = strOf(o, 'starts_at'); + const endRaw: string = strOf(o, 'ends_at'); + return fbSpanOf(startRaw, endRaw); +} + +/** + * `starts_at` + `ends_at` → 展示串(网页端 `Trip::nodeSpan` 的端上实现)。 + * + * @param startRaw `YYYY-MM-DD HH:MM:SS`(空 = 日期待补) + * @param endRaw `YYYY-MM-DD HH:MM:SS`(空 = 没设结束) + */ +export function fbSpanOf(startRaw: string, endRaw: string): string { + if (startRaw === '') { + return '日期待补'; } - return cnShort(a); + const s: Date | null = fbParseIso(startRaw); + if (s === null) { + return '日期待补'; + } + const sHasTime: boolean = !(s.getHours() === 0 && s.getMinutes() === 0); + const sHead: string = fbCnDate(s) + (sHasTime ? ' ' + fbHm(s) : ''); + + if (endRaw === '') { + return sHead; + } + const e: Date | null = fbParseIso(endRaw); + if (e === null) { + return sHead; + } + const eHasTime: boolean = !(e.getHours() === 0 && e.getMinutes() === 0); + + /* 同一天 */ + if (fbSameDay(s, e)) { + if (!eHasTime && !sHasTime) { + return fbCnDate(s); // 两头都没时分 = 一整天 + } + if (!eHasTime) { + return sHead + ' 起'; // 结束没填时分 → 说"从几点开始"更准 + } + return fbCnDate(s) + (sHasTime ? ' ' + fbHm(s) : '') + ' – ' + fbHm(e); + } + /* 跨天:结束那边写"月日"(不重复年份),与网页端一致 */ + return sHead + ' – ' + (e.getMonth() + 1).toString() + '月' + e.getDate().toString() + '日' + + (eHasTime ? ' ' + fbHm(e) : ''); +} + +/** + * 解析服务端的 `YYYY-MM-DD HH:MM:SS`。 + * + * ⚠️ 不用 `new Date(str)`:各家 JS 引擎对空格分隔的解析行为不一致 + * (Safari/部分引擎会返回 Invalid Date)。手工拆更稳。 + */ +function fbParseIso(raw: string): Date | null { + const v: string = raw.trim(); + if (v.length < 10) { + return null; + } + const y: number = parseInt(v.substring(0, 4), 10); + const mo: number = parseInt(v.substring(5, 7), 10) - 1; + const d: number = parseInt(v.substring(8, 10), 10); + let h: number = 0; + let mi: number = 0; + if (v.length >= 16) { + h = parseInt(v.substring(11, 13), 10); + mi = parseInt(v.substring(14, 16), 10); + } + if (isNaN(y) || isNaN(mo) || isNaN(d)) { + return null; + } + const out: Date = new Date(y, mo, d, + isNaN(h) ? 0 : h, isNaN(mi) ? 0 : mi, 0, 0); + return isNaN(out.getTime()) ? null : out; +} + +/** `2026年11月7日` */ +function fbCnDate(d: Date): string { + return d.getFullYear().toString() + '年' + (d.getMonth() + 1).toString() + + '月' + d.getDate().toString() + '日'; +} + +/** `16:05`(补零到两位) */ +function fbHm(d: Date): string { + const h: string = d.getHours() < 10 ? '0' + d.getHours().toString() : d.getHours().toString(); + const m: string = d.getMinutes() < 10 ? '0' + d.getMinutes().toString() : d.getMinutes().toString(); + return h + ':' + m; +} + +function fbSameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); } /** `2026-10-20T09:30:00` → `10-20 09:30`(列表里一行够用) */ @@ -458,6 +860,9 @@ function peopleOf(o: Record, k: string): FbTripNodePerson[] { function attachsOf(o: Record, k: string): FbTripNodePhoto[] { const v: Object | undefined = o[k]; const out: FbTripNodePhoto[] = []; + /* DIAG-ATTACH:看原始输入 */ + FbDiag.log('ATTACH', 'attachsOf k=' + k + ' isArray=' + Array.isArray(v).toString() + + ' raw=' + (Array.isArray(v) ? (v as Object[]).length.toString() : '-')); if (!Array.isArray(v)) { return out; } @@ -479,6 +884,8 @@ function attachsOf(o: Record, k: string): FbTripNodePhoto[] { size: numOf(a, 'size') }); } + /* DIAG-ATTACH:解析出几条(key 为空的会被跳过,所以可能少于 raw) */ + FbDiag.log('ATTACH', 'attachsOf 解析出 ' + out.length.toString() + ' 条'); return out; } diff --git a/entry/src/main/ets/data/FbUpload.ets b/entry/src/main/ets/data/FbUpload.ets index f1d511a..a3d3d0d 100644 --- a/entry/src/main/ets/data/FbUpload.ets +++ b/entry/src/main/ets/data/FbUpload.ets @@ -46,6 +46,73 @@ export async function fbUploadOne(path: string, field: string, uri: string, return r.ok ? '' : r.error; } +/** + * 暂存上传:把文件传到暂存区,返回**令牌**。 + * + * 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交, + * 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」 + * + * 这是「新增时也能挂照片」的关键:新增行程时节点还不存在, + * 只能先把文件暂存起来拿 stoken,等保存表单时把 stoken 一起提交, + * 服务端建完节点再 `claim` 上去。 + * + * @param uri picker 给的文件 URI + * @param token 访问令牌 + * @returns stoken 为空串 = 失败(看 error) + */ +export interface FbStageUpload { + /** 暂存令牌(空 = 失败) */ + stoken: string; + /** 原始文件名 */ + name: string; + /** 是否图片(服务端按 MIME 嗅探) */ + isImage: boolean; + /** 预览地址(相对路径) */ + url: string; + /** 错误说明(成功时为空) */ + error: string; +} + +export async function fbUploadStage(uri: string, token: string): Promise { + const out: FbStageUpload = { + stoken: '', name: '', isImage: false, url: '', error: '' + }; + const r: FbUploadRaw = await fbUploadRaw('/api/v1/uploads/stage', 'file', uri, token); + if (!r.ok) { + out.error = r.error !== '' ? r.error : '上传失败'; + return out; + } + /* 服务端信封:{ok:true, data:{stoken,...}}(`fbApi` 层已拆过一层, + 但 multipart 这条路是自己解析的,要按原始信封读) */ + try { + const env: Record = JSON.parse(r.body) as Record; + let dataObj: Record = env; + const nested: Object | undefined = env['data']; + if (nested !== undefined) { + dataObj = nested as Record; + } + const st: Object | undefined = dataObj['stoken']; + if (typeof st === 'string' && st !== '') { + out.stoken = st as string; + } + const nm: Object | undefined = dataObj['name']; + if (typeof nm === 'string') { + out.name = nm as string; + } + out.isImage = dataObj['is_image'] === true; + const u: Object | undefined = dataObj['url']; + if (typeof u === 'string') { + out.url = u as string; + } + if (out.stoken === '') { + out.error = '服务端没返回令牌'; + } + } catch (err) { + out.error = '响应解析失败'; + } + return out; +} + /** * 上传图片并**拿回响应载荷**(工单 #23 用)。 * diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index 9fad2f0..8a8151e 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -145,50 +145,110 @@ export const FB_DEMO_TRIPS: FbTripItem[] = [ */ export const FB_DEMO_TRIP_NODES: FbTripNode[] = [ { - id: 1, title: '出发', when: '10-02 07:30', place: '家 → 青岛北站', + id: 1, title: '出发', when: '10-02 07:30', + startsAt: '', + endsAt: '', + actualStartedAt: '', + actualEndedAt: '', place: '家 → 青岛北站', stage: 'actual', status: 'done', mode: 'drive', expenseCent: 0, nodeType: 'move', detail: '六点起床,路上买了包子当早饭。', - fromPlace: '家', toPlace: '青岛北站', lineName: '', carrier: '', + fromPlace: '家', toPlace: '青岛北站', + fromAddr: '', + toAddr: '', + fromLng: '', + fromLat: '', + toLng: '', + toLat: '', lineName: '', carrier: '', tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0, members: [], attachs: [] }, { - id: 2, title: '抵达民宿', when: '10-02 10:20', place: '崂山区·沙子口民宿', + id: 2, title: '抵达民宿', when: '10-02 10:20', + startsAt: '', + endsAt: '', + actualStartedAt: '', + actualEndedAt: '', place: '崂山区·沙子口民宿', stage: 'actual', status: 'done', mode: 'drive', expenseCent: 68000, nodeType: 'stay', detail: '老板留了钥匙在门口花盆下,房间朝海。', - fromPlace: '青岛北站', toPlace: '沙子口民宿', lineName: '', carrier: '', + fromPlace: '青岛北站', toPlace: '沙子口民宿', + fromAddr: '', + toAddr: '', + fromLng: '', + fromLat: '', + toLng: '', + toLat: '', lineName: '', carrier: '', tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0, members: [], attachs: [] }, { - id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', place: '仰口海水浴场', + id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', + startsAt: '', + endsAt: '', + actualStartedAt: '', + actualEndedAt: '', place: '仰口海水浴场', stage: 'actual', status: 'done', mode: 'walk', expenseCent: 21800, nodeType: 'play', detail: '在礁石上捡了几块小石头。', - fromPlace: '沙子口民宿', toPlace: '仰口海水浴场', lineName: '', carrier: '', + fromPlace: '沙子口民宿', toPlace: '仰口海水浴场', + fromAddr: '', + toAddr: '', + fromLng: '', + fromLat: '', + toLng: '', + toLat: '', lineName: '', carrier: '', tripNo: '', vehiclePlate: '', revisionN: 0, members: [], attachs: [] }, { - id: 4, title: '太清宫', when: '10-02 15:00', place: '崂山太清景区', + id: 4, title: '太清宫', when: '10-02 15:00', + startsAt: '', + endsAt: '', + actualStartedAt: '', + actualEndedAt: '', place: '崂山太清景区', stage: 'actual', status: 'done', mode: 'drive', expenseCent: 27000, nodeType: 'play', detail: '', - fromPlace: '仰口', toPlace: '太清宫', lineName: '', carrier: '', + fromPlace: '仰口', toPlace: '太清宫', + fromAddr: '', + toAddr: '', + fromLng: '', + fromLat: '', + toLng: '', + toLat: '', lineName: '', carrier: '', tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0, members: [], attachs: [] }, { - id: 5, title: '登山(巨峰)', when: '10-03 08:00', place: '崂山巨峰景区', + id: 5, title: '登山(巨峰)', when: '10-03 08:00', + startsAt: '', + endsAt: '', + actualStartedAt: '', + actualEndedAt: '', place: '崂山巨峰景区', stage: 'plan', status: 'planned', mode: 'drive', expenseCent: 34000, nodeType: 'play', detail: '带够水,山上买贵。', - fromPlace: '沙子口民宿', toPlace: '巨峰景区', lineName: '', carrier: '', + fromPlace: '沙子口民宿', toPlace: '巨峰景区', + fromAddr: '', + toAddr: '', + fromLng: '', + fromLat: '', + toLng: '', + toLat: '', lineName: '', carrier: '', tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0, members: [], attachs: [] }, { - id: 6, title: '返程', when: '10-03 17:00', place: '青岛 → 家', + id: 6, title: '返程', when: '10-03 17:00', + startsAt: '', + endsAt: '', + actualStartedAt: '', + actualEndedAt: '', place: '青岛 → 家', stage: 'plan', status: 'planned', mode: 'train', expenseCent: 0, nodeType: 'move', detail: '', - fromPlace: '青岛北站', toPlace: '家', lineName: '京沪高铁', carrier: '中国铁路', + fromPlace: '青岛北站', toPlace: '家', + fromAddr: '', + toAddr: '', + fromLng: '', + fromLat: '', + toLng: '', + toLat: '', lineName: '京沪高铁', carrier: '中国铁路', tripNo: 'G1234', vehiclePlate: '', revisionN: 0, members: [], attachs: [] } @@ -408,7 +468,7 @@ export function fbGroupRoster(list: FbRosterItem[]): FbGroup[] { /** 按 status 分组建任务(待办 / 进行中 / 已完成) */ export function fbGroupTask(list: FbTaskItem[]): FbGroup[] { const order: string[] = ['doing', 'todo', 'done']; - const titles: string[] = ['进行中', '待办', '已完成']; + const titles: string[] = ['进行中', '待做', '已完成']; const groups: FbGroup[] = []; for (let i = 0; i < order.length; i++) { const key: string = order[i]; diff --git a/entry/src/main/ets/model/Modules.ets b/entry/src/main/ets/model/Modules.ets index 30fd0bf..8f25157 100644 --- a/entry/src/main/ets/model/Modules.ets +++ b/entry/src/main/ets/model/Modules.ets @@ -49,7 +49,7 @@ export const FB_MODULES: FbModuleDef[] = [ { key: 'trips', name: '旅程', color: '#3F7D5A', desc: '去过的地方与行程' }, { key: 'calendar', name: '日程', color: '#B0763A', desc: '日历 / 重复日程 / 提醒' }, { key: 'resources', name: '资源', color: '#5566A8', desc: '证件 / 资料 / 附件' }, - { key: 'tasks', name: '任务', color: '#A8574F', desc: '看板与待办' }, + { key: 'tasks', name: '任务', color: '#A8574F', desc: '看板与任务' }, { key: 'days', name: '纪念日', color: '#B4633C', desc: '生日与重要日子' }, { key: 'ledger', name: '财务', color: '#5E7A3C', desc: '账本与账目' }, { key: 'books', name: '读书', color: '#6B5B95', desc: '书架与读书笔记' }, diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 7a11ba1..f5e5eed 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -436,6 +436,34 @@ export interface FbTripNode { title: string; /** 时间文案(已按服务端 `nodeSpan` 的口径折成「10-20 09:30」这种短串) */ when: string; + /** + * 开始时间**原始值**(`YYYY-MM-DD HH:MM:SS`,服务端 `starts_at`)。 + * + * 🔴 2026-11-05 新增。`when` 是给人看的展示串,**不带年份**, + * 编辑回填时从它反推会跨年错位(12 月记次年 1 月的行程会落到今年)。 + * 有了原始值就能精确回填,也才能显示结束时间。 + */ + startsAt: string; + /** + * 真实开始时间(#48;`actual_started_at`,点「开始」那一刻)。 + * + * 🔴 用户:「我计划今天 8:00 到 10:00 去公园,但可能 8:30 才点『开始』。 + * 这时候就要记录住 8:30,因为 8:30 会成为实际行程的开始时间。 + * 也就是说,**原来计划的时间和现在真正开始的时间都要记录**。」 + * + * ⚠️ 与 `startsAt`(计划)并存,不是替代关系。 + */ + actualStartedAt: string; + /** 真实结束时间(`actual_ended_at`,点「完成」那一刻;空 = 还没结束) */ + actualEndedAt: string; + /** + * 结束时间原始值(`YYYY-MM-DD HH:MM:SS`,服务端 `ends_at`;空 = 没设)。 + * + * 🔴 2026-11-05 新增。用户:「上面好像只有开始时间,没有结束时间呀,这都需要补啊。」 + * 服务端 `Trip::nodes()` 用 `SELECT *`,这两个字段本来就在返回里, + * 只是端上没解析。 + */ + endsAt: string; /** 地点(`place`,没有则「出发地 → 到达地」) */ place: string; /** 阶段:plan(计划)/ actual(实际)/ skipped(未成行) */ @@ -457,6 +485,21 @@ export interface FbTripNode { /** 出发地 / 到达地(网页版 `.node-route-line`,各带导航链接) */ fromPlace: string; toPlace: string; + /* 🔴 #51(用户:「网页端通过高德地图能拿到详细地址。不知道你这个鸿蒙端 + 有没有拿到详细地址」): + `FbGeoTip` 本来就带 `detail` / `lng` / `lat`,但 `onValue` 只传了 + 拼好的字符串 ⇒ 端上从来拿不到坐标与真实详细地址。 + 现在走 `onPick` 结构化回调,这几个字段才有值。 */ + /** 出发地详细地址(高德 `detail`,不是地名) */ + fromAddr: string; + /** 到达地详细地址 */ + toAddr: string; + /** 出发地坐标(导航链接要用) */ + fromLng: string; + fromLat: string; + /** 到达地坐标 */ + toLng: string; + toLat: string; /** 线路名 / 承运方(网页版 `.node-traffic` 的两个 `.ntag`) */ lineName: string; carrier: string; diff --git a/entry/src/main/ets/pages/account/FbAccountPage.ets b/entry/src/main/ets/pages/account/FbAccountPage.ets index 14456c1..5455f68 100644 --- a/entry/src/main/ets/pages/account/FbAccountPage.ets +++ b/entry/src/main/ets/pages/account/FbAccountPage.ets @@ -39,6 +39,9 @@ import { FbSheetRing } from '../../components/FbSheetRing'; import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; +import { FB_CAL_SETTINGS } from '../../data/FbCalSettings'; +import { FbCalMirror, FbMirrorResult, MirrorStats } from '../../data/FbCalMirror'; +import { FbCalInbound } from '../../data/FbCalInbound'; const DOMAIN: number = 0x0000; const TAG: string = 'FbAccount'; @@ -79,12 +82,28 @@ export struct FbAccountPage { /** 系统设置浮层是否打开(仅管理员) */ @Local sysOpen: boolean = false; + /* ---------- 镜像到系统日历 ---------- */ + /** 开关状态(从 preferences 读,异步一次落进来) */ + @Local mirrorOn: boolean = false; + /** 当前镜像了多少条(0 = 没生效或还没跑) */ + @Local mirrorCount: number = 0; + /** 镜像了几个日历本(每个本一个独立系统日历账户) */ + @Local mirrorBooks: number = 0; + /** 最近一次镜像的结果文案 */ + @Local mirrorNote: string = ''; + /** 正在跑(防重复点击) */ + @Local mirrorBusy: boolean = false; + /** 账号接口拿回来的信息(拿不到就只显示会话里那份) */ @Local accountNote: string = ''; @Local lastLogin: string = ''; aboutToAppear(): void { this.loadAccount(); + /* 镜像开关状态(异步读一次落进 @Local)。 + ⚠️ 必须在这里就读:用户进本页第一眼要看到开关是开还是关, + 不能等他滚动到底部才发现状态是错的。 */ + this.loadMirrorState(); } /** @@ -213,6 +232,123 @@ export struct FbAccountPage { } } + /* ==================== 镜像到系统日历 ==================== */ + + /** 读镜像开关 + 当前条数(异步一次,落进 @Local) */ + private loadMirrorState(): void { + const ctx: common.Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + this.mirrorOn = FB_CAL_SETTINGS.getMirror(); + FbCalMirror.mirroredStats(ctx).then((st: MirrorStats): void => { + this.mirrorBooks = st.books; + this.mirrorCount = st.events; + }).catch((): void => { + }); + } + + /** + * 切换镜像开关。 + * + * 🔴 **权限只能在这里申请**(用户主动点击)—— 启动时申请不合规。 + * + * 打开顺序(不能反): + * ① 申请系统日历读写权限(拒绝就开关回弹并说明) + * ② **先入站**:把用户在系统日历里的改动收回服务器 + * ③ **再出站**:把最新数据写进系统日历 + * ⚠️ 反了的话出站会把系统日历里的旧值当"期望值",覆盖掉用户改动。 + * + * 关闭:删镜像账户 + 清快照(不在系统日历里留垃圾) + */ + private async toggleMirror(on: boolean): Promise { + if (this.mirrorBusy) { + return; + } + const ctx: common.UIAbilityContext | undefined = + this.getUIContext().getHostContext() as common.UIAbilityContext | undefined; + if (ctx === undefined) { + return; + } + + if (!on) { + this.mirrorOn = false; + this.mirrorNote = '正在清除…'; + this.mirrorBusy = true; + try { + await FbCalMirror.removeAll(ctx); + FB_CAL_SETTINGS.setMirror(false); + this.mirrorCount = 0; + this.mirrorNote = '已关闭,系统日历里不再有岁月家的日程'; + } catch (err) { + const e = err as BusinessError; + this.mirrorNote = '关闭时出错:' + e.message; + } + this.mirrorBusy = false; + return; + } + + // 打开:先要权限 + this.mirrorBusy = true; + this.mirrorNote = '正在申请日历权限…'; + const granted: boolean = await FbCalMirror.requestPermission(ctx); + if (!granted) { + this.mirrorOn = false; + this.mirrorNote = '没有日历权限,镜像无法开启(可在系统设置里授权)'; + this.mirrorBusy = false; + return; + } + + this.mirrorOn = true; + FB_CAL_SETTINGS.setMirror(true); + this.mirrorNote = '正在同步…'; + + try { + // ① 先入站:把用户在系统日历里做的改动收回服务器 + await FbCalInbound.reconcile(ctx); + // ② 再出站:把最新数据写进系统日历 + const r: FbMirrorResult = await FbCalMirror.syncNow(ctx); + this.mirrorNote = '已镜像 ' + r.books + ' 个本:新增 ' + r.added + + '、更新 ' + r.updated + + (r.deleted > 0 ? '、删除 ' + r.deleted : '') + + (r.failed > 0 ? ',' + r.failed + ' 条失败' : ''); + const st2: MirrorStats = await FbCalMirror.mirroredStats(ctx); + this.mirrorBooks = st2.books; + this.mirrorCount = st2.events; + } catch (err) { + const e = err as BusinessError; + this.mirrorNote = '镜像失败:' + e.message; + } + this.mirrorBusy = false; + } + + /** 手动重跑一次镜像(打开状态下才有意义) */ + private async runMirrorNow(): Promise { + if (this.mirrorBusy || !this.mirrorOn) { + return; + } + const ctx: common.Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + this.mirrorBusy = true; + this.mirrorNote = '正在同步…'; + try { + await FbCalInbound.reconcile(ctx); + const r: FbMirrorResult = await FbCalMirror.syncNow(ctx); + this.mirrorNote = r.books + ' 个本:新增 ' + r.added + '/更新 ' + r.updated + + '/删除 ' + r.deleted + (r.failed > 0 ? '/失败 ' + r.failed : ''); + const st2: MirrorStats = await FbCalMirror.mirroredStats(ctx); + this.mirrorBooks = st2.books; + this.mirrorCount = st2.events; + } catch (err) { + const e = err as BusinessError; + this.mirrorNote = '同步失败:' + e.message; + } + this.mirrorBusy = false; + } + + build() { Stack({ alignContent: Alignment.Bottom }) { Scroll() { @@ -274,6 +410,89 @@ export struct FbAccountPage { .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) .alignItems(HorizontalAlign.Start) + // ==================== 系统日历 ==================== + /* 🔴 2026-11-03 新增。用户原话: + 「新增一个功能,把日历镜像到系统日历中。至于原因,同步日历都已经说过了, + 做法就完全照搬同步日历就可以。在个人设置页面,进行设置。」 + + 原因(SyncCalendar 文件头):本应用是 CalDAV 客户端,日程不进系统日历, + 于是小艺 / 桌面日历卡片 / 手表看到的是空的, + 甚至会给出"今天没有日程"的错误答案。 + + ⚠️ 放在**个人设置**而不是「系统设置」: + 它是每个用户自己的偏好(我要不要同步到自己的手机日历), + 不是管理员配置。而「系统设置」还要多点一层、且仅管理员可见。 + ⚠️ 权限在用户点这个开关时**才**申请(合规要求:不能启动时申请)。 */ + Column({ space: 0 }) { + this.rowTitle('系统日历') + + Row({ space: 12 }) { + Column({ space: 2 }) { + Text('镜像到系统日历') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + Text(this.mirrorOn + ? (this.mirrorBooks > 0 + ? '已镜像 ' + this.mirrorBooks + ' 个日历本 · 共 ' + this.mirrorCount + ' 条' + : '已开启,等待首次同步') + : '每个日历本单独一个颜色,小艺、桌面卡片、手表都能看到') + .fontSize(10) + .fontColor(FbColors.faint) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Toggle({ type: ToggleType.Switch, isOn: this.mirrorOn }) + .selectedColor(FbColors.brand) + .enabled(!this.mirrorBusy) + .onChange((on: boolean): void => { + this.toggleMirror(on); + }) + } + .width('100%') + .padding({ top: 9, bottom: 9 }) + .alignItems(VerticalAlign.Center) + + // 当前结果 / 状态。给一句人话,避免"开了但没生效"时用户无从判断。 + if (this.mirrorNote !== '') { + Text(this.mirrorNote) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .maxLines(3) + } + + // 手动重跑(只在开启状态显示;关闭时跑没意义) + if (this.mirrorOn) { + Row({ space: 8 }) { + Text(this.mirrorBusy ? '同步中…' : '立即同步一次') + .fontSize(FbMetric.fontSmall) + .fontColor(this.mirrorBusy ? FbColors.faint : FbColors.brandInk) + .onClick((): void => { + this.runMirrorNow(); + }) + Blank() + } + .width('100%') + .padding({ top: 4, bottom: 4 }) + } + + // 说明"改了会怎样",否则用户不知道系统日历里的改动会不会同步回来 + Text('在系统日历里改动或删除,下次同步会写回岁月家;在岁月家里改的,也会更新到系统日历。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .maxLines(3) + .padding({ top: 2, bottom: 2 }) + } + .padding(FbMetric.cardPad) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + .alignItems(HorizontalAlign.Start) + // ==================== 安全 ==================== Column({ space: 0 }) { this.rowTitle('安全') diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index ca918ad..9780605 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -46,6 +46,7 @@ import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition, fbViewTransition } from '../../common/FbAnim'; import { FB_BACK } from '../../common/FbBack'; import { fbOpenNavigation } from '../../common/FbNav'; +import { FbDiag } from '../../common/FbDiag'; /** 视图粗细顺序(决定切视图时过渡的上下方向,见 `switchModeAnimated`) */ const FB_CAL_VIEW_ORDER: string[] = ['month', 'week', 'agenda']; @@ -363,6 +364,9 @@ export struct FbCalendarPage { } aboutToAppear(): void { + /* 诊断设施在这里初始化:地址联想发生在编辑弹窗里, + 那时用户可能还没点过导航 ⇒ 不能等导航时才 init。 */ + FbDiag.init(this.getUIContext().getHostContext()); FB_BACK.register('calendar', (): boolean => this.backOnce()); // 先按设置里的默认视图展示(用户上次选的) this.mode = FB_CAL_SETTINGS.getView(); @@ -1080,6 +1084,7 @@ export struct FbCalendarPage { /* ⚠️ `fbOpenNavigation` 现在是 async(内部要 await 地理编码), 不接住 rejection 会报未处理异常。这里明确咽掉并提示 —— 导航失败不该把页面弄崩。 */ + FbDiag.init(ctx); fbOpenNavigation(ctx, place, title).catch((err: Error): void => { hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err)); }); diff --git a/entry/src/main/ets/pages/card/FbTaskCard2x2.ets b/entry/src/main/ets/pages/card/FbTaskCard2x2.ets index 775301f..939327d 100644 --- a/entry/src/main/ets/pages/card/FbTaskCard2x2.ets +++ b/entry/src/main/ets/pages/card/FbTaskCard2x2.ets @@ -1,12 +1,36 @@ /** - * 家书 FamilyBook · 2×2 服务卡片(任务 · 待办概览) + * 岁月家 FamilyBook · 2×2 任务服务卡片 * - * 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」 - * ⇒ 骨架照日程 2×2;大数字 = 未完成任务数(todo + doing); - * 有逾期时副行变红给「逾期 N 条」,让"该处理了"一眼可见。 + * 🔴 2026-11-04 重做。用户原话: + * 「任务的四个卡片,样式格式,完全按照同步日历来完成,重做。」 * - * ⚠️ 动态卡片不能自己发网络请求:只画 `fbTaskCardBuild` 推过来的字段。 - * ⚠️ 整卡可点 → 打开应用并落到「任务」模块(fbModule: 'tasks')。 + * 完全照搬同步日历的 `TodoWidget2x2`(`SyncCalendar/entry/src/main/ets/pages/widget/`): + * + * ┌──────────────────────────┐ + * │ 11月4日 (+) │ ← 日期 + 星期/农历,右上角新建 + * │ 星期三 │ + * ├──────────────────────────┤ ← 细分隔线 + * │ 12 待办未完成 │ ← 大数字(品牌紫/砖红)+ 说明 + 今日到期 + * │ 今日到期 3 项 │ + * └──────────────────────────┘ + * + * ## 与 SyncCalendar 的逐项对应 + * + * | 元素 | SyncCalendar | 本卡片 | + * |---|---|---| + * | 头部主行 | `dateMd`(`11月4日`) | 同 | + * | 头部副行 | `weekday + 农历` | `weekday`(我们没接农历,见下) | + * | 右上按钮 | `+` 圆形,路由去新建待办 | 同(走 `modCreate` 通道) | + * | 大数字 | `todoCount`,40px Bold | 同 | + * | 数字右文案 | `待办未完成` | 同 | + * | 副提示 | `今日到期 N 项`(橙)/ `今日无到期`(灰) | 同 | + * | 卡片底 | 白底 + 圆角 16 + padding 14 | 同 | + * + * ⚠️ **农历不接**:SyncCalendar 有 `LunarUtil`,岁月家没有。 + * 与其塞一个不准的农历,不如只显示星期 —— 少一行比错一行好。 + * 将来要接农历,只需在数据层加 `lunarText` 字段,本卡片加一行 Text 即可。 + * + * ⚠️ 本卡片**不展示列表**,所以没有打勾交互;点整卡进「任务」模块。 */ let storageTk2 = new LocalStorage(); @@ -16,10 +40,13 @@ let storageTk2 = new LocalStorage(); struct FbTaskCard2x2 { @LocalStorageProp('state') state: string = 'loading'; @LocalStorageProp('hint') hint: string = ''; - @LocalStorageProp('updatedAt') updatedAt: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('openCount') openCount: number = 0; + @LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('overdueCount') overdueCount: number = 0; + /** 打开 App 并落到「任务」模块 */ private openApp(): void { postCardAction(this, { action: 'router', @@ -28,20 +55,87 @@ struct FbTaskCard2x2 { }); } + /** + * 右上角 +:拉起 App 并**直接打开新建任务浮层**。 + * + * ⚠️ 用 `modCreate: 'tasks'` 而不是 `fbModule: 'tasks'`: + * 前者会让 EntryAbility 落到任务页**并立刻弹新建**(用户意图是"马上加一条"); + * 后者只是打开任务页,用户还要再点一次 +。 + */ + private addOne(): void { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'tasks', widgetAction: 'add' } + }); + } + + /** + * 副行文案。 + * + * 优先级照 SyncCalendar: + * ① 今日到期(橙色,最该行动) + * ② 没有待办 → "点击添加" + * ③ 今日无到期(灰色) + * + * ⚠️ 我们比 SyncCalendar 多一个"逾期"状态 —— 它更严重,所以排在今日到期之前。 + */ + private subText(): string { + if (this.overdueCount > 0) { + return '逾期 ' + this.overdueCount.toString() + ' 项'; + } + if (this.todayCount > 0) { + return '今日到期 ' + this.todayCount.toString() + ' 项'; + } + return this.openCount === 0 ? '点击添加' : '今日无到期'; + } + + /** 副行颜色:逾期/今日到期都要醒目 */ + private subColor(): string { + if (this.overdueCount > 0) { + return '#C0533F'; + } + return this.todayCount > 0 ? '#FF9500' : '#A8988A'; + } + + /** 右上角 + 按钮(照 SyncCalendar 的圆形按钮) */ + @Builder + addButton() { + Button() { + Text('+') + .fontSize(20) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(30) + .height(30) + .borderRadius(15) + .padding(0) + .backgroundColor('#A8574F') + .onClick((): void => { + this.addOne(); + }) + } + build() { Column({ space: 6 }) { - // ---- 抬头:模块名 + 更新时间 ---- + // ---- 头部:日期 + 星期 + + ---- Row({ space: 6 }) { - Text('任务') - .fontSize(15) - .fontWeight(FontWeight.Bold) - .fontColor('#2C231D') - .maxLines(1) - .layoutWeight(1) - Text(this.updatedAt) - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) + Column({ space: 2 }) { + Text(this.dateMd !== '' ? this.dateMd : '—') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor('#2C231D') + .maxLines(1) + Text(this.weekday) + .fontSize(11) + .fontColor('#A8988A') + .maxLines(1) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + this.addButton() } .width('100%') @@ -49,21 +143,20 @@ struct FbTaskCard2x2 { .strokeWidth(0.5) .color('#EFE6D8') - // ---- 待办数 ---- + // ---- 未完成大数字 ---- Row({ space: 8 }) { - Text(`${this.openCount}`) + Text(this.openCount.toString()) .fontSize(40) .fontWeight(FontWeight.Bold) .fontColor('#A8574F') Column({ space: 2 }) { - Text('待办任务') + Text('任务进行中') .fontSize(12) .fontColor('#2C231D') .maxLines(1) - Text(this.hint !== '' ? this.hint - : (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条' : '点击查看')) + Text(this.state === 'ok' ? this.subText() : (this.hint !== '' ? this.hint : '读取中…')) .fontSize(10) - .fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A') + .fontColor(this.state === 'ok' ? this.subColor() : '#A8988A') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } @@ -79,7 +172,8 @@ struct FbTaskCard2x2 { .padding(14) .backgroundColor('#FFFFFF') .borderRadius(16) - // ⭐ 整卡可点 - .onClick((): void => this.openApp()) + .onClick((): void => { + this.openApp(); + }) } -} +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/card/FbTaskCard2x4.ets b/entry/src/main/ets/pages/card/FbTaskCard2x4.ets index 37273b2..6621a4e 100644 --- a/entry/src/main/ets/pages/card/FbTaskCard2x4.ets +++ b/entry/src/main/ets/pages/card/FbTaskCard2x4.ets @@ -1,56 +1,83 @@ /** - * 家书 FamilyBook · 2×4 服务卡片(任务 · 左概览 / 右待办) + * 岁月家 FamilyBook · 2×4 任务服务卡片 * - * 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」 - * ⇒ 骨架照日程 2×4:左半 = 2×2 那套;右半 = 待办任务(最多三条)。 - * 排序由数据侧定好(逾期 > 进行中 > 普通,同组按截止时间升序), - * 逾期行整条用红色 deadline 文案提示。 + * 🔴 2026-11-04 重做。用户原话: + * 「任务的四个卡片,样式格式,完全按照同步日历来完成,重做。」 * - * ⚠️ 数据与 2×2 同一份(fbTaskCardBuild 算一次)。 + * 完全照搬同步日历的 `TodoWidget2x4`: + * + * ┌───────────────┬────────────────────┐ + * │ 11月4日 (+) │ ○ 交水电费 │ ← 左:日期+新建 + * │ 星期三 │ 18:00 · 家务 │ 右:可打勾的列表 + * ├───────────────┤ ○ 备课 │ + * │ 12 待办未完成│ 明天 · 工作 │ + * │ 今日到期 3│ ○ ... │ + * └───────────────┴────────────────────┘ + * + * ## 与 SyncCalendar 的逐项对应 + * + * | 元素 | SyncCalendar | 本卡片 | + * |---|---|---| + * | 布局 | `Row` 左右分栏,中间竖分隔线 | 同 | + * | 左:头 | `dateMd` + `weekday/农历` + `+` | 同(无农历) | + * | 左:统计 | 大数字 38px + `待办未完成` + 今日到期 | 同 | + * | 右:容器 | `List`(可滚,虚拟化) | 同 | + * | 右:条目 | **圆形勾选框** + 标题 + 副行 | 同 | + * | 勾选圈 | 圆形,颜色 = 日历本色,点击即完成 | 同(颜色 = 看板色) | + * | 条目底 | `#F5F7FA` 圆角 9,padding 5/4/8 | 同 | + * | 副行 | `截止时间 · 日历本` | `截止时间 · 看板` | + * | 打勾动作 | `message: {todoCheck: id}`(不拉 App) | `message: {taskDone: id}` | + * + * ## 两条关键纪律(来自 SyncCalendar 的注释,必须照搬) + * + * 1. **打勾用 `action: 'message'`,不是 `router`** + * 拉 App 会闪一下界面,体验很差。message 会进 `onFormEvent`, + * 在 FormExtension 的窗口内完成"调接口 + 重算 + 刷新卡片"。 + * + * 2. **列表用 `List` 而不是 `Column` + `slice`** + * SyncCalendar 踩过:`Column` 不能滚,第 3 条被压扁截断。 + * List 自带虚拟化,条数多也不占内存。 + * + * ⚠️ ForEach 的 key 必须带 `version` 前缀 —— 本项目 v89 的教训: + * 不带的话下次推送数据变了也不重绘。 + * ⚠️ 卡片不能自己发网络请求,打勾必须借道 FormExtensionAbility。 */ let storageTk4 = new LocalStorage(); -/** 右半「待办」一行 */ -class FbTaskRow4 { +/** 右半列表的一行 */ +class FbTaskCardRow2x4 { + id: number = 0; key: string = ''; title: string = ''; + /** 截止时间文案(空 = 没有期限) */ dueText: string = ''; - status: string = ''; + /** 看板名(副行第二段) */ + boardName: string = ''; + /** 看板主题色 —— 勾选圈的描边色 */ boardColor: string = '#A8574F'; overdue: boolean = false; + /** 'todo' / 'doing' —— 没有截止时间时副行用它兜底 */ + status: string = ''; } -/** 右半可滚动列表的条数上限。 - * - * 🔴 v95:配套 `rows` 改成 `List` 一起用。上限必须**和观影 2×4 同一个量级**, - * 否则"能滑"只是名义上能滑(数据侧先砍到 4 条,卡片就只滑得到 4 条)。 - * 30 与 4×4/6×4 的网格列表一致,数据侧也只给这么多。 - */ -const TK_2X4_MAX = 30; - @Entry(storageTk4) @Component struct FbTaskCard2x4 { @LocalStorageProp('state') state: string = 'loading'; @LocalStorageProp('hint') hint: string = ''; - @LocalStorageProp('updatedAt') updatedAt: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('openCount') openCount: number = 0; + @LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('overdueCount') overdueCount: number = 0; @LocalStorageProp('rowsJson') rowsJson: string = '[]'; - /* 🔴 v89 教训:ForEach key 必须带 version 前缀,否则下次推送不重绘 */ + /* 🔴 ForEach key 带 version 前缀,否则下次推送不重绘(本项目 v89 教训) */ @LocalStorageProp('version') version: string = ''; - /** 右半最多 TK_2X4_MAX 条(数据侧会裁;这里只兜底防御异常大数据) - * - * 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的。」 - * —— 与观影 2×4 完全同一个病:`Column` 不能滚 + 硬截 3 条 ⇒ 第三条被压扁截断。 - * ⇒ 容器改 `List`(可滚),上限提到 TK_2X4_MAX。详见 FbMovieCard2x4 的同名注释。 - */ - private rows(): FbTaskRow4[] { + private rows(): FbTaskCardRow2x4[] { try { - const all: FbTaskRow4[] = JSON.parse(this.rowsJson) as FbTaskRow4[]; - return all.slice(0, TK_2X4_MAX); + return JSON.parse(this.rowsJson) as FbTaskCardRow2x4[]; } catch (err) { return []; } @@ -64,62 +91,135 @@ struct FbTaskCard2x4 { }); } - /** 右半单条:左色条 + 标题 + 截止(逾期红) */ + /** + 新建:拉起 App 直接打开新建任务浮层 */ + private addOne(): void { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'tasks', widgetAction: 'add' } + }); + } + + /** + * 点勾选圈 → 让 FormExtensionAbility 把这条标完成。 + * + * ⭐ 用 `message` 而不是 `router`:不拉起主 App(拉起会闪界面)。 + * ⭐ 带上 id:卡片只知道自己在画哪一条,改数据要靠主键。 + */ + private checkTask(id: number): void { + postCardAction(this, { + action: 'message', + params: { taskDone: id.toString() } + }); + } + + /** 副行文案(优先级:逾期 > 今日到期 > 无) */ + private subText(): string { + if (this.overdueCount > 0) { + return '逾期 ' + this.overdueCount.toString() + ' 项'; + } + if (this.todayCount > 0) { + return '今日到期 ' + this.todayCount.toString() + ' 项'; + } + return this.openCount === 0 ? '点击添加' : '今日无到期'; + } + + private subColor(): string { + if (this.overdueCount > 0) { + return '#C0533F'; + } + return this.todayCount > 0 ? '#FF9500' : '#A8988A'; + } + + /** 右上角 +(照 SyncCalendar 的圆形按钮) */ @Builder - taskRow(item: FbTaskRow4) { - Row({ space: 8 }) { - Column() - .width(3) - .height(38) - .borderRadius(2) - .backgroundColor(item.overdue ? '#C0533F' : item.boardColor) - Column({ space: 2 }) { + addButton() { + Button() { + Text('+') + .fontSize(20) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(30) + .height(30) + .borderRadius(15) + .padding(0) + .backgroundColor('#A8574F') + .onClick((): void => { + this.addOne(); + }) + } + + /** + * 一条任务:圆形勾选框 + 标题 + 副行。 + * + * ⚠️ 尺寸比 4×4 收一档(2×4 只有 2 格高):圈 16 / 标题 12 / 副行 9。 + */ + @Builder + taskRow(item: FbTaskCardRow2x4) { + Row({ space: 6 }) { + // 勾选圈:描边用看板色,点击即完成 + Row() { + Text('') + .width(16) + .height(16) + .borderRadius(8) + .border({ width: 2, color: item.overdue ? '#C0533F' : item.boardColor }) + } + .padding({ right: 2 }) + .onClick((): void => { + this.checkTask(item.id); + }) + + Column({ space: 1 }) { Text(item.title) - .fontSize(13) - .fontColor('#2C231D') + .fontSize(12) + .fontColor(item.overdue ? '#C0533F' : '#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.rowSub(item)) + .fontSize(9) + .fontColor('#A8988A') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 4 }) { - if (item.overdue) { - Text('逾期') - .fontSize(9) - .fontColor('#C0533F') - .maxLines(1) - } - if (item.dueText !== '') { - Text(item.dueText) - .fontSize(9) - .fontColor(item.overdue ? '#C0533F' : '#A8988A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } else { - Text(item.status === 'doing' ? '进行中' : '待办') - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) - } - } - .width('100%') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .alignItems(VerticalAlign.Center) .width('100%') - .padding({ left: 8, right: 8, top: 5, bottom: 5 }) - .borderRadius(8) - .backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC') + .padding({ left: 4, right: 8, top: 5, bottom: 5 }) + .borderRadius(9) + .backgroundColor('#F7F2EC') } - /** 右半空态:没有待办 */ + /** 条目副行:`截止时间 · 看板名`(照 SyncCalendar 的「截止时间 · 日历本」) */ + private rowSub(item: FbTaskCardRow2x4): string { + const parts: string[] = []; + if (item.overdue) { + parts.push('逾期'); + } + if (item.dueText !== '') { + parts.push(item.dueText); + } + if (item.boardName !== '') { + parts.push(item.boardName); + } + if (parts.length === 0) { + parts.push(item.status === 'doing' ? '进行中' : '待做'); + } + return parts.join(' · '); + } + + /** 右半空态 */ @Builder emptyHint() { - Column() { - Text(this.hint !== '' ? this.hint : '没有待办任务') + Column({ space: 4 }) { + Text(this.hint !== '' ? this.hint + : (this.openCount === 0 ? '暂无任务' : '今日无到期')) .fontSize(12) - .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') - .maxLines(3) + .fontColor('#A8988A') + .maxLines(2) .textAlign(TextAlign.Center) } .width('100%') @@ -129,19 +229,26 @@ struct FbTaskCard2x4 { build() { Row({ space: 10 }) { - // ===== 左半:与 2×2 一致 ===== + // ===== 左半:日期 + 大数字 ===== Column({ space: 6 }) { Row({ space: 6 }) { - Text('任务') - .fontSize(15) - .fontWeight(FontWeight.Bold) - .fontColor('#2C231D') - .maxLines(1) - .layoutWeight(1) - Text(this.updatedAt) - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) + Column({ space: 2 }) { + Text(this.dateMd !== '' ? this.dateMd : '—') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.weekday) + .fontSize(10) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + this.addButton() } .width('100%') @@ -150,24 +257,23 @@ struct FbTaskCard2x4 { .color('#EFE6D8') Row({ space: 6 }) { - Text(`${this.openCount}`) + Text(this.openCount.toString()) .fontSize(38) .fontWeight(FontWeight.Bold) .fontColor('#A8574F') Column({ space: 2 }) { - Text('待办任务') + Text('任务进行中') .fontSize(11) .fontColor('#2C231D') .maxLines(1) - Text(this.hint !== '' ? this.hint - : (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条 · 点击查看' - : '点击查看')) + Text(this.state === 'ok' ? this.subText() : (this.hint !== '' ? this.hint : '读取中…')) .fontSize(9) - .fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A') + .fontColor(this.state === 'ok' ? this.subColor() : '#A8988A') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) + .layoutWeight(1) } .width('100%') .layoutWeight(1) @@ -182,25 +288,24 @@ struct FbTaskCard2x4 { .strokeWidth(0.5) .color('#EFE6D8') - // ===== 右半:待办(🔴 v95 改成可滚动)===== + // ===== 右半:可滚动的待办列表(可打勾)===== Column() { if (this.rows().length === 0) { this.emptyHint() } else { - List({ space: 6 }) { - ForEach(this.rows(), (item: FbTaskRow4) => { + /* ⚠️ 用 List 不用 Column + slice:Column 不能滚、第 3 条会被压扁截断 + (SyncCalendar 踩过,本项目 v95 也踩过)。List 自带虚拟化。 */ + List({ space: 3 }) { + ForEach(this.rows(), (item: FbTaskCardRow2x4, idx: number) => { ListItem() { this.taskRow(item) } - .width('100%') - }, (item: FbTaskRow4) => `tk_${this.version}_${item.key}`) + }, (item: FbTaskCardRow2x4, idx: number) => `${this.version}_t${item.id}_${idx}`) } .width('100%') .layoutWeight(1) - /* ⚠️ 与观影 2×4 同一套:layoutWeight 才有视口、Auto 才只在能滚时出条, - 且**不要**加 justifyContent(Center)(内容超高时首行会被推出去)。 */ - .scrollBar(BarState.Auto) - .cachedCount(4) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) } } .layoutWeight(1) @@ -211,7 +316,8 @@ struct FbTaskCard2x4 { .padding(12) .backgroundColor('#FFFFFF') .borderRadius(16) - // ⭐ 整卡可点 - .onClick((): void => this.openApp()) + .onClick((): void => { + this.openApp(); + }) } -} +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/card/FbTaskCard4x4.ets b/entry/src/main/ets/pages/card/FbTaskCard4x4.ets index b10078e..82a23cc 100644 --- a/entry/src/main/ets/pages/card/FbTaskCard4x4.ets +++ b/entry/src/main/ets/pages/card/FbTaskCard4x4.ets @@ -1,28 +1,58 @@ /** - * 家书 FamilyBook · 4×4 服务卡片(任务 · 上概述/新建,下滚动列表 + 卡上完成) + * 岁月家 FamilyBook · 4×4 任务服务卡片 * - * 用户 2026-10-25:「任务卡片,也需要4x4和4x6,跟任务模块类似,列出所有任务, - * 可以在卡片直接点击完成。有新建按钮。」 - * ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「新建任务」); - * 下半:`List` 装全部待办(数据侧排序:逾期 > 进行中 > 普通), - * 每行右侧一颗「✓ 完成」→ `postCardAction({action:'message', taskDone:id})` - * → `FbFormAbility.markTaskDone()` 调接口 → 重算重推。 + * 🔴 2026-11-04 重做。用户原话: + * 「任务的四个卡片,样式格式,完全按照同步日历来完成,重做。」 * - * ⚠️ 卡片不能自己发网络请求,完成动作必须借道 FormExtensionAbility。 - * ⚠️ ForEach key 带 version 前缀(v89 教训)。 + * 完全照搬同步日历的 `TodoWidget4x4`(**上下结构**,与 6×4 的左右分栏不同): + * + * ┌──────────────────────────────────┐ + * │ 任务 (12) (+) │ ← 标题 + 数字徽章 + 新建 + * │ 11月4日 星期三 · 今日到期 3 项 │ ← 副行(橙/灰) + * ├──────────────────────────────────┤ + * │ ○ 交水电费 │ ← 圆形勾选圈 + 标题 + * │ 18:00 · 家务 │ 副行:截止 · 看板 + * │ ○ 备课 │ + * │ 明天 · 工作 │ + * └──────────────────────────────────┘ + * + * ## 与 SyncCalendar `TodoWidget4x4` 的逐项对应 + * + * | 元素 | SyncCalendar | 本卡片 | + * |---|---|---| + * | 结构 | `Column` 上下(标题栏 + 列表) | 同 | + * | 标题栏 | `任务` 16px Bold + **数字徽章** | 同(数字徽章砖红底白字) | + * | 副行 | `dateMd weekday · 今日到期 N 项` | 同 | + * | 右上 | `+` 圆形按钮 | 同 | + * | 列表 | `List` 虚拟化,space 4 | 同 | + * | 条目 | 圈 18px + 标题 13 + 副行 10 | 同 | + * | 条目底 | `#F5F7FA` 圆角 10,padding 6/6/8 | 同(配色改暖色 `#F7F2EC`) | + * | 空态 | `✓` 大字 + 说明 + 引导 | 同 | + * + * ## 与 6×4 的区别(照 SyncCalendar) + * + * | | 4×4 | 6×4 | + * |---|---|---| + * | 结构 | **上下**(标题在上) | **左右**(左侧统计栏) | + * | 标题栏 | 带数字徽章,在顶部 | 左侧竖排统计 | + * | 适合 | 竖屏、单列 | 横屏、宽屏 | + * + * ⚠️ 打勾用 `message` 不拉 App;列表用 `List` 不用 `Column + slice`。 + * ⚠️ ForEach key 带 `version` 前缀(v89 教训:不带则下次推送不重绘)。 */ let storageTkB = new LocalStorage(); /** 列表一行 */ -class FbTaskRow9a { +class FbTaskCardRow4x4 { id: number = 0; key: string = ''; title: string = ''; dueText: string = ''; - status: string = ''; + boardName: string = ''; boardColor: string = '#A8574F'; overdue: boolean = false; + status: string = ''; } @Entry(storageTkB) @@ -30,16 +60,17 @@ class FbTaskRow9a { struct FbTaskCard4x4 { @LocalStorageProp('state') state: string = 'loading'; @LocalStorageProp('hint') hint: string = ''; - @LocalStorageProp('updatedAt') updatedAt: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('openCount') openCount: number = 0; + @LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('overdueCount') overdueCount: number = 0; @LocalStorageProp('allJson') allJson: string = '[]'; @LocalStorageProp('version') version: string = ''; - private rows(): FbTaskRow9a[] { + private rows(): FbTaskCardRow4x4[] { try { - const all: FbTaskRow9a[] = JSON.parse(this.allJson) as FbTaskRow9a[]; - return all; + return JSON.parse(this.allJson) as FbTaskCardRow4x4[]; } catch (err) { return []; } @@ -53,7 +84,7 @@ struct FbTaskCard4x4 { }); } - /** 「+ 新建」→ 进 app 直开「新建任务」浮层(EntryAbility → modCreate 通道) */ + /** + 新建:拉起 App 直接打开新建任务浮层 */ private addOne(): void { postCardAction(this, { action: 'router', @@ -62,81 +93,160 @@ struct FbTaskCard4x4 { }); } - /** 卡上直接完成(id 必须转字符串:param 数值按 int32 传) */ - private complete(item: FbTaskRow9a): void { + /** + * 点勾选圈 → 交给 FormExtensionAbility 标完成。 + * ⭐ message 不拉 App(拉会闪界面),在 FormExtension 窗口内完成改库+重推。 + */ + private checkTask(id: number): void { postCardAction(this, { action: 'message', - params: { taskDone: `${item.id}` } + params: { taskDone: id.toString() } }); } - /** 列表单行:色条 + 标题/截止 + ✓ 完成 */ + /** 右上角 +(照 SyncCalendar 的圆形按钮) */ @Builder - listRow(item: FbTaskRow9a) { + addButton() { + Button() { + Text('+') + .fontSize(20) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(30) + .height(30) + .borderRadius(15) + .padding(0) + .backgroundColor('#A8574F') + .onClick((): void => { + this.addOne(); + }) + } + + /** 顶部标题栏:`任务` + 数字徽章 + 副行 + + */ + @Builder + headerBar() { Row({ space: 8 }) { - Column() - .width(3) - .height(34) - .borderRadius(2) - .backgroundColor(item.overdue ? '#C0533F' : item.boardColor) Column({ space: 2 }) { - Text(item.title) - .fontSize(13) - .fontColor('#2C231D') + Row({ space: 6 }) { + Text('任务') + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor('#2C231D') + // 数字徽章(照 SyncCalendar:底色 = 主题色、白字、小圆角) + Text(this.openCount.toString()) + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .backgroundColor('#A8574F') + .borderRadius(9) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + } + Text(this.state === 'ok' ? this.subLine() : (this.hint !== '' ? this.hint : '读取中…')) + .fontSize(10) + .fontColor(this.state === 'ok' ? this.subColor() : '#A8988A') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 4 }) { - if (item.overdue) { - Text('逾期') - .fontSize(9) - .fontColor('#C0533F') - .maxLines(1) - } - if (item.dueText !== '') { - Text(item.dueText) - .fontSize(9) - .fontColor(item.overdue ? '#C0533F' : '#A8988A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } else { - Text(item.status === 'doing' ? '进行中' : '待办') - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) - } - } - .width('100%') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - /* ⭐ 卡上直接点完成(子组件 onClick 优先于整卡,不会连带进 app) */ - Text('✓') - .fontSize(14) - .fontColor('#FFFFFF') - .width(26) - .height(26) - .textAlign(TextAlign.Center) - .borderRadius(13) - .backgroundColor(item.overdue ? '#C0533F' : '#A8574F') - .onClick((): void => this.complete(item)) + + this.addButton() + } + .width('100%') + } + + /** 副行:`11月4日 星期三 · 今日到期 N 项`(照 SyncCalendar 的拼接口径) */ + private subLine(): string { + const head: string = this.dateMd + (this.weekday !== '' ? ' ' + this.weekday : ''); + if (this.overdueCount > 0) { + return head + ' · 逾期 ' + this.overdueCount.toString() + ' 项'; + } + if (this.todayCount > 0) { + return head + ' · 今日到期 ' + this.todayCount.toString() + ' 项'; + } + return head + (this.openCount === 0 ? ' · 点击 + 添加' : ' · 今日无到期'); + } + + private subColor(): string { + if (this.overdueCount > 0) { + return '#C0533F'; + } + return this.todayCount > 0 ? '#FF9500' : '#A8988A'; + } + + /** 条目:圆形勾选圈 + 标题 + 副行 */ + @Builder + taskRow(item: FbTaskCardRow4x4) { + Row({ space: 8 }) { + Row() { + Text('') + .width(18) + .height(18) + .borderRadius(9) + .border({ width: 2, color: item.overdue ? '#C0533F' : item.boardColor }) + } + .padding({ right: 2 }) + .onClick((): void => { + this.checkTask(item.id); + }) + + Column({ space: 2 }) { + Text(item.title) + .fontSize(13) + .fontColor(item.overdue ? '#C0533F' : '#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.rowSub(item)) + .fontSize(10) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) } .alignItems(VerticalAlign.Center) .width('100%') - .padding({ left: 8, right: 8, top: 5, bottom: 5 }) - .borderRadius(8) - .backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC') + .padding({ left: 6, right: 8, top: 6, bottom: 6 }) + .borderRadius(10) + .backgroundColor('#F7F2EC') } - /** 空态 */ + /** 副行:`截止 · 看板`(照 SyncCalendar 的「截止 · 日历本」) */ + private rowSub(item: FbTaskCardRow4x4): string { + const parts: string[] = []; + if (item.overdue) { + parts.push('逾期'); + } + if (item.dueText !== '') { + parts.push(item.dueText); + } + if (item.boardName !== '') { + parts.push(item.boardName); + } + if (parts.length === 0) { + parts.push(item.status === 'doing' ? '进行中' : '待做'); + } + return parts.join(' · '); + } + + /** 空态(照 SyncCalendar:✓ 大字 + 说明 + 引导) */ @Builder - emptyHint() { - Column() { - Text(this.hint !== '' ? this.hint : '没有待办任务') - .fontSize(12) - .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') - .maxLines(3) + emptyState() { + Column({ space: 8 }) { + Text('✓') + .fontSize(30) + .fontColor('#D6C8BA') + Text(this.hint !== '' ? this.hint + : (this.openCount === 0 ? '所有任务都完成了' : '暂无任务')) + .fontSize(13) + .fontColor('#A8988A') + .maxLines(2) .textAlign(TextAlign.Center) + Text('点右上角 + 新建') + .fontSize(10) + .fontColor('#C7B8A8') } .width('100%') .layoutWeight(1) @@ -144,66 +254,28 @@ struct FbTaskCard4x4 { } build() { - Column({ space: 6 }) { - // ===== 顶栏:标题 + 更新时间 + + 新建 ===== - Row({ space: 6 }) { - Text('任务') - .fontSize(15) - .fontWeight(FontWeight.Bold) - .fontColor('#2C231D') - .maxLines(1) - .layoutWeight(1) - Text(this.updatedAt) - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) - Button('+') - .fontSize(13) - .fontColor('#FFFFFF') - .width(26) - .height(26) - .borderRadius(13) - .padding(0) - .backgroundColor('#A8574F') - .onClick((): void => this.addOne()) - } - .width('100%') - - // ===== 概述行 ===== - Row({ space: 8 }) { - Text(`待办 ${this.openCount} 条`) - .fontSize(11) - .fontColor('#A8574F') - .fontWeight(FontWeight.Medium) - Text(this.hint !== '' ? this.hint - : (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条' - : '点 ✓ 直接完成')) - .fontSize(10) - .fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } - .width('100%') + Column({ space: 8 }) { + this.headerBar() Divider() .strokeWidth(0.5) .color('#EFE6D8') - // ===== 全部待办(可滚动,可直接完成)===== if (this.rows().length === 0) { - this.emptyHint() + this.emptyState() } else { - List({ space: 5 }) { - ForEach(this.rows(), (item: FbTaskRow9a) => { + // List 虚拟化:待办多时滑动流畅 + List({ space: 4 }) { + ForEach(this.rows(), (item: FbTaskCardRow4x4, idx: number) => { ListItem() { - this.listRow(item) + this.taskRow(item) } - }, (item: FbTaskRow9a): string => `tl_${this.version}_${item.key}`) + }, (item: FbTaskCardRow4x4, idx: number) => `${this.version}_t${item.id}_${idx}`) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) } } .width('100%') @@ -211,7 +283,8 @@ struct FbTaskCard4x4 { .padding(12) .backgroundColor('#FFFFFF') .borderRadius(16) - // ⭐ 整卡可点 - .onClick((): void => this.openApp()) + .onClick((): void => { + this.openApp(); + }) } -} +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/card/FbTaskCard6x4.ets b/entry/src/main/ets/pages/card/FbTaskCard6x4.ets index a53c570..d4a4a6f 100644 --- a/entry/src/main/ets/pages/card/FbTaskCard6x4.ets +++ b/entry/src/main/ets/pages/card/FbTaskCard6x4.ets @@ -1,45 +1,77 @@ /** - * 家书 FamilyBook · 6×4 服务卡片(任务 · 上概述/新建,下滚动列表 + 卡上完成) + * 岁月家 FamilyBook · 6×4 任务服务卡片 * - * 用户 2026-10-25:「任务卡片,也需要4x4和4x6,跟任务模块类似,列出所有任务, - * 可以在卡片直接点击完成。有新建按钮。」 - * ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「新建任务」); - * 下半:`List` 装全部待办(数据侧排序:逾期 > 进行中 > 普通), - * 每行右侧一颗「✓ 完成」→ `postCardAction({action:'message', taskDone:id})` - * → `FbFormAbility.markTaskDone()` 调接口 → 重算重推。 + * 🔴 2026-11-04 修正。用户指出: + * 「你确定4×6的样式是对的吗?怎么左侧有一个待办未完成的显示呢? + * 它不应该跟4×4的一样吗?只不过是拉长了而已。现在这两个明显不一样。」 * - * ⚠️ 卡片不能自己发网络请求,完成动作必须借道 FormExtensionAbility。 - * ⚠️ ForEach key 带 version 前缀(v89 教训)。 + * **用户说得对,我第一版做错了。** + * + * ## 错在哪 + * + * 我读 SyncCalendar 时把 6×4 看成了"左右分栏 + 左侧竖排统计"。 + * 实际读它的 `build()`: + * + * build() { + * Column({ space: 8 }) { ← Column!上下结构 + * this.headerBar() + * Divider() + * List({ space: 4 }) { ... } ← 列表在下方,占满剩余 + * } + * } + * + * **6×4 与 4×4 的结构完全一样**(都是 `Column` 上下), + * 唯一区别是头部排版细节:6×4 把副行(日期·今日到期)和标题放同一行、 + * 用 `layoutWeight(1)` 撑开,因为 6 格宽有横向余量。 + * 高度多 2 格 → 列表能多看到几条。**没有左侧统计栏**。 + * + * ## 与 4×4 的真实差异(照 SyncCalendar) + * + * | | 4×4 | 6×4 | + * |---|---|---| + * | 结构 | `Column` 上下 | **同**(`Column` 上下) | + * | 头部 | 标题行 + 副行上下排在一个 Column 里 | 副行与标题**同一行**、layoutWeight 撑开 | + * | 高度 | 4 格 | 6 格 → 列表可见条数更多 | + * | 空态 | 有 | 同 | + * + * ⇒ 本文件就是 4×4 的「拉长版」,不是左右分栏。 + * + * ⚠️ 打勾用 `message` 不拉 App;列表用 `List` 不用 `Column + slice`。 + * ⚠️ ForEach key 带 `version` 前缀(v89 教训:不带则下次推送不重绘)。 + * ⚠️ + 用 `widgetAction: 'add'`(EntryAbility 认这个参数,见其 159 行); + * 用 `modCreate` 无效 —— 那是第一版写错的键名,表现是"只进任务页、不弹新建"。 */ -let storageTkB2 = new LocalStorage(); +let storageTkC = new LocalStorage(); /** 列表一行 */ -class FbTaskRow9b { +class FbTaskCardRow6x4 { id: number = 0; key: string = ''; title: string = ''; dueText: string = ''; - status: string = ''; + boardName: string = ''; boardColor: string = '#A8574F'; overdue: boolean = false; + status: string = ''; } -@Entry(storageTkB2) +@Entry(storageTkC) @Component struct FbTaskCard6x4 { @LocalStorageProp('state') state: string = 'loading'; @LocalStorageProp('hint') hint: string = ''; - @LocalStorageProp('updatedAt') updatedAt: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('openCount') openCount: number = 0; + @LocalStorageProp('todayCount') todayCount: number = 0; @LocalStorageProp('overdueCount') overdueCount: number = 0; @LocalStorageProp('allJson') allJson: string = '[]'; @LocalStorageProp('version') version: string = ''; - private rows(): FbTaskRow9b[] { + private rows(): FbTaskCardRow6x4[] { try { - const all: FbTaskRow9b[] = JSON.parse(this.allJson) as FbTaskRow9b[]; - return all; + return JSON.parse(this.allJson) as FbTaskCardRow6x4[]; } catch (err) { return []; } @@ -53,7 +85,14 @@ struct FbTaskCard6x4 { }); } - /** 「+ 新建」→ 进 app 直开「新建任务」浮层(EntryAbility → modCreate 通道) */ + /** + * + 新建:进 App 并**直接弹出新建任务**。 + * + * 🔴 参数名必须是 `widgetAction: 'add'` —— 这是 `EntryAbility` 唯一认的键 + * (见 `EntryAbility.ets` 第 159 行 `p['widgetAction']`, + * 以及第 207 行 `if (wantCreate && (key === 'movies' || key === 'tasks'))`)。 + * 第一版写的是 `modCreate: 'tasks'`,那个键没人读 ⇒ 只切到任务页、不弹新建。 + */ private addOne(): void { postCardAction(this, { action: 'router', @@ -62,81 +101,166 @@ struct FbTaskCard6x4 { }); } - /** 卡上直接完成(id 必须转字符串:param 数值按 int32 传) */ - private complete(item: FbTaskRow9b): void { + /** + * 点勾选圈 → 交给 FormExtensionAbility 标完成。 + * ⭐ message 不拉 App(拉会闪界面),在 FormExtension 窗口内完成改库+重推。 + */ + private checkTask(id: number): void { postCardAction(this, { action: 'message', - params: { taskDone: `${item.id}` } + params: { taskDone: id.toString() } }); } - /** 列表单行:色条 + 标题/截止 + ✓ 完成 */ + /** + 按钮(照 SyncCalendar 的圆形按钮) */ @Builder - listRow(item: FbTaskRow9b) { + addButton() { + Button() { + Text('+') + .fontSize(20) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(30) + .height(30) + .borderRadius(15) + .padding(0) + .backgroundColor('#A8574F') + .onClick((): void => { + this.addOne(); + }) + } + + /** + * 顶部标题栏。 + * + * 与 4×4 的差异:6×4 把副行(日期 · 今日到期)与标题放**同一行**、 + * 用 `layoutWeight(1)` 撑开(6 格宽有横向余量);4×4 是上下两行。 + * 这是 SyncCalendar 两个文件的真实差别(diff 可见), + * 但**骨架都是 Column**,不是左右分栏。 + */ + @Builder + headerBar() { + Row({ space: 6 }) { + Row({ space: 6 }) { + Text('任务') + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor('#2C231D') + // 数字徽章(底色 = 主题色、白字、小圆角) + Text(this.openCount.toString()) + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .backgroundColor('#A8574F') + .borderRadius(9) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + } + + Text(this.state === 'ok' ? this.subLine() : (this.hint !== '' ? this.hint : '读取中…')) + .fontSize(10) + .fontColor(this.state === 'ok' ? this.subColor() : '#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + + this.addButton() + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /** 副行:`11月4日 星期三 · 今日到期 N 项`(照 SyncCalendar 的拼接口径) */ + private subLine(): string { + const head: string = this.dateMd + (this.weekday !== '' ? ' ' + this.weekday : ''); + if (this.overdueCount > 0) { + return head + ' · 逾期 ' + this.overdueCount.toString() + ' 项'; + } + if (this.todayCount > 0) { + return head + ' · 今日到期 ' + this.todayCount.toString() + ' 项'; + } + return head + (this.openCount === 0 ? ' · 点击 + 添加' : ' · 今日无到期'); + } + + private subColor(): string { + if (this.overdueCount > 0) { + return '#C0533F'; + } + return this.todayCount > 0 ? '#FF9500' : '#A8988A'; + } + + /** 条目:圆形勾选圈(20px,比 4×4 的 18px 略大)+ 标题 + 副行 */ + @Builder + taskRow(item: FbTaskCardRow6x4) { Row({ space: 8 }) { - Column() - .width(3) - .height(34) - .borderRadius(2) - .backgroundColor(item.overdue ? '#C0533F' : item.boardColor) + Row() { + Text('') + .width(20) + .height(20) + .borderRadius(10) + .border({ width: 2, color: item.overdue ? '#C0533F' : item.boardColor }) + } + .padding({ right: 2 }) + .onClick((): void => { + this.checkTask(item.id); + }) + Column({ space: 2 }) { Text(item.title) .fontSize(13) - .fontColor('#2C231D') + .fontColor(item.overdue ? '#C0533F' : '#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.rowSub(item)) + .fontSize(10) + .fontColor('#A8988A') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 4 }) { - if (item.overdue) { - Text('逾期') - .fontSize(9) - .fontColor('#C0533F') - .maxLines(1) - } - if (item.dueText !== '') { - Text(item.dueText) - .fontSize(9) - .fontColor(item.overdue ? '#C0533F' : '#A8988A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } else { - Text(item.status === 'doing' ? '进行中' : '待办') - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) - } - } - .width('100%') } - .layoutWeight(1) .alignItems(HorizontalAlign.Start) - /* ⭐ 卡上直接点完成(子组件 onClick 优先于整卡,不会连带进 app) */ - Text('✓') - .fontSize(14) - .fontColor('#FFFFFF') - .width(26) - .height(26) - .textAlign(TextAlign.Center) - .borderRadius(13) - .backgroundColor(item.overdue ? '#C0533F' : '#A8574F') - .onClick((): void => this.complete(item)) + .layoutWeight(1) } .alignItems(VerticalAlign.Center) .width('100%') - .padding({ left: 8, right: 8, top: 5, bottom: 5 }) - .borderRadius(8) - .backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC') + .padding({ left: 6, right: 8, top: 6, bottom: 6 }) + .borderRadius(10) + .backgroundColor('#F7F2EC') } - /** 空态 */ + /** 副行:`截止 · 看板`(照 SyncCalendar 的「截止 · 日历本」) */ + private rowSub(item: FbTaskCardRow6x4): string { + const parts: string[] = []; + if (item.overdue) { + parts.push('逾期'); + } + if (item.dueText !== '') { + parts.push(item.dueText); + } + if (item.boardName !== '') { + parts.push(item.boardName); + } + if (parts.length === 0) { + parts.push(item.status === 'doing' ? '进行中' : '待做'); + } + return parts.join(' · '); + } + + /** 空态(照 SyncCalendar:✓ 大字 + 说明 + 引导) */ @Builder - emptyHint() { - Column() { - Text(this.hint !== '' ? this.hint : '没有待办任务') - .fontSize(12) - .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') - .maxLines(3) + emptyState() { + Column({ space: 8 }) { + Text('✓') + .fontSize(30) + .fontColor('#D6C8BA') + Text(this.hint !== '' ? this.hint + : (this.openCount === 0 ? '所有任务都完成了' : '暂无任务')) + .fontSize(13) + .fontColor('#A8988A') + .maxLines(2) .textAlign(TextAlign.Center) + Text('点右上角 + 新建') + .fontSize(10) + .fontColor('#C7B8A8') } .width('100%') .layoutWeight(1) @@ -144,66 +268,30 @@ struct FbTaskCard6x4 { } build() { - Column({ space: 6 }) { - // ===== 顶栏:标题 + 更新时间 + + 新建 ===== - Row({ space: 6 }) { - Text('任务') - .fontSize(15) - .fontWeight(FontWeight.Bold) - .fontColor('#2C231D') - .maxLines(1) - .layoutWeight(1) - Text(this.updatedAt) - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) - Button('+') - .fontSize(13) - .fontColor('#FFFFFF') - .width(26) - .height(26) - .borderRadius(13) - .padding(0) - .backgroundColor('#A8574F') - .onClick((): void => this.addOne()) - } - .width('100%') - - // ===== 概述行 ===== - Row({ space: 8 }) { - Text(`待办 ${this.openCount} 条`) - .fontSize(11) - .fontColor('#A8574F') - .fontWeight(FontWeight.Medium) - Text(this.hint !== '' ? this.hint - : (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条' - : '点 ✓ 直接完成')) - .fontSize(10) - .fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } - .width('100%') + /* ⚠️ Column 上下结构 —— 与 4×4 完全一致,只是高度多 2 格。 + 第一版误做成 Row 左右分栏(左侧竖排统计),已按用户指正改回。 */ + Column({ space: 8 }) { + this.headerBar() Divider() .strokeWidth(0.5) .color('#EFE6D8') - // ===== 全部待办(可滚动,可直接完成)===== if (this.rows().length === 0) { - this.emptyHint() + this.emptyState() } else { - List({ space: 5 }) { - ForEach(this.rows(), (item: FbTaskRow9b) => { + // List 虚拟化:6×4 高、能放更多条,必须可滚 + List({ space: 4 }) { + ForEach(this.rows(), (item: FbTaskCardRow6x4, idx: number) => { ListItem() { - this.listRow(item) + this.taskRow(item) } - }, (item: FbTaskRow9b): string => `tl_${this.version}_${item.key}`) + }, (item: FbTaskCardRow6x4, idx: number) => `${this.version}_t${item.id}_${idx}`) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) } } .width('100%') @@ -211,7 +299,8 @@ struct FbTaskCard6x4 { .padding(12) .backgroundColor('#FFFFFF') .borderRadius(16) - // ⭐ 整卡可点 - .onClick((): void => this.openApp()) + .onClick((): void => { + this.openApp(); + }) } -} +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index 753cb9f..161a1b5 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -71,6 +71,7 @@ import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FB_BACK } from '../../common/FbBack'; import { fbOpenNavigation } from '../../common/FbNav'; +import { FbDiag } from '../../common/FbDiag'; import { fbModuleOf, FbModuleDef, fbThemeVars } from '../../model/Modules'; /** 日志域(与 `FbBackgroundSync` / `FbNav` 同一个值,端上统一 0x0000) */ @@ -208,6 +209,8 @@ export struct FbHomePage { @Local tskEditBoardId: number = 0; @Local tskEditCatId: number = 0; @Local tskEditRepeat: string = ''; + /** 编辑时的原状态(todo/doing/done) */ + @Local tskEditStatus: string = 'todo'; /** * 面板的"重画脉冲"(v79)。 * @@ -284,6 +287,9 @@ export struct FbHomePage { } aboutToAppear(): void { + /* 诊断设施在这里初始化:地址联想发生在编辑弹窗里, + 那时用户可能还没点过导航 ⇒ 不能等导航时才 init。 */ + FbDiag.init(this.getUIContext().getHostContext()); // 显示方式来自日程设置(启动时已预载进内存,可同步读) this.calStyle = FB_CAL_SETTINGS.getStyle(); /* 🔴 v73:**首页也要登记返回手势**。 @@ -486,6 +492,7 @@ export struct FbHomePage { /* ⚠️ `fbOpenNavigation` 现在是 async(内部要 await 地理编码), 不接住 rejection 会报未处理异常。这里明确咽掉并提示 —— 导航失败不该把页面弄崩。 */ + FbDiag.init(ctx); fbOpenNavigation(ctx, place, title).catch((err: Error): void => { hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err)); }); @@ -554,6 +561,7 @@ export struct FbHomePage { this.tskEditBoardId = d.base.boardId; this.tskEditCatId = d.categoryId; this.tskEditRepeat = d.repeat; + this.tskEditStatus = d.base.status !== '' ? d.base.status : 'todo'; this.tskEditOpen = true; } @@ -582,6 +590,7 @@ export struct FbHomePage { this.tskEditBoardId = 0; this.tskEditCatId = 0; this.tskEditRepeat = ''; + this.tskEditStatus = 'todo'; await this.loadTaskMeta(); this.tskEditOpen = true; } @@ -1123,7 +1132,7 @@ export struct FbHomePage { Row() { FbStatCell({ value: this.statToday, label: '今天日程' }) FbStatCell({ value: this.statDoing, label: '在做' }) - FbStatCell({ value: this.statTodo, label: '待办' }) + FbStatCell({ value: this.statTodo, label: '任务' }) FbStatCell({ value: this.statOverdue, label: '逾期', alarm: true }) } .width('100%') @@ -1198,7 +1207,7 @@ export struct FbHomePage { Column({ space: 0 }) { if (this.tasks.length === 0) { Row() { - Text('没有进行中的任务,也没有待办。') + Text('没有进行中的任务,也没有待做的任务。') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.muted) } @@ -1628,6 +1637,7 @@ export struct FbHomePage { editingBoardId: this.tskEditBoardId, editingCatId: this.tskEditCatId, editingRepeat: this.tskEditRepeat, + editingStatus: this.tskEditStatus, onClose: (): void => { this.tskEditOpen = false; }, @@ -1783,7 +1793,7 @@ export struct FbHomePage { } } if (todo > 0) { - return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待办'; + return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待做'; } return doing.toString() + ' 件在做'; } @@ -1825,7 +1835,7 @@ export struct FbHomePage { .textOverflow({ overflow: TextOverflow.Ellipsis }) // 状态词(待办 / 进行中)—— 用户第 3 条要求 - Text(t.status === 'doing' ? '进行中' : '待办') + Text(t.status === 'doing' ? '进行中' : '待做') .fontSize(10) .fontColor(t.status === 'doing' ? '#A8574F' : FbColors.faint) .backgroundColor(t.status === 'doing' ? '#14A8574F' : FbColors.inputBg) diff --git a/entry/src/main/ets/pages/settings/FbSettingsPage.ets b/entry/src/main/ets/pages/settings/FbSettingsPage.ets index 86e8808..30d6e53 100644 --- a/entry/src/main/ets/pages/settings/FbSettingsPage.ets +++ b/entry/src/main/ets/pages/settings/FbSettingsPage.ets @@ -66,6 +66,7 @@ export struct FbSettingsPage { @Local bgSyncOn: boolean = true; /** 上次同步时间的人话('' = 还没读过 / 从没同步过) */ @Local bgLastNote: string = ''; + /** 设置页的主题色(与模块「个人设置」一致,取砖红) */ private accent: string = '#A84A28'; diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 56aa179..8c6ec56 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -81,6 +81,8 @@ export struct FbTasksPage { /** v79:分类 / 循环(编辑时回填;0 / '' = 不分类、不循环) */ @Local editingCatId: number = 0; @Local editingRepeat: string = ''; + /** 编辑时的原状态(todo/doing/done)—— 表单里要回填,改过才额外打 /status */ + @Local editingStatus: string = 'todo'; /** 正在改状态的那条(防连点打重复请求) */ @Local busyId: number = 0; /** 「分类与看板」浮层是否打开 */ @@ -241,6 +243,7 @@ export struct FbTasksPage { this.editingBoardId = 0; this.editingCatId = 0; this.editingRepeat = ''; + this.editingStatus = 'todo'; this.opNote = ''; this.editOpen = true; } @@ -279,6 +282,7 @@ export struct FbTasksPage { this.editingBoardId = d.base.boardId; this.editingCatId = d.categoryId; this.editingRepeat = d.repeat; + this.editingStatus = d.base.status !== '' ? d.base.status : 'todo'; this.opNote = ''; this.editOpen = true; } @@ -370,7 +374,7 @@ export struct FbTasksPage { .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) - Text(this.todoCount().toString() + ' 项待办' + Text(this.todoCount().toString() + ' 项任务' + (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : '')) .fontSize(FbMetric.fontTiny) .fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint) @@ -573,6 +577,7 @@ export struct FbTasksPage { editingBoardId: this.editingBoardId, editingCatId: this.editingCatId, editingRepeat: this.editingRepeat, + editingStatus: this.editingStatus, onClose: (): void => { this.editOpen = false; }, diff --git a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets index 33644ff..c736015 100644 --- a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets @@ -1,3 +1,5 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { common } from '@kit.AbilityKit'; /** * 家书 FamilyBook · 旅程详情页(鸿蒙端) * @@ -46,8 +48,10 @@ import { FB_SET_TRIPS } from '../../model/FbSettingsMenus'; import { fbImageSrc } from '../../data/FbAttachQueue'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { - deleteTrip, deleteTripNode, fetchTripDetail, fetchTripNodes, fetchTripSummary + deleteTrip, deleteTripNode, fetchTripDetail, fetchTripNodes, fetchTripSummary, + nodeAction } from '../../data/FbTripRepo'; +import { FbApiResult } from '../../data/FbHttp'; import { FbTripDetail, FbTripItem, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../../model/Types'; @@ -80,6 +84,108 @@ function fbNodeTypeIcon(key: string): string { return '📌'; } +/** + * 行程卡左侧的图标:**出行方式优先,节点类型兜底**。 + * + * 🔴 2026-11-05(用户:「明明选的是坐火车,图标显示的还是一辆汽车… + * 鸿蒙版的图标显示的是一个像订书钉似的什么图标,这都不对」)。 + * + * 原来只按 `nodeType` 取 ⇒ 「赶路」永远显示 🚗,哪怕实际坐的是火车; + * 自定义中文类型的还会走到兜底 📌(用户形容"像订书钉")。 + * + * 现在: + * ① 有 `travel_mode` → 用 `fbModeIcon()`(火车 🚄 / 飞机 ✈️ / 轮船 🚢…) + * ② 没有 → 退回 `fbNodeTypeIcon()`(游玩 🎡 / 用餐 🍜 / 住宿 🏨) + * + * ⚠️ 网页端必须同步改(`Trip::leadIcon()`),否则两边图标不一致。 + */ +/** + * 打开系统地图导航(#51)。 + * + * 用 `geo:` URI —— 系统会弹出已安装的地图应用让用户选(高德/百度/花瓣…), + * 不需要在端上集成任何地图 SDK,也不用申请权限。 + * + * ⚠️ 格式是 `geo:lat,lng?q=lat,lng(名称)`:三个都要给, + * 少了 `q` 参数部分地图应用会只标点、不起导航。 + * ⚠️ 名称要做 URL 编码:中文地名直接拼进去会让 URI 解析失败。 + */ +/** + * 打开地图导航。 + * + * 🔴 #52(用户):「目的地,比如北京站、北京北、北京南站、青岛北站什么的, + * 点开之后为什么没有打开高德呢?它应该使用高德直接打开才行啊。 + * **直接打开高德进行导航**,比如青岛北站或者北京南站,打开后直接进入高德导航就可以了。」 + * + * ## 为什么原来打不开 + * + * 只用 `action: 'ohos.want.action.viewData'` + `geo:` URI。 + * `viewData` 靠 MIME/scheme 匹配应用,而 `geo:` 在鸿蒙上**不是所有机型都有 + * 注册处理者** ⇒ `startAbility` 抛异常 ⇒ 被 catch 吞掉 ⇒ 用户看到"没反应"。 + * + * ## 三级兜底 + * + * ① **高德 deeplink**(`amapuri://route/plan/…`)—— 直接进导航规划页 + * ② `geo:` 通用协议 + * ③ 高德网页版(任何浏览器都能开) + * + * ⚠️ 必须分三级:不同机型、不同高德版本注册的 scheme 不一样, + * 只试一种必然在部分设备上失败。 + * ⚠️ 每一级都要 `await`,因为 `startAbility` 是异步的 —— + * 不 await 就判断不了成功与否,会连试三次弹三个框。 + */ +async function fbOpenMap(lat: string, lng: string, name: string): Promise { + const ctx = getContext() as common.UIAbilityContext; + const nm: string = name !== '' ? name : '目的地'; + + /* ① 高德地图 deeplink:直接进「路线规划」页,dev=0 表示起终点用经纬度 */ + const amapUri: string = 'amapuri://route/plan/?dlat=' + lat + '&dlon=' + lng + + '&dname=' + encodeURIComponent(nm) + '&dev=0&t=0'; + if (await fbTryWant(ctx, amapUri)) { + return; + } + + /* ② 通用 geo 协议(系统会让用户选地图应用) */ + const geoUri: string = 'geo:' + lat + ',' + lng + + '?q=' + lat + ',' + lng + '(' + encodeURIComponent(nm) + ')'; + if (await fbTryWant(ctx, geoUri)) { + return; + } + + /* ③ 最后兜底:高德网页版(uri 走 https,浏览器一定能开) */ + const web: string = 'https://uri.amap.com/marker?position=' + lng + ',' + lat + + '&name=' + encodeURIComponent(nm) + '&callnative=1'; + if (!(await fbTryWant(ctx, web))) { + hilog.warn(0x0000, 'FbTripDetail', '三种方式都没打开地图'); + } +} + +/** + * 尝试用系统能力打开一个 uri。 + * @return 是否成功(true = 有应用接住了) + */ +async function fbTryWant(ctx: common.UIAbilityContext, uri: string): Promise { + try { + await ctx.startAbility({ + action: 'ohos.want.action.viewData', + uri: uri + }); + return true; + } catch (err) { + hilog.info(0x0000, 'FbTripDetail', '打开失败,试下一种:%{public}s', uri.substring(0, 30)); + return false; + } +} + +function fbNodeLeadIcon(nodeType: string, mode: string): string { + if (mode !== '') { + const mi: string = fbModeIcon(mode); + if (mi !== '') { + return mi; + } + } + return fbNodeTypeIcon(nodeType); +} + /** 节点类型 → 中文名(老数据的英文 key;自定义名字原样返回) */ function fbNodeTypeWord(key: string): string { if (key === 'play') { @@ -282,6 +388,14 @@ export struct FbTripDetailPage { @Local nodeSheetTripId: number = 0; /** 加行程浮层的预选阶段('plan' / 'actual') */ @Local nodeSheetStage: string = 'plan'; + /** + * 正在编辑的那一段(`null` = 不是编辑,是新增)。 + * + * 🔴 2026-11-05 新增。用户:「鸿蒙端现在行程还不能编辑,得尽快把这个功能做完。」 + */ + @Local editNode: FbTripNode | null = null; + /** 状态切换请求进行中(防连点:上一请求没回来就再点会乱序) */ + @Local statusBusy: boolean = false; /** 三项设置浮层 */ @Local vehiclesOpen: boolean = false; @Local travelModesOpen: boolean = false; @@ -603,10 +717,15 @@ export struct FbTripDetailPage { tripId: this.nodeSheetTripId, accent: this.accent, startStage: this.nodeSheetStage, + /* ⚠️ 必须传:`null` = 新增,有值 = 编辑那一段。 + 浮层内部据此切换标题、回填、保存走 PATCH 还是 POST。 */ + editing: this.editNode, onClose: (): void => { this.nodeSheetTripId = 0; + this.editNode = null; // ⚠️ 不清会下次误进编辑模式 }, onSaved: (): void => { + this.editNode = null; this.onNodeSaved(); } }) @@ -1004,8 +1123,13 @@ export struct FbTripDetailPage { // ⚠️ 同上:不写左右 margin(外层已有 pagePad padding),只留纵向间距 .margin({ top: FbMetric.cardGap }) } else { - this.stageGroup('plan', '计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行') + /* 🔴 #48(2026-11-05):**实际行程放最前**。 + 用户:「到最后整个旅程结束之后,我们最终看到的是实际行程。」 + ⇒ actual → plan → skipped(未成行走到底部)。 + ⚠️ 这与本文件上方旧注释(照网页版 plan 在前)相反 —— 那是 #48 之前的 + 口径。#48 用户明确要求实际行程在前,两端同步改。 */ this.stageGroup('actual', '实际行程', '已走过 —— 也出现在日历与首页「往年今日」') + this.stageGroup('plan', '计划的行程', '还没走 —— 可以开始 / 完成 / 取消') this.stageGroup('skipped', '未成行', '这次没去成;不上日程,但在这里留着可查') } } @@ -1070,7 +1194,7 @@ export struct FbTripDetailPage { Row({ space: 8 }) { // ---- 左侧:类型图标 + 时间轴圆点 ---- Column({ space: 6 }) { - Text(fbNodeTypeIcon(n.nodeType)) + Text(fbNodeLeadIcon(n.nodeType, n.mode)) .fontSize(16) FbDot({ dia: 7, @@ -1083,7 +1207,10 @@ export struct FbTripDetailPage { // ---- 右侧:内容 ---- Column({ space: 5 }) { - // ① 标题行 + /* ① 标题行(右侧是「编辑」「删除」) + 🔴 2026-11-05(用户:「编辑和删除要和网页版一样,放在这个行程的右上角」): + 原来这两个动作在**卡片底部**另起一行,用户要的是标题行右上角 + —— 与网页端 `.node-head` 的布局一致。 */ Row({ space: 6 }) { Text(n.title) .fontSize(FbMetric.fontBody) @@ -1092,8 +1219,30 @@ export struct FbTripDetailPage { .layoutWeight(1) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) + + /* 🔴 #47(用户:「现在就是两个字放在那儿,字号小,也不特别醒目。 + 应该像网页端一样,做成两个按钮,颜色也尽可能保持一致。」) + ⇒ 改成**胶囊按钮**:有底色、圆角、内边距,与网页端 `.btn-ghost` 同观感。 */ + this.pillBtn('编辑', '#B8862B', false, (): void => { + this.editNode = n; + this.nodeSheetTripId = this.tripId; + this.nodeSheetStage = n.stage; + }) + /* 🔴 #48:**「取消」在「删除」前面**(用户明确要求)。 + 取消 = 不去了,数据保留(stage=skipped,列在「未成行」) + 删除 = 彻底不要了(软删) + ⇒ 只有**计划行程**能取消;实际行程已经发生,谈不上"取消"。 */ + if (n.stage !== 'actual' && n.stage !== 'skipped') { + this.pillBtn('取消', '#B8862B', false, (): void => { + this.doNodeAction(n, 'cancel'); + }) + } + this.pillBtn('删除', '#C0533F', false, (): void => { + this.delNodeId = n.id; + }) } .width('100%') + .alignItems(VerticalAlign.Top) // ② 徽章行:类型 / 修正次数 / 交通方式 Row({ space: 6 }) { @@ -1172,19 +1321,40 @@ export struct FbTripDetailPage { 允许整段铺开。地址长就把卡片撑高 —— 详情页本来就是往下滚的瀑布, 高度不是稀缺资源,"看不全"才是。 ⚠️ 两行都空才整块不显示(只填了一头也照样显示,不要因为缺一头就全藏掉)。 */ - if (n.fromPlace !== '' || n.toPlace !== '') { - Column({ space: 4 }) { + /* ⑤ 出发地 → 到达地 + 🔴 2026-11-05 重做(用户:「我建议直接青岛北站,然后用个那种标签, + 然后右边一个箭头,然后到北京南站就得了,不用写从哪到哪」)。 + + 原来:上下两行「从 XXX」「到 XXX」—— 占两行、且"从/到"单独占一列, + 地址长短不一时看着就"没对齐"。 + 现在:**一行**「[青岛北站] → [北京南站]」,两端各一个胶囊标签, + 箭头只作分隔,整体垂直居中 ⇒ 视觉上一行到底、永远对齐。 + + ⚠️ `fromPlace`/`toPlace` 都空时**退回 `place`**: + 老数据(TripLog 迁移)只有 `place`,没有拆分字段。 + 不兜底的话用户会看到"什么都没有"(他报的"鸿蒙端不显示"就是这个)。 + 有了兜底至少能显示一个地名。 */ + if (n.fromPlace !== '' || n.toPlace !== '' || n.place !== '') { + Row({ space: 6 }) { if (n.fromPlace !== '') { - this.placeLine('从', n.fromPlace) + this.routePill(n.fromPlace, n.fromAddr, n.fromLat, n.fromLng, true) + } else if (n.place !== '' && n.toPlace === '') { + /* 只有 place(老数据):单标签,不画箭头 */ + this.routePill(n.place, '', '', '', false) + } + if (n.fromPlace !== '' && n.toPlace !== '') { + Text('→') + .fontSize(12) + .fontColor(FbColors.faint) } if (n.toPlace !== '') { - this.placeLine('到', n.toPlace) + this.routePill(n.toPlace, n.toAddr, n.toLat, n.toLng, false) } } .width('100%') - .padding({ top: 1, bottom: 1 }) + .alignItems(VerticalAlign.Center) + .padding({ top: 2, bottom: 2 }) } - // ⑥ 详细说明 if (n.detail !== '') { Text(n.detail) @@ -1286,30 +1456,16 @@ export struct FbTripDetailPage { .width('100%') } - // ⑩ 自己的两个动作 - Row({ space: 8 }) { - Column().layoutWeight(1) - Text('编辑') - .fontSize(FbMetric.fontTiny) - .fontColor('#B8862B') - .padding({ left: 10, right: 10, top: 4, bottom: 4 }) - .onClick((): void => { - /* ⚠️ 端上暂时只能"加一段",改一段要 `PATCH /trips//nodes/` - —— 服务端 `Api::dispatch()` 只解析三段(`/res/id/sub`), - 第 4 段会被丢掉,所以这条路由现在**不存在**。 - 与其放一颗点了没反应的按钮(本项目红线),不如明说。 */ - this.loadNote = '端上还不能改单段行程,请到网页版修改。'; - }) - Text('删除') - .fontSize(FbMetric.fontTiny) - .fontColor('#C0533F') - .padding({ left: 10, right: 10, top: 4, bottom: 4 }) - .onClick((): void => { - this.delNodeId = n.id; - }) - } - .width('100%') - .alignItems(VerticalAlign.Center) + /* ⑪ 状态动作(#47) + 🔴 用户:「在计划行程里面点击开始执行,它的状态自然变成进行中。 + 如果这个事情已经做完了,点了已完成,它自然就变成已完成的状态。 + 如果它没去,取消了,那就自然变成已取消。」 + + ⚠️ 这里显示的是**动作**,不是"当前状态"文字。 + 当前状态靠 chip 的**实心底**表达 —— 用户点名要的那种效果 + (「选中其中一个按钮之后…加了一个椭圆的那种框那种背景」)。 + ⚠️ 按当前 status 只显示有意义的动作(已完成的不再显示"开始执行")。 */ + this.nodeActions(n) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) @@ -1335,6 +1491,188 @@ export struct FbTripDetailPage { * ⇒ 不写 `maxLines` + 不写 `Ellipsis`:`Text` 默认整段换行,卡片跟着变高。 * ⚠️ 标签用固定宽度(16vp):两行的地址左边缘才能对齐,看起来像一张表。 */ + @Builder + /** + * 路线胶囊标签(出发地 / 到达地)。 + * + * 🔴 2026-11-05。用户要的是「[青岛北站] → [北京南站]」这种标签样式, + * 不要"从/到"这种字。 + * + * ⚠️ 两个标签用**同一个** builder:样式一致才对齐 + * (用户抱怨的"位都没对齐",本质是两侧元素样式不同)。 + * ⚠️ 长地名 `maxLines(1)` + 省略号 + `maxWidth`:不撑破卡片。 + */ + /** + * 一行状态动作按钮(#47)。 + * + * 按当前 `status` 决定显示哪些动作 —— 只给"有意义"的选择: + * planned → 计划中(实心) / 开始执行 / 已完成 / 已取消 + * ongoing → 进行中(实心) / 已完成 / 已取消 / 退回计划 + * done → 已完成(实心) / 退回进行中 + * cancelled → 已取消(实心) / 重新开始 + * + * ⚠️ 当前状态用**实心底**的 chip 表示,这正是用户要的效果: + * 「选中其中一个按钮之后…除了图标和文字颜色变化, + * 还加了一个椭圆的那种框那种背景」。 + */ + /** + * 行程动作按钮组(#48)。 + * + * 🔴 用户原话: + * 「计划行程右上角就不是两个按钮了,而是至少四个按钮: + * 一个是开始,一个是完成,一个是编辑,一个是删除。 + * 在删除前面还应该有一个按钮,这个按钮是取消, + * 指的就是不去了,但这个事情我并不把它删掉。」 + * 「**实际行程是没有状态的**,实际行程就是实际行程,没有所谓的状态。 + * 实际行程指的是已经发生的事情,它没有什么状态了,**只有计划行程有状态**。」 + * + * ## 规则 + * + * | stage | status | 显示 | + * |---|---|---| + * | plan | planned | 计划中(实心) + 开始 | + * | plan | ongoing | 进行中(实心) + 完成 | + * | actual | — | **无状态**(实际行程没状态) | + * | skipped | — | **无状态**(已取消,不再转) | + * + * 编辑/取消/删除在标题行(右上角),这个 builder 只管**状态动作**。 + */ + @Builder + nodeActions(n: FbTripNode) { + if (n.stage === 'actual' || n.stage === 'skipped') { + /* ⚠️ 实际行程 / 未成行**没有任何状态** —— 用户明确要求。 + 这里整块不渲染(不是渲染成空的),避免留下一条空白行。 */ + } else { + Row({ space: 6 }) { + this.pillBtn(n.status === 'ongoing' ? '进行中' : '计划中', + n.status === 'ongoing' ? '#2F6FA8' : '#8A8A8A', true, (): void => { + }) + if (n.status === 'ongoing') { + this.pillBtn('完成', '#3F7D5A', false, (): void => { + this.doNodeAction(n, 'done'); + }) + } else { + this.pillBtn('开始', '#2F6FA8', false, (): void => { + this.doNodeAction(n, 'start'); + }) + this.pillBtn('完成', '#3F7D5A', false, (): void => { + this.doNodeAction(n, 'done'); + }) + } + } + .width('100%') + .padding({ top: 2, bottom: 2 }) + } + } + + /** + * 改一段行程的执行状态(#47)。 + * + * ⚠️ 成功后**只重拉节点**,不重建整页:重建会丢滚动位置, + * 用户刚点完就跳回顶部很难受。 + * ⚠️ 失败用 `loadNote` 提示 —— 静默失败最糟(用户以为改了其实没改)。 + */ + /** + * 执行行程动作(#48)。 + * + * · `start` → status=ongoing,服务端记录真实开始时间 + * · `done` → stage 转 actual(变成实际行程),记录真实结束时间 + * · `cancel` → stage=skipped(保留数据,不是删除) + * + * ⚠️ 动作会改变分组(计划→实际/未成行),成功后必须**重拉节点**。 + * ⚠️ 失败用 `loadNote` 提示 —— 静默失败最糟。 + */ + private async doNodeAction(n: FbTripNode, act: string): Promise { + if (this.statusBusy) { + return; // 防连点 + } + this.statusBusy = true; + const res: FbApiResult = await nodeAction( + FB_TOKEN_STORE.get(), this.tripId, n.id, act); + this.statusBusy = false; + if (!res.ok) { + this.loadNote = res.error !== '' ? res.error : '操作没成功,请重试。'; + return; + } + this.loadNote = ''; + const rows2: FbTripNode[] = await fetchTripNodes(FB_TOKEN_STORE.get(), this.tripId); + this.nodes = rows2; + } + + /** + * 通用胶囊按钮。 + * + * 🔴 #47(用户:「做成两个按钮,颜色也尽可能保持一致… + * 选中其中一个按钮之后,除了图标和文字颜色变化,还加了一个椭圆的灰色底, + * 这样更能清晰表示现在正在被选中是哪一个」)。 + * + * @param label 文案 + * @param color 主色(文字与选中底都用它) + * @param selected 选中态 —— 加**灰色椭圆底**(用户明确点名要的效果) + * @param onTap 点击 + */ + @Builder + pillBtn(label: string, color: string, selected: boolean, onTap: () => void) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(selected ? '#FFFFFF' : color) + .padding({ left: 9, right: 9, top: 4, bottom: 4 }) + .backgroundColor(selected ? color : fbAlpha(color, 0.10)) + .borderRadius(FbMetric.radiusPill) + .maxLines(1) + .onClick((): void => { + onTap(); + }) + } + + /** + * 地点标签(出发地 / 到达地)。 + * + * 🔴🔴 #51(用户):「不是端上选地址,而是端上应该把服务器上的地址、 + * 详细地址拉下来。」 + * + * ⇒ 服务端存了三样东西,端上**三样都要用上**: + * · `from_place` 地名 → 主行胶囊(「北京南站」) + * · `from_addr` 详细地址 → 副行小字(「北京市丰台区永定门外大街…」) + * · `from_lng/lat` 坐标 → 让整块可点,调系统地图导航 + * + * ⚠️ 详细地址**不省略**:用户 2026-10-22 抱怨过「地址特别长,显示不全」 + * ⇒ `maxLines(2)` 且**不写** `textOverflow`,让卡片自然变高。 + * ⚠️ 没有详细地址时不显示副行(不要留一行空白)。 + */ + @Builder + routePill(place: string, addr: string, lat: string, lng: string, isStart: boolean) { + Column({ space: 2 }) { + Text(place) + .fontSize(12) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '100%' }) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .backgroundColor(isStart ? fbAlpha(this.accent, 0.10) : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + + /* 🔴 #51(用户):「详细地址,不是显示在行程列表中,而是显示在编辑时。 + 列表中,仍然是例如青岛北站,北京南站这样的,但是点击这两个标签, + 可以打开高德进行导航。所以,这里面应该拿到详细地址,但是不要显示出来, + 只有编辑的时候,才显示。」 + + ⇒ 列表里**只显示地名胶囊**,详细地址改到悬停提示(`bindPopup` 太重, + 这里用 `Text` 的 `tooltip` 效果不值得),直接不显示。 + 点胶囊 → 用坐标打开高德导航。 */ + } + .alignItems(HorizontalAlign.Start) + .constraintSize({ maxWidth: '46%' }) + .onClick((): void => { + /* 有坐标才能导航;没有就什么都不做(不要弹"无法导航"的废话) */ + if (lat !== '' && lng !== '') { + fbOpenMap(lat, lng, place).catch((): void => { + }); + } + }) + } + @Builder placeLine(tag: string, place: string) { Row({ space: 6 }) { diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 7d5da34..5cdae57 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -12,6 +12,27 @@ { "name": "ohos.permission.INTERNET" }, + /* 🔴 2026-11-03:镜像到系统日历需要读写系统日历。 + ⚠️ 这两个是 user_grant 权限,必须运行时申请,且 + **只能在用户点开"镜像到系统日历"开关那一刻申请** + (启动时申请违反合规要求,会被审核拒)。 + 申请逻辑见 data/FbCalMirror.requestPermission()。 */ + { + "name": "ohos.permission.READ_CALENDAR", + "reason": "$string:perm_read_calendar", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "inuse" + } + }, + { + "name": "ohos.permission.WRITE_CALENDAR", + "reason": "$string:perm_write_calendar", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "inuse" + } + }, { "name": "ohos.permission.GET_NETWORK_INFO" }, diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 973aa3e..9eeb6e8 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -20,13 +20,21 @@ "name": "card_desc", "value": "家庭日程:今天有几件事、都是什么" }, + { + "name": "card_desc_movie", + "value": "最近看了什么、还有哪些想看" + }, + { + "name": "card_desc_task", + "value": "还有哪些事情要做、哪些快到期了" + }, { "name": "card_2x2_name", - "value": "岁月家 · 今天 2×2" + "value": "岁月家 · 日程 2×2" }, { "name": "card_2x4_name", - "value": "岁月家 · 今日日程 2×4" + "value": "岁月家 · 日程 2×4" }, { "name": "card_4x4_name", @@ -46,11 +54,11 @@ }, { "name": "card_task_2x2_name", - "value": "岁月家 · 待办 2×2" + "value": "岁月家 · 任务 2×2" }, { "name": "card_task_2x4_name", - "value": "岁月家 · 待办 2×4" + "value": "岁月家 · 任务 2×4" }, { "name": "card_movie_4x4_name", @@ -62,11 +70,19 @@ }, { "name": "card_task_4x4_name", - "value": "岁月家 · 待办 4×4" + "value": "岁月家 · 任务 4×4" }, { "name": "card_task_6x4_name", - "value": "岁月家 · 待办 6×4" + "value": "岁月家 · 任务 6×4" + }, + { + "name": "perm_read_calendar", + "value": "把岁月家的日程显示在系统日历中" + }, + { + "name": "perm_write_calendar", + "value": "把岁月家的日程写入系统日历,并同步你对系统日历的改动" } ] } diff --git a/entry/src/main/resources/base/profile/form_config.json b/entry/src/main/resources/base/profile/form_config.json index 947603a..71566a3 100644 --- a/entry/src/main/resources/base/profile/form_config.json +++ b/entry/src/main/resources/base/profile/form_config.json @@ -71,7 +71,7 @@ { "name": "FbMovieCard2x2", "displayName": "$string:card_movie_2x2_name", - "description": "$string:card_desc", + "description": "$string:card_desc_movie", "src": "./ets/pages/card/FbMovieCard2x2.ets", "window": { "designWidth": 720, @@ -88,7 +88,7 @@ { "name": "FbMovieCard2x4", "displayName": "$string:card_movie_2x4_name", - "description": "$string:card_desc", + "description": "$string:card_desc_movie", "src": "./ets/pages/card/FbMovieCard2x4.ets", "window": { "designWidth": 720, @@ -105,7 +105,7 @@ { "name": "FbTaskCard2x2", "displayName": "$string:card_task_2x2_name", - "description": "$string:card_desc", + "description": "$string:card_desc_task", "src": "./ets/pages/card/FbTaskCard2x2.ets", "window": { "designWidth": 720, @@ -122,7 +122,7 @@ { "name": "FbTaskCard2x4", "displayName": "$string:card_task_2x4_name", - "description": "$string:card_desc", + "description": "$string:card_desc_task", "src": "./ets/pages/card/FbTaskCard2x4.ets", "window": { "designWidth": 720, @@ -139,7 +139,7 @@ { "name": "FbMovieCard4x4", "displayName": "$string:card_movie_4x4_name", - "description": "$string:card_desc", + "description": "$string:card_desc_movie", "src": "./ets/pages/card/FbMovieCard4x4.ets", "window": { "designWidth": 720, @@ -156,7 +156,7 @@ { "name": "FbMovieCard6x4", "displayName": "$string:card_movie_6x4_name", - "description": "$string:card_desc", + "description": "$string:card_desc_movie", "src": "./ets/pages/card/FbMovieCard6x4.ets", "window": { "designWidth": 720, @@ -173,7 +173,7 @@ { "name": "FbTaskCard4x4", "displayName": "$string:card_task_4x4_name", - "description": "$string:card_desc", + "description": "$string:card_desc_task", "src": "./ets/pages/card/FbTaskCard4x4.ets", "window": { "designWidth": 720, @@ -190,7 +190,7 @@ { "name": "FbTaskCard6x4", "displayName": "$string:card_task_6x4_name", - "description": "$string:card_desc", + "description": "$string:card_desc_task", "src": "./ets/pages/card/FbTaskCard6x4.ets", "window": { "designWidth": 720,