v73:桌面卡片刷新修复 + 首页日程/任务就地弹详情卡 + 一键导航

用户 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/<id>/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 通过、已装机启动。
This commit is contained in:
2026-10-21 20:01:37 +08:00
parent 15bc3c8599
commit 48a0507455
7 changed files with 1282 additions and 32 deletions
+282
View File
@@ -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() !== '';
}
+114 -2
View File
@@ -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,8 +400,31 @@ 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 }) {
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()`(= 日程模块主题色,与卡片外环同色)。 */
@@ -329,6 +440,7 @@ export struct FbEventSheet {
this.onEdit();
})
}
}
.width('100%')
.margin({ top: 14 })
}
@@ -0,0 +1,387 @@
/**
* 家书 FamilyBook · 任务详情卡(鸿蒙端)
*
* 🔴 用户 2026-10-21 原话:
* 「任务也是一样,点击任务之后弹出来详情,可以标记可以标记它是不是完成了什么之类的。」
* ⇒ 首页「近期任务」里点一行 → **就地弹出详情卡**(不跳任务模块),
* 卡上能直接勾「完成 / 未完成」。
*
* ⚠️ 为什么单独一张卡,而不是复用 `FbTaskEditSheet`(任务页的编辑浮层):
* 那是**表单**(标题框 / 备注框 / 截止时间 / 看板选择 / 保存按钮),
* 适合"我要改这个任务";用户此刻要的是"**看看它是什么、顺手勾一下完成**"。
* 拿表单当详情,点开就有五个可编辑控件 + 一颗"保存",观感是"我是不是要改什么"——
* 而且误触一下就把标题改了。两件事用两个界面。
*
* ⚠️ 与 `FbEventSheet`(日程详情)同一套视觉语言:
* 外圈主题色描边环(`FbSheetRing`)、关闭统一右上角 ✕(`FbSheetClose`)、
* 底部只放动作按钮、`92%` 宽居中、内容超高才滚动。
* ⇒ 用户在首页连点一个日程、一条任务,两张卡长得像"同一个应用的弹框"。
*
* 🔴 两处容易做错的地方(都已按正确方式实现,别改回去):
* ① 完成状态**必须走 `/tasks/<id>/status`**(`setTaskStatus`),
* 不能走 `PATCH /tasks/<id>` —— 服务端 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)
}
}
+57
View File
@@ -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<preferences.Preferences> {
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);
}
@@ -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/<id>` 拉回来的权威值;列表那份是 `/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();
},
+375 -4
View File
@@ -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/<id>` 的完整字段;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/<id>` 补 `rrule` / `reminder` / 精确 `ends_at`;
* ③ 拉不到就保留壳 + 一行小字说明(**不要**把整张卡变成"读不到")。
*
* ⚠️ 衍生条目(农历/节气/节假日)**没有 `/events/<id>` 可拉**
* (服务端 `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/<id>/status`**(`setTaskStatus`)——
* 服务端 `PATCH /tasks/<id>` 会把请求里的 status **原样忽略**
* (见 `FbTaskRepo` 文件头),打 PATCH 会"看起来成功、状态没变"。
* ⚠️ 这是本项目已经踩过一次的坑,别改成 updateTask。
*
* ⚠️ 已完成 → 取消完成统一回 `todo`(**不回 `doing`**):
* 用户只想撤销一次误勾,把它标成"进行中"是替他做了他没表达的决定。
* (与任务页 `toggleDone` 同一套规则。)
*/
private async toggleTask(): Promise<void> {
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,6 +519,14 @@ export struct FbHomePage {
}
build() {
/* 🔴 v73:外层必须是 **Stack**(叠放),不能是 Column(排列)。
浮层要"盖在页面上",而不是"排在页面后面" —— 后者会把 Scroll 挤出屏幕,
表现是"点了日程详情打不开 / 打开了但整页空了"。
(本项目 2026-10-20 真机事故就是这个:`Column { 整高遮罩; 卡片 }`
把卡片挤出可视区,编译通过、`aboutToAppear` 照常跑、`dumpLayout` 里一个节点都没有。)
⚠️ 这也是为什么**根容器是 Stack 而不是 Scroll**:遮罩与卡片必须是它的兄弟,
不能塞进 Scroll 的 Column 里。 */
Stack({ alignContent: Alignment.TopStart }) {
Scroll() {
Column({ space: 0 }) {
this.topBar()
@@ -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);
}
})
@@ -553,6 +832,80 @@ export struct FbHomePage {
// 与上面的 `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%')
.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()
+14
View File
@@ -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": [
{