端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)

#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
This commit is contained in:
2026-11-05 12:41:04 +08:00
parent 88fa38ecf9
commit 86de59616c
35 changed files with 5818 additions and 911 deletions
+56
View File
@@ -0,0 +1,56 @@
{
"app": {
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"certpath": "/home/yongquan/code/cert/FamilyBook/debug/familybook.debug.cer",
"keyAlias": "debugKey",
"keyPassword": "0000001A174821314A63FD3C2B7A9CC58F00CA52D408A03F426A3A4640B54AC9DA06CE2508DC7146C920",
"profile": "/home/yongquan/code/cert/FamilyBook/debug/familybook.debug.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "/home/yongquan/code/cert/FamilyBook/debug/familybook.debug.p12",
"storePassword": "0000001AD7D45ED5643ED71724AD765DE36D26BB1EA1552DB57361D0EE186AB5EEEB2E6B51C678ED15E6"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default",
"targetSdkVersion": "26.0.0",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
],
"buildModeSet": [
{
"name": "debug"
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}
@@ -52,6 +52,9 @@ import { FB_KEY_STORE } from '../data/FbKeyStore';
import { FbDb } from '../data/FbDb';
import { FB_MODULE_TOKEN } from '../data/FbModuleRepo';
import { FbSyncAllResult, syncAll } from '../data/FbSyncAll';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbCalMirror, FbMirrorResult } from '../data/FbCalMirror';
import { FbCalInbound, FbInboundResult } from '../data/FbCalInbound';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbBgSync';
@@ -364,6 +367,39 @@ class FbBackgroundSyncImpl {
*
* @param tag 触发来源(`deferred` / `transient` / `cardTick` / `foreground` / `netRecover`)
*/
/**
* 跑一次「系统日历镜像」(入站 → 出站)。
*
* 🔴 2026-11-03 新增。用户要求把日历镜像到系统日历,
* 这样小艺 / 桌面卡片 / 手表能看到日程。
*
* ⚠️ **整段吞异常**:镜像是附加能力,失败不能让同步算失败。
* (与上面「后台路径必须吞掉所有异常」同一条纪律。)
* ⚠️ 只在用户开着开关、且已授权时才跑。
*/
private async runMirror(ctx: common.Context, tag: string): Promise<void> {
try {
if (!FB_CAL_SETTINGS.getMirror()) {
return;
}
if (!await FbCalMirror.hasPermission()) {
return;
}
// ① 入站:系统日历里的改动 → 服务器(必须在出站之前)
const inb: FbInboundResult = await FbCalInbound.reconcile(ctx);
// ② 出站:最新数据 → 系统日历
const r: FbMirrorResult = await FbCalMirror.syncNow(ctx);
hilog.info(DOMAIN, TAG,
'镜像[%{public}s]:入站 改=%{public}d 删=%{public}d 增=%{public}d;'
+ '出站 增=%{public}d 更=%{public}d 删=%{public}d 失败=%{public}d',
tag, inb.updated, inb.deleted, inb.created,
r.added, r.updated, r.deleted, r.failed);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '镜像[%{public}s] 失败(不影响同步):%{public}s', tag, e.message);
}
}
async runOnce(ctx: common.Context, tag: string): Promise<FbBgSyncResult> {
const r: FbBgSyncResult = new FbBgSyncResult();
const t0: number = Date.now();
@@ -384,6 +420,13 @@ class FbBackgroundSyncImpl {
r.done = res.done;
r.total = res.total;
r.note = res.note;
/* 🔴 2026-11-03:同步成功后接着跑「系统日历镜像」(用户要求的功能)。
⚠️ 顺序:先入站(把用户在系统日历里的改动收回服务器),再出站(写回系统日历)。
反了的话出站会把系统日历里的旧值当"期望值",覆盖掉用户的改动。
⚠️ 整段 try/catch 包住:镜像失败**绝不能**让同步算失败 ——
同步是主业,镜像是附加能力,坏了不该拖累数据对齐。 */
await this.runMirror(ctx, tag);
await this.writeNum(ctx, K_LAST_ANY, Date.now());
hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 完成:%{public}d/%{public}d,耗时 %{public}d 秒',
tag, res.done, res.total, Math.round((Date.now() - t0) / 1000));
+115
View File
@@ -0,0 +1,115 @@
/**
* 岁月家 · 临时诊断日志(落盘)
*
* 🔴 为什么需要这个:
* 这台设备的 `hilog` 读不出应用日志(`hilog | grep familybook` 恒为空,
* 已用 pid 过滤、-T tag、-D domain 等多种方式确认)。本项目代码注释里
* 也记着"这台设备的 hilog 缓冲区读不出内容(本项目多次确认)"。
* ⇒ 需要看运行期中间值时,**落到沙箱文件**再 `hdc file recv` 取出来。
*
* ⚠️ 这是**临时诊断设施**,问题定位完就该删掉(或至少不再调用):
* 它的写入是同步的,且不做轮转 —— 长期留着会无限增长。
*
* 用法:
* FbDiag.log('NAV', '地理编码 lat=' + lat);
* 取日志:hdc file recv <沙箱路径>/files/diag.log
*/
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
/** 日志文件名(应用 files 目录下) */
const DIAG_FILE: string = 'diag.log';
/** 超过这个大小就先清空(防止无限增长) */
const MAX_BYTES: number = 256 * 1024;
export class FbDiag {
private static _ctx: common.UIAbilityContext | null = null;
private static _busy: boolean = false;
/** 页面/组件启动时传一次上下文(`getUIContext().getHostContext()`) */
static init(ctx: common.Context | undefined): void {
if (ctx === undefined) {
return;
}
FbDiag._ctx = ctx as common.UIAbilityContext;
}
/** 完整路径 */
private static path(): string {
const c: common.UIAbilityContext | null = FbDiag._ctx;
if (c === null) {
return '';
}
return c.filesDir + '/' + DIAG_FILE;
}
/**
* 追加一行。
*
* ⚠️ **不抛错**:诊断日志失败绝不能让业务逻辑挂掉。
* 所有异常在这里咽掉(这是诊断设施,不是业务)。
*/
static log(tag: string, msg: string): void {
const p: string = FbDiag.path();
if (p === '') {
return;
}
// 防止同一帧内反复开文件(比如循环里调)
if (FbDiag._busy) {
return;
}
FbDiag._busy = true;
try {
// 超过上限先截断,避免无限增长
try {
const st: fileIo.Stat = fileIo.statSync(p);
if (st.size > MAX_BYTES) {
fileIo.unlinkSync(p);
}
} catch (e1) {
// 文件不存在是正常的(第一次写),忽略
}
const line: string = '[' + new Date().toISOString() + '] ' + tag + ' ' + msg + '\n';
const f: fileIo.File = fileIo.openSync(p,
fileIo.OpenMode.CREATE | fileIo.OpenMode.APPEND | fileIo.OpenMode.WRITE_ONLY);
try {
fileIo.writeSync(f.fd, line);
} finally {
fileIo.closeSync(f);
}
} catch (err) {
// 诊断设施,静默失败
} finally {
FbDiag._busy = false;
}
}
/** 清空(重测前用) */
static clear(): void {
const p: string = FbDiag.path();
if (p === '') {
return;
}
try {
fileIo.unlinkSync(p);
} catch (err) {
// 不存在就算了
}
}
/** 读全文(给设置页「诊断」用;这里主要用于导出前自检) */
static readAll(): string {
const p: string = FbDiag.path();
if (p === '') {
return '';
}
try {
return fileIo.readTextSync(p);
} catch (err) {
return '';
}
}
}
+303 -39
View File
@@ -32,6 +32,7 @@
import { common, Want } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit';
import { FbDiag } from './FbDiag';
import { bundleManager } from '@kit.AbilityKit';
import { pasteboard } from '@kit.BasicServicesKit';
import { promptAction } from '@kit.ArkUI';
@@ -47,6 +48,21 @@ class FbMapApp {
name: string = '';
/** 探测用 scheme(必须与 `module.json5` 的 `querySchemes` 一致,否则恒 false) */
probe: string = '';
/**
* 鸿蒙版应用的**真实包名**(显式拉起用)。
*
* 🔴 2026-11-03 实测:隐式 Want 在鸿蒙上拉不起第三方地图
* (报 `16000019 No matching ability is found`)——
* 系统不认应用私有 scheme 做隐式匹配。
* ⇒ 必须显式指定包名,系统直接送到那个 Ability。
*
* ⚠️ **只有高德是真机实测的**(`bm dump -a | grep map` 查到的)。
* 其余留空 ⇒ 退回 openLink。不要在没验证时填猜测值,
* 包名写错的表现就是"点了完全没反应",比不打还难查。
*/
bundleName: string = '';
/** 鸿蒙版应用的入口 Ability 名(显式拉起用) */
abilityName: string = '';
/** 按**坐标**导航的 URI(`{lat}` / `{lon}` / `{name}` 会被替换) */
naviByPos: string = '';
/** 按**关键字**搜索的 URI(没有坐标时用这个) */
@@ -75,24 +91,43 @@ const MAP_APPS: FbMapApp[] = [
那正是"不支持"的直接来源 ⇒ 改成 `route/plan/` 只带 dname 的尽力而为形式。 */
name: '高德地图',
probe: 'amapuri://',
/* 🔴 实测(`hdc shell bm dump -a | grep -i map`):
鸿蒙版高德的包名是 `com.amap.hmapp`,入口 `EntryAbility`。
**不是**安卓的 `com.autonavi.minimap`。
`hdc shell aa start -b com.amap.hmapp -a EntryAbility -U 'amapuri://...'`
实测返回 `start ability successfully`。 */
bundleName: 'com.amap.hmapp',
abilityName: 'EntryAbility',
naviByPos: 'amapuri://route/plan/?sourceApplication=FamilyBook&dlat={lat}&dlon={lon}&dname={name}&dev=0&t=0',
naviByText: 'amapuri://route/plan/?sourceApplication=FamilyBook&dname={name}&dev=0&t=0'
},
{
name: '百度地图',
probe: 'baidumap://',
// 鸿蒙包名**未实测**,留空 ⇒ 走隐式/openLink 兜底。
// 实测后填上即可(百度/花瓣/腾讯在鸿蒙上的包名与安卓不同)。
bundleName: '',
abilityName: '',
naviByPos: 'baidumap://map/direction?destination=latlng:{lat},{lon}|name:{name}&mode=driving',
naviByText: 'baidumap://map/geocoder?address={name}'
},
{
name: '花瓣地图',
probe: 'maps://',
// 鸿蒙包名**未实测**,留空 ⇒ 走隐式/openLink 兜底。
// 实测后填上即可(百度/花瓣/腾讯在鸿蒙上的包名与安卓不同)。
bundleName: '',
abilityName: '',
naviByPos: 'maps://routes?destination={lat},{lon}&destinationName={name}',
naviByText: 'maps://search?query={name}'
},
{
name: '腾讯地图',
probe: 'qqmap://',
// 鸿蒙包名**未实测**,留空 ⇒ 走隐式/openLink 兜底。
// 实测后填上即可(百度/花瓣/腾讯在鸿蒙上的包名与安卓不同)。
bundleName: '',
abilityName: '',
naviByPos: 'qqmap://map/routeplan?type=drive&to={name}&tocoord={lat},{lon}',
naviByText: 'qqmap://map/search?keyword={name}'
}
@@ -107,6 +142,105 @@ class FbGeoPoint {
ok: boolean = false;
}
/**
* 地址清洗:把用户手写的地址洗成**系统地理编码更容易认**的形式。
*
* 🔴 2026-11-03 真机日志暴露的问题:
* 原文 `招商银行(青岛经济技术开发区支行) · 珠江路1018号`
* → 系统只抓住「珠江路1018号」,全国搜到**江苏南通**去了(差 500 公里)。
*
* 系统的解析器对这几样东西很敏感:
* · 括号 `()` `()` —— 里面的内容常被当"补充说明"丢掉,而那里正是城市名
* · 中点 `·` `•` —— 被当成分隔符,前后两段各算各的
* · 非标准行政区划名(「经济技术开发区」不是标准名,「黄岛区」才是)
*
* 所以这里做三件事:
* 1. 括号改空格(保留内容,只是去掉符号本身)
* 2. 中点改空格
* 3. 连续空格压成一个
*
* ⚠️ **不改动任何汉字**:只动符号。
* 把「经济技术开发区」翻译成「黄岛区」需要行政区划库,
* 写死在本文件里维护成本高且容易过时 —— 交给下一步的城市匹配去兜。
*/
function cleanAddrForGeocode(addr: string): string {
let t: string = addr;
// 全角括号 → 空格(内容保留)
t = t.replace(/[((]/g, ' ');
t = t.replace(/[))]/g, ' ');
// 中点 / 间隔号 / 竖线 / 逗号顿号 → 空格
t = t.replace(/[·•・||,,、]/g, ' ');
// 破折号、波浪线 → 空格
t = t.replace(/[—–~~]/g, ' ');
// 连续空格压缩
t = t.replace(/\s+/g, ' ');
// 去首尾空格
return t.trim();
}
/**
* 从地址里抽出「城市关键字」,用于在多个地理编码结果里挑对的那个。
*
* 识别规则(按优先级):
* ① `XX市` —— 最常见,直接取
* ② `XX区` —— 直辖市/开发区常见
* ③ 已知城市别名表(校区名之类)
*
* ⚠️ 只抽**一个**最可能的。抽不出来返回空串(调用方会退回"取第 1 条")。
*/
function cityHintOf(addr: string): string {
// ① 显式的「XX市」
const mCity: RegExpMatchArray | null = addr.match(/([\u4e00-\u9fa5]{2,6})市/);
if (mCity !== null) {
return mCity[1];
}
// ② 已知校区/园区 → 城市(这些是本人常用地点,写死最省事)
const KNOWN: string[] = [
'中国海洋大学|青岛', '海大|青岛', '崂山校区|青岛', '鱼山校区|青岛', '浮山校区|青岛',
'黄岛|青岛', '开发区|青岛', '市南|青岛', '市北|青岛', '李沧|青岛', '城阳|青岛', '即墨|青岛'
];
for (const pair of KNOWN) {
const parts: string[] = pair.split('|');
if (addr.indexOf(parts[0]) >= 0) {
return parts[1];
}
}
return '';
}
/**
* 在多个地理编码结果里挑**最可能是用户想去的那一个**。
*
* 🔴 为什么必须挑:`getAddressesFromLocationName` 会返回多条,
* 而「第 1 条」没有任何特殊含义。实测那次第 1 条在江苏南通,
* 用户要去青岛 —— 盲取第 1 条就是"地址错"的直接原因。
*
* 策略:
* ① 候选里有 `administrativeArea / locality / placeName` 含城市关键字 → 取第一条匹配的
* ② 一个都不匹配 → 退回第 1 条(并返回 matched=false 让调用方记日志)
*
* @returns [lat, lon, matched]
*/
function pickBestGeo(list: geoLocationManager.GeoAddress[], hint: string): number[] {
if (list.length === 0) {
return [0, 0, 0];
}
if (hint !== '') {
for (const it of list) {
const blob: string = ((it.administrativeArea ?? '') as string)
+ ((it.locality ?? '') as string)
+ ((it.subLocality ?? '') as string)
+ ((it.placeName ?? '') as string)
+ ((it.roadName ?? '') as string);
if (blob.indexOf(hint) >= 0) {
return [(it.latitude ?? 0) as number, (it.longitude ?? 0) as number, 1];
}
}
}
const f: geoLocationManager.GeoAddress = list[0];
return [(f.latitude ?? 0) as number, (f.longitude ?? 0) as number, 0];
}
/**
* 系统地理编码:地址 → 坐标。
*
@@ -126,22 +260,79 @@ async function geocodeBySystem(address: string): Promise<number[] | null> {
if (q === '') {
return null;
}
/* ---- ① 清洗 ----
🔴 2026-11-03 真机日志:原文 `招商银行(青岛经济技术开发区支行) · 珠江路1018号`
系统只抓「珠江路1018号」→ 搜到**江苏南通**(差 500 公里)。
括号和中点符号是关键干扰源(里面恰好是城市名,被当"补充说明"丢了)。 */
const cleaned: string = cleanAddrForGeocode(q);
const hint: string = cityHintOf(q);
FbDiag.log('NAV', '=== 地理编码 ===');
FbDiag.log('NAV', '原文: [' + q + ']');
FbDiag.log('NAV', '清洗后: [' + cleaned + ']');
FbDiag.log('NAV', '城市关键字: [' + hint + ']');
try {
if (!geoLocationManager.isGeocoderAvailable()) {
hilog.warn(DOMAIN, TAG, '系统地理编码服务不可用');
FbDiag.log('NAV', '系统地理编码服务不可用');
return null;
}
const req: geoLocationManager.GeoCodeRequest = { description: q, maxItems: 1 };
// ⚠️ maxItems 取 5:多条里才有挑选的余地(原来取 1 等于没得挑)
const req: geoLocationManager.GeoCodeRequest = { description: cleaned, maxItems: 5 };
const list: geoLocationManager.GeoAddress[] =
await geoLocationManager.getAddressesFromLocationName(req);
if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) {
return [list[0].latitude as number, list[0].longitude as number];
FbDiag.log('NAV', '命中条数: ' + list.length);
if (list.length === 0) {
FbDiag.log('NAV', '地理编码返回 0 条(地址无法识别)');
return null;
}
// ⚠️ 日志**不落地点文本**(可能含家庭/公司等敏感地址),只记结果
hilog.info(DOMAIN, TAG, '地理编码无结果(地点已脱敏)');
// 把候选都记下来(判断"是不是在猜"必须看多条)
const n: number = Math.min(5, list.length);
for (let i = 0; i < n; i++) {
const it: geoLocationManager.GeoAddress = list[i];
FbDiag.log('NAV', ' [' + i + '] lat=' + ((it.latitude ?? 0) as number)
+ ' lon=' + ((it.longitude ?? 0) as number)
+ ' | ' + ((it.administrativeArea ?? '') as string)
+ ((it.locality ?? '') as string)
+ ((it.subLocality ?? '') as string)
+ ((it.roadName ?? '') as string)
+ ((it.premises ?? '') as string));
}
/* ---- ② 按城市挑,不盲取第 1 条 ----
实测那次 5 条全在南通,用户要去青岛 ⇒ 盲取第 1 条就是"地址错"。 */
const picked: number[] = pickBestGeo(list, hint);
const la: number = picked[0];
const lo: number = picked[1];
const matched: boolean = picked[2] === 1;
if (list.length > 1) {
FbDiag.log('NAV', matched
? '多结果中按城市「' + hint + '」匹配成功,取该条'
: '⚠️ 多结果中无一条含城市「' + hint + '」,退回第 1 条(可能不准)');
}
FbDiag.log('NAV', '选定: lat=' + la + ' lon=' + lo + ' 城市匹配=' + (matched ? '是' : '否'));
/* ---- ③ 城市不匹配时**宁可不用** ----
导到 500 公里外比"打不开"更糟:用户可能开出去才发现错。
这里返回 null 让上层走"只按名称"的降级路径,
地图会显示名称但没坐标 —— 用户至少能看出"它没定位到"。 */
if (hint !== '' && !matched && list.length > 0) {
// 但有一种情况可以用:只有 1 条结果(说明系统很确定)
if (list.length === 1) {
FbDiag.log('NAV', '虽无城市匹配,但只有 1 条结果 ⇒ 采信');
return [la, lo];
}
FbDiag.log('NAV', '城市不匹配且有多条 ⇒ 拒绝这个坐标(避免导错城市)');
return null;
}
return [la, lo];
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '地理编码失败:%{public}s', e.message);
FbDiag.log('NAV', '地理编码异常: ' + e.message);
}
return null;
}
@@ -273,8 +464,11 @@ function installedMaps(): FbMapApp[] {
*/
export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined,
place: string, title: string): Promise<void> {
FbDiag.log('NAV', '');
FbDiag.log('NAV', '######## fbOpenNavigation 被调用 ########');
const raw: string = place.trim();
const keyword: string = raw !== '' ? raw : title.trim();
FbDiag.log('NAV', 'place=[' + raw + '] title=[' + title + ']');
if (keyword === '') {
return;
}
@@ -301,12 +495,16 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined,
高德的 route/plan **要求 dlat/dlon 必填**,原来拿不到坐标
就退到 `amapuri://poi?keyword=`(新版已废弃)⇒ 高德拒绝。
现在先求坐标,绝大多数地点都能求出。 */
const wgs: number[] | null = await geocodeBySystem(keyword);
if (wgs !== null) {
// 系统给的是 WGS84,高德要 GCJ02 —— 不转会偏 100~700 米
const gcj: number[] = wgs84ToGcj02(wgs[0], wgs[1]);
lat = gcj[0];
lon = gcj[1];
const pt: number[] | null = await geocodeBySystem(keyword);
if (pt !== null) {
/* 🔴 2026-11-03 修:**不要再转 GCJ02**。
日志里暴露了二次偏移:
地理编码给 36.0731808/120.4283078(对应香港东路23号)
转换后变 36.0734519/120.4334066(东偏约 500 米)
鸿蒙系统的地址编码在国内返回的**已经是 GCJ02 火星坐标**
(它本身就是给国内地图用的),再转一次就是偏两次。 */
lat = pt[0];
lon = pt[1];
hasPos = true;
}
}
@@ -326,10 +524,17 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined,
}
/* ---- ③ 依次尝试每一家(有坐标优先用坐标,没有退到关键字)---- */
FbDiag.log('NAV', '=== 最终决定 ===');
FbDiag.log('NAV', 'hasPos=' + (hasPos ? '是' : '否')
+ ' lat=' + lat + ' lon=' + lon
+ ' dname=[' + name + ']'
+ ' 可用地图=' + maps.length);
for (const app of maps) {
const uri: string = hasPos
? fillUri(app.naviByPos, lat, lon, name)
: fillUri(app.naviByText, 0, 0, keyword);
FbDiag.log('NAV', 'URI(' + app.name + ')=' + uri);
const ok: boolean = await openLinkWithFallback(ctx, app, uri, keyword, lat, lon, name, hasPos);
if (ok) {
@@ -340,8 +545,24 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined,
hilog.warn(DOMAIN, TAG, '%{public}s 拉起失败,试下一家', app.name);
}
// ④ 全都失败 → 复制地点并明确告知
fbCopyPlace(keyword);
/* ④ 全都失败 —— 必须让用户看见,不能"点了没反应"。
🔴 2026-11-03 用户反馈「高德在导航中时,再点导航**完全无反应**」。
无反应是最糟的:用户无法判断是应用坏了,还是地图不理他。
这里给两种情况的**不同**提示:
· 装都没装 → 复制地点(用户可以用网页地图)
· 装了但拉不起来 → 明确说"可能正在导航中",并告诉怎么办
(高德在导航中会把新的导航请求丢掉,这是它的策略) */
const anyInstalled: boolean = maps.length > 0;
if (anyInstalled) {
FbDiag.log('NAV', '所有地图都拉起失败,给出提示');
fbToast('地图没能响应。若高德正在导航中,请先退出当前导航再试');
// 同时把地点复制走,用户至少有兜底
fbCopyPlace(keyword);
} else {
fbCopyPlace(keyword);
}
}
/**
@@ -358,36 +579,79 @@ export async function fbOpenNavigation(ctx: common.UIAbilityContext | undefined,
async function openLinkWithFallback(ctx: common.UIAbilityContext, app: FbMapApp,
uri: string, keyword: string, lat: number, lon: number,
name: string, hasPos: boolean): Promise<boolean> {
try {
if (ctx.openLink !== undefined) {
await ctx.openLink(uri);
return true;
/* 🔴 2026-11-03 改:**startAbility 为主**。
用户反馈「高德已经打开并在导航中,再点导航完全无反应」。
查高德官方鸿蒙文档给的示例用的就是 `startAbility(want, callback)`,
并明确写「推荐显式跳转……只要包名和 Ability 名正确,100% 可靠」。
我们之前用 `openLink`(因为 SyncCalendar 用它能通)——
但那只能证明"**首次拉起**"能通,不能证明"**已在前台**"能通。
⚠️ 两者的复用策略不同:`openLink` 走"按 URI 找应用",
目标已在前台时的行为与显式 `startAbility` 不一样。
官方接口既然是 startAbility,就用它。 */
const tryOne = async (u: string): Promise<boolean> => {
/* ① **显式拉起**(唯一真正可靠的方式)
🔴 2026-11-03 实测:隐式 Want 报 `16000019 No matching ability is found`,
`openLink` 报"成功"但系统弹"暂无可用打开方式"——
两者都走 scheme 匹配,而鸿蒙不认第三方私有 scheme。
⇒ 指定 bundleName + abilityName,系统直接投递,不经匹配。
⚠️ 显式拉起时**不要带 action**:显式目标与隐式 action 语义互斥,
带上 `android.intent.action.VIEW` 反而会失败。 */
if (app.bundleName !== '' && app.abilityName !== '') {
try {
const w: Want = {
bundleName: app.bundleName,
abilityName: app.abilityName,
uri: u
};
await ctx.startAbility(w);
FbDiag.log('NAV', '显式拉起成功: ' + app.name);
return true;
} catch (err) {
const e: BusinessError = err as BusinessError;
FbDiag.log('NAV', '显式拉起失败(' + app.name + '): code=' + e.code + ' msg=' + e.message);
}
}
// 极老版本没有 openLink ⇒ 退回隐式 Want(有总比没有好)
const want: Want = { uri: uri };
await ctx.startAbility(want);
// ② 隐式 startAbility(包名未验证的地图走这条,虽然多半不行)
try {
const want: Want = { uri: u, action: 'android.intent.action.VIEW' };
await ctx.startAbility(want);
FbDiag.log('NAV', '隐式 startAbility 成功: ' + app.name);
return true;
} catch (err) {
const e: BusinessError = err as BusinessError;
FbDiag.log('NAV', '隐式 startAbility 失败(' + app.name + '): code=' + e.code + ' msg=' + e.message);
}
// ③ openLink 最后兜底
if (ctx.openLink !== undefined) {
try {
await ctx.openLink(u);
FbDiag.log('NAV', 'openLink 成功: ' + app.name);
return true;
} catch (err2) {
const e2: BusinessError = err2 as BusinessError;
FbDiag.log('NAV', 'openLink 失败(' + app.name + '): code=' + e2.code + ' msg=' + e2.message);
}
}
return false;
};
if (await tryOne(uri)) {
return true;
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, 'openLink 失败(%{public}s):%{public}s', app.name, e.message);
}
/* 再退一步:有坐标时改用关键字搜索重试。
"按坐标失败"最常见的原因是参数名与该版本应用不符,
关键字那条的参数面小得多、通常还活着。 */
/* 再退一步:有坐标时改用**只按名称**的形式重试。
某些高德版本对 dlat/dlon 之外的参数更宽容。 */
if (hasPos) {
try {
const retry: string = fillUri(app.naviByText, 0, 0, keyword);
if (ctx.openLink !== undefined) {
await ctx.openLink(retry);
} else {
await ctx.startAbility({ uri: retry } as Want);
}
hilog.info(DOMAIN, TAG, '改用关键字搜索成功(%{public}s)', app.name);
const retry: string = fillUri(app.naviByText, 0, 0, keyword);
if (await tryOne(retry)) {
FbDiag.log('NAV', '改用名称形式成功: ' + app.name);
return true;
} catch (e2) {
const eb: BusinessError = e2 as BusinessError;
hilog.warn(DOMAIN, TAG, '关键字搜索也失败(%{public}s):%{public}s', app.name, eb.message);
}
}
return false;
+249 -45
View File
@@ -29,6 +29,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbGeoTip } from '../model/Types';
import { FB_GEO_MIN_LEN, fbGeoTips } from '../data/FbGeoRepo';
import { FbDiag } from '../common/FbDiag';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbAddrInput';
@@ -50,6 +51,22 @@ export struct FbAddrInput {
@Event onValue: (v: string) => void = (_v: string): void => {
};
/**
* **结构化选中回调**(#51)。
*
* 🔴 用户:「鸿蒙端如果添加一个行程,出发地和目的地地址是不是也能通过
* 高德地图 API 拿到详细地址?」
*
* `FbGeoTip` 本来就有 `detail` / `address` / `lng` / `lat`(高德返回就有),
* 但 `onValue` 只传了"地名 · 详细地址"**拼好的字符串** ⇒ 父组件拿不到坐标,
* 也没法分别写 `from_place` / `from_addr` / `from_lng` / `from_lat`。
*
* ⇒ 加这个回调把整条 tip 交出去。
* ⚠️ 保留 `onValue`(6 处调用方只关心文字,改签名改动面太大)。
*/
@Event onPick: (t: FbGeoTip) => void = (_t: FbGeoTip): void => {
};
/** 候选列表 */
@Local tips: FbGeoTip[] = [];
/** 是否展开候选面板 */
@@ -66,6 +83,46 @@ export struct FbAddrInput {
*/
@Local private seq: number = 0;
/**
* 最后一次**程序写入**的值(选中候选后回写的那种)。
*
* 🔴 2026-11-03 用户报:「点选之后,那个框还会再弹出来一次」。
* 根因:`onValue()` 回写 → 父组件改 `value` → `TextInput` 重渲染
* → 再次触发 `onChange` → 又发起一次搜索 → 候选框重新弹出。
*
* 解法照 SyncCalendar(`EventEditPage.ets` 的 `lastAppliedText`):
* 在 onChange 里精确比对,命中就**直接 return**,不再搜。
*/
@Local private lastApplied: string = '';
/**
* 首次 `onChange` 是否已经发生过。
*
* 🔴 2026-11-05(用户:「一打开之后,马上弹出来两个框让我选地址」):
* ArkUI 会为**新挂载**的 `TextInput` 触发一次 `onChange`(即使 text 没变),
* 而编辑浮层打开时 `value` 已经预填了地址 ⇒ 那一次 onChange 被当成
* "用户输入" ⇒ 去搜 ⇒ 有候选 ⇒ `bindPopup` 弹出。
* 两个地址框都这样 ⇒ 一进浮层就弹两个候选框。
*
* ⇒ 第一次 onChange 只认作初始化回填,**不搜索**。
*/
@Local private primed: boolean = false;
/**
* **时间窗抑制**(#53):选中医选后的这段时间内,一律不搜索。
*
* 🔴 前三次修"选中后又弹一次"都靠值比对(原串 → trim),都没修好。
* 根因是值比对这条路本身不可靠:ArkUI 在 TextInput 内容被程序改写时,
* onChange 的**触发次数和时机不确定**(重渲染、光标定位、输入法同步各一次),
* 只要有一次值与记录的不一致,就会搜一次、框就重弹。
*
* ⇒ 改用时间窗:选中后 600ms 内任何 onChange 都直接返回。
* 600ms 足以覆盖所有回写触发的 onChange,而人不可能在这段时间打完第二个字。
*/
private suppressUntil: number = 0;
/** 选中后抑制搜索的时长(毫秒) */
private static readonly SUPPRESS_MS: number = 600;
/** 收起面板并清空候选 */
private close(): void {
this.open = false;
@@ -74,12 +131,59 @@ export struct FbAddrInput {
/** 输入变化 → 防抖 → 查候选 */
private onType(v: string): void {
/* 🔴 #53:**时间窗抑制**(最优先,先于一切值比对)。
刚选中候选后的 600ms 内,任何 onChange 都不搜 ——
不管 ArkUI 触发几次、值有没有微妙差异。 */
if (Date.now() < this.suppressUntil) {
FbDiag.log('GEO', 'onChange 在抑制窗口内,跳过 v=[' + v + ']');
return;
}
/* 🔴 2026-11-05:**新挂载时的那次 onChange 是初始化,不是用户输入**。
编辑浮层预填了地址 ⇒ 那一次 onChange 会去搜 ⇒ 候选框一进页面就弹出来。
⇒ 第一次直接吞掉(同时记下 lastApplied,防止后续误判)。
⚠️ 不能用"判 lastApplied 是否为空"代替:初始化时它本来就是空的,
与"用户刚开始打字"无法区分。只有"第几次"这个信息能区分。 */
if (!this.primed) {
this.primed = true;
this.lastApplied = v;
return;
}
/* 🔴 2026-11-03:**程序回写导致的重入**必须挡掉。
选中候选时 `pick()` 会 `onValue(t.name)` → 父组件改 `value`
→ TextInput 重渲染 → 又触发 onChange → 又搜一次 → 框重新弹出。
判据:值等于我刚提交的那个 ⇒ 是回写,不是用户打字 ⇒ 直接返回。
⚠️ 用完立刻清空 `lastApplied`:否则用户后续真的手动输入同一个字符串时
会被误判成"回写"而不搜索。 */
/* 🔴 #52:**trim 后比对**。
父组件回写时可能带首尾空格/全角差异,原串比对会失败
⇒ 判定成"用户输入" ⇒ 又搜一次 ⇒ 框重弹(用户:「这个并没有解决」)。 */
if (this.lastApplied !== '' && v.trim() === this.lastApplied.trim()) {
FbDiag.log('GEO', 'onChange 是程序回写,跳过搜索 v=[' + v + ']');
/* 🔴 #51(用户:「选中,又会弹出一次地址选择框,就是那个联想框,
这个问题在添加日程时就有」):
**不要在这里清 `lastApplied`**。
原来这里 `this.lastApplied = ''` —— 但选中候选时 ArkUI 可能触发
**不止一次** onChange(重渲染一次、光标定位一次),
第一次消费掉标记后,第二次就挡不住了 ⇒ 又搜一次 ⇒ 框重弹。
⇒ 标记留到"用户真的输入了别的值"时再清(见下面那条分支)。
这样无论回写触发几次 onChange,都会被同一个标记挡住。 */
return;
}
/* 走到这里说明 v 与 lastApplied 不同 ⇒ 是**用户真实输入** ⇒ 清掉标记 */
this.lastApplied = '';
this.onValue(v); // 值先给父组件(保存用的是它,不能等联想)
if (this.timer >= 0) {
clearTimeout(this.timer);
this.timer = -1;
}
const kw: string = v.trim();
FbDiag.log('GEO', 'onChange v=[' + v + '] kw.len=' + kw.length + ' MIN=' + FB_GEO_MIN_LEN);
/* 字太少 ⇒ 不查,并把已有候选收起来。
⚠️ 一定要收:用户已经把字删了,留着旧候选会被误点。 */
if (kw.length < FB_GEO_MIN_LEN) {
@@ -97,18 +201,73 @@ export struct FbAddrInput {
const my: number = this.seq;
/* ⚠️ 不再传 token:改用鸿蒙自带 Map Kit 的 `site.queryAutoComplete()`,
鉴权走 AGC 应用认证,与我们的 API 令牌无关(见 FbGeoRepo 文件头)。 */
FbDiag.log('GEO', 'search 开始 kw=[' + kw + ']');
const list: FbGeoTip[] = await fbGeoTips(kw, this.limit);
FbDiag.log('GEO', 'search 返回 ' + list.length + ' 条 seq=' + my + '/' + this.seq);
if (my !== this.seq) {
return; // 已经有一次更新的输入了,这份丢弃
FbDiag.log('GEO', '丢弃过期结果');
return;
}
this.tips = list;
this.open = list.length > 0;
FbDiag.log('GEO', '面板 open=' + this.open);
}
/**
* 把一条候选拼成**要存进输入框的值**。
*
* 🔴 2026-11-03 修(用户指出):「你放到输入框的地址,不是详细地址啊,
* 没有黄岛区珠江路这样的字样」。
*
* 原来只写 `t.name`(如「招商银行(青岛经济技术开发区支行)」),
* **没有省市区和门牌**。而这一栏的值会原样存进服务端 `location`:
* · 以后导航做地理编码时又只搜「招商银行」→ 全国几百个 → 可能导到外省;
* · 等于把我们今晚修好的"详细地址"问题绕回了原点。
*
* ⇒ 照网页端 `addr_suggest.js` 第 566 行的格式:
* name + ' · ' + detail
* 「招商银行(青岛经济技术开发区支行) · 青岛市黄岛区珠江路1018号」
*
* ⚠️ 分隔符必须与网页端**完全一致**(空格+中点+空格):
* 两端存进库的字符串要同形,否则服务端去重判断和用户观感都会不一致。
*
* ⚠️ `detail` 已含「省市区 + 门牌」(服务端 `Geo::suggest` 拼好的
* `district + address`),这里直接拼,不要二次组合。
*
* ⚠️ 兜底:个别条目 detail 为空(纯 POI 无名址)⇒ 只写名字,
* 不留下「名字 · 」这种尾巴。
*/
private valueOfTip(t: FbGeoTip): string {
const nm: string = t.name.trim();
const dt: string = t.detail.trim();
if (nm === '') {
return dt;
}
if (dt === '' || dt === nm) {
return nm;
}
return nm + ' · ' + dt;
}
/** 选中一条候选 */
private pick(t: FbGeoTip): void {
this.onValue(t.name);
const v: string = this.valueOfTip(t);
/* ⚠️ 顺序很重要:**先记下要写入的值,再 onValue**。
反过来的话父组件同步改 value 会立刻触发 onChange,
那一刻 lastApplied 还是空的 ⇒ 挡不住 ⇒ 又弹一次。 */
/* #52:存 trim 后的值,与 onType 里的 trim 比对保持同一口径 */
/* #53:开启抑制窗口(在 onValue **之前**设,
因为 onValue 会立刻触发父组件回写 → onChange,必须先挡住) */
this.suppressUntil = Date.now() + FbAddrInput.SUPPRESS_MS;
this.lastApplied = v.trim();
this.onValue(v);
/* #51:把结构化数据也交出去(地名/详细地址/经纬度分开)。
⚠️ 放在 onValue 之后:先用文字更新输入框(用户立刻看到),
再让父组件收坐标 —— 顺序反了会出现"输入框还空着但坐标已存"的瞬间。 */
this.onPick(t);
this.close();
FbDiag.log('GEO', '已选中并写入: [' + v + ']');
}
build() {
@@ -128,52 +287,97 @@ export struct FbAddrInput {
.onChange((v: string): void => {
this.onType(v);
})
/* 🔴🔴 2026-11-03 改:候选面板从「布局流内联」改成 **bindPopup 浮层**。
用户原话:「下拉框在下面,完全被输入法遮挡」。
照 SyncCalendar 的 `EventEditPage.ets` 写法。
/* 候选面板:只在有候选时占位(用 if 而不是 visibility,
否则空列表也会把下面的字段撑开一块空白)。 */
if (this.open && this.tips.length > 0) {
Column() {
ForEach(this.tips, (t: FbGeoTip, idx: number): void => {
Column({ space: 2 }) {
/* 第一行:地名(用户认的是名字) */
Text(t.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
/* 第二行:详细地址 —— **这就是用户要的"参考"**。
高德对纯行政区划条目不给 address,那时 detail 就是省市区本身;
万一都空就整行不画(不要留一条空灰线)。 */
if (t.detail !== '') {
Text(t.detail)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
为什么 inline Column 不行:
· 它在布局流里 ⇒ 输入法弹起时被顶住/被盖住;
· 候选出现/消失会改变输入框位置 ⇒ 输入框上下跳(很难受)。
bindPopup 的三个关键参数:
· `placement: Placement.Top` —— 向**上**弹,躲开输入法;
· `focusable: false` —— **不抢焦点**,否则输入法会收起、体验断裂;
· `autoCancel: true` —— 点外部关闭(符合下拉的直觉)。 */
.bindPopup(this.open, {
builder: (): void => {
this.tipPanel()
},
/* 🔴 #52(用户:「有时候弹出的框会在输入框下面。我们之前定的是在输入框
上面弹出来,不然如果在下面弹出来,会被输入键盘挡住。」)
`Placement.Top` 只是**偏好**:上方空间不足时系统会自动翻到下方。
真正能控制的是**让它在上面放得下** ⇒ 候选列表限高 + 内部滚动
(见 tipPanel 的 constraintSize)。 */
placement: Placement.Top,
popupColor: FbColors.surface,
enableArrow: false,
showInSubWindow: false,
focusable: false,
autoCancel: true,
radius: FbMetric.radiusS,
onStateChange: (e: PopupStateChangeParam): void => {
/* 用户点浮层外部 / 系统收起时同步状态,
否则 `open` 还是 true,下次想弹会弹不出来。 */
if (!e.isVisible && this.open) {
this.open = false;
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.pick(t);
})
}, (t: FbGeoTip, idx: number): string => t.name + '|' + t.detail + '|' + idx.toString())
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: FbColors.line })
/* 最多约 5 条高度,多了内部滚动 —— 不把整张表单顶下去 */
.constraintSize({ maxHeight: 260 })
.clip(true)
}
}
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
/**
* 候选浮层的内容(bindPopup 的 builder)。
*
* ⚠️ 必须是 `@Builder` —— `bindPopup` 的 builder 签名就要求 void 返回。
* 这里读的是 `@Local tips`,V2 下状态变化会正常触发重建
* (与文件头说的"@Builder 值类型参数不参与观察"不冲突:
* 那个坑是**按值传参**,这里是直接读组件状态)。
*/
@Builder
tipPanel() {
Column() {
ForEach(this.tips, (t: FbGeoTip, idx: number): void => {
Column({ space: 2 }) {
/* 第一行:地名(用户认的是名字) */
Text(t.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
/* #52:限高,让候选在上方也放得下,不被输入法挡住 */
.constraintSize({ maxHeight: 180 })
.width('100%')
.textAlign(TextAlign.Start)
/* 第二行:详细地址 —— **这就是用户要的"参考"**。
高德对纯行政区划条目不给 address,那时 detail 就是省市区本身;
万一都空就整行不画(不要留一条空灰线)。 */
if (t.detail !== '') {
Text(t.detail)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.pick(t);
})
}, (t: FbGeoTip, idx: number): string => t.name + '|' + t.detail + '|' + idx.toString())
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
/* 最多约 5 条高度,多了内部滚动 —— 浮层不该占满整屏 */
.constraintSize({ maxHeight: 260 })
.clip(true)
}
}
@@ -531,10 +531,17 @@ export struct FbMovieNewSheet {
.width('100%')
.padding({ top: 6, bottom: 4 })
}
.flexShrink(1)
/* 🔴 #55(用户:「"记一场电影"这种输入,输入框怎么上下划不动?」)
根因:`Scroll` 在 `Column` 里只写了 `flexShrink(1)` ——
那只是"允许被压缩",**不等于"占满剩余高度"**。
没有 `layoutWeight(1)` 时,Scroll 的高度 = 内容高度
(超出父级后被裁切)⇒ 自身没有可滚动空间 ⇒ **划不动**。
⇒ 必须给它 `layoutWeight(1)` 才能拿到父级剩下的高度。 */
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
.edgeEffect(EdgeEffect.Spring)
// ---- 结果提示 ----
if (this.note2 !== '') {
@@ -320,20 +320,54 @@ export struct FbPersonEditSheet {
// 小件
// ============================================================
/** 一个输入行 */
/**
* 一个输入行(**两排版式**,全局规范 —— 用户 2026-11-05)。
*
* 🔴 「点击名册里面的"添加",姓名之类的内容全部都按这种形式显示。
* 它会按照主题色,加粗大一点显示。**所有地方都需要这样改**。」
* 🔴 「为了解决字数原因导致输入框左右来回对不齐的问题,我们全部按两排来显示:
* 第一排显示片名…下一行开始是片名的输入框…这样一列下来就行了。」
*
* ## 规范
*
* | 项 | 规则 |
* |---|---|
* | 标签颜色 | **主题色** `this.accent` |
* | 标签字重 | **Bold** |
* | 标签字号 | `fontSmall`(比正文大一号) |
* | 标签位置 | **独占第一排**(不是和输入框同行) |
* | 输入框 | 第二排,整宽 |
* | 必填 | **红色**,**无星号** |
*
* ## 为什么两排能解决对齐
*
* 原来同行时标签宽度由字数决定(「姓名」2 字 vs「我对他的称呼」6 字),
* 输入框起点跟着变 ⇒ 整列左右错开。标签独占一行后,
* 所有输入框都从同一左边距开始 ⇒ **天然对齐**,与标签字数无关。
*
* ⚠️ `hint` 参数保留(它是 placeholder),另加 `req` 表示必填。
* 原来用 `hint='必填'` 冒充必填标记 —— 那只会在输入框里显示灰字,
* 既不是红色也不在标签上,用户看不出哪个字段是必填。
*/
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
field(label: string, value: string, hint: string, onChange: (v: string) => void,
req: boolean = false) {
Column({ space: 6 }) {
/* 第一排:标签(主题色 / 必填红,加粗,独占一行) */
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(req ? '#C0533F' : this.accent)
.width('100%')
/* 第二排:输入框(整宽,与标签左边缘对齐) */
TextInput({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.height(42)
.width('100%')
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
onChange(v);
@@ -765,7 +799,7 @@ export struct FbPersonEditSheet {
Scroll() {
Column({ space: 12 }) {
this.section('基本')
this.field('姓名', this.fName, '必填', (v: string): void => {
this.field('姓名', this.fName, '', (v: string): void => {
this.fName = v;
})
// 🔴 「我怎么称呼他」—— 只有我看得到(视角属性),与姓名分开
@@ -18,7 +18,9 @@ import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createTask, updateTask, FbTaskDraft } from '../data/FbTaskRepo';
import { createTask, updateTask, FbTaskDraft,
setTaskStatus
} from '../data/FbTaskRepo';
import { FbApiResult } from '../data/FbHttp';
@ComponentV2
@@ -38,6 +40,13 @@ export struct FbTaskEditSheet {
/** v79:分类 / 循环(编辑时回填) */
@Param editingCatId: number = 0;
@Param editingRepeat: string = '';
/**
* 编辑时的原状态(`todo` / `doing` / `done`)。
*
* 🔴 2026-11-04:没有它的话,编辑一条**已完成**的任务会显示成"待办"(默认值),
* 保存时又把 'todo' 发出去 ⇒ **静默把已完成改回待办**。比不做还糟。
*/
@Param editingStatus: string = 'todo';
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@@ -47,6 +56,18 @@ export struct FbTaskEditSheet {
@Local boardId: number = 0;
/** v79:分类 id(0 = 不分类) */
@Local catId: number = 0;
/**
* 任务状态:`todo`(待办)/ `doing`(进行中)/ `done`(已完成)。
*
* 🔴 2026-11-04 新增。用户指出:「新建任务这个功能里面,怎么没有选择是待办、
* 正在进行,还是已完成的地方呢?」—— 原来 `save()` 里写死 `'todo'`,
* 用户建"进行中"的任务只能先建成待办再回去拖看板。
*
* ⚠️ 服务端 `Task::STATUS` 就是这三个值,别自造。
*/
@Local status: string = 'todo';
/** 编辑时记住原状态 —— 用于判断"用户改没改",改过才额外打 /status */
@Local status0: string = 'todo';
/** v79:循环规则('' = 不循环) */
@Local repeat: string = '';
/** 截止日期 `YYYY-MM-DD`,空 = 没有期限 */
@@ -63,6 +84,9 @@ export struct FbTaskEditSheet {
this.boardId = this.editingBoardId;
this.catId = this.editingCatId;
this.repeat = this.editingRepeat;
// 状态回填:新建时 editingStatus 是默认 'todo',编辑时是这条的原状态
this.status = this.editingStatus !== '' ? this.editingStatus : 'todo';
this.status0 = this.status;
// `2026-10-25 18:00` → 日期 + 时分(服务端给的是空格分隔)
const raw: string = this.editingDueAt.trim();
if (raw.length >= 16) {
@@ -187,6 +211,24 @@ export struct FbTaskEditSheet {
.width('100%')
.alignItems(HorizontalAlign.Start)
/* ---- 状态(2026-11-04 新增)----
🔴 用户指出「怎么没有选择是待办、正在进行,还是已完成的地方」。
放在「看板」之前:状态是任务最基本的属性,
而看板是"归到哪一类",后者依赖前者。 */
Column({ space: 6 }) {
Text('状态')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 8 }) {
this.statusChip('todo', '待做')
this.statusChip('doing', '进行中')
this.statusChip('done', '已完成')
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 看板 ----
Column({ space: 6 }) {
Text('看板')
@@ -332,7 +374,9 @@ export struct FbTaskEditSheet {
// 没有期限就发空串(服务端 `Api::dt('due_at', '')` → null)
dueAt: this.dueDate !== '' ? fbJoinDateTime(this.dueDate, this.dueTime) : '',
boardId: this.boardId,
status: 'todo',
/* 🔴 2026-11-04:不再写死 'todo' —— 用户可以在表单里选状态。
⚠️ 新建走 `createTask()`,服务端 POST 是**认** status 的(见 FbTaskRepo 文件头)。 */
status: this.status,
/* 0 = 顶层任务(新建子任务走 `FbTaskPanel.addKid`,不在这里) */
parentId: 0,
/* -1 = 这次不改负责人:表单里没有这一项,传 0 会把已指派的负责人清空 */
@@ -344,11 +388,29 @@ export struct FbTaskEditSheet {
const res: FbApiResult = this.editingId > 0
? await updateTask(token, this.editingId, d)
: await createTask(token, d);
this.saving = false;
if (!res.ok) {
this.saving = false;
this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
/* 🔴 2026-11-04:编辑时若改了状态,**必须额外打 `/status`**。
原因(见 FbTaskRepo 文件头):服务端 `PATCH /tasks/<id>` 会**显式忽略**
请求里的 status(原样回填),只有 `POST /tasks/<id>/status` 才生效。
⇒ 不补这一下,用户改了状态、界面说"保存成功"、回去看还是原状态。
⚠️ 只在"改过"时才打:没改就多一次无谓请求(还会白刷一次服务端 update)。
⚠️ 新建不用补:`POST /tasks` 是认 status 的。 */
if (this.editingId > 0 && this.status !== this.status0) {
const st: FbApiResult = await setTaskStatus(token, this.editingId, this.status);
if (!st.ok) {
this.saving = false;
// 内容已存好,只有状态没改成功 —— 说清楚,别让用户以为整条都没保存
this.note2 = '内容已保存,但状态没改成功,请再试一次。';
return;
}
}
this.saving = false;
this.onSaved();
}
@@ -367,6 +429,26 @@ export struct FbTaskEditSheet {
}
/** 分类单选胶囊(v79) */
@Builder
/**
* 状态 chip。
*
* ⚠️ 用 `!==` 而不是 `===` 判断"未选中":`status` 理论上只会是三个合法值,
* 但万一服务端返回了别的(脏数据),不能三个都不亮 —— 那样用户会以为控件坏了。
*/
@Builder
statusChip(v: string, name: string) {
Text(name)
.fontSize(FbMetric.fontTiny)
.fontColor(this.status === v ? '#FFFFFF' : FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.status === v ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.status = v;
})
}
@Builder
catChip(id: number, name: string) {
Text(name)
@@ -329,7 +329,7 @@ export struct FbTaskPanel {
if (w === 'done') {
return '已完成';
}
return w === 'doing' ? '进行中' : '待办';
return w === 'doing' ? '进行中' : '待做';
}
/**
File diff suppressed because it is too large Load Diff
+421
View File
@@ -0,0 +1,421 @@
/**
* 岁月家 FamilyBook · 系统日历镜像(入站回程)
*
* 🔴 2026-11-03 新增,2026-11-04 改为**按日历本多账户**。
* 照 SyncCalendar 的 `MirrorInbound`。
*
* ## 这是「系统日历 ↔ 岁月家 App ↔ 服务器」闭环的**回程**
*
* 出站(服务器 → 系统日历)在 `FbCalMirror`,本文件负责反向:
* 用户在系统日历里改了 / 新建了 / 删了,都要回写到服务器。
*
* ## 多账户带来的结构变化(2026-11-04)
*
* 第一版只有一个账户(`岁月家`),所以只需对账一次。
* 现在每个日历本一个账户(`岁月家·工作`、`岁月家·家庭`…),
* ⇒ **必须逐个账户对账**,且每个账户的条目只能回写到它对应的那个本。
*
* for (每个镜像账户) {
* bucket = 从账户名解析出的 book<id>
* bookId = 从 bucket 解析出的数字
* 对该账户内的条目做三方比对,回写时指定 calendar_id = bookId
* }
*
* ⚠️ 跨本错写是这里最容易犯的错:把"工作"账户里的一条写进"家庭"本。
* 所以 `pushCreate` / `pushUpdate` 都必须带 `calendar_id = bookId`。
*
* ## 防回灌(唯一的核心难题,与单账户时相同)
*
* 系统日历里既有"我们写进去的",也有"用户改的/新建的/删的"。
* 靠 `mirror_snapshot`(上次写入值的内容指纹)三方比对:
*
* | 系统日历当前状态 | 判定 | 动作 |
* |-----------------------------------|------------------|---------------------------|
* | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 |
* | 有 identifier,指纹 != 快照 | 用户改了 | 写回服务器 |
* | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) |
* | 快照有,系统里没了 | 用户删了 | 服务器删除 |
* | identifier 不是我们的格式 | 用户在系统日历新建 | 新建并推到服务器 |
*
* ⚠️ 「没有快照就补快照后跳过」这条极关键:首次启用时快照是空的,
* 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。
*
* ⚠️ 必须有网络才能回写(SyncCalendar 是离线排队,这里直接推)。
* 离线时本轮跳过,下次再对账 —— 不会丢改动,因为快照没更新、
* 下一轮还会判定成"用户改了"。
*/
import { common } from '@kit.AbilityKit';
import { calendarManager } from '@kit.CalendarKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbApiResult } from './FbHttp';
import { FbCalEventInput, FbCalRepo } from './FbCalRepo';
import { FbCalMirror, FbMirrorSnap } from './FbCalMirror';
import { FbCalStore } from './FbCalStore';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { FbDiag } from '../common/FbDiag';
/** 一次入站对账的统计 */
export class FbInboundResult {
created: number = 0;
updated: number = 0;
deleted: number = 0;
skipped: number = 0;
failed: number = 0;
/** 对账了几个账户 */
books: number = 0;
}
export class FbCalInbound {
/** 账户 name 前缀,必须与 `FbCalMirror` 完全一致 */
private static readonly ACCOUNT_PREFIX: string = 'familybook.mirror.book';
/**
* 入站对账主入口:**逐个镜像账户**对账。
*
* ⚠️ 必须在出站镜像 `FbCalMirror.syncNow()` **之前**调用 ——
* 它产生的服务器改动要赶上本轮,否则下一轮出站会用旧数据把用户改动覆盖掉。
*/
static async reconcile(ctx: common.Context): Promise<FbInboundResult> {
const res: FbInboundResult = new FbInboundResult();
if (!await FbCalMirror.hasPermission()) {
return res;
}
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
FbDiag.log('INBOUND', '未登录,跳过入站对账');
return res;
}
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx);
// 收集所有镜像账户
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
const mirrors: calendarManager.Calendar[] = [];
for (const c of cals) {
if (c.getAccount().name.startsWith(FbCalInbound.ACCOUNT_PREFIX)) {
mirrors.push(c);
}
}
if (mirrors.length === 0) {
return res; // 还没镜像过,无从对账
}
// 快照一次读全(按 identifier 索引,天然区分桶)
const snaps: Map<string, FbMirrorSnap> = await FbCalMirror.snapLoad(ctx);
// 本地条目索引(uid → 服务器 id + 所属本),一次建好所有账户共用
const localIndex: Map<string, LocalRef> = await FbCalInbound.localIndex();
for (const cal of mirrors) {
const accName: string = cal.getAccount().name;
const bookIdStr: string = accName.substring(FbCalInbound.ACCOUNT_PREFIX.length);
const bookId: number = parseInt(bookIdStr, 10);
if (isNaN(bookId) || bookId <= 0) {
FbDiag.log('INBOUND', '账户名解析不出 bookId,跳过: ' + accName);
continue;
}
const bucket: string = FbCalMirror.bucketOf(bookId);
try {
await FbCalInbound.reconcileBook(ctx, cal, bucket, bookId, snaps, localIndex, token, res);
res.books++;
} catch (err) {
const e = err as BusinessError;
res.failed++;
FbDiag.log('INBOUND', '对账失败 bookId=' + bookId + ': ' + e.code + ' ' + e.message);
}
}
if (res.created + res.updated + res.deleted > 0) {
FbDiag.log('INBOUND', '对账完成:本=' + res.books
+ ' 新建=' + res.created + ' 修改=' + res.updated
+ ' 删除=' + res.deleted + ' 跳过=' + res.skipped + ' 失败=' + res.failed);
}
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '对账异常: ' + e.message);
}
return res;
}
/* ==================== 单个日历本 ==================== */
private static async reconcileBook(ctx: common.Context, cal: calendarManager.Calendar,
bucket: string, bookId: number, snaps: Map<string, FbMirrorSnap>,
localIndex: Map<string, LocalRef>, token: string, res: FbInboundResult): Promise<void> {
const prefix: string = bucket + '|';
// 系统日历现有条目
let events: calendarManager.Event[] = [];
try {
events = await cal.getEvents();
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', 'getEvents() 失败 bookId=' + bookId + ': ' + e.message);
return;
}
// 分成两类:带本桶 identifier 的(可判定改/删)vs 不是我们的(用户新建)
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
const others: calendarManager.Event[] = [];
for (const ev of events) {
const id: string = ev.identifier ?? '';
if (id.startsWith(prefix)) {
mine.set(id, ev);
} else {
others.push(ev);
}
}
// ---------- ① 修改 ----------
for (const id of Array.from(mine.keys())) {
const ev: calendarManager.Event | undefined = mine.get(id);
if (ev === undefined) {
continue;
}
const fp: string = FbCalMirror.fingerprintOf(ev);
const snap: FbMirrorSnap | undefined = snaps.get(id);
if (snap === undefined) {
/* ⚠️ 没有快照 ⇒ 认作是我们自己写的,补一份后跳过。
绝不误判成"用户改了" —— 首次启用时快照是空的,
那样会把全部日程标脏、全量重推服务器。 */
await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp);
res.skipped++;
continue;
}
snaps.delete(id); // 还活着,稍后剩下的才是"被删了"
if (snap.fingerprint === fp) {
res.skipped++; // 没动
continue;
}
const ref: LocalRef | undefined = localIndex.get(id);
if (ref === undefined || ref.id <= 0) {
res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站处理
continue;
}
/* ⚠️ 跨本防护:万一 identifier 的桶与当前账户不一致(历史脏数据),
宁可跳过也不要把 A 本的事写到 B 本。 */
if (ref.calId !== bookId) {
FbDiag.log('INBOUND', '跳过跨本条目 ' + id + '(本地本=' + ref.calId
+ ' 账户本=' + bookId + ')');
res.skipped++;
continue;
}
const ok: boolean = await FbCalInbound.pushUpdate(ref.id, ev, bookId, token);
if (ok) {
await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp);
res.updated++;
} else {
res.failed++;
}
}
// ---------- ② 删除:快照里剩下、且属于本桶的 ----------
for (const id of Array.from(snaps.keys())) {
const snap: FbMirrorSnap | undefined = snaps.get(id);
if (snap === undefined) {
continue;
}
if (snap.bucket !== bucket) {
continue; // 别的桶的,留给那本处理
}
const ref: LocalRef | undefined = localIndex.get(id);
if (ref !== undefined && ref.id > 0 && ref.calId === bookId) {
const ok: boolean = await FbCalInbound.pushDelete(ref.id, token);
if (ok) {
res.deleted++;
} else {
res.failed++;
}
} else {
res.skipped++;
}
await FbCalMirror.snapRemove(ctx, id);
snaps.delete(id);
}
// ---------- ③ 新建:identifier 不是本桶格式的(用户在系统日历里手动加的) ----------
for (const ev of others) {
if (ev.id === undefined) {
continue;
}
/* ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。
否则下一次出站镜像会把同一条再加一遍 ⇒ 系统日历里出现重复。 */
const ident: string = bucket + '|sc-' + ev.id.toString() + '-' + ev.startTime.toString();
const stamped: calendarManager.Event = ev;
stamped.identifier = ident;
try {
await cal.updateEvent(stamped);
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '无法写回 identifier,跳过新建(避免重复): ' + e.message);
res.skipped++;
continue;
}
const newId: number = await FbCalInbound.pushCreate(ev, bookId, token);
if (newId > 0) {
await FbCalInbound.saveSnap(ctx, bucket, ident, ev,
FbCalMirror.fingerprintOf(ev));
res.created++;
} else {
res.failed++;
}
}
}
/* ==================== 推服务器 ==================== */
/**
* 系统日历 Event → 接口输入。
*
* ⚠️ `calendar_id` 必须带上 —— 否则服务端会落到默认本,
* 用户在"工作"账户里新建的事会跑到别处去。
*/
private static toInput(ev: calendarManager.Event, bookId: number): FbCalEventInput {
const input: FbCalEventInput = {
calendar_id: bookId,
title: ev.title !== undefined && ev.title !== '' ? ev.title : '(无标题)',
starts_at: FbCalInbound.fmtDateTime(ev.startTime),
ends_at: FbCalInbound.fmtDateTime(ev.endTime),
all_day: ev.isAllDay === true,
location: ev.location?.location !== undefined ? (ev.location.location as string) : '',
note: ev.description !== undefined ? ev.description : ''
};
return input;
}
private static async pushUpdate(id: number, ev: calendarManager.Event,
bookId: number, token: string): Promise<boolean> {
try {
const r: FbApiResult = await FbCalRepo.inst().update(id,
FbCalInbound.toInput(ev, bookId), token);
if (!r.ok) {
FbDiag.log('INBOUND', '更新服务器失败 id=' + id + ': ' + r.error);
return false;
}
return true;
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '更新服务器异常 id=' + id + ': ' + e.message);
return false;
}
}
private static async pushDelete(id: number, token: string): Promise<boolean> {
try {
const r: FbApiResult = await FbCalRepo.inst().remove(id, token);
if (!r.ok) {
FbDiag.log('INBOUND', '删除服务器失败 id=' + id + ': ' + r.error);
return false;
}
return true;
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '删除服务器异常 id=' + id + ': ' + e.message);
return false;
}
}
/** 新建 → 返回新条目 id(0 = 失败) */
private static async pushCreate(ev: calendarManager.Event, bookId: number,
token: string): Promise<number> {
try {
const r: FbApiResult = await FbCalRepo.inst().create(
FbCalInbound.toInput(ev, bookId), token);
if (!r.ok) {
FbDiag.log('INBOUND', '新建服务器失败: ' + r.error);
return 0;
}
if (r.data === '') {
return 0;
}
const obj: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
const idVal: Object | undefined = obj['id'];
if (typeof idVal === 'number') {
return idVal as number;
}
return 0;
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '新建服务器异常: ' + e.message);
return 0;
}
}
/* ==================== 本地索引 ==================== */
/**
* 建 `identifier → 本地条目` 的索引。
*
* identifier 的算法必须与出站 `FbCalMirror.identifierOf()` **完全一致**,
* 否则两边永远对不上号(表现:用户在系统日历改了,服务器没变)。
* ⇒ 这里直接调 `FbCalMirror.identifierOf()`,不自己算。
*/
private static async localIndex(): Promise<Map<string, LocalRef>> {
const out: Map<string, LocalRef> = new Map<string, LocalRef>();
try {
const now: Date = new Date();
const from: Date = new Date(now.getFullYear(), now.getMonth() - 13, 1);
const to: Date = new Date(now.getFullYear(), now.getMonth() + 26, 1);
const p = (n: number): string => n < 10 ? '0' + n : '' + n;
const fromIso: string = from.getFullYear() + '-' + p(from.getMonth() + 1) + '-01';
const toIso: string = to.getFullYear() + '-' + p(to.getMonth() + 1) + '-01';
const rows = await FbCalStore.inst().between(fromIso, toIso);
for (const e of rows) {
if (e.id <= 0 || e.calId <= 0) {
continue; // 衍生条目推不上去
}
const bucket: string = FbCalMirror.bucketOf(e.calId);
const ident: string = FbCalMirror.identifierOf(bucket, e);
const ref: LocalRef = new LocalRef();
ref.id = e.id;
ref.calId = e.calId;
out.set(ident, ref);
}
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '建本地索引失败: ' + e.message);
}
return out;
}
/* ==================== 辅助 ==================== */
private static async saveSnap(ctx: common.Context, bucket: string, identifier: string,
ev: calendarManager.Event, fp: string): Promise<void> {
const s: FbMirrorSnap = new FbMirrorSnap();
s.identifier = identifier;
s.bucket = bucket;
s.uid = FbCalMirror.uidOf(identifier);
s.sysId = ev.id ?? -1;
s.fingerprint = fp;
await FbCalMirror.snapPut(ctx, s);
}
/** 时间戳 → `YYYY-MM-DD HH:MM:SS`(接口要这个格式) */
private static fmtDateTime(ts: number): string {
const d: Date = new Date(ts);
const p = (n: number): string => n < 10 ? '0' + n : '' + n;
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
}
/** 本地条目引用(回写时要知道"改哪条、在哪个本") */
class LocalRef {
/** 服务器条目 id */
id: number = 0;
/** 所属日历本 id —— 用于跨本防护 */
calId: number = 0;
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -34,6 +34,17 @@ const K_VIEW: string = 'default_view'; // month | week | agenda
const K_STYLE: string = 'display_style'; // timeline | list
const K_BOOK: string = 'default_book_id'; // 新建日程默认落到哪本
const K_HIDDEN: string = 'hidden_books'; // 逗号分隔的日历本 id
/**
* 镜像到系统日历的开关。
*
* 🔴 2026-11-03 新增。用户原话:「新增一个功能,把日历镜像到系统日历中」。
* 原因(SyncCalendar 文件头):本应用是 CalDAV 客户端,日程不进系统日历,
* 导致小艺 / 桌面日历卡片 / 手表看到的是空的。
*
* ⚠️ 这里只存"开关状态"。真正的镜像逻辑在 `data/FbCalMirror.ets`,
* 这个开关决定要不要跑它。
*/
const K_MIRROR: string = 'mirror_to_system';
/** 视图:月 / 周 / 列表(与 SyncCalendar 的 VIEW_ORDER 同一套) */
export type FbCalView = 'month' | 'week' | 'agenda';
@@ -45,6 +56,8 @@ class FbCalSettingsImpl {
private style: FbCalStyle = 'timeline';
private bookId: number = 0;
private hidden: string = '';
/** 是否镜像到系统日历 */
private mirror: boolean = false;
private store: preferences.Preferences | null = null;
private ready: boolean = false;
@@ -59,10 +72,12 @@ class FbCalSettingsImpl {
const s: preferences.ValueType = await this.store.get(K_STYLE, 'timeline');
const b: preferences.ValueType = await this.store.get(K_BOOK, 0);
const h: preferences.ValueType = await this.store.get(K_HIDDEN, '');
const mir: preferences.ValueType = await this.store.get(K_MIRROR, false);
this.view = (v === 'week' || v === 'agenda') ? v : 'month';
this.style = (s === 'list') ? 'list' : 'timeline';
this.bookId = typeof b === 'number' ? b : 0;
this.hidden = typeof h === 'string' ? h : '';
this.mirror = mir === true;
this.ready = true;
hilog.info(DOMAIN, TAG, '日程设置预加载完成:%{public}s / %{public}s', this.view, this.style);
} catch (err) {
@@ -109,6 +124,17 @@ class FbCalSettingsImpl {
return this.hiddenIds().indexOf(id) < 0;
}
/**
* 是否镜像到系统日历。
*
* ⚠️ 这个值**只是开关**,不代表"已经镜像成功" ——
* 权限被撤销 / 系统日历服务未就绪时,开关是开的但实际没镜像。
* 界面上要同时显示"镜像了多少条"来让用户判断真实状态。
*/
getMirror(): boolean {
return this.mirror;
}
// ---- 异步写(同时更新内存) ----
async setView(v: FbCalView): Promise<void> {
this.view = v;
@@ -120,6 +146,12 @@ class FbCalSettingsImpl {
await this.put(K_STYLE, s);
}
/** 写镜像开关 */
async setMirror(on: boolean): Promise<void> {
this.mirror = on;
await this.put(K_MIRROR, on);
}
async setBookId(id: number): Promise<void> {
this.bookId = id;
await this.put(K_BOOK, id);
+56 -1
View File
@@ -428,8 +428,16 @@ export class FbTaskCardRow {
dueText: string = '';
/** `todo` / `doing`(done 的不进卡片) */
status: string = '';
/** 看板主题色(左侧色条) */
/** 看板主题色(左侧色条 / 勾选圈颜色) */
boardColor: string = '#A8574F';
/**
* 看板名。
*
* 2026-11-04:SyncCalendar 卡片条目的副行是「截止时间 · 日历本」,
* 我们的对应物就是「截止时间 · 看板名」。加了它条目信息才完整 ——
* 只显示"18:00"看不出是哪个看板的事。
*/
boardName: string = '';
overdue: boolean = false;
}
@@ -442,6 +450,21 @@ export class FbTaskCardData {
openCount: number = 0;
/** 其中逾期数(标题行给红色提示) */
overdueCount: number = 0;
/* ---------- 2026-11-04:照 SyncCalendar 的 TodoWidget* 卡片补的字段 ---------- */
/** 日期文字(`11月4日`)—— 四个卡片的头部 */
dateMd: string = '';
/** 星期(`星期三`)—— 头部副行 */
weekday: string = '';
/**
* 今日到期数。
*
* ⚠️ 与 `overdueCount` 是**两个不同概念**:
* `overdueCount` = 已经晚了(该道歉)
* `todayCount` = 今天还来得及(该行动)
* SyncCalendar 的卡片把"今日到期"放主位、用橙色,就是因为它更能驱动行动。
*/
todayCount: number = 0;
/** 逾期优先展示的几条(逾期 > 进行中 > 普通,同组按截止时间升序),最多 4 条 */
rowsJson: string = '[]';
/** 🔴 v91:4×4/6×4 的滚动列表(同一排序,最多 30 条)—— 2×2/2×4 的卡不吃这个字段 */
@@ -458,6 +481,18 @@ function taskRank(t: FbTaskItem): number {
}
/** 一条任务 → 卡片行 */
/** 星期中文(`Date.getDay()` 索引 0=周日) */
const WEEKDAY_CN: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
/** 卡片用的日期小工具 */
class FbCardDate {
/** 本地时区的 `YYYY-MM-DD`(**不要用 toISOString()**:那是 UTC,晚上会差一天) */
static isoOf(d: Date): string {
const p = (n: number): string => n < 10 ? '0' + n : '' + n;
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
}
function taskRowOf(t: FbTaskItem): FbTaskCardRow {
const r: FbTaskCardRow = new FbTaskCardRow();
r.id = t.id;
@@ -466,6 +501,7 @@ function taskRowOf(t: FbTaskItem): FbTaskCardRow {
r.dueText = t.dueText;
r.status = t.status;
r.boardColor = t.boardColor !== '' ? t.boardColor : '#A8574F';
r.boardName = t.board !== undefined ? t.board : '';
r.overdue = t.overdue;
return r;
}
@@ -548,6 +584,25 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardDa
return a.dueAt < b.dueAt ? -1 : 1;
});
/* ---------- 2026-11-04:照 SyncCalendar 卡片补的日期与今日到期 ---------- */
const now: Date = new Date();
out.dateMd = (now.getMonth() + 1).toString() + '月' + now.getDate().toString() + '日';
out.weekday = WEEKDAY_CN[now.getDay()];
/* 今日到期:比 `dueAt` 的**日期部分**与今天是否相同。
⚠️ 不用"24 小时内":晚 23:50 和次日 00:10 的任务,用户认知里分属两天。
⚠️ `dueAt` 格式是 `2026-10-25 18:00`(见 FbTaskItem 注释),取前 10 位即可。 */
const todayIso: string = FbCardDate.isoOf(now);
let todayCount: number = 0;
for (const t of open) {
if (t.dueAt !== '' && t.dueAt.length >= 10 && t.dueAt.substring(0, 10) === todayIso
&& !t.overdue) {
// 已逾期的今天任务不算"今日到期" —— 它归 overdueCount,否则两个数字会重复计
todayCount++;
}
}
out.todayCount = todayCount;
/* 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的」
—— 2×4 的右半改成可滚动后,条数不能只给 4 条(否则 List 滑两下就到底)。
⇒ 与 4×4/6×4 的 `all` 合并成同一份(同一个排序、同一个上限 30)。
+27
View File
@@ -401,6 +401,33 @@ export class FbDb {
}
}
// ---- v8 → v9:日历镜像快照表 ----
// 🔴 2026-11-03:新增「镜像到系统日历」功能,这张表记录"上次写了什么",
// 用于区分"我们写的"和"用户在系统日历里改的"(防回灌死循环)。
// ⚠️ 建表语句必须与 FbSchema 里**完全一致** —— 只改一处就是
// "新装有、老库没有"或反之。
if (from < 9) {
const mirDdl: string[] = [
`CREATE TABLE IF NOT EXISTS mirror_snapshot (
identifier TEXT PRIMARY KEY,
bucket TEXT NOT NULL DEFAULT '',
uid TEXT NOT NULL DEFAULT '',
sys_id INTEGER NOT NULL DEFAULT -1,
fingerprint TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`,
];
for (const sql of mirDdl) {
try {
await store.executeSql(sql);
} catch (err) {
hilog.warn(DOMAIN, TAG, '建 mirror_snapshot 跳过:%{public}s', JSON.stringify(err));
}
}
}
store.version = FB_DB_VERSION;
}
+135 -1
View File
@@ -42,6 +42,9 @@ import { site } from '@kit.MapKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbGeoTip } from '../model/Types';
import { FbDiag } from '../common/FbDiag';
import { FbApiResult, fbApi } from './FbHttp';
import { FB_TOKEN_STORE } from './FbTokenStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbGeoRepo';
@@ -71,17 +74,20 @@ interface FbLatLng {
* ⚠️ 不需要 token:Map Kit 是本机 HMS Core 能力,
* 鉴权走 AGC 应用认证(错误码 1002600003),与我们的 API 令牌无关。
*/
export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
async function fbGeoViaMapKit(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
const q: string = kw.trim();
/* 本地先挡一道:省一次往返,也让"字太少"的抖动更小 */
if (q.length < FB_GEO_MIN_LEN) {
return [];
}
FbDiag.log('GEO', '=== 联想请求 === kw=[' + q + '] len=' + q.length);
try {
const params: site.QueryAutoCompleteParams = { query: q };
const res: site.QueryAutoCompleteResult = await site.queryAutoComplete(params);
const sites: site.Site[] | undefined = res.sites;
FbDiag.log('GEO', '返回 sites=' + (sites === undefined ? 'undefined' : String(sites.length)));
if (sites === undefined || sites.length === 0) {
/* ⚠️ 查不到**不是错误**:用户打了半截字本来就可能没结果。
直接给空,让面板收起,别弹提示。 */
@@ -112,6 +118,8 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip
lat = numberToStr(loc.latitude);
}
FbDiag.log('GEO', ' tip[' + out.length + '] name=[' + name
+ '] detail=[' + detail + '] lng=' + lng + ' lat=' + lat);
out.push({
name: name !== '' ? name : detail,
detail: detail,
@@ -124,6 +132,7 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip
break;
}
}
FbDiag.log('GEO', '最终返回 ' + out.length + ' 条');
return out;
} catch (err) {
const e: BusinessError = err as BusinessError;
@@ -131,10 +140,135 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip
if (e.code !== 1002603001) {
hilog.warn(DOMAIN, TAG, '地址联想失败:code=%{public}d %{public}s', e.code, e.message);
}
/* 🔴 诊断关键:错误码是所有"联想不出来"的根因分类器
1002600003 = AGC 应用认证失败(client_id 不对)
1002600004 = AGC 没开 Map Kit 服务
1002600002 = 连不上 Map Kit 服务器
1002603001 = 查不到(正常) */
FbDiag.log('GEO', '❌ 异常 code=' + e.code + ' msg=' + e.message);
return [];
}
}
/**
* 地址联想(**优先走我们的服务端**)。
*
* 🔴 2026-11-03 改。原来的唯一数据源是 Map Kit,但它报
* `1002600003 App authentication failed`(AGC 认证失败),
* 而 AGC 那条链路要开地图服务 + 重新生成 Profile,可能等 24 小时。
*
* 现在的顺序:
* ① `/api/v1/geo/suggest` —— 服务端用高德 Web 服务 key 转发 inputtips
* ✅ 不依赖 AGC、数据源与网页端完全一致(网页也走高德)
* ✅ 高德直接给省市区 + 门牌 + 坐标,地址天生完整
* ② Map Kit —— 服务端不通时的兜底(万一有 AGC 权限而服务器故障)
*
* @param kw 关键词
* @param limit 最多几条
* @returns 候选列表;**任何失败都返回空数组**,不抛错
*/
export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
const q: string = kw.trim();
if (q.length < FB_GEO_MIN_LEN) {
return [];
}
// ① 服务端
const viaServer: FbGeoTip[] = await fbGeoViaServer(q, limit);
if (viaServer.length > 0) {
FbDiag.log('GEO', '服务端返回 ' + viaServer.length + ' 条');
return viaServer;
}
// ② Map Kit 兜底
FbDiag.log('GEO', '服务端无结果,改用 Map Kit');
return await fbGeoViaMapKit(q, limit);
}
/**
* 服务端地址联想:`GET /api/v1/geo/suggest?q=xxx&limit=N`
*
* ⚠️ 需要 API 令牌(`FB_TOKEN_STORE.get()`)。未登录直接返回空,
* 不去请求(省一次 401 往返)。
*/
async function fbGeoViaServer(q: string, limit: number): Promise<FbGeoTip[]> {
const token: string = FB_TOKEN_STORE.get();
FbDiag.log('GEO', '服务端: token长度=' + token.length);
if (token === '') {
FbDiag.log('GEO', '未登录,跳过服务端联想');
return [];
}
try {
const res: FbApiResult = await fbApi('/api/v1/geo/suggest', {
method: 'GET',
token: token,
query: { 'q': q, 'limit': String(limit) },
timeoutMs: 6000
});
FbDiag.log('GEO', '服务端响应 ok=' + res.ok + ' status=' + res.status
+ ' dataLen=' + res.data.length + ' code=' + res.code + ' err=' + res.error);
if (!res.ok) {
return [];
}
/* 响应形状:{ ok: true, data: { tips: [ {...}, ... ] } }
⚠️ `res.data` 是**原始 JSON 字符串**(FbHttp 的约定),要自己解析。 */
FbDiag.log('GEO', '原始响应前80字: ' + res.data.substring(0, Math.min(80, res.data.length)));
/* 🔴 2026-11-03 修:**`res.data` 已经是 data 层本身**。
`FbHttp.fbApi()` 会把 `{ok, data}` 信封拆掉再序列化回来,
所以这里拿到的是 `{"tips":[...]}`,**没有** 外层 `data`。
原代码找 `row['data']` ⇒ 恒 undefined ⇒ 提前返回空(真机日志已验证)。
⇒ 直接从顶层取 `tips`。 */
const parsed: Object = JSON.parse(res.data) as Object;
const dataRec: Record<string, Object> = parsed as Record<string, Object>;
const tipsVal: Object | undefined = dataRec['tips'] as Object | undefined;
FbDiag.log('GEO', 'tips 类型=' + typeof tipsVal
+ ' isArray=' + Array.isArray(tipsVal));
if (tipsVal === undefined) {
FbDiag.log('GEO', 'tips 为 undefined');
return [];
}
// ArkTS 不允许裸 any,逐条按结构读
const arr: Object[] = tipsVal as Object[];
FbDiag.log('GEO', 'tips 数组长度=' + arr.length);
const out: FbGeoTip[] = [];
for (const it of arr) {
const r: Record<string, Object> = it as Record<string, Object>;
const tip: FbGeoTip = {
name: strOf(r['name']),
detail: strOf(r['detail']),
district: strOf(r['district']),
address: strOf(r['address']),
lng: strOf(r['lng']),
lat: strOf(r['lat'])
};
if (tip.name === '' && tip.detail === '') {
continue;
}
out.push(tip);
}
return out;
} catch (err) {
FbDiag.log('GEO', '服务端异常: ' + JSON.stringify(err));
return [];
}
}
/** 从 Record 里安全取字符串(ArkTS 没有 any,必须显式判类型) */
function strOf(v: Object | undefined): string {
if (v === undefined || v === null) {
return '';
}
if (typeof v === 'string') {
return v as string;
}
return '';
}
/** 数字转字符串(Map Kit 给 number;对外统一成字符串,与网页端一致) */
function numberToStr(v: number): string {
if (typeof v !== 'number' || isNaN(v)) {
+29 -1
View File
@@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db';
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
*/
export const FB_DB_VERSION: number = 8;
export const FB_DB_VERSION: number = 9;
/**
* 建表语句。
@@ -355,6 +355,30 @@ export const FB_SCHEMA_SQL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`,
// 同步时按 uid 去重/更新
`CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`,
// ---------- 日历镜像快照(v9 新增)----------
//
// 用户 2026-11-03:「把日历镜像到系统日历中,原因同步日历都已经说过了」。
// 原因:本应用是 CalDAV 客户端,日程不进系统日历,
// 导致小艺 / 桌面日历卡片 / 手表看到的是空的
// (甚至给出"今天没有日程"的错误答案)。
//
// 🔴 这张表是**防回灌的地基**:镜像会把日程写进系统日历;
// 下一轮若直接读系统日历当"用户改动",就会把我们刚写的当成用户改的
// → 回写服务器 → 服务器变了 → 又镜像回去 …… 死循环。
// 记下"上次写了什么",一轮三方比对就能分清谁改的。
//
// ⚠️ `bucket` 在 FamilyBook 里恒为 `family`(只有一套日程,
// 不像 SyncCalendar 有多个 DAV 账户)。留这一列是为了将来按日历本拆。
`CREATE TABLE IF NOT EXISTS mirror_snapshot (
identifier TEXT PRIMARY KEY COMMENT '<bucket>|<uid>[@<发生时刻>]',
bucket TEXT NOT NULL DEFAULT '',
uid TEXT NOT NULL DEFAULT '',
sys_id INTEGER NOT NULL DEFAULT -1,
fingerprint TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`,
];
/** 建表语句分组说明(供日志/排查用) */
@@ -383,6 +407,10 @@ export const FB_TABLE_GROUPS: FbTableGroup[] = [
group: '日程',
tables: ['cal_events']
},
{
group: '系统日历镜像',
tables: ['mirror_snapshot']
},
{
group: '同步元数据(本地专有)',
tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache']
+412 -5
View File
@@ -1,3 +1,4 @@
import { FbDiag } from '../common/FbDiag';
/**
* 家书 FamilyBook · 旅程的**写操作**(鸿蒙端)
*
@@ -23,7 +24,7 @@
*/
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
import { fbUploadOne } from './FbUpload';
import { fbUploadOne, fbUploadStage, FbStageUpload } from './FbUpload';
import { FbTripDetail, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../model/Types';
/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */
@@ -65,6 +66,55 @@ export interface FbTripNodeDraft {
/** 'plan' 计划 / 'actual' 实际 */
category: string;
detail: string;
/* ---------- 2026-11-05:对齐网页端表单补的字段 ---------- */
/**
* 出发地地名(网页端 `from_place`)。
*
* 🔴 用户:「出发地、目的地要分开。现在地点这两个都放在一起,能对吗?」
* 原来端上只有一个 `place`,出发地和到达地填不了。
*/
fromPlace: string;
/** 到达地地名(网页端 `to_place`) */
toPlace: string;
/**
* 出发地详细地址(网页端 `from_addr`)。
* ⚠️ 端上没有独立的"详细地址"输入,暂用同名字段兜底
* (网页端是「地名 + 高德详细地址」两份)。
*/
fromAddr: string;
/** 到达地详细地址 */
toAddr: string;
/* #51:坐标(高德返回;空串 = 纯手打没选候选,不传) */
fromLng: string;
fromLat: string;
toLng: string;
toLat: string;
/**
* 执行状态:`planned` / `ongoing` / `done` / `cancelled`。
*
* 🔴 #47(2026-11-05 用户要求):
* **表单不参与状态管理**。新建固定发 `'planned'`;
* 编辑**发空串** ⇒ `updateTripNode()` 不把 status 放进 body
* ⇒ 服务端沿用库里原值(用户通过卡片动作改的状态不会被编辑冲掉)。
*/
status: string;
/**
* 参与人 id 数组(网页端 `members[]`)。
* ⚠️ 空数组 = 清空参与人;与"没传"不同 —— 服务端 `nodeData` 用 `$has()` 判定。
*/
members: number[];
/**
* 暂存文件令牌(先上传、保存时一起提交的照片)。
*
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数
* 一起提交,后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
*
* 服务端拿到它:`Trip::saveNode()` 先建节点 → `StagedUpload::claim()` 挂图。
* ⚠️ 空数组 = 这次没有新照片。
*/
stageTokens: string[];
}
/**
@@ -197,6 +247,20 @@ export function parseNodes(json: string): FbTripNode[] {
nodeType: strOf(o, 'node_type'),
detail: strOf(o, 'detail'),
fromPlace: strOf(o, 'from_place'),
/* #51:详细地址与坐标(服务端 SELECT * 已返回,原来端上没映射) */
fromAddr: strOf(o, 'from_addr'),
toAddr: strOf(o, 'to_addr'),
fromLng: strOf(o, 'from_lng'),
fromLat: strOf(o, 'from_lat'),
toLng: strOf(o, 'to_lng'),
toLat: strOf(o, 'to_lat'),
/* 🔴 2026-11-05:原始时间值(服务端 SELECT * 已经返回,原来端上丢了)。
用于编辑回填 —— `when` 不带年份,反推会跨年错位。 */
startsAt: strOf(o, 'starts_at'),
/* #48:真实开始/结束时间(服务端 SELECT * 已返回,原来端上没解析) */
actualStartedAt: strOf(o, 'actual_started_at'),
actualEndedAt: strOf(o, 'actual_ended_at'),
endsAt: strOf(o, 'ends_at'),
toPlace: strOf(o, 'to_place'),
lineName: strOf(o, 'line_name'),
carrier: strOf(o, 'carrier'),
@@ -304,10 +368,241 @@ export async function addTripNode(token: string, tripId: number, d: FbTripNodeDr
if (d.vehicleId > 0) {
body['vehicle_id'] = d.vehicleId;
}
/* 2026-11-05:出发地 / 到达地 / 执行状态 / 参与人(对齐网页端) */
body['from_place'] = d.fromPlace;
body['to_place'] = d.toPlace;
body['from_addr'] = d.fromAddr;
body['to_addr'] = d.toAddr;
/* #51:坐标(空串不传 —— 服务端 coordOrNull 会写 NULL,等于没坐标) */
if (d.fromLng !== '' && d.fromLat !== '') {
body['from_lng'] = d.fromLng;
body['from_lat'] = d.fromLat;
}
if (d.toLng !== '' && d.toLat !== '') {
body['to_lng'] = d.toLng;
body['to_lat'] = d.toLat;
}
/* 🔴 #47:**编辑时不传 status**(`d.status === ''`)。
用户点了「开始执行」把状态改成 ongoing,再回头编辑标题 ——
如果这里传 'planned',状态就被打回计划中。
服务端 `nodeData()` 用 `array_key_exists` 判定"这次传没传",
不发这个键 ⇒ 沿用库里原值。status 为空表示"表单不参与状态管理"。 */
if (d.status !== '') {
body['status'] = d.status;
}
if (d.members.length > 0) {
body['members'] = d.members;
}
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
}
/**
* 给一段行程传附件(照片/视频)。
*
* 🔴 2026-11-05 新增。用户:「它不能这样传图片吗?网页端有什么功能,
* 这个就应该有什么功能,应该一模一样啊。」
*
* 落点与网页端**完全相同**:`attachments` 表
* `target_type = 'trip'`, `target_id = nodeId`
* 服务端路由:`POST /api/v1/trips/<tripId>/nodes/attach?node_id=<nid>`
*
* @returns 空串 = 成功;否则是错误说明
*/
export async function addNodeAttach(token: string, tripId: number, nodeId: number,
uri: string): Promise<string> {
const path: string = '/api/v1/trips/' + tripId.toString()
+ '/nodes/attach?node_id=' + nodeId.toString();
return await fbUploadOne(path, 'file', uri, token);
}
/**
* 删一张附件。
*
* 服务端路由:`DELETE /api/v1/trips/<tripId>/nodes/attach?node_id=&attachment_id=`
* @returns 空串 = 成功;否则是错误说明
*/
export async function deleteNodeAttach(token: string, tripId: number, nodeId: number,
attachId: number): Promise<string> {
const query: Record<string, string> = {};
query['node_id'] = nodeId.toString();
query['attachment_id'] = attachId.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: query };
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/attach',
opts);
return res.ok ? '' : (res.error !== '' ? res.error : '删除失败');
}
/**
* 改一段行程的执行状态。
*
* 🔴 #47(2026-11-05):用户要求状态由**动作**驱动,不再由编辑表单选:
* planned(新建固定)→ ongoing(开始执行)→ done(已完成)/ cancelled(已取消)
*
* 服务端:`PATCH /api/v1/trips/<tripId>/nodes/status`,body `{node_id, status}`
* ⚠️ 节点 id 走 body —— dispatch 只解析三段。
*
* @param status `planned` / `ongoing` / `done` / `cancelled`
*/
export async function setNodeStatus(token: string, tripId: number, nodeId: number,
status: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['node_id'] = nodeId;
body['status'] = status;
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/status', opts);
}
/**
* 节点类型(#51)。
*
* 🔴 用户:「这个类型和网页端不一样啊,网页端比这个多得多呀,
* 我们这上面的类型怎么这么少呢?」
*
* 实测库里 **9 种**,而端上硬编码 5 种(照抄了 `Trip::NODE_TYPES` 那个
* **兜底常量**,真实数据在 `node_types` 表、用户可增删)。
* ⇒ 从 `GET /api/v1/trips/<id>/node-types` 动态拉。
*/
export interface FbNodeTypeItem {
id: number;
name: string;
icon: string;
}
export async function fetchNodeTypes(token: string, tripId: number): Promise<FbNodeTypeItem[]> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi(
'/api/v1/trips/' + tripId.toString() + '/node-types', opts);
if (!res.ok || res.data === '') {
return [];
}
const out: FbNodeTypeItem[] = [];
try {
const env: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
const items: Object | undefined = env['items'];
if (items !== undefined && Array.isArray(items)) {
for (const it of items as Object[]) {
const o: Record<string, Object> = it as Record<string, Object>;
const nm: Object | undefined = o['name'];
if (typeof nm !== 'string' || nm === '') {
continue;
}
const row: FbNodeTypeItem = {
id: typeof o['id'] === 'number' ? o['id'] as number : 0,
name: nm as string,
icon: typeof o['icon'] === 'string' ? o['icon'] as string : '📌'
};
out.push(row);
}
}
} catch (err) {
return [];
}
return out;
}
/**
* 执行一个行程动作(#48)。
*
* 🔴 用户要求状态由**动作**驱动,且三个动作语义不同:
* · `start` → status=ongoing,**记录真实开始时间**
* · `done` → **stage 转成 actual**(变成实际行程),记录真实结束时间
* · `cancel` → stage=skipped(保留数据,不是删除)
*
* ⚠️ `done` 之后这段就是实际行程了,**不再有状态**(用户:实际行程没状态)。
* ⚠️ 与「删除」完全不同:删除走 `deleteTripNode`(软删)。
*
* 服务端:`PATCH /api/v1/trips/<id>/nodes/action`,body `{node_id, action}`
*/
export async function nodeAction(token: string, tripId: number, nodeId: number,
act: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['node_id'] = nodeId;
body['action'] = act;
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/action', opts);
}
/**
* 暂存上传(转发 `FbUpload.fbUploadStage`)。
*
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交,
* 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
*
* ⚠️ 不在这里重复写 multipart —— 全项目只有 `FbUpload.ets` 一份实现
* (那个文件头写着"唯一的 multipart 实现",别再抄第二份)。
*/
export async function stageUpload(token: string, uri: string): Promise<FbStageUpload> {
return await fbUploadStage(uri, token);
}
/**
* 改一段行程。
*
* 🔴 2026-11-05 新增。用户:「鸿蒙端:现在行程还不能编辑,得尽快把这个功能做完。」
*
* 原来端上只有 `add` / `delete` —— 想改一个字就得删了重建,
* 而 `trip_nodes.id` 上挂着成员、车辆、附件,删了就全丢。
*
* ## 寻址方式(与 `deleteTripNode` 一致)
*
* PATCH /api/v1/trips/<tripId>/nodes?node_id=<nodeId>
*
* ⚠️ 不用 `/trips/1/nodes/5`:`Api::dispatch()` 只解析三段,
* 第 4 段会被丢掉(见 `deleteTripNode` 的注释)。
* ⚠️ 同时把 `id` 放进 body:服务端读 body,
* 两边任何一边改了口径另一边也还能工作。
*
* ## 空值语义(与新建不同)
*
* 新建时"没填"和"要清空"是一回事;**编辑时不是**。
* 用户把车次删掉 → 必须传空串才能清掉。
* ⇒ 这里**不做**"空就不传"的过滤,照直传。
* 服务端 `nodeData()` 用 `$has()` 判定"这次有没有传这个字段",
* 传了空串就写成 NULL(清空),没传才沿用旧值。
*/
export async function updateTripNode(token: string, tripId: number, nodeId: number,
d: FbTripNodeDraft): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['id'] = nodeId;
body['title'] = d.title;
body['place'] = d.place;
body['detail'] = d.detail;
body['starts_at'] = d.startsAt;
body['ends_at'] = d.endsAt;
body['node_type'] = d.nodeType;
body['travel_mode'] = d.travelMode;
body['category'] = d.category;
/* ⚠️ 这两个**总是带**(含空串):
编辑场景下"清空车次/取消选车"必须能生效,空就不传会导致永远清不掉。 */
body['trip_no'] = d.tripNo;
body['vehicle_id'] = d.vehicleId;
/* 2026-11-05:出发地 / 到达地 / 执行状态 / 参与人(对齐网页端) */
body['from_place'] = d.fromPlace;
body['to_place'] = d.toPlace;
body['from_addr'] = d.fromAddr;
body['to_addr'] = d.toAddr;
/* #51:坐标(空串不传 —— 服务端 coordOrNull 会写 NULL,等于没坐标) */
if (d.fromLng !== '' && d.fromLat !== '') {
body['from_lng'] = d.fromLng;
body['from_lat'] = d.fromLat;
}
if (d.toLng !== '' && d.toLat !== '') {
body['to_lng'] = d.toLng;
body['to_lat'] = d.toLat;
}
body['status'] = d.status;
/* ⚠️ 空数组也传:编辑时"把人全去掉"必须能生效
(服务端 `members` 用 `Api::ints()` 读,空数组 = 清空) */
body['members'] = d.members;
const query: Record<string, string> = {};
query['node_id'] = nodeId.toString();
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body, query: query };
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
}
/**
* 删一段行程。
*
@@ -350,12 +645,119 @@ function numOf(o: Record<string, Object>, k: string): number {
}
/** 节点时间文案:`starts_at` 有就用它,否则用 `ends_at`;都没有给空串 */
/**
* 行程时间跨度文案。
*
* 🔴🔴 2026-11-05 修复(用户:「鸿蒙端的结束时间还是没有,只有开始时间」)。
*
* **原实现的 bug**:
*
* const a = strOf(o,'starts_at') !== '' ? strOf(o,'starts_at') : strOf(o,'ends_at');
* return cnShort(a);
*
* 那行三元看着像"两个都认",其实只是"开始为空时拿结束顶替" ——
* **两个都有时,结束时间被彻底丢弃**。
*
* 现在逐条对齐网页端 `Trip::nodeSpan($start, $end)` 的四种形态:
* · 同一天、两头都有时分 → `2026年11月7日 16:50 – 21:41`
* · 同一天、结束没时分 → `2026年11月7日 16:50 起`
* · 同一天、两头都没时分 → `2026年11月7日`
* · 跨天 → `2026年10月2日 07:30 – 10月3日 18:00`
*
* ⚠️ 必须与网页端**同一套文案**,否则同一段行程在两端显示不一样。
*/
function spanOf(o: Record<string, Object>): string {
const a: string = strOf(o, 'starts_at') !== '' ? strOf(o, 'starts_at') : strOf(o, 'ends_at');
if (a === '') {
return '';
const startRaw: string = strOf(o, 'starts_at');
const endRaw: string = strOf(o, 'ends_at');
return fbSpanOf(startRaw, endRaw);
}
/**
* `starts_at` + `ends_at` → 展示串(网页端 `Trip::nodeSpan` 的端上实现)。
*
* @param startRaw `YYYY-MM-DD HH:MM:SS`(空 = 日期待补)
* @param endRaw `YYYY-MM-DD HH:MM:SS`(空 = 没设结束)
*/
export function fbSpanOf(startRaw: string, endRaw: string): string {
if (startRaw === '') {
return '日期待补';
}
return cnShort(a);
const s: Date | null = fbParseIso(startRaw);
if (s === null) {
return '日期待补';
}
const sHasTime: boolean = !(s.getHours() === 0 && s.getMinutes() === 0);
const sHead: string = fbCnDate(s) + (sHasTime ? ' ' + fbHm(s) : '');
if (endRaw === '') {
return sHead;
}
const e: Date | null = fbParseIso(endRaw);
if (e === null) {
return sHead;
}
const eHasTime: boolean = !(e.getHours() === 0 && e.getMinutes() === 0);
/* 同一天 */
if (fbSameDay(s, e)) {
if (!eHasTime && !sHasTime) {
return fbCnDate(s); // 两头都没时分 = 一整天
}
if (!eHasTime) {
return sHead + ' 起'; // 结束没填时分 → 说"从几点开始"更准
}
return fbCnDate(s) + (sHasTime ? ' ' + fbHm(s) : '') + ' – ' + fbHm(e);
}
/* 跨天:结束那边写"月日"(不重复年份),与网页端一致 */
return sHead + ' – ' + (e.getMonth() + 1).toString() + '月' + e.getDate().toString() + '日'
+ (eHasTime ? ' ' + fbHm(e) : '');
}
/**
* 解析服务端的 `YYYY-MM-DD HH:MM:SS`。
*
* ⚠️ 不用 `new Date(str)`:各家 JS 引擎对空格分隔的解析行为不一致
* (Safari/部分引擎会返回 Invalid Date)。手工拆更稳。
*/
function fbParseIso(raw: string): Date | null {
const v: string = raw.trim();
if (v.length < 10) {
return null;
}
const y: number = parseInt(v.substring(0, 4), 10);
const mo: number = parseInt(v.substring(5, 7), 10) - 1;
const d: number = parseInt(v.substring(8, 10), 10);
let h: number = 0;
let mi: number = 0;
if (v.length >= 16) {
h = parseInt(v.substring(11, 13), 10);
mi = parseInt(v.substring(14, 16), 10);
}
if (isNaN(y) || isNaN(mo) || isNaN(d)) {
return null;
}
const out: Date = new Date(y, mo, d,
isNaN(h) ? 0 : h, isNaN(mi) ? 0 : mi, 0, 0);
return isNaN(out.getTime()) ? null : out;
}
/** `2026年11月7日` */
function fbCnDate(d: Date): string {
return d.getFullYear().toString() + '年' + (d.getMonth() + 1).toString()
+ '月' + d.getDate().toString() + '日';
}
/** `16:05`(补零到两位) */
function fbHm(d: Date): string {
const h: string = d.getHours() < 10 ? '0' + d.getHours().toString() : d.getHours().toString();
const m: string = d.getMinutes() < 10 ? '0' + d.getMinutes().toString() : d.getMinutes().toString();
return h + ':' + m;
}
function fbSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
/** `2026-10-20T09:30:00` → `10-20 09:30`(列表里一行够用) */
@@ -458,6 +860,9 @@ function peopleOf(o: Record<string, Object>, k: string): FbTripNodePerson[] {
function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
const v: Object | undefined = o[k];
const out: FbTripNodePhoto[] = [];
/* DIAG-ATTACH:看原始输入 */
FbDiag.log('ATTACH', 'attachsOf k=' + k + ' isArray=' + Array.isArray(v).toString()
+ ' raw=' + (Array.isArray(v) ? (v as Object[]).length.toString() : '-'));
if (!Array.isArray(v)) {
return out;
}
@@ -479,6 +884,8 @@ function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
size: numOf(a, 'size')
});
}
/* DIAG-ATTACH:解析出几条(key 为空的会被跳过,所以可能少于 raw) */
FbDiag.log('ATTACH', 'attachsOf 解析出 ' + out.length.toString() + ' 条');
return out;
}
+67
View File
@@ -46,6 +46,73 @@ export async function fbUploadOne(path: string, field: string, uri: string,
return r.ok ? '' : r.error;
}
/**
* 暂存上传:把文件传到暂存区,返回**令牌**。
*
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交,
* 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
*
* 这是「新增时也能挂照片」的关键:新增行程时节点还不存在,
* 只能先把文件暂存起来拿 stoken,等保存表单时把 stoken 一起提交,
* 服务端建完节点再 `claim` 上去。
*
* @param uri picker 给的文件 URI
* @param token 访问令牌
* @returns stoken 为空串 = 失败(看 error)
*/
export interface FbStageUpload {
/** 暂存令牌(空 = 失败) */
stoken: string;
/** 原始文件名 */
name: string;
/** 是否图片(服务端按 MIME 嗅探) */
isImage: boolean;
/** 预览地址(相对路径) */
url: string;
/** 错误说明(成功时为空) */
error: string;
}
export async function fbUploadStage(uri: string, token: string): Promise<FbStageUpload> {
const out: FbStageUpload = {
stoken: '', name: '', isImage: false, url: '', error: ''
};
const r: FbUploadRaw = await fbUploadRaw('/api/v1/uploads/stage', 'file', uri, token);
if (!r.ok) {
out.error = r.error !== '' ? r.error : '上传失败';
return out;
}
/* 服务端信封:{ok:true, data:{stoken,...}}(`fbApi` 层已拆过一层,
但 multipart 这条路是自己解析的,要按原始信封读) */
try {
const env: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
let dataObj: Record<string, Object> = env;
const nested: Object | undefined = env['data'];
if (nested !== undefined) {
dataObj = nested as Record<string, Object>;
}
const st: Object | undefined = dataObj['stoken'];
if (typeof st === 'string' && st !== '') {
out.stoken = st as string;
}
const nm: Object | undefined = dataObj['name'];
if (typeof nm === 'string') {
out.name = nm as string;
}
out.isImage = dataObj['is_image'] === true;
const u: Object | undefined = dataObj['url'];
if (typeof u === 'string') {
out.url = u as string;
}
if (out.stoken === '') {
out.error = '服务端没返回令牌';
}
} catch (err) {
out.error = '响应解析失败';
}
return out;
}
/**
* 上传图片并**拿回响应载荷**(工单 #23 用)。
*
+73 -13
View File
@@ -145,50 +145,110 @@ export const FB_DEMO_TRIPS: FbTripItem[] = [
*/
export const FB_DEMO_TRIP_NODES: FbTripNode[] = [
{
id: 1, title: '出发', when: '10-02 07:30', place: '家 → 青岛北站',
id: 1, title: '出发', when: '10-02 07:30',
startsAt: '',
endsAt: '',
actualStartedAt: '',
actualEndedAt: '', place: '家 → 青岛北站',
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 0,
nodeType: 'move', detail: '六点起床,路上买了包子当早饭。',
fromPlace: '家', toPlace: '青岛北站', lineName: '', carrier: '',
fromPlace: '家', toPlace: '青岛北站',
fromAddr: '',
toAddr: '',
fromLng: '',
fromLat: '',
toLng: '',
toLat: '', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 2, title: '抵达民宿', when: '10-02 10:20', place: '崂山区·沙子口民宿',
id: 2, title: '抵达民宿', when: '10-02 10:20',
startsAt: '',
endsAt: '',
actualStartedAt: '',
actualEndedAt: '', place: '崂山区·沙子口民宿',
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 68000,
nodeType: 'stay', detail: '老板留了钥匙在门口花盆下,房间朝海。',
fromPlace: '青岛北站', toPlace: '沙子口民宿', lineName: '', carrier: '',
fromPlace: '青岛北站', toPlace: '沙子口民宿',
fromAddr: '',
toAddr: '',
fromLng: '',
fromLat: '',
toLng: '',
toLat: '', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', place: '仰口海水浴场',
id: 3, title: '海边散步 + 午餐', when: '10-02 12:00',
startsAt: '',
endsAt: '',
actualStartedAt: '',
actualEndedAt: '', place: '仰口海水浴场',
stage: 'actual', status: 'done', mode: 'walk', expenseCent: 21800,
nodeType: 'play', detail: '在礁石上捡了几块小石头。',
fromPlace: '沙子口民宿', toPlace: '仰口海水浴场', lineName: '', carrier: '',
fromPlace: '沙子口民宿', toPlace: '仰口海水浴场',
fromAddr: '',
toAddr: '',
fromLng: '',
fromLat: '',
toLng: '',
toLat: '', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '', revisionN: 0,
members: [], attachs: []
},
{
id: 4, title: '太清宫', when: '10-02 15:00', place: '崂山太清景区',
id: 4, title: '太清宫', when: '10-02 15:00',
startsAt: '',
endsAt: '',
actualStartedAt: '',
actualEndedAt: '', place: '崂山太清景区',
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 27000,
nodeType: 'play', detail: '',
fromPlace: '仰口', toPlace: '太清宫', lineName: '', carrier: '',
fromPlace: '仰口', toPlace: '太清宫',
fromAddr: '',
toAddr: '',
fromLng: '',
fromLat: '',
toLng: '',
toLat: '', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 5, title: '登山(巨峰)', when: '10-03 08:00', place: '崂山巨峰景区',
id: 5, title: '登山(巨峰)', when: '10-03 08:00',
startsAt: '',
endsAt: '',
actualStartedAt: '',
actualEndedAt: '', place: '崂山巨峰景区',
stage: 'plan', status: 'planned', mode: 'drive', expenseCent: 34000,
nodeType: 'play', detail: '带够水,山上买贵。',
fromPlace: '沙子口民宿', toPlace: '巨峰景区', lineName: '', carrier: '',
fromPlace: '沙子口民宿', toPlace: '巨峰景区',
fromAddr: '',
toAddr: '',
fromLng: '',
fromLat: '',
toLng: '',
toLat: '', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 6, title: '返程', when: '10-03 17:00', place: '青岛 → 家',
id: 6, title: '返程', when: '10-03 17:00',
startsAt: '',
endsAt: '',
actualStartedAt: '',
actualEndedAt: '', place: '青岛 → 家',
stage: 'plan', status: 'planned', mode: 'train', expenseCent: 0,
nodeType: 'move', detail: '',
fromPlace: '青岛北站', toPlace: '家', lineName: '京沪高铁', carrier: '中国铁路',
fromPlace: '青岛北站', toPlace: '家',
fromAddr: '',
toAddr: '',
fromLng: '',
fromLat: '',
toLng: '',
toLat: '', lineName: '京沪高铁', carrier: '中国铁路',
tripNo: 'G1234', vehiclePlate: '', revisionN: 0,
members: [], attachs: []
}
@@ -408,7 +468,7 @@ export function fbGroupRoster(list: FbRosterItem[]): FbGroup<FbRosterItem>[] {
/** 按 status 分组建任务(待办 / 进行中 / 已完成) */
export function fbGroupTask(list: FbTaskItem[]): FbGroup<FbTaskItem>[] {
const order: string[] = ['doing', 'todo', 'done'];
const titles: string[] = ['进行中', '待办', '已完成'];
const titles: string[] = ['进行中', '待做', '已完成'];
const groups: FbGroup<FbTaskItem>[] = [];
for (let i = 0; i < order.length; i++) {
const key: string = order[i];
+1 -1
View File
@@ -49,7 +49,7 @@ export const FB_MODULES: FbModuleDef[] = [
{ key: 'trips', name: '旅程', color: '#3F7D5A', desc: '去过的地方与行程' },
{ key: 'calendar', name: '日程', color: '#B0763A', desc: '日历 / 重复日程 / 提醒' },
{ key: 'resources', name: '资源', color: '#5566A8', desc: '证件 / 资料 / 附件' },
{ key: 'tasks', name: '任务', color: '#A8574F', desc: '看板与待办' },
{ key: 'tasks', name: '任务', color: '#A8574F', desc: '看板与任务' },
{ key: 'days', name: '纪念日', color: '#B4633C', desc: '生日与重要日子' },
{ key: 'ledger', name: '财务', color: '#5E7A3C', desc: '账本与账目' },
{ key: 'books', name: '读书', color: '#6B5B95', desc: '书架与读书笔记' },
+43
View File
@@ -436,6 +436,34 @@ export interface FbTripNode {
title: string;
/** 时间文案(已按服务端 `nodeSpan` 的口径折成「10-20 09:30」这种短串) */
when: string;
/**
* 开始时间**原始值**(`YYYY-MM-DD HH:MM:SS`,服务端 `starts_at`)。
*
* 🔴 2026-11-05 新增。`when` 是给人看的展示串,**不带年份**,
* 编辑回填时从它反推会跨年错位(12 月记次年 1 月的行程会落到今年)。
* 有了原始值就能精确回填,也才能显示结束时间。
*/
startsAt: string;
/**
* 真实开始时间(#48;`actual_started_at`,点「开始」那一刻)。
*
* 🔴 用户:「我计划今天 8:00 到 10:00 去公园,但可能 8:30 才点『开始』。
* 这时候就要记录住 8:30,因为 8:30 会成为实际行程的开始时间。
* 也就是说,**原来计划的时间和现在真正开始的时间都要记录**。」
*
* ⚠️ 与 `startsAt`(计划)并存,不是替代关系。
*/
actualStartedAt: string;
/** 真实结束时间(`actual_ended_at`,点「完成」那一刻;空 = 还没结束) */
actualEndedAt: string;
/**
* 结束时间原始值(`YYYY-MM-DD HH:MM:SS`,服务端 `ends_at`;空 = 没设)。
*
* 🔴 2026-11-05 新增。用户:「上面好像只有开始时间,没有结束时间呀,这都需要补啊。」
* 服务端 `Trip::nodes()` 用 `SELECT *`,这两个字段本来就在返回里,
* 只是端上没解析。
*/
endsAt: string;
/** 地点(`place`,没有则「出发地 → 到达地」) */
place: string;
/** 阶段:plan(计划)/ actual(实际)/ skipped(未成行) */
@@ -457,6 +485,21 @@ export interface FbTripNode {
/** 出发地 / 到达地(网页版 `.node-route-line`,各带导航链接) */
fromPlace: string;
toPlace: string;
/* 🔴 #51(用户:「网页端通过高德地图能拿到详细地址。不知道你这个鸿蒙端
有没有拿到详细地址」):
`FbGeoTip` 本来就带 `detail` / `lng` / `lat`,但 `onValue` 只传了
拼好的字符串 ⇒ 端上从来拿不到坐标与真实详细地址。
现在走 `onPick` 结构化回调,这几个字段才有值。 */
/** 出发地详细地址(高德 `detail`,不是地名) */
fromAddr: string;
/** 到达地详细地址 */
toAddr: string;
/** 出发地坐标(导航链接要用) */
fromLng: string;
fromLat: string;
/** 到达地坐标 */
toLng: string;
toLat: string;
/** 线路名 / 承运方(网页版 `.node-traffic` 的两个 `.ntag`) */
lineName: string;
carrier: string;
@@ -39,6 +39,9 @@ import { FbSheetRing } from '../../components/FbSheetRing';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_CAL_SETTINGS } from '../../data/FbCalSettings';
import { FbCalMirror, FbMirrorResult, MirrorStats } from '../../data/FbCalMirror';
import { FbCalInbound } from '../../data/FbCalInbound';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbAccount';
@@ -79,12 +82,28 @@ export struct FbAccountPage {
/** 系统设置浮层是否打开(仅管理员) */
@Local sysOpen: boolean = false;
/* ---------- 镜像到系统日历 ---------- */
/** 开关状态(从 preferences 读,异步一次落进来) */
@Local mirrorOn: boolean = false;
/** 当前镜像了多少条(0 = 没生效或还没跑) */
@Local mirrorCount: number = 0;
/** 镜像了几个日历本(每个本一个独立系统日历账户) */
@Local mirrorBooks: number = 0;
/** 最近一次镜像的结果文案 */
@Local mirrorNote: string = '';
/** 正在跑(防重复点击) */
@Local mirrorBusy: boolean = false;
/** 账号接口拿回来的信息(拿不到就只显示会话里那份) */
@Local accountNote: string = '';
@Local lastLogin: string = '';
aboutToAppear(): void {
this.loadAccount();
/* 镜像开关状态(异步读一次落进 @Local)。
⚠️ 必须在这里就读:用户进本页第一眼要看到开关是开还是关,
不能等他滚动到底部才发现状态是错的。 */
this.loadMirrorState();
}
/**
@@ -213,6 +232,123 @@ export struct FbAccountPage {
}
}
/* ==================== 镜像到系统日历 ==================== */
/** 读镜像开关 + 当前条数(异步一次,落进 @Local) */
private loadMirrorState(): void {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.mirrorOn = FB_CAL_SETTINGS.getMirror();
FbCalMirror.mirroredStats(ctx).then((st: MirrorStats): void => {
this.mirrorBooks = st.books;
this.mirrorCount = st.events;
}).catch((): void => {
});
}
/**
* 切换镜像开关。
*
* 🔴 **权限只能在这里申请**(用户主动点击)—— 启动时申请不合规。
*
* 打开顺序(不能反):
* ① 申请系统日历读写权限(拒绝就开关回弹并说明)
* ② **先入站**:把用户在系统日历里的改动收回服务器
* ③ **再出站**:把最新数据写进系统日历
* ⚠️ 反了的话出站会把系统日历里的旧值当"期望值",覆盖掉用户改动。
*
* 关闭:删镜像账户 + 清快照(不在系统日历里留垃圾)
*/
private async toggleMirror(on: boolean): Promise<void> {
if (this.mirrorBusy) {
return;
}
const ctx: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
if (ctx === undefined) {
return;
}
if (!on) {
this.mirrorOn = false;
this.mirrorNote = '正在清除…';
this.mirrorBusy = true;
try {
await FbCalMirror.removeAll(ctx);
FB_CAL_SETTINGS.setMirror(false);
this.mirrorCount = 0;
this.mirrorNote = '已关闭,系统日历里不再有岁月家的日程';
} catch (err) {
const e = err as BusinessError;
this.mirrorNote = '关闭时出错:' + e.message;
}
this.mirrorBusy = false;
return;
}
// 打开:先要权限
this.mirrorBusy = true;
this.mirrorNote = '正在申请日历权限…';
const granted: boolean = await FbCalMirror.requestPermission(ctx);
if (!granted) {
this.mirrorOn = false;
this.mirrorNote = '没有日历权限,镜像无法开启(可在系统设置里授权)';
this.mirrorBusy = false;
return;
}
this.mirrorOn = true;
FB_CAL_SETTINGS.setMirror(true);
this.mirrorNote = '正在同步…';
try {
// ① 先入站:把用户在系统日历里做的改动收回服务器
await FbCalInbound.reconcile(ctx);
// ② 再出站:把最新数据写进系统日历
const r: FbMirrorResult = await FbCalMirror.syncNow(ctx);
this.mirrorNote = '已镜像 ' + r.books + ' 个本:新增 ' + r.added
+ '、更新 ' + r.updated
+ (r.deleted > 0 ? '、删除 ' + r.deleted : '')
+ (r.failed > 0 ? ',' + r.failed + ' 条失败' : '');
const st2: MirrorStats = await FbCalMirror.mirroredStats(ctx);
this.mirrorBooks = st2.books;
this.mirrorCount = st2.events;
} catch (err) {
const e = err as BusinessError;
this.mirrorNote = '镜像失败:' + e.message;
}
this.mirrorBusy = false;
}
/** 手动重跑一次镜像(打开状态下才有意义) */
private async runMirrorNow(): Promise<void> {
if (this.mirrorBusy || !this.mirrorOn) {
return;
}
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.mirrorBusy = true;
this.mirrorNote = '正在同步…';
try {
await FbCalInbound.reconcile(ctx);
const r: FbMirrorResult = await FbCalMirror.syncNow(ctx);
this.mirrorNote = r.books + ' 个本:新增 ' + r.added + '/更新 ' + r.updated
+ '/删除 ' + r.deleted + (r.failed > 0 ? '/失败 ' + r.failed : '');
const st2: MirrorStats = await FbCalMirror.mirroredStats(ctx);
this.mirrorBooks = st2.books;
this.mirrorCount = st2.events;
} catch (err) {
const e = err as BusinessError;
this.mirrorNote = '同步失败:' + e.message;
}
this.mirrorBusy = false;
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
@@ -274,6 +410,89 @@ export struct FbAccountPage {
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
.alignItems(HorizontalAlign.Start)
// ==================== 系统日历 ====================
/* 🔴 2026-11-03 新增。用户原话:
「新增一个功能,把日历镜像到系统日历中。至于原因,同步日历都已经说过了,
做法就完全照搬同步日历就可以。在个人设置页面,进行设置。」
原因(SyncCalendar 文件头):本应用是 CalDAV 客户端,日程不进系统日历,
于是小艺 / 桌面日历卡片 / 手表看到的是空的,
甚至会给出"今天没有日程"的错误答案。
⚠️ 放在**个人设置**而不是「系统设置」:
它是每个用户自己的偏好(我要不要同步到自己的手机日历),
不是管理员配置。而「系统设置」还要多点一层、且仅管理员可见。
⚠️ 权限在用户点这个开关时**才**申请(合规要求:不能启动时申请)。 */
Column({ space: 0 }) {
this.rowTitle('系统日历')
Row({ space: 12 }) {
Column({ space: 2 }) {
Text('镜像到系统日历')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(this.mirrorOn
? (this.mirrorBooks > 0
? '已镜像 ' + this.mirrorBooks + ' 个日历本 · 共 ' + this.mirrorCount + ' 条'
: '已开启,等待首次同步')
: '每个日历本单独一个颜色,小艺、桌面卡片、手表都能看到')
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.mirrorOn })
.selectedColor(FbColors.brand)
.enabled(!this.mirrorBusy)
.onChange((on: boolean): void => {
this.toggleMirror(on);
})
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Center)
// 当前结果 / 状态。给一句人话,避免"开了但没生效"时用户无从判断。
if (this.mirrorNote !== '') {
Text(this.mirrorNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
}
// 手动重跑(只在开启状态显示;关闭时跑没意义)
if (this.mirrorOn) {
Row({ space: 8 }) {
Text(this.mirrorBusy ? '同步中…' : '立即同步一次')
.fontSize(FbMetric.fontSmall)
.fontColor(this.mirrorBusy ? FbColors.faint : FbColors.brandInk)
.onClick((): void => {
this.runMirrorNow();
})
Blank()
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
// 说明"改了会怎样",否则用户不知道系统日历里的改动会不会同步回来
Text('在系统日历里改动或删除,下次同步会写回岁月家;在岁月家里改的,也会更新到系统日历。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
.padding({ top: 2, bottom: 2 })
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
.alignItems(HorizontalAlign.Start)
// ==================== 安全 ====================
Column({ space: 0 }) {
this.rowTitle('安全')
@@ -46,6 +46,7 @@ import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition, fbViewTransition } from '../../common/FbAnim';
import { FB_BACK } from '../../common/FbBack';
import { fbOpenNavigation } from '../../common/FbNav';
import { FbDiag } from '../../common/FbDiag';
/** 视图粗细顺序(决定切视图时过渡的上下方向,见 `switchModeAnimated`) */
const FB_CAL_VIEW_ORDER: string[] = ['month', 'week', 'agenda'];
@@ -363,6 +364,9 @@ export struct FbCalendarPage {
}
aboutToAppear(): void {
/* 诊断设施在这里初始化:地址联想发生在编辑弹窗里,
那时用户可能还没点过导航 ⇒ 不能等导航时才 init。 */
FbDiag.init(this.getUIContext().getHostContext());
FB_BACK.register('calendar', (): boolean => this.backOnce());
// 先按设置里的默认视图展示(用户上次选的)
this.mode = FB_CAL_SETTINGS.getView();
@@ -1080,6 +1084,7 @@ export struct FbCalendarPage {
/* ⚠️ `fbOpenNavigation` 现在是 async(内部要 await 地理编码),
不接住 rejection 会报未处理异常。这里明确咽掉并提示 ——
导航失败不该把页面弄崩。 */
FbDiag.init(ctx);
fbOpenNavigation(ctx, place, title).catch((err: Error): void => {
hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err));
});
+121 -27
View File
@@ -1,12 +1,36 @@
/**
* 家书 FamilyBook · 2×2 服务卡片(任务 · 待办概览)
* 岁月家 FamilyBook · 2×2 任务服务卡片
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 骨架照日程 2×2;大数字 = 未完成任务数(todo + doing);
* 有逾期时副行变红给「逾期 N 条」,让"该处理了"一眼可见。
* 🔴 2026-11-04 重做。用户原话:
* 「任务的四个卡片,样式格式,完全按照同步日历来完成,重做。」
*
* ⚠️ 动态卡片不能自己发网络请求:只画 `fbTaskCardBuild` 推过来的字段。
* ⚠️ 整卡可点 → 打开应用并落到「任务」模块(fbModule: 'tasks')。
* 完全照搬同步日历的 `TodoWidget2x2`(`SyncCalendar/entry/src/main/ets/pages/widget/`):
*
* ┌──────────────────────────┐
* │ 11月4日 (+) │ ← 日期 + 星期/农历,右上角新建
* │ 星期三 │
* ├──────────────────────────┤ ← 细分隔线
* │ 12 待办未完成 │ ← 大数字(品牌紫/砖红)+ 说明 + 今日到期
* │ 今日到期 3 项 │
* └──────────────────────────┘
*
* ## 与 SyncCalendar 的逐项对应
*
* | 元素 | SyncCalendar | 本卡片 |
* |---|---|---|
* | 头部主行 | `dateMd`(`11月4日`) | 同 |
* | 头部副行 | `weekday + 农历` | `weekday`(我们没接农历,见下) |
* | 右上按钮 | `+` 圆形,路由去新建待办 | 同(走 `modCreate` 通道) |
* | 大数字 | `todoCount`,40px Bold | 同 |
* | 数字右文案 | `待办未完成` | 同 |
* | 副提示 | `今日到期 N 项`(橙)/ `今日无到期`(灰) | 同 |
* | 卡片底 | 白底 + 圆角 16 + padding 14 | 同 |
*
* ⚠️ **农历不接**:SyncCalendar 有 `LunarUtil`,岁月家没有。
* 与其塞一个不准的农历,不如只显示星期 —— 少一行比错一行好。
* 将来要接农历,只需在数据层加 `lunarText` 字段,本卡片加一行 Text 即可。
*
* ⚠️ 本卡片**不展示列表**,所以没有打勾交互;点整卡进「任务」模块。
*/
let storageTk2 = new LocalStorage();
@@ -16,10 +40,13 @@ let storageTk2 = new LocalStorage();
struct FbTaskCard2x2 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
/** 打开 App 并落到「任务」模块 */
private openApp(): void {
postCardAction(this, {
action: 'router',
@@ -28,20 +55,87 @@ struct FbTaskCard2x2 {
});
}
/**
* 右上角 +:拉起 App 并**直接打开新建任务浮层**。
*
* ⚠️ 用 `modCreate: 'tasks'` 而不是 `fbModule: 'tasks'`:
* 前者会让 EntryAbility 落到任务页**并立刻弹新建**(用户意图是"马上加一条");
* 后者只是打开任务页,用户还要再点一次 +。
*/
private addOne(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks', widgetAction: 'add' }
});
}
/**
* 副行文案。
*
* 优先级照 SyncCalendar:
* ① 今日到期(橙色,最该行动)
* ② 没有待办 → "点击添加"
* ③ 今日无到期(灰色)
*
* ⚠️ 我们比 SyncCalendar 多一个"逾期"状态 —— 它更严重,所以排在今日到期之前。
*/
private subText(): string {
if (this.overdueCount > 0) {
return '逾期 ' + this.overdueCount.toString() + ' 项';
}
if (this.todayCount > 0) {
return '今日到期 ' + this.todayCount.toString() + ' 项';
}
return this.openCount === 0 ? '点击添加' : '今日无到期';
}
/** 副行颜色:逾期/今日到期都要醒目 */
private subColor(): string {
if (this.overdueCount > 0) {
return '#C0533F';
}
return this.todayCount > 0 ? '#FF9500' : '#A8988A';
}
/** 右上角 + 按钮(照 SyncCalendar 的圆形按钮) */
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => {
this.addOne();
})
}
build() {
Column({ space: 6 }) {
// ---- 抬头:模块名 + 更新时间 ----
// ---- 头部:日期 + 星期 + + ----
Row({ space: 6 }) {
Text('任务')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
Column({ space: 2 }) {
Text(this.dateMd !== '' ? this.dateMd : '—')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
Text(this.weekday)
.fontSize(11)
.fontColor('#A8988A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
@@ -49,21 +143,20 @@ struct FbTaskCard2x2 {
.strokeWidth(0.5)
.color('#EFE6D8')
// ---- 待办数 ----
// ---- 未完成大数字 ----
Row({ space: 8 }) {
Text(`${this.openCount}`)
Text(this.openCount.toString())
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor('#A8574F')
Column({ space: 2 }) {
Text('待办任务')
Text('任务进行中')
.fontSize(12)
.fontColor('#2C231D')
.maxLines(1)
Text(this.hint !== '' ? this.hint
: (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条' : '点击查看'))
Text(this.state === 'ok' ? this.subText() : (this.hint !== '' ? this.hint : '读取中…'))
.fontSize(10)
.fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A')
.fontColor(this.state === 'ok' ? this.subColor() : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
@@ -79,7 +172,8 @@ struct FbTaskCard2x2 {
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
.onClick((): void => {
this.openApp();
})
}
}
}
+204 -98
View File
@@ -1,56 +1,83 @@
/**
* 家书 FamilyBook · 2×4 服务卡片(任务 · 左概览 / 右待办)
* 岁月家 FamilyBook · 2×4 任务服务卡片
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 骨架照日程 2×4:左半 = 2×2 那套;右半 = 待办任务(最多三条)。
* 排序由数据侧定好(逾期 > 进行中 > 普通,同组按截止时间升序),
* 逾期行整条用红色 deadline 文案提示。
* 🔴 2026-11-04 重做。用户原话:
* 「任务的四个卡片,样式格式,完全按照同步日历来完成,重做。」
*
* ⚠️ 数据与 2×2 同一份(fbTaskCardBuild 算一次)。
* 完全照搬同步日历的 `TodoWidget2x4`:
*
* ┌───────────────┬────────────────────┐
* │ 11月4日 (+) │ ○ 交水电费 │ ← 左:日期+新建
* │ 星期三 │ 18:00 · 家务 │ 右:可打勾的列表
* ├───────────────┤ ○ 备课 │
* │ 12 待办未完成│ 明天 · 工作 │
* │ 今日到期 3│ ○ ... │
* └───────────────┴────────────────────┘
*
* ## 与 SyncCalendar 的逐项对应
*
* | 元素 | SyncCalendar | 本卡片 |
* |---|---|---|
* | 布局 | `Row` 左右分栏,中间竖分隔线 | 同 |
* | 左:头 | `dateMd` + `weekday/农历` + `+` | 同(无农历) |
* | 左:统计 | 大数字 38px + `待办未完成` + 今日到期 | 同 |
* | 右:容器 | `List`(可滚,虚拟化) | 同 |
* | 右:条目 | **圆形勾选框** + 标题 + 副行 | 同 |
* | 勾选圈 | 圆形,颜色 = 日历本色,点击即完成 | 同(颜色 = 看板色) |
* | 条目底 | `#F5F7FA` 圆角 9,padding 5/4/8 | 同 |
* | 副行 | `截止时间 · 日历本` | `截止时间 · 看板` |
* | 打勾动作 | `message: {todoCheck: id}`(不拉 App) | `message: {taskDone: id}` |
*
* ## 两条关键纪律(来自 SyncCalendar 的注释,必须照搬)
*
* 1. **打勾用 `action: 'message'`,不是 `router`**
* 拉 App 会闪一下界面,体验很差。message 会进 `onFormEvent`,
* 在 FormExtension 的窗口内完成"调接口 + 重算 + 刷新卡片"。
*
* 2. **列表用 `List` 而不是 `Column` + `slice`**
* SyncCalendar 踩过:`Column` 不能滚,第 3 条被压扁截断。
* List 自带虚拟化,条数多也不占内存。
*
* ⚠️ ForEach 的 key 必须带 `version` 前缀 —— 本项目 v89 的教训:
* 不带的话下次推送数据变了也不重绘。
* ⚠️ 卡片不能自己发网络请求,打勾必须借道 FormExtensionAbility。
*/
let storageTk4 = new LocalStorage();
/** 右半「待办」一行 */
class FbTaskRow4 {
/** 右半列表的一行 */
class FbTaskCardRow2x4 {
id: number = 0;
key: string = '';
title: string = '';
/** 截止时间文案(空 = 没有期限) */
dueText: string = '';
status: string = '';
/** 看板名(副行第二段) */
boardName: string = '';
/** 看板主题色 —— 勾选圈的描边色 */
boardColor: string = '#A8574F';
overdue: boolean = false;
/** 'todo' / 'doing' —— 没有截止时间时副行用它兜底 */
status: string = '';
}
/** 右半可滚动列表的条数上限。
*
* 🔴 v95:配套 `rows` 改成 `List` 一起用。上限必须**和观影 2×4 同一个量级**,
* 否则"能滑"只是名义上能滑(数据侧先砍到 4 条,卡片就只滑得到 4 条)。
* 30 与 4×4/6×4 的网格列表一致,数据侧也只给这么多。
*/
const TK_2X4_MAX = 30;
@Entry(storageTk4)
@Component
struct FbTaskCard2x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
@LocalStorageProp('rowsJson') rowsJson: string = '[]';
/* 🔴 v89 教训:ForEach key 必须带 version 前缀,否则下次推送不重绘 */
/* 🔴 ForEach key 带 version 前缀,否则下次推送不重绘(本项目 v89 教训) */
@LocalStorageProp('version') version: string = '';
/** 右半最多 TK_2X4_MAX 条(数据侧会裁;这里只兜底防御异常大数据)
*
* 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的。」
* —— 与观影 2×4 完全同一个病:`Column` 不能滚 + 硬截 3 条 ⇒ 第三条被压扁截断。
* ⇒ 容器改 `List`(可滚),上限提到 TK_2X4_MAX。详见 FbMovieCard2x4 的同名注释。
*/
private rows(): FbTaskRow4[] {
private rows(): FbTaskCardRow2x4[] {
try {
const all: FbTaskRow4[] = JSON.parse(this.rowsJson) as FbTaskRow4[];
return all.slice(0, TK_2X4_MAX);
return JSON.parse(this.rowsJson) as FbTaskCardRow2x4[];
} catch (err) {
return [];
}
@@ -64,62 +91,135 @@ struct FbTaskCard2x4 {
});
}
/** 右半单条:左色条 + 标题 + 截止(逾期红) */
/** + 新建:拉起 App 直接打开新建任务浮层 */
private addOne(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks', widgetAction: 'add' }
});
}
/**
* 点勾选圈 → 让 FormExtensionAbility 把这条标完成。
*
* ⭐ 用 `message` 而不是 `router`:不拉起主 App(拉起会闪界面)。
* ⭐ 带上 id:卡片只知道自己在画哪一条,改数据要靠主键。
*/
private checkTask(id: number): void {
postCardAction(this, {
action: 'message',
params: { taskDone: id.toString() }
});
}
/** 副行文案(优先级:逾期 > 今日到期 > 无) */
private subText(): string {
if (this.overdueCount > 0) {
return '逾期 ' + this.overdueCount.toString() + ' 项';
}
if (this.todayCount > 0) {
return '今日到期 ' + this.todayCount.toString() + ' 项';
}
return this.openCount === 0 ? '点击添加' : '今日无到期';
}
private subColor(): string {
if (this.overdueCount > 0) {
return '#C0533F';
}
return this.todayCount > 0 ? '#FF9500' : '#A8988A';
}
/** 右上角 +(照 SyncCalendar 的圆形按钮) */
@Builder
taskRow(item: FbTaskRow4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor(item.overdue ? '#C0533F' : item.boardColor)
Column({ space: 2 }) {
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => {
this.addOne();
})
}
/**
* 一条任务:圆形勾选框 + 标题 + 副行。
*
* ⚠️ 尺寸比 4×4 收一档(2×4 只有 2 格高):圈 16 / 标题 12 / 副行 9。
*/
@Builder
taskRow(item: FbTaskCardRow2x4) {
Row({ space: 6 }) {
// 勾选圈:描边用看板色,点击即完成
Row() {
Text('')
.width(16)
.height(16)
.borderRadius(8)
.border({ width: 2, color: item.overdue ? '#C0533F' : item.boardColor })
}
.padding({ right: 2 })
.onClick((): void => {
this.checkTask(item.id);
})
Column({ space: 1 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.fontSize(12)
.fontColor(item.overdue ? '#C0533F' : '#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.rowSub(item))
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
if (item.overdue) {
Text('逾期')
.fontSize(9)
.fontColor('#C0533F')
.maxLines(1)
}
if (item.dueText !== '') {
Text(item.dueText)
.fontSize(9)
.fontColor(item.overdue ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
} else {
Text(item.status === 'doing' ? '进行中' : '待办')
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC')
.padding({ left: 4, right: 8, top: 5, bottom: 5 })
.borderRadius(9)
.backgroundColor('#F7F2EC')
}
/** 右半空态:没有待办 */
/** 条目副行:`截止时间 · 看板名`(照 SyncCalendar 的「截止时间 · 日历本」) */
private rowSub(item: FbTaskCardRow2x4): string {
const parts: string[] = [];
if (item.overdue) {
parts.push('逾期');
}
if (item.dueText !== '') {
parts.push(item.dueText);
}
if (item.boardName !== '') {
parts.push(item.boardName);
}
if (parts.length === 0) {
parts.push(item.status === 'doing' ? '进行中' : '待做');
}
return parts.join(' · ');
}
/** 右半空态 */
@Builder
emptyHint() {
Column() {
Text(this.hint !== '' ? this.hint : '没有待办任务')
Column({ space: 4 }) {
Text(this.hint !== '' ? this.hint
: (this.openCount === 0 ? '暂无任务' : '今日无到期'))
.fontSize(12)
.fontColor(this.hint !== '' ? '#C0533F' : '#A8988A')
.maxLines(3)
.fontColor('#A8988A')
.maxLines(2)
.textAlign(TextAlign.Center)
}
.width('100%')
@@ -129,19 +229,26 @@ struct FbTaskCard2x4 {
build() {
Row({ space: 10 }) {
// ===== 左半:与 2×2 一致 =====
// ===== 左半:日期 + 大数字 =====
Column({ space: 6 }) {
Row({ space: 6 }) {
Text('任务')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
Column({ space: 2 }) {
Text(this.dateMd !== '' ? this.dateMd : '—')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.weekday)
.fontSize(10)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
@@ -150,24 +257,23 @@ struct FbTaskCard2x4 {
.color('#EFE6D8')
Row({ space: 6 }) {
Text(`${this.openCount}`)
Text(this.openCount.toString())
.fontSize(38)
.fontWeight(FontWeight.Bold)
.fontColor('#A8574F')
Column({ space: 2 }) {
Text('待办任务')
Text('任务进行中')
.fontSize(11)
.fontColor('#2C231D')
.maxLines(1)
Text(this.hint !== '' ? this.hint
: (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条 · 点击查看'
: '点击查看'))
Text(this.state === 'ok' ? this.subText() : (this.hint !== '' ? this.hint : '读取中…'))
.fontSize(9)
.fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A')
.fontColor(this.state === 'ok' ? this.subColor() : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.layoutWeight(1)
@@ -182,25 +288,24 @@ struct FbTaskCard2x4 {
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 右半:待办(🔴 v95 改成可滚动)=====
// ===== 右半:可滚动的待办列表(可打勾)=====
Column() {
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 6 }) {
ForEach(this.rows(), (item: FbTaskRow4) => {
/* ⚠️ 用 List 不用 Column + slice:Column 不能滚、第 3 条会被压扁截断
(SyncCalendar 踩过,本项目 v95 也踩过)。List 自带虚拟化。 */
List({ space: 3 }) {
ForEach(this.rows(), (item: FbTaskCardRow2x4, idx: number) => {
ListItem() {
this.taskRow(item)
}
.width('100%')
}, (item: FbTaskRow4) => `tk_${this.version}_${item.key}`)
}, (item: FbTaskCardRow2x4, idx: number) => `${this.version}_t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
/* ⚠️ 与观影 2×4 同一套:layoutWeight 才有视口、Auto 才只在能滚时出条,
且**不要**加 justifyContent(Center)(内容超高时首行会被推出去)。 */
.scrollBar(BarState.Auto)
.cachedCount(4)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.layoutWeight(1)
@@ -211,7 +316,8 @@ struct FbTaskCard2x4 {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
.onClick((): void => {
this.openApp();
})
}
}
}
+194 -121
View File
@@ -1,28 +1,58 @@
/**
* 家书 FamilyBook · 4×4 服务卡片(任务 · 上概述/新建,下滚动列表 + 卡上完成)
* 岁月家 FamilyBook · 4×4 任务服务卡片
*
* 用户 2026-10-25:「任务卡片,也需要4x4和4x6,跟任务模块类似,列出所有任务,
* 可以在卡片直接点击完成。有新建按钮。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「新建任务」);
* 下半:`List` 装全部待办(数据侧排序:逾期 > 进行中 > 普通),
* 每行右侧一颗「✓ 完成」→ `postCardAction({action:'message', taskDone:id})`
* → `FbFormAbility.markTaskDone()` 调接口 → 重算重推。
* 🔴 2026-11-04 重做。用户原话:
* 「任务的四个卡片,样式格式,完全按照同步日历来完成,重做。」
*
* ⚠️ 卡片不能自己发网络请求,完成动作必须借道 FormExtensionAbility。
* ⚠️ ForEach key 带 version 前缀(v89 教训)。
* 完全照搬同步日历的 `TodoWidget4x4`(**上下结构**,与 6×4 的左右分栏不同):
*
* ┌──────────────────────────────────┐
* │ 任务 (12) (+) │ ← 标题 + 数字徽章 + 新建
* │ 11月4日 星期三 · 今日到期 3 项 │ ← 副行(橙/灰)
* ├──────────────────────────────────┤
* │ ○ 交水电费 │ ← 圆形勾选圈 + 标题
* │ 18:00 · 家务 │ 副行:截止 · 看板
* │ ○ 备课 │
* │ 明天 · 工作 │
* └──────────────────────────────────┘
*
* ## 与 SyncCalendar `TodoWidget4x4` 的逐项对应
*
* | 元素 | SyncCalendar | 本卡片 |
* |---|---|---|
* | 结构 | `Column` 上下(标题栏 + 列表) | 同 |
* | 标题栏 | `任务` 16px Bold + **数字徽章** | 同(数字徽章砖红底白字) |
* | 副行 | `dateMd weekday · 今日到期 N 项` | 同 |
* | 右上 | `+` 圆形按钮 | 同 |
* | 列表 | `List` 虚拟化,space 4 | 同 |
* | 条目 | 圈 18px + 标题 13 + 副行 10 | 同 |
* | 条目底 | `#F5F7FA` 圆角 10,padding 6/6/8 | 同(配色改暖色 `#F7F2EC`) |
* | 空态 | `✓` 大字 + 说明 + 引导 | 同 |
*
* ## 与 6×4 的区别(照 SyncCalendar)
*
* | | 4×4 | 6×4 |
* |---|---|---|
* | 结构 | **上下**(标题在上) | **左右**(左侧统计栏) |
* | 标题栏 | 带数字徽章,在顶部 | 左侧竖排统计 |
* | 适合 | 竖屏、单列 | 横屏、宽屏 |
*
* ⚠️ 打勾用 `message` 不拉 App;列表用 `List` 不用 `Column + slice`。
* ⚠️ ForEach key 带 `version` 前缀(v89 教训:不带则下次推送不重绘)。
*/
let storageTkB = new LocalStorage();
/** 列表一行 */
class FbTaskRow9a {
class FbTaskCardRow4x4 {
id: number = 0;
key: string = '';
title: string = '';
dueText: string = '';
status: string = '';
boardName: string = '';
boardColor: string = '#A8574F';
overdue: boolean = false;
status: string = '';
}
@Entry(storageTkB)
@@ -30,16 +60,17 @@ class FbTaskRow9a {
struct FbTaskCard4x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
@LocalStorageProp('allJson') allJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbTaskRow9a[] {
private rows(): FbTaskCardRow4x4[] {
try {
const all: FbTaskRow9a[] = JSON.parse(this.allJson) as FbTaskRow9a[];
return all;
return JSON.parse(this.allJson) as FbTaskCardRow4x4[];
} catch (err) {
return [];
}
@@ -53,7 +84,7 @@ struct FbTaskCard4x4 {
});
}
/** 「+ 新建」→ 进 app 直开「新建任务」浮层(EntryAbility → modCreate 通道) */
/** + 新建:拉起 App 直接打开新建任务浮层 */
private addOne(): void {
postCardAction(this, {
action: 'router',
@@ -62,81 +93,160 @@ struct FbTaskCard4x4 {
});
}
/** 卡上直接完成(id 必须转字符串:param 数值按 int32 传) */
private complete(item: FbTaskRow9a): void {
/**
* 点勾选圈 → 交给 FormExtensionAbility 标完成。
* ⭐ message 不拉 App(拉会闪界面),在 FormExtension 窗口内完成改库+重推。
*/
private checkTask(id: number): void {
postCardAction(this, {
action: 'message',
params: { taskDone: `${item.id}` }
params: { taskDone: id.toString() }
});
}
/** 列表单行:色条 + 标题/截止 + ✓ 完成 */
/** 右上角 +(照 SyncCalendar 的圆形按钮) */
@Builder
listRow(item: FbTaskRow9a) {
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => {
this.addOne();
})
}
/** 顶部标题栏:`任务` + 数字徽章 + 副行 + + */
@Builder
headerBar() {
Row({ space: 8 }) {
Column()
.width(3)
.height(34)
.borderRadius(2)
.backgroundColor(item.overdue ? '#C0533F' : item.boardColor)
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
Row({ space: 6 }) {
Text('任务')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
// 数字徽章(照 SyncCalendar:底色 = 主题色、白字、小圆角)
Text(this.openCount.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#A8574F')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.state === 'ok' ? this.subLine() : (this.hint !== '' ? this.hint : '读取中…'))
.fontSize(10)
.fontColor(this.state === 'ok' ? this.subColor() : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
if (item.overdue) {
Text('逾期')
.fontSize(9)
.fontColor('#C0533F')
.maxLines(1)
}
if (item.dueText !== '') {
Text(item.dueText)
.fontSize(9)
.fontColor(item.overdue ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
} else {
Text(item.status === 'doing' ? '进行中' : '待办')
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
/* ⭐ 卡上直接点完成(子组件 onClick 优先于整卡,不会连带进 app) */
Text('✓')
.fontSize(14)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(item.overdue ? '#C0533F' : '#A8574F')
.onClick((): void => this.complete(item))
this.addButton()
}
.width('100%')
}
/** 副行:`11月4日 星期三 · 今日到期 N 项`(照 SyncCalendar 的拼接口径) */
private subLine(): string {
const head: string = this.dateMd + (this.weekday !== '' ? ' ' + this.weekday : '');
if (this.overdueCount > 0) {
return head + ' · 逾期 ' + this.overdueCount.toString() + ' 项';
}
if (this.todayCount > 0) {
return head + ' · 今日到期 ' + this.todayCount.toString() + ' 项';
}
return head + (this.openCount === 0 ? ' · 点击 + 添加' : ' · 今日无到期');
}
private subColor(): string {
if (this.overdueCount > 0) {
return '#C0533F';
}
return this.todayCount > 0 ? '#FF9500' : '#A8988A';
}
/** 条目:圆形勾选圈 + 标题 + 副行 */
@Builder
taskRow(item: FbTaskCardRow4x4) {
Row({ space: 8 }) {
Row() {
Text('')
.width(18)
.height(18)
.borderRadius(9)
.border({ width: 2, color: item.overdue ? '#C0533F' : item.boardColor })
}
.padding({ right: 2 })
.onClick((): void => {
this.checkTask(item.id);
})
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor(item.overdue ? '#C0533F' : '#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.rowSub(item))
.fontSize(10)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC')
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F7F2EC')
}
/** 空态 */
/** 副行:`截止 · 看板`(照 SyncCalendar 的「截止 · 日历本」) */
private rowSub(item: FbTaskCardRow4x4): string {
const parts: string[] = [];
if (item.overdue) {
parts.push('逾期');
}
if (item.dueText !== '') {
parts.push(item.dueText);
}
if (item.boardName !== '') {
parts.push(item.boardName);
}
if (parts.length === 0) {
parts.push(item.status === 'doing' ? '进行中' : '待做');
}
return parts.join(' · ');
}
/** 空态(照 SyncCalendar:✓ 大字 + 说明 + 引导) */
@Builder
emptyHint() {
Column() {
Text(this.hint !== '' ? this.hint : '没有待办任务')
.fontSize(12)
.fontColor(this.hint !== '' ? '#C0533F' : '#A8988A')
.maxLines(3)
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#D6C8BA')
Text(this.hint !== '' ? this.hint
: (this.openCount === 0 ? '所有任务都完成了' : '暂无任务'))
.fontSize(13)
.fontColor('#A8988A')
.maxLines(2)
.textAlign(TextAlign.Center)
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#C7B8A8')
}
.width('100%')
.layoutWeight(1)
@@ -144,66 +254,28 @@ struct FbTaskCard4x4 {
}
build() {
Column({ space: 6 }) {
// ===== 顶栏:标题 + 更新时间 + + 新建 =====
Row({ space: 6 }) {
Text('任务')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => this.addOne())
}
.width('100%')
// ===== 概述行 =====
Row({ space: 8 }) {
Text(`待办 ${this.openCount} 条`)
.fontSize(11)
.fontColor('#A8574F')
.fontWeight(FontWeight.Medium)
Text(this.hint !== '' ? this.hint
: (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条'
: '点 ✓ 直接完成'))
.fontSize(10)
.fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部待办(可滚动,可直接完成)=====
if (this.rows().length === 0) {
this.emptyHint()
this.emptyState()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbTaskRow9a) => {
// List 虚拟化:待办多时滑动流畅
List({ space: 4 }) {
ForEach(this.rows(), (item: FbTaskCardRow4x4, idx: number) => {
ListItem() {
this.listRow(item)
this.taskRow(item)
}
}, (item: FbTaskRow9a): string => `tl_${this.version}_${item.key}`)
}, (item: FbTaskCardRow4x4, idx: number) => `${this.version}_t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
@@ -211,7 +283,8 @@ struct FbTaskCard4x4 {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
.onClick((): void => {
this.openApp();
})
}
}
}
+211 -122
View File
@@ -1,45 +1,77 @@
/**
* 家书 FamilyBook · 6×4 服务卡片(任务 · 上概述/新建,下滚动列表 + 卡上完成)
* 岁月家 FamilyBook · 6×4 任务服务卡片
*
* 用户 2026-10-25:「任务卡片,也需要4x4和4x6,跟任务模块类似,列出所有任务,
* 可以在卡片直接点击完成。有新建按钮。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「新建任务」);
* 下半:`List` 装全部待办(数据侧排序:逾期 > 进行中 > 普通),
* 每行右侧一颗「✓ 完成」→ `postCardAction({action:'message', taskDone:id})`
* → `FbFormAbility.markTaskDone()` 调接口 → 重算重推。
* 🔴 2026-11-04 修正。用户指出:
* 「你确定4×6的样式是对的吗?怎么左侧有一个待办未完成的显示呢?
* 它不应该跟4×4的一样吗?只不过是拉长了而已。现在这两个明显不一样。」
*
* ⚠️ 卡片不能自己发网络请求,完成动作必须借道 FormExtensionAbility。
* ⚠️ ForEach key 带 version 前缀(v89 教训)。
* **用户说得对,我第一版做错了。**
*
* ## 错在哪
*
* 我读 SyncCalendar 时把 6×4 看成了"左右分栏 + 左侧竖排统计"。
* 实际读它的 `build()`:
*
* build() {
* Column({ space: 8 }) { ← Column!上下结构
* this.headerBar()
* Divider()
* List({ space: 4 }) { ... } ← 列表在下方,占满剩余
* }
* }
*
* **6×4 与 4×4 的结构完全一样**(都是 `Column` 上下),
* 唯一区别是头部排版细节:6×4 把副行(日期·今日到期)和标题放同一行、
* 用 `layoutWeight(1)` 撑开,因为 6 格宽有横向余量。
* 高度多 2 格 → 列表能多看到几条。**没有左侧统计栏**。
*
* ## 与 4×4 的真实差异(照 SyncCalendar)
*
* | | 4×4 | 6×4 |
* |---|---|---|
* | 结构 | `Column` 上下 | **同**(`Column` 上下) |
* | 头部 | 标题行 + 副行上下排在一个 Column 里 | 副行与标题**同一行**、layoutWeight 撑开 |
* | 高度 | 4 格 | 6 格 → 列表可见条数更多 |
* | 空态 | 有 | 同 |
*
* ⇒ 本文件就是 4×4 的「拉长版」,不是左右分栏。
*
* ⚠️ 打勾用 `message` 不拉 App;列表用 `List` 不用 `Column + slice`。
* ⚠️ ForEach key 带 `version` 前缀(v89 教训:不带则下次推送不重绘)。
* ⚠️ + 用 `widgetAction: 'add'`(EntryAbility 认这个参数,见其 159 行);
* 用 `modCreate` 无效 —— 那是第一版写错的键名,表现是"只进任务页、不弹新建"。
*/
let storageTkB2 = new LocalStorage();
let storageTkC = new LocalStorage();
/** 列表一行 */
class FbTaskRow9b {
class FbTaskCardRow6x4 {
id: number = 0;
key: string = '';
title: string = '';
dueText: string = '';
status: string = '';
boardName: string = '';
boardColor: string = '#A8574F';
overdue: boolean = false;
status: string = '';
}
@Entry(storageTkB2)
@Entry(storageTkC)
@Component
struct FbTaskCard6x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
@LocalStorageProp('allJson') allJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbTaskRow9b[] {
private rows(): FbTaskCardRow6x4[] {
try {
const all: FbTaskRow9b[] = JSON.parse(this.allJson) as FbTaskRow9b[];
return all;
return JSON.parse(this.allJson) as FbTaskCardRow6x4[];
} catch (err) {
return [];
}
@@ -53,7 +85,14 @@ struct FbTaskCard6x4 {
});
}
/** 「+ 新建」→ 进 app 直开「新建任务」浮层(EntryAbility → modCreate 通道) */
/**
* + 新建:进 App 并**直接弹出新建任务**。
*
* 🔴 参数名必须是 `widgetAction: 'add'` —— 这是 `EntryAbility` 唯一认的键
* (见 `EntryAbility.ets` 第 159 行 `p['widgetAction']`,
* 以及第 207 行 `if (wantCreate && (key === 'movies' || key === 'tasks'))`)。
* 第一版写的是 `modCreate: 'tasks'`,那个键没人读 ⇒ 只切到任务页、不弹新建。
*/
private addOne(): void {
postCardAction(this, {
action: 'router',
@@ -62,81 +101,166 @@ struct FbTaskCard6x4 {
});
}
/** 卡上直接完成(id 必须转字符串:param 数值按 int32 传) */
private complete(item: FbTaskRow9b): void {
/**
* 点勾选圈 → 交给 FormExtensionAbility 标完成。
* ⭐ message 不拉 App(拉会闪界面),在 FormExtension 窗口内完成改库+重推。
*/
private checkTask(id: number): void {
postCardAction(this, {
action: 'message',
params: { taskDone: `${item.id}` }
params: { taskDone: id.toString() }
});
}
/** 列表单行:色条 + 标题/截止 + ✓ 完成 */
/** + 按钮(照 SyncCalendar 的圆形按钮) */
@Builder
listRow(item: FbTaskRow9b) {
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => {
this.addOne();
})
}
/**
* 顶部标题栏。
*
* 与 4×4 的差异:6×4 把副行(日期 · 今日到期)与标题放**同一行**、
* 用 `layoutWeight(1)` 撑开(6 格宽有横向余量);4×4 是上下两行。
* 这是 SyncCalendar 两个文件的真实差别(diff 可见),
* 但**骨架都是 Column**,不是左右分栏。
*/
@Builder
headerBar() {
Row({ space: 6 }) {
Row({ space: 6 }) {
Text('任务')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
// 数字徽章(底色 = 主题色、白字、小圆角)
Text(this.openCount.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#A8574F')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.state === 'ok' ? this.subLine() : (this.hint !== '' ? this.hint : '读取中…'))
.fontSize(10)
.fontColor(this.state === 'ok' ? this.subColor() : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
this.addButton()
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/** 副行:`11月4日 星期三 · 今日到期 N 项`(照 SyncCalendar 的拼接口径) */
private subLine(): string {
const head: string = this.dateMd + (this.weekday !== '' ? ' ' + this.weekday : '');
if (this.overdueCount > 0) {
return head + ' · 逾期 ' + this.overdueCount.toString() + ' 项';
}
if (this.todayCount > 0) {
return head + ' · 今日到期 ' + this.todayCount.toString() + ' 项';
}
return head + (this.openCount === 0 ? ' · 点击 + 添加' : ' · 今日无到期');
}
private subColor(): string {
if (this.overdueCount > 0) {
return '#C0533F';
}
return this.todayCount > 0 ? '#FF9500' : '#A8988A';
}
/** 条目:圆形勾选圈(20px,比 4×4 的 18px 略大)+ 标题 + 副行 */
@Builder
taskRow(item: FbTaskCardRow6x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(34)
.borderRadius(2)
.backgroundColor(item.overdue ? '#C0533F' : item.boardColor)
Row() {
Text('')
.width(20)
.height(20)
.borderRadius(10)
.border({ width: 2, color: item.overdue ? '#C0533F' : item.boardColor })
}
.padding({ right: 2 })
.onClick((): void => {
this.checkTask(item.id);
})
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.fontColor(item.overdue ? '#C0533F' : '#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.rowSub(item))
.fontSize(10)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
if (item.overdue) {
Text('逾期')
.fontSize(9)
.fontColor('#C0533F')
.maxLines(1)
}
if (item.dueText !== '') {
Text(item.dueText)
.fontSize(9)
.fontColor(item.overdue ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
} else {
Text(item.status === 'doing' ? '进行中' : '待办')
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
/* ⭐ 卡上直接点完成(子组件 onClick 优先于整卡,不会连带进 app) */
Text('✓')
.fontSize(14)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(item.overdue ? '#C0533F' : '#A8574F')
.onClick((): void => this.complete(item))
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC')
.padding({ left: 6, right: 8, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F7F2EC')
}
/** 空态 */
/** 副行:`截止 · 看板`(照 SyncCalendar 的「截止 · 日历本」) */
private rowSub(item: FbTaskCardRow6x4): string {
const parts: string[] = [];
if (item.overdue) {
parts.push('逾期');
}
if (item.dueText !== '') {
parts.push(item.dueText);
}
if (item.boardName !== '') {
parts.push(item.boardName);
}
if (parts.length === 0) {
parts.push(item.status === 'doing' ? '进行中' : '待做');
}
return parts.join(' · ');
}
/** 空态(照 SyncCalendar:✓ 大字 + 说明 + 引导) */
@Builder
emptyHint() {
Column() {
Text(this.hint !== '' ? this.hint : '没有待办任务')
.fontSize(12)
.fontColor(this.hint !== '' ? '#C0533F' : '#A8988A')
.maxLines(3)
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#D6C8BA')
Text(this.hint !== '' ? this.hint
: (this.openCount === 0 ? '所有任务都完成了' : '暂无任务'))
.fontSize(13)
.fontColor('#A8988A')
.maxLines(2)
.textAlign(TextAlign.Center)
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#C7B8A8')
}
.width('100%')
.layoutWeight(1)
@@ -144,66 +268,30 @@ struct FbTaskCard6x4 {
}
build() {
Column({ space: 6 }) {
// ===== 顶栏:标题 + 更新时间 + + 新建 =====
Row({ space: 6 }) {
Text('任务')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => this.addOne())
}
.width('100%')
// ===== 概述行 =====
Row({ space: 8 }) {
Text(`待办 ${this.openCount} 条`)
.fontSize(11)
.fontColor('#A8574F')
.fontWeight(FontWeight.Medium)
Text(this.hint !== '' ? this.hint
: (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条'
: '点 ✓ 直接完成'))
.fontSize(10)
.fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
/* ⚠️ Column 上下结构 —— 与 4×4 完全一致,只是高度多 2 格。
第一版误做成 Row 左右分栏(左侧竖排统计),已按用户指正改回。 */
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部待办(可滚动,可直接完成)=====
if (this.rows().length === 0) {
this.emptyHint()
this.emptyState()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbTaskRow9b) => {
// List 虚拟化:6×4 高、能放更多条,必须可滚
List({ space: 4 }) {
ForEach(this.rows(), (item: FbTaskCardRow6x4, idx: number) => {
ListItem() {
this.listRow(item)
this.taskRow(item)
}
}, (item: FbTaskRow9b): string => `tl_${this.version}_${item.key}`)
}, (item: FbTaskCardRow6x4, idx: number) => `${this.version}_t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
@@ -211,7 +299,8 @@ struct FbTaskCard6x4 {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
.onClick((): void => {
this.openApp();
})
}
}
}
+14 -4
View File
@@ -71,6 +71,7 @@ import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FB_BACK } from '../../common/FbBack';
import { fbOpenNavigation } from '../../common/FbNav';
import { FbDiag } from '../../common/FbDiag';
import { fbModuleOf, FbModuleDef, fbThemeVars } from '../../model/Modules';
/** 日志域(与 `FbBackgroundSync` / `FbNav` 同一个值,端上统一 0x0000) */
@@ -208,6 +209,8 @@ export struct FbHomePage {
@Local tskEditBoardId: number = 0;
@Local tskEditCatId: number = 0;
@Local tskEditRepeat: string = '';
/** 编辑时的原状态(todo/doing/done) */
@Local tskEditStatus: string = 'todo';
/**
* 面板的"重画脉冲"(v79)。
*
@@ -284,6 +287,9 @@ export struct FbHomePage {
}
aboutToAppear(): void {
/* 诊断设施在这里初始化:地址联想发生在编辑弹窗里,
那时用户可能还没点过导航 ⇒ 不能等导航时才 init。 */
FbDiag.init(this.getUIContext().getHostContext());
// 显示方式来自日程设置(启动时已预载进内存,可同步读)
this.calStyle = FB_CAL_SETTINGS.getStyle();
/* 🔴 v73:**首页也要登记返回手势**。
@@ -486,6 +492,7 @@ export struct FbHomePage {
/* ⚠️ `fbOpenNavigation` 现在是 async(内部要 await 地理编码),
不接住 rejection 会报未处理异常。这里明确咽掉并提示 ——
导航失败不该把页面弄崩。 */
FbDiag.init(ctx);
fbOpenNavigation(ctx, place, title).catch((err: Error): void => {
hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err));
});
@@ -554,6 +561,7 @@ export struct FbHomePage {
this.tskEditBoardId = d.base.boardId;
this.tskEditCatId = d.categoryId;
this.tskEditRepeat = d.repeat;
this.tskEditStatus = d.base.status !== '' ? d.base.status : 'todo';
this.tskEditOpen = true;
}
@@ -582,6 +590,7 @@ export struct FbHomePage {
this.tskEditBoardId = 0;
this.tskEditCatId = 0;
this.tskEditRepeat = '';
this.tskEditStatus = 'todo';
await this.loadTaskMeta();
this.tskEditOpen = true;
}
@@ -1123,7 +1132,7 @@ export struct FbHomePage {
Row() {
FbStatCell({ value: this.statToday, label: '今天日程' })
FbStatCell({ value: this.statDoing, label: '在做' })
FbStatCell({ value: this.statTodo, label: '待办' })
FbStatCell({ value: this.statTodo, label: '任务' })
FbStatCell({ value: this.statOverdue, label: '逾期', alarm: true })
}
.width('100%')
@@ -1198,7 +1207,7 @@ export struct FbHomePage {
Column({ space: 0 }) {
if (this.tasks.length === 0) {
Row() {
Text('没有进行中的任务,也没有待办。')
Text('没有进行中的任务,也没有待做的任务。')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
@@ -1628,6 +1637,7 @@ export struct FbHomePage {
editingBoardId: this.tskEditBoardId,
editingCatId: this.tskEditCatId,
editingRepeat: this.tskEditRepeat,
editingStatus: this.tskEditStatus,
onClose: (): void => {
this.tskEditOpen = false;
},
@@ -1783,7 +1793,7 @@ export struct FbHomePage {
}
}
if (todo > 0) {
return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待办';
return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待做';
}
return doing.toString() + ' 件在做';
}
@@ -1825,7 +1835,7 @@ export struct FbHomePage {
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 状态词(待办 / 进行中)—— 用户第 3 条要求
Text(t.status === 'doing' ? '进行中' : '待办')
Text(t.status === 'doing' ? '进行中' : '待做')
.fontSize(10)
.fontColor(t.status === 'doing' ? '#A8574F' : FbColors.faint)
.backgroundColor(t.status === 'doing' ? '#14A8574F' : FbColors.inputBg)
@@ -66,6 +66,7 @@ export struct FbSettingsPage {
@Local bgSyncOn: boolean = true;
/** 上次同步时间的人话('' = 还没读过 / 从没同步过) */
@Local bgLastNote: string = '';
/** 设置页的主题色(与模块「个人设置」一致,取砖红) */
private accent: string = '#A84A28';
@@ -81,6 +81,8 @@ export struct FbTasksPage {
/** v79:分类 / 循环(编辑时回填;0 / '' = 不分类、不循环) */
@Local editingCatId: number = 0;
@Local editingRepeat: string = '';
/** 编辑时的原状态(todo/doing/done)—— 表单里要回填,改过才额外打 /status */
@Local editingStatus: string = 'todo';
/** 正在改状态的那条(防连点打重复请求) */
@Local busyId: number = 0;
/** 「分类与看板」浮层是否打开 */
@@ -241,6 +243,7 @@ export struct FbTasksPage {
this.editingBoardId = 0;
this.editingCatId = 0;
this.editingRepeat = '';
this.editingStatus = 'todo';
this.opNote = '';
this.editOpen = true;
}
@@ -279,6 +282,7 @@ export struct FbTasksPage {
this.editingBoardId = d.base.boardId;
this.editingCatId = d.categoryId;
this.editingRepeat = d.repeat;
this.editingStatus = d.base.status !== '' ? d.base.status : 'todo';
this.opNote = '';
this.editOpen = true;
}
@@ -370,7 +374,7 @@ export struct FbTasksPage {
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.todoCount().toString() + ' 项待办'
Text(this.todoCount().toString() + ' 项任务'
+ (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint)
@@ -573,6 +577,7 @@ export struct FbTasksPage {
editingBoardId: this.editingBoardId,
editingCatId: this.editingCatId,
editingRepeat: this.editingRepeat,
editingStatus: this.editingStatus,
onClose: (): void => {
this.editOpen = false;
},
@@ -1,3 +1,5 @@
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
/**
* 家书 FamilyBook · 旅程详情页(鸿蒙端)
*
@@ -46,8 +48,10 @@ import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
import { fbImageSrc } from '../../data/FbAttachQueue';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import {
deleteTrip, deleteTripNode, fetchTripDetail, fetchTripNodes, fetchTripSummary
deleteTrip, deleteTripNode, fetchTripDetail, fetchTripNodes, fetchTripSummary,
nodeAction
} from '../../data/FbTripRepo';
import { FbApiResult } from '../../data/FbHttp';
import {
FbTripDetail, FbTripItem, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary
} from '../../model/Types';
@@ -80,6 +84,108 @@ function fbNodeTypeIcon(key: string): string {
return '📌';
}
/**
* 行程卡左侧的图标:**出行方式优先,节点类型兜底**。
*
* 🔴 2026-11-05(用户:「明明选的是坐火车,图标显示的还是一辆汽车…
* 鸿蒙版的图标显示的是一个像订书钉似的什么图标,这都不对」)。
*
* 原来只按 `nodeType` 取 ⇒ 「赶路」永远显示 🚗,哪怕实际坐的是火车;
* 自定义中文类型的还会走到兜底 📌(用户形容"像订书钉")。
*
* 现在:
* ① 有 `travel_mode` → 用 `fbModeIcon()`(火车 🚄 / 飞机 ✈️ / 轮船 🚢…)
* ② 没有 → 退回 `fbNodeTypeIcon()`(游玩 🎡 / 用餐 🍜 / 住宿 🏨)
*
* ⚠️ 网页端必须同步改(`Trip::leadIcon()`),否则两边图标不一致。
*/
/**
* 打开系统地图导航(#51)。
*
* 用 `geo:` URI —— 系统会弹出已安装的地图应用让用户选(高德/百度/花瓣…),
* 不需要在端上集成任何地图 SDK,也不用申请权限。
*
* ⚠️ 格式是 `geo:lat,lng?q=lat,lng(名称)`:三个都要给,
* 少了 `q` 参数部分地图应用会只标点、不起导航。
* ⚠️ 名称要做 URL 编码:中文地名直接拼进去会让 URI 解析失败。
*/
/**
* 打开地图导航。
*
* 🔴 #52(用户):「目的地,比如北京站、北京北、北京南站、青岛北站什么的,
* 点开之后为什么没有打开高德呢?它应该使用高德直接打开才行啊。
* **直接打开高德进行导航**,比如青岛北站或者北京南站,打开后直接进入高德导航就可以了。」
*
* ## 为什么原来打不开
*
* 只用 `action: 'ohos.want.action.viewData'` + `geo:` URI。
* `viewData` 靠 MIME/scheme 匹配应用,而 `geo:` 在鸿蒙上**不是所有机型都有
* 注册处理者** ⇒ `startAbility` 抛异常 ⇒ 被 catch 吞掉 ⇒ 用户看到"没反应"。
*
* ## 三级兜底
*
* ① **高德 deeplink**(`amapuri://route/plan/…`)—— 直接进导航规划页
* ② `geo:` 通用协议
* ③ 高德网页版(任何浏览器都能开)
*
* ⚠️ 必须分三级:不同机型、不同高德版本注册的 scheme 不一样,
* 只试一种必然在部分设备上失败。
* ⚠️ 每一级都要 `await`,因为 `startAbility` 是异步的 ——
* 不 await 就判断不了成功与否,会连试三次弹三个框。
*/
async function fbOpenMap(lat: string, lng: string, name: string): Promise<void> {
const ctx = getContext() as common.UIAbilityContext;
const nm: string = name !== '' ? name : '目的地';
/* ① 高德地图 deeplink:直接进「路线规划」页,dev=0 表示起终点用经纬度 */
const amapUri: string = 'amapuri://route/plan/?dlat=' + lat + '&dlon=' + lng
+ '&dname=' + encodeURIComponent(nm) + '&dev=0&t=0';
if (await fbTryWant(ctx, amapUri)) {
return;
}
/* ② 通用 geo 协议(系统会让用户选地图应用) */
const geoUri: string = 'geo:' + lat + ',' + lng
+ '?q=' + lat + ',' + lng + '(' + encodeURIComponent(nm) + ')';
if (await fbTryWant(ctx, geoUri)) {
return;
}
/* ③ 最后兜底:高德网页版(uri 走 https,浏览器一定能开) */
const web: string = 'https://uri.amap.com/marker?position=' + lng + ',' + lat
+ '&name=' + encodeURIComponent(nm) + '&callnative=1';
if (!(await fbTryWant(ctx, web))) {
hilog.warn(0x0000, 'FbTripDetail', '三种方式都没打开地图');
}
}
/**
* 尝试用系统能力打开一个 uri。
* @return 是否成功(true = 有应用接住了)
*/
async function fbTryWant(ctx: common.UIAbilityContext, uri: string): Promise<boolean> {
try {
await ctx.startAbility({
action: 'ohos.want.action.viewData',
uri: uri
});
return true;
} catch (err) {
hilog.info(0x0000, 'FbTripDetail', '打开失败,试下一种:%{public}s', uri.substring(0, 30));
return false;
}
}
function fbNodeLeadIcon(nodeType: string, mode: string): string {
if (mode !== '') {
const mi: string = fbModeIcon(mode);
if (mi !== '') {
return mi;
}
}
return fbNodeTypeIcon(nodeType);
}
/** 节点类型 → 中文名(老数据的英文 key;自定义名字原样返回) */
function fbNodeTypeWord(key: string): string {
if (key === 'play') {
@@ -282,6 +388,14 @@ export struct FbTripDetailPage {
@Local nodeSheetTripId: number = 0;
/** 加行程浮层的预选阶段('plan' / 'actual') */
@Local nodeSheetStage: string = 'plan';
/**
* 正在编辑的那一段(`null` = 不是编辑,是新增)。
*
* 🔴 2026-11-05 新增。用户:「鸿蒙端现在行程还不能编辑,得尽快把这个功能做完。」
*/
@Local editNode: FbTripNode | null = null;
/** 状态切换请求进行中(防连点:上一请求没回来就再点会乱序) */
@Local statusBusy: boolean = false;
/** 三项设置浮层 */
@Local vehiclesOpen: boolean = false;
@Local travelModesOpen: boolean = false;
@@ -603,10 +717,15 @@ export struct FbTripDetailPage {
tripId: this.nodeSheetTripId,
accent: this.accent,
startStage: this.nodeSheetStage,
/* ⚠️ 必须传:`null` = 新增,有值 = 编辑那一段。
浮层内部据此切换标题、回填、保存走 PATCH 还是 POST。 */
editing: this.editNode,
onClose: (): void => {
this.nodeSheetTripId = 0;
this.editNode = null; // ⚠️ 不清会下次误进编辑模式
},
onSaved: (): void => {
this.editNode = null;
this.onNodeSaved();
}
})
@@ -1004,8 +1123,13 @@ export struct FbTripDetailPage {
// ⚠️ 同上:不写左右 margin(外层已有 pagePad padding),只留纵向间距
.margin({ top: FbMetric.cardGap })
} else {
this.stageGroup('plan', '计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行')
/* 🔴 #48(2026-11-05):**实际行程放最前**。
用户:「到最后整个旅程结束之后,我们最终看到的是实际行程。」
⇒ actual → plan → skipped(未成行走到底部)。
⚠️ 这与本文件上方旧注释(照网页版 plan 在前)相反 —— 那是 #48 之前的
口径。#48 用户明确要求实际行程在前,两端同步改。 */
this.stageGroup('actual', '实际行程', '已走过 —— 也出现在日历与首页「往年今日」')
this.stageGroup('plan', '计划的行程', '还没走 —— 可以开始 / 完成 / 取消')
this.stageGroup('skipped', '未成行', '这次没去成;不上日程,但在这里留着可查')
}
}
@@ -1070,7 +1194,7 @@ export struct FbTripDetailPage {
Row({ space: 8 }) {
// ---- 左侧:类型图标 + 时间轴圆点 ----
Column({ space: 6 }) {
Text(fbNodeTypeIcon(n.nodeType))
Text(fbNodeLeadIcon(n.nodeType, n.mode))
.fontSize(16)
FbDot({
dia: 7,
@@ -1083,7 +1207,10 @@ export struct FbTripDetailPage {
// ---- 右侧:内容 ----
Column({ space: 5 }) {
// ① 标题行
/* ① 标题行(右侧是「编辑」「删除」)
🔴 2026-11-05(用户:「编辑和删除要和网页版一样,放在这个行程的右上角」):
原来这两个动作在**卡片底部**另起一行,用户要的是标题行右上角
—— 与网页端 `.node-head` 的布局一致。 */
Row({ space: 6 }) {
Text(n.title)
.fontSize(FbMetric.fontBody)
@@ -1092,8 +1219,30 @@ export struct FbTripDetailPage {
.layoutWeight(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
/* 🔴 #47(用户:「现在就是两个字放在那儿,字号小,也不特别醒目。
应该像网页端一样,做成两个按钮,颜色也尽可能保持一致。」)
⇒ 改成**胶囊按钮**:有底色、圆角、内边距,与网页端 `.btn-ghost` 同观感。 */
this.pillBtn('编辑', '#B8862B', false, (): void => {
this.editNode = n;
this.nodeSheetTripId = this.tripId;
this.nodeSheetStage = n.stage;
})
/* 🔴 #48:**「取消」在「删除」前面**(用户明确要求)。
取消 = 不去了,数据保留(stage=skipped,列在「未成行」)
删除 = 彻底不要了(软删)
⇒ 只有**计划行程**能取消;实际行程已经发生,谈不上"取消"。 */
if (n.stage !== 'actual' && n.stage !== 'skipped') {
this.pillBtn('取消', '#B8862B', false, (): void => {
this.doNodeAction(n, 'cancel');
})
}
this.pillBtn('删除', '#C0533F', false, (): void => {
this.delNodeId = n.id;
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ② 徽章行:类型 / 修正次数 / 交通方式
Row({ space: 6 }) {
@@ -1172,19 +1321,40 @@ export struct FbTripDetailPage {
允许整段铺开。地址长就把卡片撑高 —— 详情页本来就是往下滚的瀑布,
高度不是稀缺资源,"看不全"才是。
⚠️ 两行都空才整块不显示(只填了一头也照样显示,不要因为缺一头就全藏掉)。 */
if (n.fromPlace !== '' || n.toPlace !== '') {
Column({ space: 4 }) {
/* ⑤ 出发地 → 到达地
🔴 2026-11-05 重做(用户:「我建议直接青岛北站,然后用个那种标签,
然后右边一个箭头,然后到北京南站就得了,不用写从哪到哪」)。
原来:上下两行「从 XXX」「到 XXX」—— 占两行、且"从/到"单独占一列,
地址长短不一时看着就"没对齐"。
现在:**一行**「[青岛北站] → [北京南站]」,两端各一个胶囊标签,
箭头只作分隔,整体垂直居中 ⇒ 视觉上一行到底、永远对齐。
⚠️ `fromPlace`/`toPlace` 都空时**退回 `place`**:
老数据(TripLog 迁移)只有 `place`,没有拆分字段。
不兜底的话用户会看到"什么都没有"(他报的"鸿蒙端不显示"就是这个)。
有了兜底至少能显示一个地名。 */
if (n.fromPlace !== '' || n.toPlace !== '' || n.place !== '') {
Row({ space: 6 }) {
if (n.fromPlace !== '') {
this.placeLine('从', n.fromPlace)
this.routePill(n.fromPlace, n.fromAddr, n.fromLat, n.fromLng, true)
} else if (n.place !== '' && n.toPlace === '') {
/* 只有 place(老数据):单标签,不画箭头 */
this.routePill(n.place, '', '', '', false)
}
if (n.fromPlace !== '' && n.toPlace !== '') {
Text('→')
.fontSize(12)
.fontColor(FbColors.faint)
}
if (n.toPlace !== '') {
this.placeLine('到', n.toPlace)
this.routePill(n.toPlace, n.toAddr, n.toLat, n.toLng, false)
}
}
.width('100%')
.padding({ top: 1, bottom: 1 })
.alignItems(VerticalAlign.Center)
.padding({ top: 2, bottom: 2 })
}
// ⑥ 详细说明
if (n.detail !== '') {
Text(n.detail)
@@ -1286,30 +1456,16 @@ export struct FbTripDetailPage {
.width('100%')
}
// ⑩ 自己的两个动作
Row({ space: 8 }) {
Column().layoutWeight(1)
Text('编辑')
.fontSize(FbMetric.fontTiny)
.fontColor('#B8862B')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick((): void => {
/* ⚠️ 端上暂时只能"加一段",改一段要 `PATCH /trips/<id>/nodes/<nid>`
—— 服务端 `Api::dispatch()` 只解析三段(`/res/id/sub`),
第 4 段会被丢掉,所以这条路由现在**不存在**。
与其放一颗点了没反应的按钮(本项目红线),不如明说。 */
this.loadNote = '端上还不能改单段行程,请到网页版修改。';
})
Text('删除')
.fontSize(FbMetric.fontTiny)
.fontColor('#C0533F')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick((): void => {
this.delNodeId = n.id;
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
/* ⑪ 状态动作(#47)
🔴 用户:「在计划行程里面点击开始执行,它的状态自然变成进行中。
如果这个事情已经做完了,点了已完成,它自然就变成已完成的状态。
如果它没去,取消了,那就自然变成已取消。」
⚠️ 这里显示的是**动作**,不是"当前状态"文字。
当前状态靠 chip 的**实心底**表达 —— 用户点名要的那种效果
(「选中其中一个按钮之后…加了一个椭圆的那种框那种背景」)。
⚠️ 按当前 status 只显示有意义的动作(已完成的不再显示"开始执行")。 */
this.nodeActions(n)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
@@ -1335,6 +1491,188 @@ export struct FbTripDetailPage {
* ⇒ 不写 `maxLines` + 不写 `Ellipsis`:`Text` 默认整段换行,卡片跟着变高。
* ⚠️ 标签用固定宽度(16vp):两行的地址左边缘才能对齐,看起来像一张表。
*/
@Builder
/**
* 路线胶囊标签(出发地 / 到达地)。
*
* 🔴 2026-11-05。用户要的是「[青岛北站] → [北京南站]」这种标签样式,
* 不要"从/到"这种字。
*
* ⚠️ 两个标签用**同一个** builder:样式一致才对齐
* (用户抱怨的"位都没对齐",本质是两侧元素样式不同)。
* ⚠️ 长地名 `maxLines(1)` + 省略号 + `maxWidth`:不撑破卡片。
*/
/**
* 一行状态动作按钮(#47)。
*
* 按当前 `status` 决定显示哪些动作 —— 只给"有意义"的选择:
* planned → 计划中(实心) / 开始执行 / 已完成 / 已取消
* ongoing → 进行中(实心) / 已完成 / 已取消 / 退回计划
* done → 已完成(实心) / 退回进行中
* cancelled → 已取消(实心) / 重新开始
*
* ⚠️ 当前状态用**实心底**的 chip 表示,这正是用户要的效果:
* 「选中其中一个按钮之后…除了图标和文字颜色变化,
* 还加了一个椭圆的那种框那种背景」。
*/
/**
* 行程动作按钮组(#48)。
*
* 🔴 用户原话:
* 「计划行程右上角就不是两个按钮了,而是至少四个按钮:
* 一个是开始,一个是完成,一个是编辑,一个是删除。
* 在删除前面还应该有一个按钮,这个按钮是取消,
* 指的就是不去了,但这个事情我并不把它删掉。」
* 「**实际行程是没有状态的**,实际行程就是实际行程,没有所谓的状态。
* 实际行程指的是已经发生的事情,它没有什么状态了,**只有计划行程有状态**。」
*
* ## 规则
*
* | stage | status | 显示 |
* |---|---|---|
* | plan | planned | 计划中(实心) + 开始 |
* | plan | ongoing | 进行中(实心) + 完成 |
* | actual | — | **无状态**(实际行程没状态) |
* | skipped | — | **无状态**(已取消,不再转) |
*
* 编辑/取消/删除在标题行(右上角),这个 builder 只管**状态动作**。
*/
@Builder
nodeActions(n: FbTripNode) {
if (n.stage === 'actual' || n.stage === 'skipped') {
/* ⚠️ 实际行程 / 未成行**没有任何状态** —— 用户明确要求。
这里整块不渲染(不是渲染成空的),避免留下一条空白行。 */
} else {
Row({ space: 6 }) {
this.pillBtn(n.status === 'ongoing' ? '进行中' : '计划中',
n.status === 'ongoing' ? '#2F6FA8' : '#8A8A8A', true, (): void => {
})
if (n.status === 'ongoing') {
this.pillBtn('完成', '#3F7D5A', false, (): void => {
this.doNodeAction(n, 'done');
})
} else {
this.pillBtn('开始', '#2F6FA8', false, (): void => {
this.doNodeAction(n, 'start');
})
this.pillBtn('完成', '#3F7D5A', false, (): void => {
this.doNodeAction(n, 'done');
})
}
}
.width('100%')
.padding({ top: 2, bottom: 2 })
}
}
/**
* 改一段行程的执行状态(#47)。
*
* ⚠️ 成功后**只重拉节点**,不重建整页:重建会丢滚动位置,
* 用户刚点完就跳回顶部很难受。
* ⚠️ 失败用 `loadNote` 提示 —— 静默失败最糟(用户以为改了其实没改)。
*/
/**
* 执行行程动作(#48)。
*
* · `start` → status=ongoing,服务端记录真实开始时间
* · `done` → stage 转 actual(变成实际行程),记录真实结束时间
* · `cancel` → stage=skipped(保留数据,不是删除)
*
* ⚠️ 动作会改变分组(计划→实际/未成行),成功后必须**重拉节点**。
* ⚠️ 失败用 `loadNote` 提示 —— 静默失败最糟。
*/
private async doNodeAction(n: FbTripNode, act: string): Promise<void> {
if (this.statusBusy) {
return; // 防连点
}
this.statusBusy = true;
const res: FbApiResult = await nodeAction(
FB_TOKEN_STORE.get(), this.tripId, n.id, act);
this.statusBusy = false;
if (!res.ok) {
this.loadNote = res.error !== '' ? res.error : '操作没成功,请重试。';
return;
}
this.loadNote = '';
const rows2: FbTripNode[] = await fetchTripNodes(FB_TOKEN_STORE.get(), this.tripId);
this.nodes = rows2;
}
/**
* 通用胶囊按钮。
*
* 🔴 #47(用户:「做成两个按钮,颜色也尽可能保持一致…
* 选中其中一个按钮之后,除了图标和文字颜色变化,还加了一个椭圆的灰色底,
* 这样更能清晰表示现在正在被选中是哪一个」)。
*
* @param label 文案
* @param color 主色(文字与选中底都用它)
* @param selected 选中态 —— 加**灰色椭圆底**(用户明确点名要的效果)
* @param onTap 点击
*/
@Builder
pillBtn(label: string, color: string, selected: boolean, onTap: () => void) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(selected ? '#FFFFFF' : color)
.padding({ left: 9, right: 9, top: 4, bottom: 4 })
.backgroundColor(selected ? color : fbAlpha(color, 0.10))
.borderRadius(FbMetric.radiusPill)
.maxLines(1)
.onClick((): void => {
onTap();
})
}
/**
* 地点标签(出发地 / 到达地)。
*
* 🔴🔴 #51(用户):「不是端上选地址,而是端上应该把服务器上的地址、
* 详细地址拉下来。」
*
* ⇒ 服务端存了三样东西,端上**三样都要用上**:
* · `from_place` 地名 → 主行胶囊(「北京南站」)
* · `from_addr` 详细地址 → 副行小字(「北京市丰台区永定门外大街…」)
* · `from_lng/lat` 坐标 → 让整块可点,调系统地图导航
*
* ⚠️ 详细地址**不省略**:用户 2026-10-22 抱怨过「地址特别长,显示不全」
* ⇒ `maxLines(2)` 且**不写** `textOverflow`,让卡片自然变高。
* ⚠️ 没有详细地址时不显示副行(不要留一行空白)。
*/
@Builder
routePill(place: string, addr: string, lat: string, lng: string, isStart: boolean) {
Column({ space: 2 }) {
Text(place)
.fontSize(12)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '100%' })
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(isStart ? fbAlpha(this.accent, 0.10) : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
/* 🔴 #51(用户):「详细地址,不是显示在行程列表中,而是显示在编辑时。
列表中,仍然是例如青岛北站,北京南站这样的,但是点击这两个标签,
可以打开高德进行导航。所以,这里面应该拿到详细地址,但是不要显示出来,
只有编辑的时候,才显示。」
⇒ 列表里**只显示地名胶囊**,详细地址改到悬停提示(`bindPopup` 太重,
这里用 `Text` 的 `tooltip` 效果不值得),直接不显示。
点胶囊 → 用坐标打开高德导航。 */
}
.alignItems(HorizontalAlign.Start)
.constraintSize({ maxWidth: '46%' })
.onClick((): void => {
/* 有坐标才能导航;没有就什么都不做(不要弹"无法导航"的废话) */
if (lat !== '' && lng !== '') {
fbOpenMap(lat, lng, place).catch((): void => {
});
}
})
}
@Builder
placeLine(tag: string, place: string) {
Row({ space: 6 }) {
+21
View File
@@ -12,6 +12,27 @@
{
"name": "ohos.permission.INTERNET"
},
/* 🔴 2026-11-03:镜像到系统日历需要读写系统日历。
⚠️ 这两个是 user_grant 权限,必须运行时申请,且
**只能在用户点开"镜像到系统日历"开关那一刻申请**
(启动时申请违反合规要求,会被审核拒)。
申请逻辑见 data/FbCalMirror.requestPermission()。 */
{
"name": "ohos.permission.READ_CALENDAR",
"reason": "$string:perm_read_calendar",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:perm_write_calendar",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
},
@@ -20,13 +20,21 @@
"name": "card_desc",
"value": "家庭日程:今天有几件事、都是什么"
},
{
"name": "card_desc_movie",
"value": "最近看了什么、还有哪些想看"
},
{
"name": "card_desc_task",
"value": "还有哪些事情要做、哪些快到期了"
},
{
"name": "card_2x2_name",
"value": "岁月家 · 今天 2×2"
"value": "岁月家 · 日程 2×2"
},
{
"name": "card_2x4_name",
"value": "岁月家 · 今日日程 2×4"
"value": "岁月家 · 日程 2×4"
},
{
"name": "card_4x4_name",
@@ -46,11 +54,11 @@
},
{
"name": "card_task_2x2_name",
"value": "岁月家 · 待办 2×2"
"value": "岁月家 · 任务 2×2"
},
{
"name": "card_task_2x4_name",
"value": "岁月家 · 待办 2×4"
"value": "岁月家 · 任务 2×4"
},
{
"name": "card_movie_4x4_name",
@@ -62,11 +70,19 @@
},
{
"name": "card_task_4x4_name",
"value": "岁月家 · 待办 4×4"
"value": "岁月家 · 任务 4×4"
},
{
"name": "card_task_6x4_name",
"value": "岁月家 · 待办 6×4"
"value": "岁月家 · 任务 6×4"
},
{
"name": "perm_read_calendar",
"value": "把岁月家的日程显示在系统日历中"
},
{
"name": "perm_write_calendar",
"value": "把岁月家的日程写入系统日历,并同步你对系统日历的改动"
}
]
}
@@ -71,7 +71,7 @@
{
"name": "FbMovieCard2x2",
"displayName": "$string:card_movie_2x2_name",
"description": "$string:card_desc",
"description": "$string:card_desc_movie",
"src": "./ets/pages/card/FbMovieCard2x2.ets",
"window": {
"designWidth": 720,
@@ -88,7 +88,7 @@
{
"name": "FbMovieCard2x4",
"displayName": "$string:card_movie_2x4_name",
"description": "$string:card_desc",
"description": "$string:card_desc_movie",
"src": "./ets/pages/card/FbMovieCard2x4.ets",
"window": {
"designWidth": 720,
@@ -105,7 +105,7 @@
{
"name": "FbTaskCard2x2",
"displayName": "$string:card_task_2x2_name",
"description": "$string:card_desc",
"description": "$string:card_desc_task",
"src": "./ets/pages/card/FbTaskCard2x2.ets",
"window": {
"designWidth": 720,
@@ -122,7 +122,7 @@
{
"name": "FbTaskCard2x4",
"displayName": "$string:card_task_2x4_name",
"description": "$string:card_desc",
"description": "$string:card_desc_task",
"src": "./ets/pages/card/FbTaskCard2x4.ets",
"window": {
"designWidth": 720,
@@ -139,7 +139,7 @@
{
"name": "FbMovieCard4x4",
"displayName": "$string:card_movie_4x4_name",
"description": "$string:card_desc",
"description": "$string:card_desc_movie",
"src": "./ets/pages/card/FbMovieCard4x4.ets",
"window": {
"designWidth": 720,
@@ -156,7 +156,7 @@
{
"name": "FbMovieCard6x4",
"displayName": "$string:card_movie_6x4_name",
"description": "$string:card_desc",
"description": "$string:card_desc_movie",
"src": "./ets/pages/card/FbMovieCard6x4.ets",
"window": {
"designWidth": 720,
@@ -173,7 +173,7 @@
{
"name": "FbTaskCard4x4",
"displayName": "$string:card_task_4x4_name",
"description": "$string:card_desc",
"description": "$string:card_desc_task",
"src": "./ets/pages/card/FbTaskCard4x4.ets",
"window": {
"designWidth": 720,
@@ -190,7 +190,7 @@
{
"name": "FbTaskCard6x4",
"displayName": "$string:card_task_6x4_name",
"description": "$string:card_desc",
"description": "$string:card_desc_task",
"src": "./ets/pages/card/FbTaskCard6x4.ets",
"window": {
"designWidth": 720,