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:
@@ -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() !== '';
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user