From 48a050745583c92d5039ba6cbce7d75e0c6e96a4 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 21 Oct 2026 20:01:37 +0800 Subject: [PATCH] =?UTF-8?q?v73=EF=BC=9A=E6=A1=8C=E9=9D=A2=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=88=B7=E6=96=B0=E4=BF=AE=E5=A4=8D=20+=20=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E6=97=A5=E7=A8=8B/=E4=BB=BB=E5=8A=A1=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E5=BC=B9=E8=AF=A6=E6=83=85=E5=8D=A1=20+=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-21 三条: 「卡片里面的数据并不刷新……应用里面的日程已经有了,但卡片上的日程却并不刷新」 「首页上的那个日程和任务,点击之后都应该像日程和任务里面的卡片一样弹出来, 不要进入到日程里面……有地址的还可以直接一键导航」 「任务也是一样,点击任务之后弹出来详情,可以标记它是不是完成了」 ① 卡片不刷新(根因) 卡片进程(FormExtensionAbility)与应用进程靠 preferences 传 formIds / 翻页偏移。 SDK 明文禁止跨进程用 preferences,且 getPreferences() 在**进程内**缓存实例、 同名再取不读盘 ⇒ 应用进程可能永久持有空注册表 ⇒ pushAll() 首行 `if (list.length === 0) return` 直接返回 ⇒ 卡片只靠系统 30 分钟自刷。 修:store() 每次先 removePreferencesFromCacheSync 再取;并写死单写者纪律 (register/unregister/setOffset/clearOffset 只由卡片进程调,应用进程只读)。 ② 首页点日程 → 弹 FbEventSheet(canEdit: false,首页不给编辑入口) 有地址的行整行可点 + 右侧「➤ 导航」胶囊;「编辑日程」才跳日历模块 (编辑是日历页的职责,不在首页复制第二份表单)。 ③ 首页点任务 → 新的 FbTaskDetailSheet 状态圆可点(勾完成)、标题带删除线、底部「标记为已完成/未完成」+「改一改」。 状态**必须走 POST /tasks//status**(PATCH 会忽略 status)。 ④ 一键导航 FbNav.ets:startAbility 隐式 Want(不是 openLink,那是 App Linking); canOpenLink 需 module.json5 声明 querySchemes(未声明恒 false 且不抛错); 高德/百度/花瓣/腾讯按序探测,一个地图都没装则复制地点到剪贴板 + Toast。 ⑤ 首页登记 FB_BACK(与其余 10 个模块一致),根容器 Stack(浮层是兄弟节点, 不能当 Column 子项排,否则卡片被挤出屏幕)。 静态守卫 905/0、v73 反向验证 17/0、assembleHap 通过、已装机启动。 --- entry/src/main/ets/common/FbNav.ets | 282 ++++++++++++ .../src/main/ets/components/FbEventSheet.ets | 144 ++++++- .../main/ets/components/FbTaskDetailSheet.ets | 387 +++++++++++++++++ entry/src/main/ets/data/FbCardForms.ets | 57 +++ .../ets/pages/calendar/FbCalendarPage.ets | 27 ++ entry/src/main/ets/pages/home/FbHomePage.ets | 403 +++++++++++++++++- entry/src/main/module.json5 | 14 + 7 files changed, 1282 insertions(+), 32 deletions(-) create mode 100644 entry/src/main/ets/common/FbNav.ets create mode 100644 entry/src/main/ets/components/FbTaskDetailSheet.ets diff --git a/entry/src/main/ets/common/FbNav.ets b/entry/src/main/ets/common/FbNav.ets new file mode 100644 index 0000000..5e4ed00 --- /dev/null +++ b/entry/src/main/ets/common/FbNav.ets @@ -0,0 +1,282 @@ +/** + * 家书 FamilyBook · 「一键导航」(鸿蒙端) + * + * 🔴 用户 2026-10-21:「一个日程,我在首页上点击之后就能弹出和日程里面的卡片是一样的, + * 看它里面的详情啊。呃,如果是有这个地址的,还可以直接一键导航。」 + * ⇒ 详情卡上有地点时给一颗「导航」按钮,点了直接拉起地图应用。 + * + * ⚠️ **为什么不能只写一个 URI 就完事**(这是本文件存在的全部理由): + * 1. 日程的 `location` 是**用户手打的文字**("学校三楼会议室"、"海底捞(万象城店)"), + * **没有经纬度** —— 服务端 `events.location` 就是 `varchar`,没存坐标。 + * 而各家地图的导航 URI 基本都要 `lat/lon`。⇒ 有坐标走"精确导航", + * 没坐标只能走"**按关键字搜索**"(用户在地图里点一下"到这里去",一步之差)。 + * 2. 用户手机上装哪个地图**说不准**:高德 / 百度 / 华为花瓣 / 腾讯,也可能一个没有。 + * ⇒ 必须按顺序**探测已安装**(`bundleManager.canOpenLink`),挑第一个能用的; + * 全都用不了时**不能静默失败**(点了没反应最让人困惑), + * ⇒ 退到"复制地点名 + 明确告知"。 + * 3. `canOpenLink` 要求 `module.json5` 里声明 `querySchemes` + * (**只声明了才查得到**,否则恒为 false —— 这条极容易漏,是本轮改动的一部分)。 + * + * 🔴 探测顺序的取舍:**高德放第一**。 + * 本应用的地点联想(地址输入联想)走的就是高德 Web API(见服务端 `Geo`), + * 用户填的地址字符串本来就是"高德的语义"(比如"青岛市崂山区"这种行政区划写法), + * 在高德里搜到的命中率最高。其余按国内装机量排。 + * + * ⚠️ 拉起方式用 `startAbility({ uri })` 的**隐式 Want**,而不是 `openLink`: + * `openLink` 走的是 **App Linking**(要求目标应用在 AGC 登记了该域名), + * 地图类应用的 deeplink scheme 不是 App Linking ⇒ `openLink` 会报 + * `16000019 No matching ability is found`。隐式 Want 才是这类 scheme 的正路。 + * (`call.makeCall` 那次踩的是相反的情况:拨号该用系统直达 API。 + * 两者判据是"**这个动作是不是系统标准动作**"——拨号是,导航不是。) + */ + +import { common, Want } from '@kit.AbilityKit'; +import { bundleManager } from '@kit.AbilityKit'; +import { pasteboard } from '@kit.BasicServicesKit'; +import { promptAction } from '@kit.ArkUI'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbNav'; + +/** 一家地图应用怎么被认出来、怎么被拉起 */ +class FbMapApp { + /** 给用户看的名字(提示语里用) */ + name: string = ''; + /** 探测用 scheme(必须与 `module.json5` 的 `querySchemes` 一致,否则恒 false) */ + probe: string = ''; + /** 按**坐标**导航的 URI(`{lat}` / `{lon}` / `{name}` 会被替换) */ + naviByPos: string = ''; + /** 按**关键字**搜索的 URI(没有坐标时用这个) */ + naviByText: string = ''; +} + +/** + * 支持的地图(**顺序即优先级**,理由见文件头)。 + * + * ⚠️ URI 里的参数名各家不一样,别"统一改写": + * · 高德 `amapuri://`:`dlat/dlon/dname/t`(t=0 驾车 1 公交 2 步行 3 骑行), + * 关键字搜索是 `amapuri://poi?keyword=`; + * · 百度 `baidumap://`:`destination=latlng:lat,lon|name:xx` / `query=`; + * · 花瓣 `maps://`:`routes?destination=` / `search?query=`。 + * ⚠️ 参数名写错**不会报错**,只会拉起一个"空白的地图页"或"找不到地点", + * 所以在真机上验收这一项时,**要真的看一眼地图里是不是那个地方**。 + */ +const MAP_APPS: FbMapApp[] = [ + { + name: '高德地图', + probe: 'amapuri://', + naviByPos: 'amapuri://route/plan?dlat={lat}&dlon={lon}&dname={name}&dev=0&t=0', + naviByText: 'amapuri://poi?keyword={name}&dev=0' + }, + { + name: '百度地图', + probe: 'baidumap://', + naviByPos: 'baidumap://map/direction?destination=latlng:{lat},{lon}|name:{name}&mode=driving', + naviByText: 'baidumap://map/geocoder?address={name}' + }, + { + name: '花瓣地图', + probe: 'maps://', + naviByPos: 'maps://routes?destination={lat},{lon}&destinationName={name}', + naviByText: 'maps://search?query={name}' + }, + { + name: '腾讯地图', + probe: 'qqmap://', + naviByPos: 'qqmap://map/routeplan?type=drive&to={name}&tocoord={lat},{lon}', + naviByText: 'qqmap://map/search?keyword={name}' + } +]; + +/** 地点文字里抽出经纬度(`"青岛市... (36.0671, 120.3826)"` 这种) */ +class FbGeoPoint { + lat: number = 0; + lon: number = 0; + /** 去掉坐标后的纯地点名(给 dname 用,别把括号里的数字念出来) */ + clean: string = ''; + ok: boolean = false; +} + +/** + * 从地点文字里认出坐标。 + * + * 为什么要有这一步:**用户可能自己把坐标粘进去**(从地图复制过来的、"经度,纬度"格式), + * 这时就能精确导航而不是"按名字搜"。认不出就 `ok=false`,走关键字那条路。 + * + * ⚠️ 中国境内经纬度的范围校验是**有意义的**:`(120.38, 36.07)`(先经后纬) + * 与 `(36.07, 120.38)` 看起来都"像两个小数",但前者是错位的。 + * ⇒ 先按 `纬度,经度` 试;不成立再试 `经度,纬度`;都不成立就不认。 + */ +function parseGeoOf(text: string): FbGeoPoint { + const out: FbGeoPoint = new FbGeoPoint(); + const m: RegExpMatchArray | null = text.match(/(-?\d{1,3}(?:\.\d+)?)\s*[,,]\s*(-?\d{1,3}(?:\.\d+)?)/); + if (m === null) { + return out; + } + const a: number = parseFloat(m[1]); + const b: number = parseFloat(m[2]); + let lat: number = 0; + let lon: number = 0; + if (Math.abs(a) <= 90 && Math.abs(b) <= 180) { + lat = a; + lon = b; + } else if (Math.abs(b) <= 90 && Math.abs(a) <= 180) { + lat = b; + lon = a; + } else { + return out; + } + out.lat = lat; + out.lon = lon; + out.clean = (text.substring(0, m.index ?? 0) + ' ' + text.substring((m.index ?? 0) + m[0].length)) + .replace(/[((【\[]\s*[))】\]]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + out.ok = true; + return out; +} + +/** 把 URI 模板里的占位符换成真值(**必须 encode**:地点名里常有括号/空格/斜杠) */ +function fillUri(tpl: string, lat: number, lon: number, name: string): string { + return tpl + .replace('{lat}', lat.toFixed(6)) + .replace('{lon}', lon.toFixed(6)) + .replace(/\{name\}/g, encodeURIComponent(name)); +} + +/** 这台机器上装了哪些地图(按优先级返回,可能为空) */ +function installedMaps(): FbMapApp[] { + const out: FbMapApp[] = []; + for (const a of MAP_APPS) { + try { + /* ⚠️ `canOpenLink` 在**没声明 `querySchemes`** 时对所有 scheme 都返回 false + (不抛错!)—— 所以"一个都没装"和"忘了声明"在运行期长得一模一样。 + 这就是为什么 `module.json5` 里那四个 scheme 是**功能的一部分**, + 而不是可选的配置。守卫里钉了它。 */ + if (bundleManager.canOpenLink(a.probe)) { + out.push(a); + } + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '探测 %{public}s 失败:%{public}s', a.name, e.message); + } + } + return out; +} + +/** + * 打开导航。 + * + * **永远不抛错、永远给用户一个交代**(这点比"调通 URI"更重要: + * 一个点了没反应的按钮,用户会以为整个应用坏了)。 + * + * @param ctx UIAbilityContext(`startAbility` 只在这个上下文上有) + * @param place 地点文字(用户填的 `location`,可能含坐标) + * @param title 日程标题(地点为空时退化成"搜这个日程名";也给提示语用) + */ +export function fbOpenNavigation(ctx: common.UIAbilityContext | undefined, place: string, title: string): void { + const raw: string = place.trim(); + const keyword: string = raw !== '' ? raw : title.trim(); + if (keyword === '') { + return; + } + if (ctx === undefined) { + // 拿不到上下文只可能是渲染期就点了(正常不会)—— 别静默 + fbToast('打不开地图,请稍后再试'); + return; + } + + const geo: FbGeoPoint = parseGeoOf(raw); + // 地点名里若只有坐标,`clean` 会是空串 ⇒ 用日程标题当 dname(地图里显示得更好看) + let name: string = geo.ok ? geo.clean : raw; + if (name === '') { + name = title.trim(); + } + + const maps: FbMapApp[] = installedMaps(); + if (maps.length === 0) { + /* 一个地图都没有 —— **不要**静默返回。 + 最有用的一步是"把地点复制走":用户可能用浏览器地图、或者发给别人。 */ + fbCopyPlace(keyword); + return; + } + + const app: FbMapApp = maps[0]; + const uri: string = geo.ok + ? fillUri(app.naviByPos, geo.lat, geo.lon, name) + : fillUri(app.naviByText, 0, 0, keyword); + + const want: Want = { uri: uri }; + ctx.startAbility(want).then((): void => { + hilog.info(DOMAIN, TAG, '已拉起 %{public}s(%{public}s)', + app.name, geo.ok ? '按坐标' : '按关键字'); + }).catch((err: BusinessError): void => { + /* 拉起失败(URI 参数被目标应用判非法、应用被禁用…)。 + ⚠️ 这里**再退一步**:换成关键字搜索重试一次。 + "按坐标"失败的最常见原因是我拼的参数名与该版本应用不符, + 而"按关键字搜索"的参数面小得多、通常还活着。 */ + hilog.warn(DOMAIN, TAG, '按%s拉起 %{public}s 失败:%{public}s', + geo.ok ? '坐标' : '关键字', app.name, err.message); + if (geo.ok) { + const retry: Want = { uri: fillUri(app.naviByText, 0, 0, keyword) }; + ctx.startAbility(retry).then((): void => { + hilog.info(DOMAIN, TAG, '改用关键字搜索成功'); + }).catch((e2: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '关键字搜索也失败:%{public}s', e2.message); + fbCopyPlace(keyword); + }); + return; + } + fbCopyPlace(keyword); + }); +} + +/** + * 兜底:把地点复制到剪贴板并**明确告诉用户**。 + * + * 🔴 为什么兜底是"复制"而不是"弹个错误": + * 用户点导航的**真实意图**是"我要去这个地方"。地图打不开时, + * 把这个地点名放进剪贴板是**离意图最近的一步**(他可以粘到浏览器/微信里)。 + * 弹一句"打开地图失败"是让他自己再想办法 —— 那等于什么都没做。 + */ +function fbCopyPlace(text: string): void { + try { + const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); + pasteboard.getSystemPasteboard().setData(data).then((): void => { + fbToast('没有可用的地图应用,地点已复制:' + text); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '复制地点失败:%{public}s', err.message); + fbToast('没有可用的地图应用'); + }); + } catch (err) { + fbToast('没有可用的地图应用'); + } +} + +/** + * 一行提示(Toast)。 + * + * ⚠️ 用 `promptAction.showToast` 的**静态**写法:本工程没有引入 + * `UIContext.getPromptAction()` 的依赖链,静态写法在页面外(比如本文件的 + * Promise 回调里)也能用 —— 而 `getUIContext()` 在回调里可能已经失效。 + */ +function fbToast(msg: string): void { + try { + promptAction.showToast({ message: msg, duration: 2600 }); + } catch (err) { + hilog.warn(DOMAIN, TAG, 'Toast 失败:%{public}s', msg); + } +} + +/** + * 这个地点"值不值得给一颗导航按钮"。 + * + * ⚠️ 不判空就画按钮,会出现"点开一条没有地点的日程、却有个导航键, + * 点了去搜日程标题"这种莫名其妙的行为。 + * 只有**真的有地点**(非空白)才给按钮。 + */ +export function fbCanNavigate(place: string): boolean { + return place.trim() !== ''; +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventSheet.ets b/entry/src/main/ets/components/FbEventSheet.ets index e5b82eb..baccd4b 100644 --- a/entry/src/main/ets/components/FbEventSheet.ets +++ b/entry/src/main/ets/components/FbEventSheet.ets @@ -21,6 +21,7 @@ import { FbCalBook } from '../model/Types'; import { FbDot } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; +import { fbCanNavigate } from '../common/FbNav'; const FB_SHEET_BOTTOM_GAP: number = 16; @@ -44,6 +45,19 @@ export struct FbEventSheet { @Param accent: string = ''; /** 能不能编辑(不能写的日历本不给"编辑"按钮) */ @Param canEdit: boolean = false; + /** + * 点「导航」。 + * + * 🔴 v73(用户 2026-10-21「有地址的可以一键导航」): + * 地点那一行从"纯文字"变成"可点"。 + * ⚠️ 为什么把动作**抛给宿主**而不是在这里直接拉地图: + * 拉起应用要 `UIAbilityContext`,而组件里 `getUIContext().getHostContext()` + * 拿到的是 `common.Context`,还得强转 —— 那个强转在**卡片/浮层重建**时可能拿到 + * undefined。宿主页本来就有可靠的上下文来源,且"要不要拉地图"属于导航决策 + * (跟"当前在哪个模块"有关),不是这张卡的职责。 + */ + @Event onNavigate: () => void = (): void => { + }; @Event onClose: () => void = (): void => { }; @Event onEdit: () => void = (): void => { @@ -58,6 +72,21 @@ export struct FbEventSheet { return m !== undefined ? fbThemeVars(m.color).brand : FbTint.brand; } + /** + * 取主题色的**半透明版本**(八位色 `#AARRGGBB`)。 + * + * 🔴 本项目踩过:ArkUI 的八位色是 **alpha 在前**(`#AARRGGBB`), + * 与 CSS 的 `#RRGGBBAA` **相反** ⇒ 直接把 CSS 那套搬过来会得到 + * "几乎透明的怪色"或者干脆渲染成黑色。 + * ⚠️ 还要兼容"传进来的本身就是八位色"(模块色可能带 alpha): + * 先剥掉已有的 alpha,再拼新的,否则会得到 10 位非法色值。 + */ + private accentAlpha(aa: string): string { + const c: string = this.accentColor().replace('#', ''); + const rgb: string = c.length >= 8 ? c.substring(2) : c; + return '#' + aa + rgb; + } + /** * 标题文字。 * @@ -195,6 +224,57 @@ export struct FbEventSheet { .alignItems(VerticalAlign.Top) } + /** + * 「地点」这一行 —— 比普通 `row()` 多一颗「导航」标签,整行可点。 + * + * ⚠️ 为什么单独抽一个 Builder 而不是给 `row()` 加参数: + * `row()` 有 2 个参数,加个 `tappable: boolean` 就变成 3 个 —— + * 3 个参数的 @Builder 更危险(多值参数不参与状态观察,本项目已踩 6+ 次)。 + * 这里的数据是**每次打开浮层才建一次**(详情卡整体由 `if (detailOpen())` + * 重建),不存在"数据变了行不刷新"的场景,所以用 Builder 是安全的; + * 但仍保持**单参数**,把风险面压到最小。 + */ + @Builder + navRow(place: string) { + Row({ space: 10 }) { + Text('地点') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(56) + + Text(place) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + /* 标签用模块色(与「编辑日程」按钮同色系),一眼看出"这是这颗票能做的事"。 + ⚠️ 底色用**极浅的主题色**(`fbAlpha` 那套思路:八位色 `#AARRGGBB`, + alpha 在前)—— 深底白字会把这一行压得比标题还重。 */ + if (fbCanNavigate(place)) { + Row({ space: 3 }) { + Text('➤') + .fontSize(10) + .fontColor(this.accentColor()) + Text('导航') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .borderRadius(FbMetric.radiusPill) + .backgroundColor(this.accentAlpha('1A')) + .border({ width: 1, color: this.accentAlpha('33') }) + } + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.onNavigate(); + }) + } + build() { // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— @@ -280,7 +360,15 @@ export struct FbEventSheet { this.row('提醒', this.remindText()) } if (this.event.location !== '') { - this.row('地点', this.event.location) + /* 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」): + 地点不再是纯文字 —— **整行可点**,右侧带一颗「导航」小标签。 + ⚠️ 为什么整行可点而不是只让那颗小标签可点: + 小标签只有 40vp 宽,手指点不中会以为"没反应"; + 整行(含地点文字)都是热区,命中率高得多。 + ⚠️ 但**视觉上**仍要有那颗「导航」标签 —— 只有"整行可点" + 而没有任何标记的话,用户根本不知道地点能点 + (本项目踩过:卡尾"查看行程"是纯文本,用户以为按钮坏了)。 */ + this.navRow(this.event.location) } if (this.event.note !== '') { this.row('备注', this.event.note) @@ -312,22 +400,46 @@ export struct FbEventSheet { } // ---- 底部按钮(只有动作,关闭统一到右上角 ✕)---- - if (this.canEdit && this.event !== null) { + /* 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」): + 有地点时底部多一颗「导航」—— 与上面那行可点的地点**并存**, + 是有意的:地点行是"看的时候顺手点",底部按钮是"看完了决定要去"。 + 两个入口做同一件事,比只留一个更好用(日期详情卡不长, + 地点行可能已经滚出视野)。 + ⚠️ 判据用 `event.location`,不用别的字段 —— 那是唯一权威的地点来源。 + ⚠️ 两个按钮用 `layoutWeight(1)` 均分宽度;只有一个时它自己占满。 */ + if (this.event !== null + && (fbCanNavigate(this.event.location) || this.canEdit)) { Row({ space: 10 }) { - /* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」 - ⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。 - 改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */ - Text('编辑日程') - .fontSize(FbMetric.fontSmall) - .fontColor(Color.White) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(42) - .backgroundColor(this.accentColor()) - .borderRadius(FbMetric.radiusS) - .onClick((): void => { - this.onEdit(); - }) + if (fbCanNavigate(this.event.location)) { + Text('导航') + .fontSize(FbMetric.fontSmall) + .fontColor(this.accentColor()) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.accentAlpha('14')) + .borderRadius(FbMetric.radiusS) + .border({ width: 1, color: this.accentAlpha('33') }) + .onClick((): void => { + this.onNavigate(); + }) + } + if (this.canEdit) { + /* 🔴 用户 2026-10-19:「点击日程弹出的框,编辑按钮也颜色也要修改。」 + ⇒ 原来写死名册青 #4A7C82,在日程页里跟模块色(棕)不一致。 + 改成 `accentColor()`(= 日程模块主题色,与卡片外环同色)。 */ + Text('编辑日程') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onEdit(); + }) + } } .width('100%') .margin({ top: 14 }) diff --git a/entry/src/main/ets/components/FbTaskDetailSheet.ets b/entry/src/main/ets/components/FbTaskDetailSheet.ets new file mode 100644 index 0000000..616e178 --- /dev/null +++ b/entry/src/main/ets/components/FbTaskDetailSheet.ets @@ -0,0 +1,387 @@ +/** + * 家书 FamilyBook · 任务详情卡(鸿蒙端) + * + * 🔴 用户 2026-10-21 原话: + * 「任务也是一样,点击任务之后弹出来详情,可以标记可以标记它是不是完成了什么之类的。」 + * ⇒ 首页「近期任务」里点一行 → **就地弹出详情卡**(不跳任务模块), + * 卡上能直接勾「完成 / 未完成」。 + * + * ⚠️ 为什么单独一张卡,而不是复用 `FbTaskEditSheet`(任务页的编辑浮层): + * 那是**表单**(标题框 / 备注框 / 截止时间 / 看板选择 / 保存按钮), + * 适合"我要改这个任务";用户此刻要的是"**看看它是什么、顺手勾一下完成**"。 + * 拿表单当详情,点开就有五个可编辑控件 + 一颗"保存",观感是"我是不是要改什么"—— + * 而且误触一下就把标题改了。两件事用两个界面。 + * + * ⚠️ 与 `FbEventSheet`(日程详情)同一套视觉语言: + * 外圈主题色描边环(`FbSheetRing`)、关闭统一右上角 ✕(`FbSheetClose`)、 + * 底部只放动作按钮、`92%` 宽居中、内容超高才滚动。 + * ⇒ 用户在首页连点一个日程、一条任务,两张卡长得像"同一个应用的弹框"。 + * + * 🔴 两处容易做错的地方(都已按正确方式实现,别改回去): + * ① 完成状态**必须走 `/tasks//status`**(`setTaskStatus`), + * 不能走 `PATCH /tasks/` —— 服务端 PATCH 会把 status 原样忽略, + * 表现是"提示保存成功、但状态没变"(`FbTaskRepo` 文件头写着)。 + * ② 勾完**立刻**把卡片上的状态改掉(本地先改),再让宿主去重拉对齐派生字段 + * (逾期、子任务进度这些是服务端算的)。等网络回来再改,用户会以为没点上。 + */ + +import { FbColors, FbMetric, FbTint } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; +import { FbTaskItem } from '../model/Types'; +import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; + +const FB_SHEET_BOTTOM_GAP: number = 16; + +@ComponentV2 +export struct FbTaskDetailSheet { + /** + * 这条任务(首页给的那份 `FbTaskItem`)。 + * + * ⚠️ 用 `| null` 而不是给一份对象字面量默认值 —— 与本工程其它浮层 + * (`FbEventSheet.event`)同一个写法。理由: + * ① 对象字面量默认值在 `@Param` 上会走 `arkts-no-untyped-obj-literals` 那条规则; + * ② 这份数据**永远由宿主传入**(开着卡就一定有它),默认值只是"没给时不崩", + * 给一个空壳反而会画出「未命名任务」这种假内容。 + */ + @Param task: FbTaskItem | null = null; + /** + * 卡上显示的**当前状态**(`todo` / `doing` / `done`)。 + * + * ⚠️ 从 `task.status` 拷一份出来放在宿主那边管,是因为"勾完"之后要**立刻**变: + * 如果把 `task` 整个换掉,宿主得重建这份数据(还要重拉接口),中间会有一次闪烁。 + * 这里只用一个字符串,宿主那边改一次就行。 + */ + @Param status: string = 'todo'; + /** 正在提交(按钮变灰,防连点) */ + @Param busy: boolean = false; + /** 提交失败/成功的说明(空 = 不显示) */ + @Param note: string = ''; + /** 环色 —— 任务模块主题色(与任务页浮层一致) */ + @Param accent: string = ''; + /** 点「完成 / 取消完成」 */ + @Event onToggle: () => void = (): void => { + }; + /** 点「改一改」(跳任务页的编辑浮层) */ + @Event onEdit: () => void = (): void => { + }; + @Event onClose: () => void = (): void => { + }; + + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('tasks'); + return m !== undefined ? fbThemeVars(m.color).brand : FbTint.brand; + } + + /** 取主题色的半透明版本(八位色 `#AARRGGBB`:**alpha 在前**) */ + private accentAlpha(aa: string): string { + const c: string = this.accentColor().replace('#', ''); + const rgb: string = c.length >= 8 ? c.substring(2) : c; + return '#' + aa + rgb; + } + + private isDone(): boolean { + return this.status === 'done'; + } + + /* ---- 取值器 ---- + ⚠️ 为什么要一串 `xxxOf()` 而不是在 `build()` 里直接写 `this.task!.xxx`: + ① `build()` 里**不能声明变量**(只有 UI 组件语法),条件与文案都得靠方法; + ② `task` 是可空类型,每个访问点都写 `!` 会让"哪些地方依赖它"看不出来; + ③ 这些取值器同时是**兜底**(空 → 空串),漏传一份任务也不会崩。 + ⚠️ 不要把它们改成 `@Param` 多参数 `@Builder` —— 那会踩"多值参数不参与观察"。 */ + private titleOf(): string { + const t: FbTaskItem | null = this.task; + return t !== null && t.title !== '' ? t.title : '未命名任务'; + } + + private boardOf(): string { + const t: FbTaskItem | null = this.task; + return t !== null ? t.board : ''; + } + + private boardColorOf(): string { + const t: FbTaskItem | null = this.task; + return t !== null && t.boardColor !== '' ? t.boardColor : '#9AA0A6'; + } + + private dueOf(): string { + const t: FbTaskItem | null = this.task; + return t !== null ? t.dueText : ''; + } + + private overdueOf(): boolean { + const t: FbTaskItem | null = this.task; + return t !== null && t.overdue && !this.isDone(); + } + + private assigneeOf(): string { + const t: FbTaskItem | null = this.task; + return t !== null ? t.assignee : ''; + } + + private childOf(): number { + const t: FbTaskItem | null = this.task; + return t !== null ? t.childTotal : 0; + } + + private childText(): string { + const t: FbTaskItem | null = this.task; + if (t === null) { + return ''; + } + return t.childDone.toString() + ' / ' + t.childTotal.toString() + ' 已完成'; + } + + private noteOf(): string { + const t: FbTaskItem | null = this.task; + return t !== null ? t.note : ''; + } + + /** 状态中文(`待办` / `进行中` / `已完成`) */ + private statusText(): string { + if (this.status === 'done') { + return '已完成'; + } + return this.status === 'doing' ? '进行中' : '待办'; + } + + /** + * 状态的强调色。 + * + * ⚠️ 完成用**绿**、逾期用**红** —— 与任务页/网页版同一套语义。 + * 这里不跟模块主题色走:状态是"事实",不是"我在哪个模块"。 + */ + private statusTint(): string { + if (this.status === 'done') { + return '#3F7D5A'; + } + return this.status === 'doing' ? '#A8574F' : '#7C8AA0'; + } + + private statusWash(): string { + if (this.status === 'done') { + return '#EAF3EE'; + } + return this.status === 'doing' ? '#FBEFEE' : '#F1F3F5'; + } + + @Builder + row(label: string, value: string) { + Row({ space: 10 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(56) + Text(value) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Top) + } + + build() { + Column() { + /* 🔴 包裹这层 `Column` 只为了挂 `.width('92%')`。 + ⚠️ **不能**把属性直接挂在 `FbSheetRing` 上(`FbSheetRing(...) { … }.width(...)`): + 带**尾随闭包**的自定义组件后面接属性方法,ArkTS 编译器会报 + 「Declaration or statement expected」外加一条「Cannot find name 'width'」, + 位置正好落在那行属性的开头 —— 看着像"括号没配平",其实是语法不支持。 + ⇒ 照 `FbEventSheet` / `FbMovieDetailSheet` 的既有形态:环组件**自身不挂属性**, + 外面包一层容器,把宽度/边距挂到容器上。 + (编译实测:不包这层就红在 `FbTaskDetailSheet.ets:370`。) */ + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 0 }) { + // ---- 标题区:状态徽标 + 标题 + 关闭 ---- + Row({ space: 10 }) { + /* 左侧那颗**可点的状态圆**:点一下就是"完成/取消完成"。 + 🔴 用户要的就是"可以标记它是不是完成了"—— + 把这件事做成**标题左边那颗圈**(与任务页列表里那颗同一个交互), + 而不是只在底部放个按钮:这是用户最习惯的位置。 */ + Stack() { + Column() + .width(22) + .height(22) + .borderRadius(11) + .backgroundColor(this.isDone() ? this.statusTint() : Color.Transparent) + .border({ width: 1.5, color: this.isDone() ? this.statusTint() : '#C9CDD2' }) + if (this.isDone()) { + Text('✓') + .fontSize(13) + .fontColor(Color.White) + .fontWeight(FontWeight.Medium) + } + } + .width(30) + .height(30) + .onClick((): void => { + if (!this.busy) { + this.onToggle(); + } + }) + + Text(this.titleOf()) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(this.isDone() ? FbColors.faint : FbColors.ink) + .decoration({ + type: this.isDone() ? TextDecorationType.LineThrough : TextDecorationType.None, + color: FbColors.faint + }) + .maxLines(2) + .layoutWeight(1) + + Text(this.statusText()) + .fontSize(FbMetric.fontTiny) + .fontColor(this.statusTint()) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .backgroundColor(this.statusWash()) + .borderRadius(FbMetric.radiusPill) + + FbSheetClose({ + onClose: (): void => { + this.onClose(); + }, + color: '' + }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + + Divider() + .color(FbColors.line) + .margin({ top: 12, bottom: 4 }) + + Scroll() { + Column({ space: 0 }) { + if (this.boardOf() !== '') { + // 看板:左侧带看板本色的小点(与首页那行同一个色,视觉上能对上号) + Row({ space: 10 }) { + Text('看板') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(56) + Row({ space: 6 }) { + Column() + .width(8) + .height(8) + .borderRadius(4) + .backgroundColor(this.boardColorOf()) + Text(this.boardOf()) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + } + .layoutWeight(1) + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + } + if (this.dueOf() !== '') { + Row({ space: 10 }) { + Text('截止') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(56) + Text(this.dueOf()) + .fontSize(FbMetric.fontSmall) + .fontColor(this.overdueOf() ? '#C0533F' : FbColors.ink) + .layoutWeight(1) + if (this.overdueOf()) { + Text('已逾期') + .fontSize(FbMetric.fontTiny) + .fontColor('#C0533F') + .padding({ left: 7, right: 7, top: 2, bottom: 2 }) + .backgroundColor('#FDECEC') + .borderRadius(FbMetric.radiusPill) + } + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + } + if (this.assigneeOf() !== '') { + this.row('负责人', this.assigneeOf()) + } + if (this.childOf() > 0) { + this.row('子任务', this.childText()) + } + if (this.noteOf() !== '') { + this.row('备注', this.noteOf()) + } + if (this.note !== '') { + Text(this.note) + .fontSize(FbMetric.fontTiny) + .fontColor(this.statusTint()) + .width('100%') + .padding({ top: 8 }) + } + Row().height(8) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + /* 与 `FbEventSheet` 同一条纪律: + `flexShrink(1)` 而不是 `layoutWeight(1)` —— 后者会让短内容也把卡片顶到 72%, + 底下留一大片空白。 */ + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + // ---- 底部:主操作(勾完成)+ 次操作(改一改)---- + Row({ space: 10 }) { + Text(this.busy ? '处理中…' : (this.isDone() ? '标记为未完成' : '标记为已完成')) + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + /* 已完成时按钮**不用主色实底**:那时候它是"撤销"而不是"完成", + 视觉强度该低一档(与 `FbTaskEditSheet` 删除按钮的取舍同一个思路)。 */ + .backgroundColor(this.busy ? '#C9CDD2' + : (this.isDone() ? this.statusTint() : this.accentColor())) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + if (!this.busy) { + this.onToggle(); + } + }) + + Text('改一改') + .fontSize(FbMetric.fontSmall) + .fontColor(this.accentColor()) + .textAlign(TextAlign.Center) + .width(88) + .height(42) + .backgroundColor(this.accentAlpha('14')) + .borderRadius(FbMetric.radiusS) + .border({ width: 1, color: this.accentAlpha('33') }) + .onClick((): void => { + this.onEdit(); + }) + } + .width('100%') + .margin({ top: 14 }) + } + .width('100%') + .constraintSize({ maxHeight: '72%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbCardForms.ets b/entry/src/main/ets/data/FbCardForms.ets index a714a18..99dd977 100644 --- a/entry/src/main/ets/data/FbCardForms.ets +++ b/entry/src/main/ets/data/FbCardForms.ets @@ -16,6 +16,36 @@ * ⚠️ 偏移量做**夹取**(±366 天):`onFormEvent` 的 message 是外部输入, * 连续点 ‹ 几百次也不该把它算成一个荒唐的年份(`new Date(y, m, d + 99999)` * 在部分设备上会算出 Invalid Date,卡片直接白掉)。 + * + * 🔴🔴 v73(用户 2026-10-21「卡片上的日程并不刷新」)—— **本文件的最大坑,别再踩**: + * + * 【症状】应用里日程已经改了/已经有了,桌面卡片上的日程**永远是旧的**; + * 偶尔连"刚添加的卡片"都不带数据。 + * + * 【根因】这份状态是**跨进程**的(写的一方是 `FbFormAbility` 那个卡片进程, + * 读的一方是应用进程的 `FB_CARD_FORMS.pushAll`),而 + * `@ohos.data.preferences` 的 SDK 文档**明确写着**: + * 「Preferences are **not thread-safe** and may cause file damage and + * data loss when used in **multi-process** scenarios. + * **Do not use preferences in multi-process scenarios.**」 + * 比"数据可能损坏"更直接的问题是**实例缓存**: + * `getPreferences()` 把实例缓存在**进程内**,之后同名的调用 + * **直接返回缓存、不再读盘**。 + * ⇒ 应用进程只要在"桌面上还没有卡片"的时候读过一次 `fb_card_forms`, + * 它就拿住了一个**空注册表**的缓存实例,此后**永远**读到空串 + * ⇒ `pushAll()` 第一行 `if (list.length === 0) return;` 直接返回 + * ⇒ 卡片**一次都收不到应用推的数据**(只剩系统 30 分钟自己刷)。 + * 偏移量也有同一个毛病:用户把卡翻到"明天",应用进程读到的还是缓存里的 0, + * 推过去的数据是今天的 ⇒ 卡片"翻着翻着自己跳回今天"。 + * + * 【修法】① 读之前**显式作废本进程的实例缓存** + * (`removePreferencesFromCacheSync` + 重新 `getPreferences`), + * 保证读到的永远是磁盘上的最新值; + * ② 保持"**单写者**":本文件的 `register/unregister/setOffset/clearOffset` + * **只被 `FbFormAbility`(卡片进程)调用**,应用进程**只读不写** + * ⇒ 不会出现两个进程同时写同一个文件(那才是文档警告的"损坏"场景)。 + * ⚠️ 这条是硬约束:**以后不要让应用侧去写 formIds / 偏移量**。 + * 要写也得走"给卡片进程发消息、让它来写"。 */ import { common } from '@kit.AbilityKit'; @@ -40,7 +70,34 @@ const SEP_ITEM: string = ';'; const SEP_KV: string = '='; class FbCardFormsImpl { + /** + * 取 preferences 实例 —— **每次都作废进程内缓存再取**。 + * + * 🔴🔴 v73 的核心修复(详见文件头"最大坑"那段): + * `getPreferences()` 会把实例缓存在进程内,之后同名调用**不再读盘** + * ⇒ 卡片进程写进去的 formIds / 偏移量,应用进程**永远看不到** + * ⇒ 日程改了、桌面卡片不刷新。 + * ⇒ 这里每次先用 `removePreferencesFromCacheSync` 把本进程的缓存摘掉, + * 再 `getPreferences` 让系统**重新从磁盘读**。 + * + * ⚠️ `removePreferencesFromCacheSync` 是 **API 10+**(本工程 compatibleSdk 24,够用); + * 它**不会删数据**,只是把内存里的那份实例从缓存表里摘出来(名字里有 + * "remove" 很容易被误读成"删文件",其实是 `fromCache`)。 + * ⚠️ 摘缓存后,**旧实例不能再用**(SDK 原文:"Avoid using a removed Preferences + * instance to perform data operations, which may cause data inconsistency") + * ⇒ 所以本类**不缓存 `Preferences` 实例**,需要时现取。 + * ⚠️ 每次读写都重开有开销(~几毫秒),但本类的调用频率极低 + * (加卡/删卡/翻页/应用内改完日程推一次),完全不值得为此引入缓存。 + * 相比之下,"读到旧数据"的代价是**整个卡片功能失效**。 + */ private async store(ctx: common.Context): Promise { + try { + preferences.removePreferencesFromCacheSync(ctx, STORE_NAME); + } catch (err) { + // 摘缓存失败不致命:下面照样能取到(可能拿到缓存的旧实例),先记一条 + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '作废卡片状态缓存失败(继续用缓存实例):%{public}s', e.message); + } return preferences.getPreferences(ctx, STORE_NAME); } diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index e0ec69f..0e781ba 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -44,6 +44,7 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition, fbViewTransition } from '../../common/FbAnim'; import { FB_BACK } from '../../common/FbBack'; +import { fbOpenNavigation } from '../../common/FbNav'; /** 视图粗细顺序(决定切视图时过渡的上下方向,见 `switchModeAnimated`) */ const FB_CAL_VIEW_ORDER: string[] = ['month', 'week', 'agenda']; @@ -744,6 +745,29 @@ export struct FbCalendarPage { this.detailToken += 1; // 丢弃在途请求 } + /** + * 详情卡上点「导航」/ 点地点行 → 拉起地图。 + * + * 🔴 v73(用户 2026-10-21「有地址的还可以直接一键导航」)。 + * + * ⚠️ 地点取 `detailFull.location`(**优先**),退到列表那份 `detailBase.place`: + * 详情是 `/events/` 拉回来的权威值;列表那份是 `/calendar` 的精简条目。 + * 两者一般一致,但"刚在网页端改了地点、端上还没刷新"时会在详情里更新 —— + * 所以详情有值就一定用详情。 + * ⚠️ 拿 `UIAbilityContext`(`startAbility` 只在这个上下文上有, + * 用 `common.Context` 编译就报错 —— 名册拨号那次实测过)。 + */ + private navigateDetail(): void { + const placeOfFull: string = this.detailFull !== null ? this.detailFull.location : ''; + const placeOfBase: string = this.detailBase !== null ? this.detailBase.place : ''; + const place: string = placeOfFull !== '' ? placeOfFull : placeOfBase; + const title: string = this.detailFull !== null ? this.detailFull.title + : (this.detailBase !== null ? this.detailBase.title : ''); + const ctx: common.UIAbilityContext | undefined = + this.getUIContext().getHostContext() as common.UIAbilityContext | undefined; + fbOpenNavigation(ctx, place, title); + } + /** 日程系列变了之后刷新数据 */ private refresh(): void { this.detailBase = null; @@ -876,6 +900,9 @@ export struct FbCalendarPage { bookName: this.detailBase !== null ? this.detailBase.calName : '', bookColor: this.detailBase !== null ? this.detailBase.color : '#9AA0A6', canEdit: this.detailBase !== null && isEditableEvent(this.detailBase), + onNavigate: (): void => { + this.navigateDetail(); + }, onClose: (): void => { this.closeDetail(); }, diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index 23a7bb6..10b83e8 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -37,18 +37,32 @@ * `/home` 给 `memories` 字段,这里加第 ④ 块。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, FB_MASK } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbRecentDay, FbTaskItem, - FbWeather } from '../../model/Types'; + FbWeather, isEditableEvent } from '../../model/Types'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; import { parseHome } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FB_CAL_SETTINGS, FbCalStyle } from '../../data/FbCalSettings'; +import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo'; +import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; +import { setTaskStatus } from '../../data/FbTaskRepo'; +import { FB_CARD_FORMS } from '../../data/FbCardForms'; +import { fbApi, FbApiResult } from '../../data/FbHttp'; import { FbSectionTitle } from '../../components/Widgets'; import { FbStatCell } from '../../components/FbStatCell'; import { FbDayEventPanel } from '../../components/FbDayEventPanel'; +import { FbEventSheet } from '../../components/FbEventSheet'; +import { FbTaskDetailSheet } from '../../components/FbTaskDetailSheet'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; +import { FB_BACK } from '../../common/FbBack'; +import { fbOpenNavigation } from '../../common/FbNav'; +import { fbModuleOf, FbModuleDef, fbThemeVars } from '../../model/Modules'; @ComponentV2 export struct FbHomePage { @@ -103,12 +117,81 @@ export struct FbHomePage { */ @Local calStyle: FbCalStyle = 'timeline'; + // ============================================================ + // v73(用户 2026-10-21):首页点日程 / 点任务 → **就地弹详情卡** + // ============================================================ + /* 🔴 用户原话: + 「应用里面啊,就是首页上的那个日程和这个任务,点击之后都应该像那个日程和任务 + 里面的卡片一样弹出来,不要进入到日程里面。比如说一个日程,我在首页上点击之后 + 就能弹出和日程里面的卡片是一样的,看它里面的详情啊。呃,如果是有这个地址的, + 还可以直接一键导航。任务也是一样,点击任务之后弹出来详情,可以标记可以标记 + 它是不是完成了什么之类的。」 + ⇒ 下面这八组状态就是这件事的全部:日程详情卡 / 任务详情卡 / 各自的开关。 + ⚠️ **不要**改回"点一下 `switchModule('calendar')`"—— 那是用户明确否掉的旧行为 + (原来 `onPick` 只有一行 `this.app.switchModule('calendar')`, + 点哪一条都跳到日程页,用户还得自己再找一遍那条)。 */ + + /** 摘要卡显示的那条日程(列表那份;非 null = 日程详情卡开着) */ + @Local evtBase: FbCalEvent | null = null; + /** 日程详情(`/events/` 的完整字段;null = 还在拉 / 拉不到) */ + @Local evtFull: FbCalEventFull | null = null; + /** 日程详情加载中 */ + @Local evtLoading: boolean = false; + /** 日程详情加载失败说明(空 = 没失败) */ + @Local evtError: string = ''; + /** 丢弃在途请求的令牌(连点两条日程时,先回来的那条不该覆盖后点的) */ + @Local evtToken: number = 0; + + /** 摘要卡显示的那条任务(非 null = 任务详情卡开着) */ + @Local tskBase: FbTaskItem | null = null; + /** 任务卡上显示的当前状态(勾完**立刻**变,不等网络) */ + @Local tskStatus: string = 'todo'; + /** 任务状态正在提交(防连点) */ + @Local tskBusy: boolean = false; + /** 任务卡的提示(失败原因 / 成功说明) */ + @Local tskNote: string = ''; + + /** + * 详情卡开着 ⇒ 底部悬浮快捷栏让位。 + * + * ⚠️ 与任务页/日程页同一套机制(`sheetCount` 计数):悬浮栏在**更外层 + * `Index` 的 Stack** 里,层级比本页高 ⇒ 不让位就会盖住卡片底部的按钮、 + * 还把点击抢走(名册/日程页都踩过)。 + */ + @Monitor('evtBase', 'tskBase') + onDetailChanged(): void { + this.app.sheetCount = (this.evtBase !== null || this.tskBase !== null) ? 1 : 0; + } + aboutToAppear(): void { // 显示方式来自日程设置(启动时已预载进内存,可同步读) this.calStyle = FB_CAL_SETTINGS.getStyle(); + /* 🔴 v73:**首页也要登记返回手势**。 + 原来首页根本没登记过 —— 因为以前它没有浮层,"返回"就该由 `Index` 处理 + (回上一个模块 / 退到桌面)。现在多了两张详情卡,不登记的话: + 卡开着按返回 ⇒ **卡片留在屏幕上**、模块却被切走了,回来还是那张卡。 + ⚠️ 登记/注销必须成对(见 `common/FbBack.ets` 的纪律)。 */ + FB_BACK.register('home', (): boolean => this.backOnce()); this.load(); } + aboutToDisappear(): void { + FB_BACK.unregister('home'); + } + + /** 返回手势:先关详情卡,再交给 `Index`(返回 true = 已消费) */ + private backOnce(): boolean { + if (this.evtBase !== null) { + this.closeEvent(); + return true; + } + if (this.tskBase !== null) { + this.closeTask(); + return true; + } + return false; + } + /** * 拉首页数据。 * @@ -154,6 +237,192 @@ export struct FbHomePage { }); } + // ============================================================ + // v73:日程详情卡 + // ============================================================ + + /** + * 点一条日程 → 弹详情卡(**不跳模块**)。 + * + * 逻辑与日程页的 `openEvent` 保持同一套(那是最成熟的一份,别另写一套): + * ① 先用列表那条垫一份"壳"(标题/时间/地点/备注够画,用户立刻看到内容); + * ② 可编辑条目再拉 `/events/` 补 `rrule` / `reminder` / 精确 `ends_at`; + * ③ 拉不到就保留壳 + 一行小字说明(**不要**把整张卡变成"读不到")。 + * + * ⚠️ 衍生条目(农历/节气/节假日)**没有 `/events/` 可拉** + * (服务端 `ref_id` 是 0,它们不是 events 表里的行)⇒ 不要发那个请求。 + */ + private openEvent(e: FbCalEvent): void { + this.evtBase = e; + this.evtFull = null; + this.evtError = ''; + if (!isEditableEvent(e)) { + this.evtLoading = false; + this.evtFull = this.shellOf(e); + return; + } + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.evtLoading = false; + this.evtFull = this.shellOf(e); + return; + } + this.evtLoading = true; + this.evtToken += 1; + const my: number = this.evtToken; + FbCalRepo.inst().fetchEvent(e.id, token).then((full: FbCalEventFull | null): void => { + if (my !== this.evtToken) { + return; // 已经有更新的一次点击了,这份丢弃 + } + this.evtLoading = false; + if (full === null) { + this.evtError = '读不到详情'; + this.evtFull = this.shellOf(e); + return; + } + this.evtFull = full; + }); + } + + /** 列表那条 → 完整字段的壳(只垫列表**已知**的字段,别编) */ + private shellOf(b: FbCalEvent): FbCalEventFull { + const shell: FbCalEventFull = { + id: b.id, calendar_id: b.calId, title: b.title, location: b.place, + starts_at: b.date + ' ' + (b.allDay ? '00:00:00' : (b.time + ':00')), + ends_at: b.endTime !== '' ? (b.date + ' ' + b.endTime + ':00') : '', + all_day: b.allDay, note: b.note, rrule: '', reminder_minutes: '', + status: 'confirmed', transp: false, created_by: 0 + }; + return shell; + } + + private closeEvent(): void { + this.evtBase = null; + this.evtFull = null; + this.evtError = ''; + this.evtLoading = false; + this.evtToken += 1; // 丢弃在途请求 + } + + /** 详情卡上点「导航」/ 点地点行 → 拉起地图 */ + private navigateEvent(): void { + const placeOfFull: string = this.evtFull !== null ? this.evtFull.location : ''; + const placeOfBase: string = this.evtBase !== null ? this.evtBase.place : ''; + const place: string = placeOfFull !== '' ? placeOfFull : placeOfBase; + const title: string = this.evtFull !== null ? this.evtFull.title + : (this.evtBase !== null ? this.evtBase.title : ''); + /* ⚠️ 必须是 `UIAbilityContext`(`startAbility` 只在这个上下文上有; + 用 `common.Context` 编译就报 "Property 'startAbility' does not exist", + 名册拨号那次实测过)。 */ + const ctx: common.UIAbilityContext | undefined = + this.getUIContext().getHostContext() as common.UIAbilityContext | undefined; + fbOpenNavigation(ctx, place, title); + } + + /** + * 日程详情卡上点「编辑」→ 切到日程模块。 + * + * ⚠️ 这里**仍然要跳模块**,而且是唯一该跳的地方:编辑是日程页的职责 + * (它有日历本选择、重复规则、提醒、地点联想那一整套表单)。 + * 在首页再复制一份编辑表单 = 同一件事两个实现,必然漂移。 + * 用户否掉的是"**看点详情就跳走**",不是"编辑也不许跳"。 + */ + private editEventFromDetail(): void { + this.closeEvent(); + this.app.switchModule('calendar'); + } + + // ============================================================ + // v73:任务详情卡 + // ============================================================ + + private openTask(t: FbTaskItem): void { + this.tskBase = t; + this.tskStatus = t.status; + this.tskBusy = false; + this.tskNote = ''; + } + + private closeTask(): void { + this.tskBase = null; + this.tskBusy = false; + this.tskNote = ''; + } + + /** + * 勾完成 / 取消完成。 + * + * 🔴🔴 **必须走 `/tasks//status`**(`setTaskStatus`)—— + * 服务端 `PATCH /tasks/` 会把请求里的 status **原样忽略** + * (见 `FbTaskRepo` 文件头),打 PATCH 会"看起来成功、状态没变"。 + * ⚠️ 这是本项目已经踩过一次的坑,别改成 updateTask。 + * + * ⚠️ 已完成 → 取消完成统一回 `todo`(**不回 `doing`**): + * 用户只想撤销一次误勾,把它标成"进行中"是替他做了他没表达的决定。 + * (与任务页 `toggleDone` 同一套规则。) + */ + private async toggleTask(): Promise { + const t: FbTaskItem | null = this.tskBase; + if (t === null || this.tskBusy) { + return; + } + this.tskBusy = true; + this.tskNote = ''; + const next: string = this.tskStatus === 'done' ? 'todo' : 'done'; + const res: FbApiResult = await setTaskStatus(FB_TOKEN_STORE.get(), t.id, next); + this.tskBusy = false; + if (!res.ok) { + this.tskNote = res.error !== '' ? res.error : '改状态失败,请稍后再试。'; + return; + } + /* 本地先把状态改掉(卡上立刻有反馈),再重拉首页数据对齐派生字段 + (逾期、看板计数、"近期任务"里这条还在不在,都是服务端算的)。 + ⚠️ 顺序不能反 —— 等网络回来再改,用户会以为"没点上"又点一次。 */ + this.tskStatus = next; + this.tskNote = next === 'done' ? '已标记完成' : '已改回未完成'; + this.load(); + // 桌面卡片上的数字(今天日程/在做/待办)也可能受影响 —— 顺手推一次 + this.pushCards(); + } + + /** + * 任务卡上点「改一改」→ 切到任务模块。 + * + * ⚠️ 与日程同理:改标题/改截止/看板/负责人是一整套表单,属于任务页的职责。 + * 用户要的"弹详情、能勾完成"这里已经给了;"改内容"跳过去是对的。 + */ + private editTaskFromDetail(): void { + this.closeTask(); + this.app.switchModule('tasks'); + } + + /** + * 把最新数据推给桌面上所有日程卡片。 + * + * ⚠️ 不 await、失败只记日志:这是"顺手做的一件好事",绝不能影响勾选动作。 + */ + private pushCards(): void { + const ctx: common.Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + FB_CARD_FORMS.pushAll(ctx).catch((err: BusinessError): void => { + hilog.warn(0x0000, 'FbHome', '刷新桌面卡片失败:%{public}s', err.message); + }); + } + + /** 日程模块主题色(详情卡环色 / 编辑按钮色) */ + private calAccent(): string { + const m: FbModuleDef | undefined = fbModuleOf('calendar'); + return m !== undefined ? fbThemeVars(m.color).brand : '#A84A28'; + } + + /** 任务模块主题色 */ + private taskAccent(): string { + const m: FbModuleDef | undefined = fbModuleOf('tasks'); + return m !== undefined ? fbThemeVars(m.color).brand : '#A8574F'; + } + /** 问候语时段(与 Web 端 home.php 里那段 PHP 同口径) */ private hello(): string { const h: number = new Date().getHours(); @@ -250,9 +519,17 @@ export struct FbHomePage { } build() { - Scroll() { - Column({ space: 0 }) { - this.topBar() + /* 🔴 v73:外层必须是 **Stack**(叠放),不能是 Column(排列)。 + 浮层要"盖在页面上",而不是"排在页面后面" —— 后者会把 Scroll 挤出屏幕, + 表现是"点了日程详情打不开 / 打开了但整页空了"。 + (本项目 2026-10-20 真机事故就是这个:`Column { 整高遮罩; 卡片 }` + 把卡片挤出可视区,编译通过、`aboutToAppear` 照常跑、`dumpLayout` 里一个节点都没有。) + ⚠️ 这也是为什么**根容器是 Stack 而不是 Scroll**:遮罩与卡片必须是它的兄弟, + 不能塞进 Scroll 的 Column 里。 */ + Stack({ alignContent: Alignment.TopStart }) { + Scroll() { + Column({ space: 0 }) { + this.topBar() // ==================== ① 个人信息卡(问候 + 今天 + 天气)==================== // 🔴 用户 2026-10-12 定的:这一块**要白色卡片**("这样显得好看")。 @@ -413,8 +690,10 @@ export struct FbHomePage { emptyTitle: '今天没有安排', emptyHint: '过得轻松一点', onPick: (e: FbCalEvent): void => { - // 点条目 ⇒ 切到日程模块(首页不做就地编辑:那是日程页的职责) - this.app.switchModule('calendar'); + /* 🔴 v73(用户 2026-10-21):**就地弹详情卡**,不跳模块。 + 旧实现是 `this.app.switchModule('calendar')` —— 点哪条都跳到日程页, + 用户还得自己再找一遍那条(用户明确否掉了这个行为)。 */ + this.openEvent(e); } }) @@ -541,18 +820,92 @@ export struct FbHomePage { Blank().height(FB_DOCK_RESERVE) } .width('100%') + } + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + // 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets + .onDidScroll(fbOnScroll) + + // 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断) + // 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets + .onTouch(fbOnTouch) + .align(Alignment.TopStart) + + // ==================== 日程详情卡(v73)==================== + // ⚠️ 遮罩与卡片是 Stack 的**兄弟**,不是 Column 的子项(见 build 开头那段说明)。 + if (this.evtBase !== null) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) + .onClick((): void => { + this.closeEvent(); + }) + + FbEventSheet({ + event: this.evtFull, + loading: this.evtLoading, + error: this.evtError, + accent: this.calAccent(), + bookName: this.evtBase !== null ? this.evtBase.calName : '', + bookColor: this.evtBase !== null ? this.evtBase.color : '#9AA0A6', + /* 🔴 首页**不给编辑入口**(`canEdit: false` 是有意的): + 用户要的是"看详情、能导航",而"编辑"要跳去日程模块 —— + 首页这颗按钮点下去会先关掉卡、再切模块,用户会看到"卡闪一下没了、 + 页面换成了日程",那正是他说"不要进入到日程里面"的那种突兀感。 + ⇒ 编辑请从日程模块进(那里点开同一条就带「编辑日程」)。 + ⚠️ 也别把这条改成"跳过去并自动打开这条的编辑"—— 那要传一串参数穿两个模块, + 复杂度远大于收益,而且用户没提这个需求。 */ + canEdit: false, + onNavigate: (): void => { + this.navigateEvent(); + }, + onClose: (): void => { + this.closeEvent(); + }, + onEdit: (): void => { + this.editEventFromDetail(); + } + }) + .transition(fbSheetTransition()) + } + + // ==================== 任务详情卡(v73)==================== + if (this.tskBase !== null) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) + .onClick((): void => { + this.closeTask(); + }) + + FbTaskDetailSheet({ + task: this.tskBase, + status: this.tskStatus, + busy: this.tskBusy, + note: this.tskNote, + accent: this.taskAccent(), + onToggle: (): void => { + this.toggleTask(); + }, + onEdit: (): void => { + this.editTaskFromDetail(); + }, + onClose: (): void => { + this.closeTask(); + } + }) + .transition(fbSheetTransition()) + } } .width('100%') .height('100%') - .scrollBar(BarState.Off) - .edgeEffect(EdgeEffect.Spring) - // 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets - .onDidScroll(fbOnScroll) - - // 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断) - // 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets - .onTouch(fbOnTouch) - .align(Alignment.TopStart) + .alignContent(Alignment.TopStart) } /** 天气地点行:`青岛市 · 崂山区 · 东南风 12 km/h` */ @@ -599,6 +952,15 @@ export struct FbHomePage { * + 子任务进度 + 截止时间。 * ⚠️ 看板色**必须**用真实派生色(`boardColor`)—— 用户 2026-10-08 报过 * 「首页近期任务……颜色并没有跟看板颜色一致」。 + * + * 🔴 v73(用户 2026-10-21):「任务也是一样,点击任务之后弹出来详情, + * 可以标记它是不是完成了什么之类的。」 + * ⇒ 整行可点,弹 `FbTaskDetailSheet`(**不再跳任务模块**)。 + * ⚠️ 这是 `@Builder taskRow(t, isLast)` —— **两个参数**,本项目的著名反模式。 + * 它在这里是安全的,理由与 `memoryRow` 完全相同: + * 列表由 `ForEach` 按 `t.id` 重建(数据变化 ⇒ 增删列表项 ⇒ 走重建路径, + * 不依赖 Builder 参数刷新)。⚠️ 如果哪天出现"数据变了、这一行文字没变", + * **先改成组件**(参照 `FbStatCell`),别在这个 Builder 上打补丁。 */ @Builder taskRow(t: FbTaskItem, isLast: boolean) { @@ -640,6 +1002,15 @@ export struct FbHomePage { .width('100%') .padding({ top: 11, bottom: 11 }) .alignItems(VerticalAlign.Center) + /* 🔴 v73:整行点开任务详情卡。 + ⚠️ 热区放在这个 `Row` 上(它含圆点/标题/状态/截止,一行都算), + 不是放在 `Column` 上 —— Column 还包着分隔线,点分隔线也会触发, + 两者视觉上不是"同一件东西"。 + ⚠️ 手指反馈:`stateStyles` 用不到(这里不是 Button), + 所以靠"点开后有一张卡弹出来"作为反馈 —— 那已经足够强。 */ + .onClick((): void => { + this.openTask(t); + }) if (!isLast) { Divider() diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 8f96143..dfa5eb2 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -27,6 +27,20 @@ ], "deliveryWithInstall": true, "installationFree": false, + /* 🔴 v73(用户 2026-10-21「有地址的可以一键导航」): + 这是 `bundleManager.canOpenLink()` 的**前置条件**,不是可选配置。 + ⚠️ 没声明时 `canOpenLink` **不抛错、直接返回 false** ⇒ + "手机上装了高德"和"我忘了声明"在运行期长得一模一样, + 症状是"导航按钮点了只是复制了一下地点",极难归因。 + ⚠️ 四个 scheme 必须与 `common/FbNav.ets` 的 `MAP_APPS[].probe` **一一对应**; + 加一家地图要么两处一起加,要么两处一起没有。守卫 `_fb_arkts_guard.js` 钉了这条。 + ⚠️ 只声明 scheme 名(`amapuri`),不带 `://`。 */ + "querySchemes": [ + "amapuri", + "baidumap", + "maps", + "qqmap" + ], "pages": "$profile:main_pages", "abilities": [ {