Compare commits
12
Commits
8ae4e11507
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d993dd3d4b | ||
|
|
b7bc4b5403 | ||
|
|
96335728f5 | ||
|
|
a32c049199 | ||
|
|
6ab7bcfb76 | ||
|
|
8e0c08ccfb | ||
|
|
8be539d8a3 | ||
|
|
29898d5dff | ||
|
|
c711cdb7d5 | ||
|
|
2f124eada0 | ||
|
|
942f71bfdf | ||
|
|
24d8117ce7 |
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"bundleName": "synccalendar.yangyq.net",
|
"bundleName": "synccalendar.yangyq.net",
|
||||||
"vendor": "yangyq",
|
"vendor": "yangyq",
|
||||||
"versionCode": 800,
|
"versionCode": 10000,
|
||||||
"versionName": "0.0.8",
|
"versionName": "0.1.0",
|
||||||
"buildVersion": "1",
|
"buildVersion": "1",
|
||||||
"icon": "$media:layered_image",
|
"icon": "$media:layered_image",
|
||||||
"label": "$string:app_name"
|
"label": "$string:app_name"
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export class AppSettings {
|
|||||||
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
|
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
|
||||||
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
|
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
|
||||||
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
|
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
|
||||||
|
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
|
||||||
|
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
|
||||||
|
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
|
||||||
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
|
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
|
||||||
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
|
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
|
||||||
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
|
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
|
||||||
@@ -42,8 +45,13 @@ export class AppSettings {
|
|||||||
|
|
||||||
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
|
// 在线文档网址(自托管,AGC 上架的"隐私政策网址"填 PRIVACY_URL)。
|
||||||
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
|
// ⚠️ 隐私政策与用户协议必须与安装包实际申请的 user_grant 权限保持一致:
|
||||||
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / LOCATION / APPROXIMATELY_LOCATION(均前台),
|
// 应用仅申请 READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(均前台)。
|
||||||
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)与"添加/修改/删除日历活动"(WRITE_CALENDAR)。
|
// 文档中不得出现"在后台获取位置"(LOCATION_IN_BACKGROUND)。
|
||||||
|
// ⚠️ 0.1.0 起已**移除 LOCATION / APPROXIMATELY_LOCATION**:实测本 SDK(API 26)的
|
||||||
|
// `geoLocationManager.getAddressesFromLocationName`(地址→坐标)无 @permission 标注,
|
||||||
|
// 地理编码不需要定位权限;且应用从不调用 getCurrentLocation,本就不读实时位置。
|
||||||
|
// ⇒ 隐私政策/隐私标签里都**不得**再声称"读取设备精确位置或大致位置"。
|
||||||
|
// ⇒ 若将来真要用实时定位,必须同时改 module.json5、本注释、upa.html 与 AGC 隐私标签。
|
||||||
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
|
// 四份文档均在设置页通过内置浏览器(DocViewer)打开,内容在线更新、无需重新发版。
|
||||||
/** 隐私政策网址 */
|
/** 隐私政策网址 */
|
||||||
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
|
static readonly PRIVACY_URL: string = 'https://synccalendar.yangyq.net/upa.html';
|
||||||
@@ -350,6 +358,17 @@ export class AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ⭐ 诊断用:卡片驱动同步的时间预算(毫秒)。0 = 未设置(用内置默认值)。 */
|
||||||
|
static async getCardBudgetMs(context: common.Context): Promise<number> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences =
|
||||||
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
|
return await store.get(AppSettings.KEY_CARD_BUDGET_MS, 0) as number;
|
||||||
|
} catch (err) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
||||||
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
||||||
@@ -634,15 +653,23 @@ export class AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自动同步间隔(分钟),默认 1 分钟 */
|
/**
|
||||||
|
* 自动同步间隔(分钟),默认 15 分钟。
|
||||||
|
*
|
||||||
|
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
|
||||||
|
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
|
||||||
|
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
|
||||||
|
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
|
||||||
|
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
|
||||||
|
*/
|
||||||
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
|
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AppSettings.STORE);
|
await preferences.getPreferences(context, AppSettings.STORE);
|
||||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
|
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
|
||||||
return v > 0 ? v : 1;
|
return v > 0 ? v : 15;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return 1;
|
return 15;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import process from '@ohos.process';
|
||||||
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
|
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
|
||||||
import { SyncEngine } from './SyncEngine';
|
import { SyncEngine } from './SyncEngine';
|
||||||
import { CardDataService } from './CardDataService';
|
import { CardDataService } from './CardDataService';
|
||||||
@@ -57,6 +58,15 @@ export class BackgroundSyncRunner {
|
|||||||
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
|
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
|
||||||
private static cardTransientId: number = 0;
|
private static cardTransientId: number = 0;
|
||||||
|
|
||||||
|
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
|
||||||
|
private static pidOf(): number {
|
||||||
|
try {
|
||||||
|
return process.pid;
|
||||||
|
} catch (err) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
|
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
|
||||||
*
|
*
|
||||||
@@ -88,8 +98,16 @@ export class BackgroundSyncRunner {
|
|||||||
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
|
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
|
||||||
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
|
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
|
||||||
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
|
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
|
||||||
const budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
|
let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
|
||||||
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,可用窗口 ${Math.round(budget / 1000)} 秒)`);
|
// ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
|
||||||
|
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
|
||||||
|
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
|
||||||
|
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
|
||||||
|
if (overrideMs > 0) {
|
||||||
|
budget = overrideMs;
|
||||||
|
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
|
||||||
|
}
|
||||||
|
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
|
||||||
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
|
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
|
||||||
const worker: Promise<void> =
|
const worker: Promise<void> =
|
||||||
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
|
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
|
||||||
@@ -164,8 +182,12 @@ export class BackgroundSyncRunner {
|
|||||||
*/
|
*/
|
||||||
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
|
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
|
||||||
const r = new BackgroundSyncResult();
|
const r = new BackgroundSyncResult();
|
||||||
|
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
|
||||||
|
const t0: number = Date.now();
|
||||||
|
const el = (): number => Date.now() - t0;
|
||||||
try {
|
try {
|
||||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
|
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
|
||||||
return r;
|
return r;
|
||||||
@@ -176,12 +198,14 @@ export class BackgroundSyncRunner {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 导入失败不影响正常同步
|
// 导入失败不影响正常同步
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
|
||||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
||||||
try {
|
try {
|
||||||
await MirrorInbound.reconcile(context);
|
await MirrorInbound.reconcile(context);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 对账失败不影响正常同步
|
// 对账失败不影响正常同步
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
|
||||||
for (const acc of accounts) {
|
for (const acc of accounts) {
|
||||||
if (acc.type !== TYPE_CALDAV) {
|
if (acc.type !== TYPE_CALDAV) {
|
||||||
continue;
|
continue;
|
||||||
@@ -196,13 +220,16 @@ export class BackgroundSyncRunner {
|
|||||||
r.failed++;
|
r.failed++;
|
||||||
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
|
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
|
||||||
}
|
}
|
||||||
// 全部账号同步成功才结束"一次性全量重拉"状态
|
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||||
if (r.attempted > 0 && r.ok === r.attempted) {
|
if (r.attempted > 0 && r.ok === r.attempted) {
|
||||||
await AppSettings.markFullRefetchDone(context);
|
await AppSettings.markFullRefetchDone(context);
|
||||||
}
|
}
|
||||||
await SyncEngine.settleLocalEvents(context);
|
await SyncEngine.settleLocalEvents(context);
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
|
||||||
await SyncEngine.pruneOrphanRows(context, accounts);
|
await SyncEngine.pruneOrphanRows(context, accounts);
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
|
||||||
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
|
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
|
||||||
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
|
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
|
||||||
await AccountStore.saveAll(context, accounts);
|
await AccountStore.saveAll(context, accounts);
|
||||||
@@ -215,6 +242,7 @@ export class BackgroundSyncRunner {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 镜像失败不影响同步
|
// 镜像失败不影响同步
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
|
||||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||||
try {
|
try {
|
||||||
await CardDataService.pushToAllForms(context);
|
await CardDataService.pushToAllForms(context);
|
||||||
@@ -222,10 +250,10 @@ export class BackgroundSyncRunner {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 卡片/提醒刷新失败不影响同步结果
|
// 卡片/提醒刷新失败不影响同步结果
|
||||||
}
|
}
|
||||||
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号`);
|
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}`);
|
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,7 +425,22 @@ export class EventDb {
|
|||||||
rs.close();
|
rs.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteKeys: string[] = [];
|
// ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
|
||||||
|
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
|
||||||
|
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
|
||||||
|
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
|
||||||
|
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
|
||||||
|
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
|
||||||
|
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||||
|
for (const e of existing) {
|
||||||
|
if (e.dirty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
|
||||||
|
if (!keyed.has(k)) {
|
||||||
|
keyed.set(k, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
|
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
|
||||||
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
|
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
|
||||||
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
|
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
|
||||||
@@ -438,10 +453,7 @@ export class EventDb {
|
|||||||
continue; // 同一资源内的重复条目 → 只保留一条
|
continue; // 同一资源内的重复条目 → 只保留一条
|
||||||
}
|
}
|
||||||
seenKeys.add(key);
|
seenKeys.add(key);
|
||||||
remoteKeys.push(key);
|
const found: LocalEvent | undefined = keyed.get(key);
|
||||||
const found = existing.find((x: LocalEvent): boolean =>
|
|
||||||
!x.dirty && x.uid === r.uid && x.startTime === r.startTime
|
|
||||||
&& x.recurrenceId === r.recurrenceId);
|
|
||||||
if (found === undefined) {
|
if (found === undefined) {
|
||||||
// 新增
|
// 新增
|
||||||
const e = new LocalEvent();
|
const e = new LocalEvent();
|
||||||
@@ -517,7 +529,8 @@ export class EventDb {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
|
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
|
||||||
if (!remoteKeys.includes(localKey)) {
|
// ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
|
||||||
|
if (!seenKeys.has(localKey)) {
|
||||||
const del = new relationalStore.RdbPredicates('events');
|
const del = new relationalStore.RdbPredicates('events');
|
||||||
del.equalTo('id', local.id);
|
del.equalTo('id', local.id);
|
||||||
await store.delete(del);
|
await store.delete(del);
|
||||||
|
|||||||
@@ -1,30 +1,57 @@
|
|||||||
// entry/src/main/ets/common/LogUtil.ets
|
// entry/src/main/ets/common/LogUtil.ets
|
||||||
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。
|
// 日志工具:默认**只输出到 console(hilog)**,不落盘。
|
||||||
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。
|
//
|
||||||
|
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
|
||||||
|
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
|
||||||
|
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
|
||||||
|
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
|
||||||
|
// 保证升级上来的设备沙箱也是干净的。
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { fileIo as fs } from '@kit.CoreFileKit';
|
import { fileIo as fs } from '@kit.CoreFileKit';
|
||||||
import { util } from '@kit.ArkTS';
|
import { util } from '@kit.ArkTS';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
|
||||||
export class LogUtil {
|
export class LogUtil {
|
||||||
|
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
|
||||||
|
private static readonly ENABLE_FILE_LOG: boolean = false;
|
||||||
|
private static dir: string = '';
|
||||||
private static path: string = '';
|
private static path: string = '';
|
||||||
private static readonly MAX_BYTES: number = 512 * 1024;
|
private static readonly MAX_BYTES: number = 512 * 1024;
|
||||||
|
|
||||||
static init(context: common.Context): void {
|
static init(context: common.Context): void {
|
||||||
try {
|
try {
|
||||||
LogUtil.path = `${context.filesDir}/sync.log`;
|
LogUtil.dir = context.filesDir;
|
||||||
|
LogUtil.path = `${LogUtil.dir}/sync.log`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
LogUtil.dir = '';
|
||||||
LogUtil.path = '';
|
LogUtil.path = '';
|
||||||
}
|
}
|
||||||
|
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
|
||||||
|
LogUtil.removeLegacyDebugFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
|
||||||
|
private static removeLegacyDebugFiles(): void {
|
||||||
|
for (const name of ['sync.log', 'mirror_diag.txt']) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(`${LogUtil.dir}/${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
// 文件不存在属正常,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static logFilePath(): string {
|
static logFilePath(): string {
|
||||||
return LogUtil.path;
|
return LogUtil.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */
|
/** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
|
||||||
static write(msg: string): void {
|
static write(msg: string): void {
|
||||||
console.info(`SyncLog ${msg}`);
|
console.info(`SyncLog ${msg}`);
|
||||||
|
if (!LogUtil.ENABLE_FILE_LOG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (LogUtil.path === '') {
|
if (LogUtil.path === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,25 @@ export class MirrorInbound {
|
|||||||
|
|
||||||
/* ==================== 单个日历本 ==================== */
|
/* ==================== 单个日历本 ==================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取回该账户下已有日程。
|
||||||
|
*
|
||||||
|
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
|
||||||
|
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
|
||||||
|
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
|
||||||
|
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
|
||||||
|
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
|
||||||
|
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
|
||||||
|
*/
|
||||||
|
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
|
||||||
|
try {
|
||||||
|
return await cal.getEvents();
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
|
||||||
|
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async reconcileBook(
|
private static async reconcileBook(
|
||||||
context: common.Context,
|
context: common.Context,
|
||||||
cm: calendarManager.CalendarManager,
|
cm: calendarManager.CalendarManager,
|
||||||
@@ -109,8 +128,7 @@ export class MirrorInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
||||||
const events: calendarManager.Event[] = await cal.getEvents(
|
const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
|
||||||
undefined, MirrorInbound.QUERY_FIELDS);
|
|
||||||
|
|
||||||
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
||||||
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// entry/src/main/ets/common/PlaceSuggest.ets
|
||||||
|
// 地点补全/选点:基于鸿蒙系统内置的 Map Kit(Site Kit + sceneMap)
|
||||||
|
//
|
||||||
|
// 为什么用华为 Map Kit 而不是高德:
|
||||||
|
// 1) 系统内置、无需第三方 SDK 依赖,也不需要我们自己维护一个 API Key;
|
||||||
|
// 2) Site Kit 的 queryAutoComplete / searchByText / reverseGeocode **不需要任何权限**
|
||||||
|
// (SDK 声明里没有 @permission),符合本项目「权限只在用户主动点该功能时申请」的硬约束;
|
||||||
|
// 3) HarmonyOS 5.0 及以上 Map Kit 的地图加载/POI 搜索/地理编码当前免费(官方论坛口径)。
|
||||||
|
//
|
||||||
|
// ⚠️ 前置条件(必须由用户在 AGC 完成,代码无法替代):
|
||||||
|
// AGC 项目 → 开放能力管理 → 打开「地图服务」,并**重新申请 Profile**(旧的 Profile 不含地图能力)。
|
||||||
|
// 未开通时调用会抛 1002600004(The Map permission is not enabled)。
|
||||||
|
// 因此本类所有入口都必须**优雅降级**:失败只返回空列表,绝不影响日程本身的保存。
|
||||||
|
import { site, sceneMap, mapCommon } from '@kit.MapKit';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { LogUtil } from './LogUtil';
|
||||||
|
|
||||||
|
/** 一条地点候选(界面直接消费) */
|
||||||
|
export class PlaceItem {
|
||||||
|
/** Site Kit 的地点 ID,可用于 searchById 取详情 */
|
||||||
|
siteId: string = '';
|
||||||
|
/** 地点名称,如「星巴克(中关村店)」 */
|
||||||
|
name: string = '';
|
||||||
|
/** 完整格式化地址,如「北京市海淀区中关村大街1号」 */
|
||||||
|
address: string = '';
|
||||||
|
/** 纬度(无法取得时为 0) */
|
||||||
|
lat: number = 0;
|
||||||
|
/** 经度(无法取得时为 0) */
|
||||||
|
lng: number = 0;
|
||||||
|
/** 是否有有效坐标 */
|
||||||
|
hasCoord: boolean = false;
|
||||||
|
|
||||||
|
/** 界面上第一行显示:有名称用名称,否则用地址 */
|
||||||
|
get title(): string {
|
||||||
|
return this.name !== '' ? this.name : this.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 界面上第二行显示:名称+地址重复时不显示 */
|
||||||
|
get subtitle(): string {
|
||||||
|
if (this.address === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (this.name === '' || this.address === this.name) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return this.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入日程 location 字段的文本:优先完整地址,退化为名称 */
|
||||||
|
get locationText(): string {
|
||||||
|
return this.address !== '' ? this.address : this.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaceSuggest {
|
||||||
|
/** 地图能力是否可用(false 时界面应显示提示而不是反复重试) */
|
||||||
|
private static unavailable: boolean = false;
|
||||||
|
/** 最近一次失败原因(给界面显示,便于用户/开发者判断是"没开通"还是"网络问题") */
|
||||||
|
private static lastError: string = '';
|
||||||
|
/** 当前在途请求序号:用于丢弃过期结果(边打字边搜时后发先至) */
|
||||||
|
private static seq: number = 0;
|
||||||
|
|
||||||
|
static isUnavailable(): boolean {
|
||||||
|
return PlaceSuggest.unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getLastError(): string {
|
||||||
|
return PlaceSuggest.lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键字补全:输入几个字返回候选地点。
|
||||||
|
* 失败/未开通一律返回空数组(不抛异常,不影响其他功能)。
|
||||||
|
* @param keyword 用户已输入的关键字
|
||||||
|
* @param context 可选,传了就用带 context 的重载(定位更准)
|
||||||
|
*/
|
||||||
|
static async query(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
|
||||||
|
const q: string = keyword.trim();
|
||||||
|
if (q === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (PlaceSuggest.unavailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// 序号机制:只接受最后一次请求的结果
|
||||||
|
PlaceSuggest.seq++;
|
||||||
|
const mySeq: number = PlaceSuggest.seq;
|
||||||
|
try {
|
||||||
|
const params: site.QueryAutoCompleteParams = {
|
||||||
|
query: q,
|
||||||
|
language: 'zh-CN',
|
||||||
|
isCityLimit: false
|
||||||
|
};
|
||||||
|
const res: site.QueryAutoCompleteResult = context !== undefined
|
||||||
|
? await site.queryAutoComplete(context, params)
|
||||||
|
: await site.queryAutoComplete(params);
|
||||||
|
if (mySeq !== PlaceSuggest.seq) {
|
||||||
|
// 已有更新的请求发出,丢弃本次结果
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return PlaceSuggest.toItems(res.sites);
|
||||||
|
} catch (err) {
|
||||||
|
PlaceSuggest.handleError('补全', err);
|
||||||
|
// 补全失败时退一步用关键字搜索(部分环境 autoComplete 不可用但 searchByText 可用)
|
||||||
|
return await PlaceSuggest.searchFallback(q, context, mySeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键字搜索(补全失败时的兜底,也用于用户直接点"搜索")。
|
||||||
|
*/
|
||||||
|
static async search(keyword: string, context?: common.Context): Promise<PlaceItem[]> {
|
||||||
|
const q: string = keyword.trim();
|
||||||
|
if (q === '' || PlaceSuggest.unavailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
PlaceSuggest.seq++;
|
||||||
|
const mySeq: number = PlaceSuggest.seq;
|
||||||
|
return await PlaceSuggest.searchFallback(q, context, mySeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async searchFallback(keyword: string, context: common.Context | undefined,
|
||||||
|
mySeq: number): Promise<PlaceItem[]> {
|
||||||
|
try {
|
||||||
|
const params: site.SearchByTextParams = {
|
||||||
|
query: keyword,
|
||||||
|
language: 'zh-CN'
|
||||||
|
};
|
||||||
|
const res: site.SearchByTextResult = context !== undefined
|
||||||
|
? await site.searchByText(context, params)
|
||||||
|
: await site.searchByText(params);
|
||||||
|
if (mySeq !== PlaceSuggest.seq) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return PlaceSuggest.toItems(res.sites);
|
||||||
|
} catch (err) {
|
||||||
|
PlaceSuggest.handleError('搜索', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开系统地图选点页,返回用户选中的地点(用户取消返回 null)。
|
||||||
|
* 这是最省事也最不容易出错的方式:地图、搜索、周边都由系统界面提供。
|
||||||
|
*/
|
||||||
|
static async pickOnMap(context: common.UIAbilityContext): Promise<PlaceItem | null> {
|
||||||
|
try {
|
||||||
|
const options: sceneMap.LocationChoosingOptions = {
|
||||||
|
language: 'zh-CN',
|
||||||
|
searchEnabled: true,
|
||||||
|
showNearbyPoi: true
|
||||||
|
};
|
||||||
|
const r: sceneMap.LocationChoosingResult = await sceneMap.chooseLocation(context, options);
|
||||||
|
const item: PlaceItem = new PlaceItem();
|
||||||
|
item.siteId = r.siteId !== undefined ? r.siteId : '';
|
||||||
|
item.name = r.name !== undefined ? r.name : '';
|
||||||
|
item.address = r.address !== undefined ? r.address : '';
|
||||||
|
if (r.location !== undefined) {
|
||||||
|
item.lat = r.location.latitude;
|
||||||
|
item.lng = r.location.longitude;
|
||||||
|
item.hasCoord = true;
|
||||||
|
}
|
||||||
|
// 两者都空视为取消/无效选择
|
||||||
|
if (item.name === '' && item.address === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
} catch (err) {
|
||||||
|
PlaceSuggest.handleError('地图选点', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成"名称 — 地址"这种便于粘贴进备注/地点的单行文本 */
|
||||||
|
static formatOneLine(item: PlaceItem): string {
|
||||||
|
if (item.name !== '' && item.address !== '' && item.name !== item.address) {
|
||||||
|
return `${item.name}|${item.address}`;
|
||||||
|
}
|
||||||
|
return item.locationText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static toItems(sites: site.Site[] | undefined): PlaceItem[] {
|
||||||
|
const out: PlaceItem[] = [];
|
||||||
|
if (sites === undefined) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < sites.length; i++) {
|
||||||
|
const s: site.Site = sites[i];
|
||||||
|
const item: PlaceItem = new PlaceItem();
|
||||||
|
item.siteId = s.siteId !== undefined ? s.siteId : '';
|
||||||
|
item.name = s.name !== undefined ? s.name : '';
|
||||||
|
// formatAddress 是"省市区+街道门牌"的完整串,最适合写进 LOCATION
|
||||||
|
item.address = s.formatAddress !== undefined ? s.formatAddress : '';
|
||||||
|
if (item.address === '' && s.addressComponent !== undefined) {
|
||||||
|
// 极少数数据只有结构化 addressComponent(字段仅 countryName/locality/city/streetNumber),
|
||||||
|
// 按"国 → 城市 → 区县(locality) → 街道门牌"拼一个保底地址
|
||||||
|
item.address = PlaceSuggest.joinAddress(s.addressComponent);
|
||||||
|
}
|
||||||
|
if (s.location !== undefined) {
|
||||||
|
item.lat = s.location.latitude;
|
||||||
|
item.lng = s.location.longitude;
|
||||||
|
item.hasCoord = true;
|
||||||
|
}
|
||||||
|
if (item.name !== '' || item.address !== '') {
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从结构化地址组件拼一条保底地址(SDK 里就这几个字段) */
|
||||||
|
private static joinAddress(ac: site.AddressComponent): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (ac.countryName !== undefined && ac.countryName !== '') {
|
||||||
|
parts.push(ac.countryName);
|
||||||
|
}
|
||||||
|
if (ac.city !== undefined && ac.city.cityName !== undefined && ac.city.cityName !== '') {
|
||||||
|
parts.push(ac.city.cityName);
|
||||||
|
}
|
||||||
|
if (ac.locality !== undefined && ac.locality !== '') {
|
||||||
|
parts.push(ac.locality);
|
||||||
|
}
|
||||||
|
if (ac.streetNumber !== undefined) {
|
||||||
|
const sn: site.StreetNumber = ac.streetNumber;
|
||||||
|
if (sn.streetName !== undefined && sn.streetName !== '') {
|
||||||
|
parts.push(sn.streetName);
|
||||||
|
}
|
||||||
|
if (sn.streetNumber !== undefined && sn.streetNumber !== '') {
|
||||||
|
parts.push(sn.streetNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一错误处理:
|
||||||
|
* - 1002600004 地图权限未开通 → 标记不可用,界面不再重试(避免每次打字都打一遍网络)
|
||||||
|
* - 1002600003 鉴权失败(Profile 未含地图能力)
|
||||||
|
* - 1002600002 连不上服务器
|
||||||
|
*/
|
||||||
|
private static handleError(op: string, err: Object): void {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
const code: number = e.code !== undefined ? e.code : 0;
|
||||||
|
PlaceSuggest.lastError = `${op}失败(code=${code}): ${e.message}`;
|
||||||
|
if (code === 1002600004 || code === 1002600003) {
|
||||||
|
PlaceSuggest.unavailable = true;
|
||||||
|
LogUtil.write(`[地点] ${op}不可用(code=${code}):需在 AGC 打开「地图服务」并重新申请 Profile`);
|
||||||
|
} else {
|
||||||
|
LogUtil.write(`[地点] ${PlaceSuggest.lastError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,7 +122,23 @@ export class SyncEngine {
|
|||||||
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
|
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
|
||||||
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
||||||
// 0) 刷新服务器端日历本颜色(每次同步都校正)
|
// 0) 刷新服务器端日历本颜色(每次同步都校正)
|
||||||
await SyncEngine.refreshCalendarColors(acc, auth);
|
// ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
|
||||||
|
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
|
||||||
|
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
|
||||||
|
if (colorChanged) {
|
||||||
|
try {
|
||||||
|
const all: DavAccount[] = await AccountStore.loadAll(context);
|
||||||
|
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
all[idx] = acc;
|
||||||
|
await AccountStore.saveAll(context, all);
|
||||||
|
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 1) 推送该账号日历本下的本地修改
|
// 1) 推送该账号日历本下的本地修改
|
||||||
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
||||||
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
|
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
|
||||||
@@ -160,12 +176,12 @@ export class SyncEngine {
|
|||||||
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||||
const byUid: Map<string, LocalEvent[]> = new Map();
|
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||||
|
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
|
||||||
|
const byEtag: Map<string, LocalEvent[]> = new Map();
|
||||||
for (const row of existingRows) {
|
for (const row of existingRows) {
|
||||||
const arr: LocalEvent[] | undefined = byUid.get(row.uid);
|
SyncEngine.groupPush(byUid, row.uid, row);
|
||||||
if (arr === undefined) {
|
if (row.etag !== '') {
|
||||||
byUid.set(row.uid, [row]);
|
SyncEngine.groupPush(byEtag, row.etag, row);
|
||||||
} else {
|
|
||||||
arr.push(row);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||||
@@ -174,9 +190,27 @@ export class SyncEngine {
|
|||||||
let fetched: number = 0;
|
let fetched: number = 0;
|
||||||
let parseFail: number = 0;
|
let parseFail: number = 0;
|
||||||
const needFetch: RemoteItem[] = [];
|
const needFetch: RemoteItem[] = [];
|
||||||
|
let hitUid: number = 0; // 按"资源名 = uid"命中
|
||||||
|
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
||||||
const group: LocalEvent[] | undefined = byUid.get(resUid);
|
let group: LocalEvent[] | undefined = byUid.get(resUid);
|
||||||
|
if (group !== undefined) {
|
||||||
|
hitUid++;
|
||||||
|
} else if (it.etag !== '') {
|
||||||
|
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
|
||||||
|
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
|
||||||
|
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
|
||||||
|
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
|
||||||
|
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
|
||||||
|
// 那 9 秒窗口连一个日历本都跑不完)。
|
||||||
|
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
|
||||||
|
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
|
||||||
|
group = byEtag.get(it.etag);
|
||||||
|
if (group !== undefined) {
|
||||||
|
hitEtag++;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
||||||
&& group[0].etag === it.etag) {
|
&& group[0].etag === it.etag) {
|
||||||
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
||||||
@@ -187,6 +221,7 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
needFetch.push(it);
|
needFetch.push(it);
|
||||||
}
|
}
|
||||||
|
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
|
||||||
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
||||||
const batchSize: number = 5;
|
const batchSize: number = 5;
|
||||||
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
||||||
@@ -344,8 +379,13 @@ export class SyncEngine {
|
|||||||
/**
|
/**
|
||||||
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
|
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
|
||||||
* 失败静默(颜色不影响数据正确性)
|
* 失败静默(颜色不影响数据正确性)
|
||||||
|
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
|
||||||
|
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
|
||||||
|
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
|
||||||
|
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
|
||||||
*/
|
*/
|
||||||
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> {
|
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
|
||||||
|
let changed: boolean = false;
|
||||||
let entries: DavColorEntry[] = [];
|
let entries: DavColorEntry[] = [];
|
||||||
try {
|
try {
|
||||||
entries = await DavClient.propfindColors(acc.serverUrl, auth);
|
entries = await DavClient.propfindColors(acc.serverUrl, auth);
|
||||||
@@ -363,9 +403,11 @@ export class SyncEngine {
|
|||||||
if (found !== undefined && found.color !== '') {
|
if (found !== undefined && found.color !== '') {
|
||||||
while (acc.calendarColors.length <= i) {
|
while (acc.calendarColors.length <= i) {
|
||||||
acc.calendarColors.push('');
|
acc.calendarColors.push('');
|
||||||
|
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
|
||||||
}
|
}
|
||||||
if (acc.calendarColors[i] !== found.color) {
|
if (acc.calendarColors[i] !== found.color) {
|
||||||
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
|
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
acc.calendarColors[i] = found.color;
|
acc.calendarColors[i] = found.color;
|
||||||
} else if (found === undefined) {
|
} else if (found === undefined) {
|
||||||
@@ -377,16 +419,32 @@ export class SyncEngine {
|
|||||||
acc.calendarWritable.push('1');
|
acc.calendarWritable.push('1');
|
||||||
}
|
}
|
||||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||||
|
let writable: string;
|
||||||
if (found !== undefined && found.privilegeKnown) {
|
if (found !== undefined && found.privilegeKnown) {
|
||||||
// 服务器声明了权限:直接采用
|
// 服务器声明了权限:直接采用
|
||||||
acc.calendarWritable[i] = found.writable ? '1' : '0';
|
writable = found.writable ? '1' : '0';
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
|
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
|
||||||
} else {
|
} else {
|
||||||
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
|
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
|
||||||
const w: boolean = await DavClient.probeWritable(target, auth);
|
const w: boolean = await DavClient.probeWritable(target, auth);
|
||||||
acc.calendarWritable[i] = w ? '1' : '0';
|
writable = w ? '1' : '0';
|
||||||
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
|
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
|
||||||
}
|
}
|
||||||
|
if (acc.calendarWritable[i] !== writable) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
acc.calendarWritable[i] = writable;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
|
||||||
|
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
|
||||||
|
const arr: LocalEvent[] | undefined = m.get(key);
|
||||||
|
if (arr === undefined) {
|
||||||
|
m.set(key, [row]);
|
||||||
|
} else {
|
||||||
|
arr.push(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,8 +459,7 @@ export class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
||||||
private static resUidFromHref(href: string): string {
|
private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||||
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
|
||||||
if (segs.length === 0) {
|
if (segs.length === 0) {
|
||||||
return String(Date.now());
|
return String(Date.now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
|
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { calendarManager } from '@kit.CalendarKit';
|
import { calendarManager } from '@kit.CalendarKit';
|
||||||
import { fileIo as fs } from '@kit.CoreFileKit';
|
|
||||||
import { AccountStore, BookPalette, DavAccount } from './AccountStore';
|
import { AccountStore, BookPalette, DavAccount } from './AccountStore';
|
||||||
import { EventDb, LocalEvent } from './EventDb';
|
import { EventDb, LocalEvent } from './EventDb';
|
||||||
import { IcsTime, IcsUtil } from './IcsUtil';
|
import { IcsTime, IcsUtil } from './IcsUtil';
|
||||||
@@ -594,16 +593,31 @@ export class SystemCalendarMirror {
|
|||||||
LogUtil.write(
|
LogUtil.write(
|
||||||
`镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`);
|
`镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`);
|
||||||
|
|
||||||
|
// ⭐ 0.0.8 防护:设备「重启后尚未解锁」时(direct boot),日历数据在加密存储里读不到,
|
||||||
|
// getAllCalendars() 会返回空列表,此时 createCalendar 必然失败——真机实测表现为
|
||||||
|
// 12 个镜像账户连续 `201 CreateCalendar failed!`(2026-10-16 07:55),还可能留下半创建账户。
|
||||||
|
// 正常设备至少有一个本机日历,所以「清单为 0」只可能是日历服务未就绪 ⇒ 本轮直接跳过创建,
|
||||||
|
// 等下一次同步(解锁后)自然恢复。
|
||||||
|
let calCount: number = 0;
|
||||||
|
try {
|
||||||
|
calCount = (await cm.getAllCalendars()).length;
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`镜像跳过:读不到系统日历账户列表 ${(err as BusinessError).message}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (calCount === 0) {
|
||||||
|
LogUtil.write('镜像跳过:系统日历账户清单为 0(日历服务未就绪,如设备重启后尚未解锁),本轮不创建镜像账户');
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
const winStart: number = SystemCalendarMirror.windowStart();
|
const winStart: number = SystemCalendarMirror.windowStart();
|
||||||
const winEnd: number = SystemCalendarMirror.windowEnd();
|
const winEnd: number = SystemCalendarMirror.windowEnd();
|
||||||
// ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名)
|
// ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名)
|
||||||
const format: number = await AppSettings.getMirrorDisplayFormat(context);
|
const format: number = await AppSettings.getMirrorDisplayFormat(context);
|
||||||
const plans: Map<string, MirrorOcc[]> = new Map<string, MirrorOcc[]>();
|
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
try {
|
try {
|
||||||
const plan: MirrorOcc[] =
|
const plan: MirrorOcc[] =
|
||||||
await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res);
|
await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res);
|
||||||
plans.set(ref.bookId, plan);
|
|
||||||
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format);
|
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format);
|
||||||
res.books++;
|
res.books++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -612,8 +626,7 @@ export class SystemCalendarMirror {
|
|||||||
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
|
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ⭐ 每次镜像后自动导出一份对照诊断,便于"系统日历里有、App 里没有"这类问题逐条比对
|
// ⭐ 0.0.8:不再导出 mirror_diag.txt(诊断落盘功能已随「同步记录」一并移除)
|
||||||
await SystemCalendarMirror.dumpDiagnosis(context, cm, refs, plans, winStart, winEnd);
|
|
||||||
LogUtil.write(
|
LogUtil.write(
|
||||||
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
|
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
|
||||||
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
|
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
|
||||||
@@ -854,7 +867,7 @@ export class SystemCalendarMirror {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
|
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
|
||||||
* 实际写入(syncBook)与诊断导出(dumpDiagnosis)都用它,保证诊断看到的就是真正写进去的。
|
* 实际写入(syncBook)以它为准,保证"计划"与"写入"完全一致。
|
||||||
*/
|
*/
|
||||||
private static async planFor(
|
private static async planFor(
|
||||||
context: common.Context,
|
context: common.Context,
|
||||||
@@ -1015,145 +1028,6 @@ export class SystemCalendarMirror {
|
|||||||
`窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`);
|
`窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ⭐ 诊断导出:把"App 侧计划写入"与"系统日历里实际存在"的完整对照写到
|
|
||||||
* `${filesDir}/mirror_diag.txt`(可用 hdc 直接取出分析)。
|
|
||||||
*
|
|
||||||
* 为什么需要它:用户反馈"系统日历里有、App 里没有"这种问题靠描述很难定位,
|
|
||||||
* 必须把两侧的清单原样摆在一起逐条比。每次镜像后自动重写一份。
|
|
||||||
*/
|
|
||||||
private static async dumpDiagnosis(
|
|
||||||
context: common.Context,
|
|
||||||
cm: calendarManager.CalendarManager,
|
|
||||||
refs: BookRef[],
|
|
||||||
plans: Map<string, MirrorOcc[]>,
|
|
||||||
winStart: number,
|
|
||||||
winEnd: number
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push(`镜像诊断 ${new Date().toLocaleString()}`);
|
|
||||||
lines.push(`窗口 ${SystemCalendarMirror.fmt(winStart)} ~ ${SystemCalendarMirror.fmt(winEnd)} ` +
|
|
||||||
`(${winStart} ~ ${winEnd})`);
|
|
||||||
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
|
|
||||||
// ⭐ 全设备日历账户清单:一眼看出有没有"本应用建的、但不属于当前选中本"的残留账户
|
|
||||||
// (历史 calKey 命名的账户、重复账户)。每次镜像都导出一份,省得再靠按钮抓。
|
|
||||||
const keepNames: Set<string> = new Set<string>();
|
|
||||||
for (const r of refs) {
|
|
||||||
keepNames.add(r.sysAccountName);
|
|
||||||
}
|
|
||||||
let mineN: number = 0;
|
|
||||||
let keepN: number = 0;
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`[全设备日历账户] 共 ${cals.length} 个日历本:`);
|
|
||||||
for (const c of cals) {
|
|
||||||
let accName: string = '';
|
|
||||||
let disp: string = '';
|
|
||||||
let n: number = -1;
|
|
||||||
try {
|
|
||||||
accName = c.getAccount().name;
|
|
||||||
disp = c.getAccount().displayName ?? '';
|
|
||||||
} catch (err) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
n = (await SystemCalendarMirror.queryEvents(c)).length;
|
|
||||||
} catch (err) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
let tag: string = 'other';
|
|
||||||
if (accName.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
|
|
||||||
if (keepNames.has(accName)) {
|
|
||||||
tag = 'KEEP';
|
|
||||||
keepN++;
|
|
||||||
} else {
|
|
||||||
tag = 'MINE(残留)';
|
|
||||||
mineN++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(` [${tag}] #${c.id} acc=${accName} display=${disp} 条目=${n}`);
|
|
||||||
}
|
|
||||||
lines.push(` 小结:本应用账户 ${keepN + mineN} 个(本次保留 ${keepN},残留 ${mineN})`);
|
|
||||||
for (const ref of refs) {
|
|
||||||
const plan: MirrorOcc[] | undefined = plans.get(ref.bookId);
|
|
||||||
const list: MirrorOcc[] = plan !== undefined ? plan : [];
|
|
||||||
let cal: calendarManager.Calendar | undefined = undefined;
|
|
||||||
for (const c of cals) {
|
|
||||||
if (c.getAccount().name === ref.sysAccountName) {
|
|
||||||
cal = c;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
lines.push(`[本] ${ref.name} | bookId=${ref.bookId} | calKey=${ref.calKey} | 账户=${ref.sysAccountName} | 账户存在=${cal !== undefined}`);
|
|
||||||
if (cal === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const sysEvents: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
|
|
||||||
const sysMap: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
|
||||||
for (const ev of sysEvents) {
|
|
||||||
const id: string = ev.identifier ?? '';
|
|
||||||
if (id !== '') {
|
|
||||||
sysMap.set(id, ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(` 计划写入=${list.length} 系统日历实际=${sysEvents.length}(其中带 identifier=${sysMap.size})`);
|
|
||||||
|
|
||||||
// ① 系统日历里有、计划里没有 → 多出来的(正常会被删掉;删不掉就是残留)
|
|
||||||
const planIds: Set<string> = new Set<string>();
|
|
||||||
for (const o of list) {
|
|
||||||
planIds.add(o.identifier);
|
|
||||||
}
|
|
||||||
const extra: string[] = [];
|
|
||||||
for (const id of sysMap.keys()) {
|
|
||||||
if (planIds.has(id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const ev: calendarManager.Event | undefined = sysMap.get(id);
|
|
||||||
if (ev === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
extra.push(` ${SystemCalendarMirror.stamp(ev.startTime)} ${ev.title ?? ''} [${id}]`);
|
|
||||||
}
|
|
||||||
lines.push(` ① 系统多出(${extra.length}):`);
|
|
||||||
for (let i: number = 0; i < extra.length && i < 120; i++) {
|
|
||||||
lines.push(extra[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ② 计划里有、系统日历没有 → 漏写
|
|
||||||
const miss: string[] = [];
|
|
||||||
for (const o of list) {
|
|
||||||
if (!sysMap.has(o.identifier)) {
|
|
||||||
miss.push(` ${SystemCalendarMirror.stamp(o.start)} ${o.title} [${o.identifier}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(` ② 漏写(${miss.length}):`);
|
|
||||||
for (let i: number = 0; i < miss.length && i < 60; i++) {
|
|
||||||
lines.push(miss[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ③ 系统日历里该账户的**全部**条目(供逐条核对,最多 400 行)
|
|
||||||
lines.push(` ③ 系统日历条目(${sysEvents.length},最多列 400):`);
|
|
||||||
const sorted: calendarManager.Event[] = sysEvents.slice().sort(
|
|
||||||
(a: calendarManager.Event, b: calendarManager.Event): number => a.startTime - b.startTime);
|
|
||||||
for (let i: number = 0; i < sorted.length && i < 400; i++) {
|
|
||||||
const ev: calendarManager.Event = sorted[i];
|
|
||||||
lines.push(` ${SystemCalendarMirror.stamp(ev.startTime)}~${SystemCalendarMirror.stamp(ev.endTime)} ` +
|
|
||||||
`${ev.isAllDay === true ? '[全天]' : ''}${ev.title ?? ''} [${ev.identifier ?? ''}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const path: string = `${context.filesDir}/mirror_diag.txt`;
|
|
||||||
const text: string = lines.join('\n') + '\n';
|
|
||||||
const file: fs.File = fs.openSync(path,
|
|
||||||
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
|
|
||||||
fs.writeSync(file.fd, text);
|
|
||||||
fs.closeSync(file.fd);
|
|
||||||
LogUtil.write(`镜像诊断已导出:${path}(${lines.length} 行)`);
|
|
||||||
} catch (err) {
|
|
||||||
LogUtil.write(`导出镜像诊断失败: ${(err as BusinessError).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 时间戳 → `MM-DD HH:mm` */
|
/** 时间戳 → `MM-DD HH:mm` */
|
||||||
private static stamp(ts: number): string {
|
private static stamp(ts: number): string {
|
||||||
const d: Date = new Date(ts);
|
const d: Date = new Date(ts);
|
||||||
@@ -1272,7 +1146,9 @@ export class SystemCalendarMirror {
|
|||||||
try {
|
try {
|
||||||
await cal.setConfig({ enableReminder: true, color: ref.color });
|
await cal.setConfig({ enableReminder: true, color: ref.color });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 设色失败不影响镜像本身
|
// 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
|
||||||
}
|
}
|
||||||
return cal;
|
return cal;
|
||||||
}
|
}
|
||||||
@@ -1285,7 +1161,8 @@ export class SystemCalendarMirror {
|
|||||||
try {
|
try {
|
||||||
await cal.setConfig({ enableReminder: true, color: ref.color });
|
await cal.setConfig({ enableReminder: true, color: ref.color });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 同上
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
|
||||||
}
|
}
|
||||||
return cal;
|
return cal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const DOMAIN = 0x0000;
|
|||||||
export default class EntryAbility extends UIAbility {
|
export default class EntryAbility extends UIAbility {
|
||||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||||
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log
|
// 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
|
||||||
LogUtil.init(this.context);
|
LogUtil.init(this.context);
|
||||||
LogUtil.write('---- App 启动 ----');
|
LogUtil.write('---- App 启动 ----');
|
||||||
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
|
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
|
||||||
|
|||||||
@@ -3,11 +3,21 @@
|
|||||||
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
||||||
import { Want } from '@kit.AbilityKit';
|
import { Want } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import process from '@ohos.process';
|
||||||
import { CardDataService, CardData } from '../common/CardDataService';
|
import { CardDataService, CardData } from '../common/CardDataService';
|
||||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
|
||||||
export default class EntryFormAbility extends FormExtensionAbility {
|
export default class EntryFormAbility extends FormExtensionAbility {
|
||||||
|
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
|
||||||
|
private static pidOf(): number {
|
||||||
|
try {
|
||||||
|
return process.pid;
|
||||||
|
} catch (err) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 组装更新数据并推送到指定卡片 */
|
/** 组装更新数据并推送到指定卡片 */
|
||||||
private pushData(formId: string, dayOffset: number = 0): void {
|
private pushData(formId: string, dayOffset: number = 0): void {
|
||||||
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
|
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
|
||||||
@@ -41,7 +51,7 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
|||||||
|
|
||||||
onUpdateForm(formId: string): void {
|
onUpdateForm(formId: string): void {
|
||||||
LogUtil.init(this.context);
|
LogUtil.init(this.context);
|
||||||
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
|
LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
|
||||||
CardDataService.registerForm(this.context, formId);
|
CardDataService.registerForm(this.context, formId);
|
||||||
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
|
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
|
||||||
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// entry/src/main/ets/pages/EventEditPage.ets
|
// entry/src/main/ets/pages/EventEditPage.ets
|
||||||
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
|
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
|
||||||
import { curves, router } from '@kit.ArkUI';
|
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { notificationManager } from '@kit.NotificationKit';
|
import { notificationManager } from '@kit.NotificationKit';
|
||||||
@@ -13,6 +13,7 @@ import { ReminderService } from '../common/ReminderService';
|
|||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
import { IcsUtil } from '../common/IcsUtil';
|
import { IcsUtil } from '../common/IcsUtil';
|
||||||
import { RruleUtil } from '../common/RruleUtil';
|
import { RruleUtil } from '../common/RruleUtil';
|
||||||
|
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
|
||||||
|
|
||||||
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
||||||
class SeriesBounds {
|
class SeriesBounds {
|
||||||
@@ -34,6 +35,34 @@ class BookChoice {
|
|||||||
struct EventEditPage {
|
struct EventEditPage {
|
||||||
@State title: string = '';
|
@State title: string = '';
|
||||||
@State location: string = '';
|
@State location: string = '';
|
||||||
|
/**
|
||||||
|
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
|
||||||
|
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
|
||||||
|
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
|
||||||
|
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
|
||||||
|
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
|
||||||
|
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
|
||||||
|
*/
|
||||||
|
@State placeItems: PlaceItem[] = [];
|
||||||
|
@State placeLoading: boolean = false;
|
||||||
|
@State placeOpen: boolean = false;
|
||||||
|
@State placeHint: string = '';
|
||||||
|
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
|
||||||
|
private placePicked: boolean = false;
|
||||||
|
/**
|
||||||
|
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
|
||||||
|
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
|
||||||
|
* 不是用户打字造成的",从而不重复发起搜索。
|
||||||
|
*/
|
||||||
|
private lastAppliedText: string = '';
|
||||||
|
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
|
||||||
|
private placeFocused: boolean = false;
|
||||||
|
/** 输入防抖定时器 */
|
||||||
|
private placeTimer: number = -1;
|
||||||
|
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
|
||||||
|
private formScroller: Scroller = new Scroller();
|
||||||
@State description: string = '';
|
@State description: string = '';
|
||||||
@State allDay: boolean = false;
|
@State allDay: boolean = false;
|
||||||
@State startMs: number = 0;
|
@State startMs: number = 0;
|
||||||
@@ -41,7 +70,20 @@ struct EventEditPage {
|
|||||||
@State books: BookChoice[] = [];
|
@State books: BookChoice[] = [];
|
||||||
@State chosenKey: string = '';
|
@State chosenKey: string = '';
|
||||||
@State isSaving: boolean = false;
|
@State isSaving: boolean = false;
|
||||||
|
/**
|
||||||
|
* 表单状态行文字。
|
||||||
|
* ⚠️ 它渲染在表单**最底部**("删除日程"按钮上方),用户在顶部点"保存"时根本看不见。
|
||||||
|
* 所以它只能作为"事后可复查的记录",**不能**作为唯一的错误反馈手段 ——
|
||||||
|
* 真正的提醒必须走 showFormError():Toast(必可见)+ 字段标红 + 滚到出错字段。
|
||||||
|
*/
|
||||||
@State statusMsg: string = '';
|
@State statusMsg: string = '';
|
||||||
|
/**
|
||||||
|
* ⭐ 哪个字段校验没过('' = 没有)。
|
||||||
|
* 用途:把对应输入框描红并显示一条紧贴字段的红字说明,
|
||||||
|
* 让用户一眼看到"哪儿错了",而不是在一屏之外找一行小字。
|
||||||
|
* 取值:'title' | 'time' | ''
|
||||||
|
*/
|
||||||
|
@State errorField: string = '';
|
||||||
@State isExisting: boolean = false;
|
@State isExisting: boolean = false;
|
||||||
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
|
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
|
||||||
/**
|
/**
|
||||||
@@ -87,9 +129,36 @@ struct EventEditPage {
|
|||||||
private event: LocalEvent | null = null;
|
private event: LocalEvent | null = null;
|
||||||
|
|
||||||
aboutToAppear(): Promise<void> {
|
aboutToAppear(): Promise<void> {
|
||||||
|
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
|
||||||
|
this.setupKeyboardAvoid();
|
||||||
return this.initPage();
|
return this.initPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 页面销毁:清掉地点补全的防抖定时器 */
|
||||||
|
aboutToDisappear(): void {
|
||||||
|
this.cancelSuggestTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 键盘避让:改成 RESIZE 模式。
|
||||||
|
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
|
||||||
|
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
|
||||||
|
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
|
||||||
|
*
|
||||||
|
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
|
||||||
|
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
|
||||||
|
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
|
||||||
|
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
|
||||||
|
*/
|
||||||
|
private setupKeyboardAvoid(): void {
|
||||||
|
try {
|
||||||
|
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async initPage(): Promise<void> {
|
private async initPage(): Promise<void> {
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
@@ -214,6 +283,191 @@ struct EventEditPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入防抖:停止输入 350ms 后才真正发请求。
|
||||||
|
* 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。
|
||||||
|
* ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限),
|
||||||
|
* 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。
|
||||||
|
*/
|
||||||
|
private scheduleSuggest(v: string): void {
|
||||||
|
this.cancelSuggestTimer();
|
||||||
|
const q: string = v.trim();
|
||||||
|
if (q.length < 1) {
|
||||||
|
this.closePlacePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.placeTimer = setTimeout(() => {
|
||||||
|
this.placeTimer = -1;
|
||||||
|
this.runSuggest(q, false);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelSuggestTimer(): void {
|
||||||
|
if (this.placeTimer !== -1) {
|
||||||
|
clearTimeout(this.placeTimer);
|
||||||
|
this.placeTimer = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSuggest(q: string, forceOpen: boolean): Promise<void> {
|
||||||
|
if (this.placePicked && !forceOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (PlaceSuggest.isUnavailable()) {
|
||||||
|
// 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输)
|
||||||
|
this.placeHint = '地图服务不可用,可直接手动输入地点';
|
||||||
|
this.placeItems = [];
|
||||||
|
this.placeOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
|
||||||
|
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
|
||||||
|
this.placeLoading = true;
|
||||||
|
this.placeHint = '';
|
||||||
|
this.placeOpen = true;
|
||||||
|
try {
|
||||||
|
const c = this.getUIContext().getHostContext();
|
||||||
|
const ctx: common.Context | undefined = c;
|
||||||
|
// 先补全;补全空则内部会自动退到关键字搜索
|
||||||
|
let items: PlaceItem[] = await PlaceSuggest.query(q, ctx);
|
||||||
|
if (items.length === 0 && !PlaceSuggest.isUnavailable()) {
|
||||||
|
items = await PlaceSuggest.search(q, ctx);
|
||||||
|
}
|
||||||
|
// ⭐ 丢弃过期结果:用户又改了输入框,就别再覆盖成旧候选
|
||||||
|
if (q !== this.location.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.placeItems = items.slice(0, 6);
|
||||||
|
if (this.placeItems.length === 0) {
|
||||||
|
this.placeHint = PlaceSuggest.isUnavailable()
|
||||||
|
? '地图服务不可用,可直接手动输入地点'
|
||||||
|
: '没有找到匹配地点,可直接手动输入';
|
||||||
|
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
|
||||||
|
this.placeOpen = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
LogUtil.write(`地点补全异常: ${e.message}`);
|
||||||
|
this.placeItems = [];
|
||||||
|
this.placeHint = '地点搜索失败,可直接手动输入';
|
||||||
|
this.placeOpen = true;
|
||||||
|
} finally {
|
||||||
|
this.placeLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 候选浮层(bindPopup)的内容。
|
||||||
|
*
|
||||||
|
* 为什么是浮层而不是内联列表:
|
||||||
|
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
|
||||||
|
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
|
||||||
|
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
|
||||||
|
*
|
||||||
|
* 为什么向上弹(Placement.Top):
|
||||||
|
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
|
||||||
|
* 向上弹则整块候选都落在输入框与标题之间的空白区。
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
placePopup() {
|
||||||
|
Column({ space: 0 }) {
|
||||||
|
if (this.placeLoading) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
|
||||||
|
Text('搜索中…')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_hint'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||||
|
} else if (this.placeItems.length > 0) {
|
||||||
|
// 限高 + 内部滚动:候选最多 6 条,一次全铺开会顶到屏幕顶部
|
||||||
|
List() {
|
||||||
|
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
|
||||||
|
ListItem() {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(it.title)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
|
if (it.subtitle !== '') {
|
||||||
|
Text(it.subtitle)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
|
||||||
|
}
|
||||||
|
.onClick(() => {
|
||||||
|
this.applyPlace(it);
|
||||||
|
})
|
||||||
|
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.constraintSize({ maxHeight: 216 })
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
|
||||||
|
} else {
|
||||||
|
Text(this.placeHint !== '' ? this.placeHint : '没有找到匹配地点,可直接手动输入')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_hint'))
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 4, bottom: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选中一条候选:写进地点输入框并收起列表 */
|
||||||
|
private applyPlace(it: PlaceItem): void {
|
||||||
|
// ⭐ 顺序很关键:先收起浮层,再写文本。
|
||||||
|
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
|
||||||
|
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
|
||||||
|
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
|
||||||
|
this.cancelSuggestTimer();
|
||||||
|
this.closePlacePopup();
|
||||||
|
this.placePicked = true;
|
||||||
|
this.lastAppliedText = it.locationText;
|
||||||
|
this.location = it.locationText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收起候选浮层(统一入口,保证 placePicked 之外的状态都被清干净) */
|
||||||
|
private closePlacePopup(): void {
|
||||||
|
this.placeOpen = false;
|
||||||
|
this.placeLoading = false;
|
||||||
|
this.placeItems = [];
|
||||||
|
this.placeHint = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
|
||||||
|
private async pickOnMap(): Promise<void> {
|
||||||
|
const c = this.getUIContext().getHostContext();
|
||||||
|
if (c === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const it: PlaceItem | null = await PlaceSuggest.pickOnMap(c as common.UIAbilityContext);
|
||||||
|
if (it === null) {
|
||||||
|
// 用户取消,或地图服务不可用
|
||||||
|
if (PlaceSuggest.isUnavailable()) {
|
||||||
|
this.placeHint = '地图服务不可用,可直接手动输入地点';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.applyPlace(it);
|
||||||
|
}
|
||||||
|
|
||||||
private chosenBook(): BookChoice | null {
|
private chosenBook(): BookChoice | null {
|
||||||
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
|
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
|
||||||
if (found !== undefined) {
|
if (found !== undefined) {
|
||||||
@@ -437,15 +691,68 @@ struct EventEditPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⭐ 统一的表单错误提示(**唯一的错误出口**)。
|
||||||
|
*
|
||||||
|
* 为什么需要专门做这件事:
|
||||||
|
* 校验提示原本只写进 `statusMsg`,而它渲染在表单最底部("删除日程"按钮上方)。
|
||||||
|
* 用户在顶部点"保存"时,那行红字远在屏幕之外 —— 看起来就是"点了没反应"。
|
||||||
|
*
|
||||||
|
* 三件事一起做,保证用户一定看得见、也知道该改哪儿:
|
||||||
|
* ① **Toast**:系统级浮在屏幕中上部,不受滚动位置影响,必定可见;
|
||||||
|
* ② **字段标红**:给对应输入框加红框 + 紧贴字段的红字说明;
|
||||||
|
* ③ **滚回出错字段并聚焦**:把视野直接带到问题所在(标题 → 打光标;时间 → 滚到时间卡片)。
|
||||||
|
*
|
||||||
|
* @param msg 给用户看的话术(要具体,说清"该做什么",不要只说"错误")
|
||||||
|
* @param field 'title' | 'time' | ''('' 表示没有具体字段,只弹 Toast)
|
||||||
|
*/
|
||||||
|
private showFormError(msg: string, field: string): void {
|
||||||
|
this.statusMsg = msg;
|
||||||
|
this.errorField = field;
|
||||||
|
// ① Toast:不走布局,无论滚到哪儿都能看到
|
||||||
|
try {
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: msg,
|
||||||
|
duration: 2500,
|
||||||
|
bottom: '70%' // 抬到屏幕偏上方,避免被键盘/底部按钮遮挡
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Toast 失败也不影响其它两种提示
|
||||||
|
}
|
||||||
|
// ③ 滚回出错位置:延时一帧,等状态刷新(标红后高度可能变化)后再滚
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (field === 'title') {
|
||||||
|
this.formScroller.scrollEdge(Edge.Top);
|
||||||
|
try {
|
||||||
|
this.getUIContext().getFocusController().requestFocus('titleInput');
|
||||||
|
} catch (e2) {
|
||||||
|
// 聚焦失败(例如输入法正在切换)不阻塞流程
|
||||||
|
}
|
||||||
|
} else if (field === 'time') {
|
||||||
|
// 时间卡片在表单上部,滚到顶基本就能看到;再留一点余量
|
||||||
|
this.formScroller.scrollEdge(Edge.Top);
|
||||||
|
} else {
|
||||||
|
this.formScroller.scrollEdge(Edge.Top);
|
||||||
|
}
|
||||||
|
} catch (err2) {
|
||||||
|
const e = err2 as BusinessError;
|
||||||
|
LogUtil.write(`错误提示定位失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
}
|
||||||
|
|
||||||
private validate(): boolean {
|
private validate(): boolean {
|
||||||
if (this.title.trim() === '') {
|
if (this.title.trim() === '') {
|
||||||
this.statusMsg = '请输入日程标题';
|
this.showFormError('请先填写日程标题', 'title');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.endMs < this.startMs) {
|
if (this.endMs < this.startMs) {
|
||||||
this.statusMsg = '结束时间不能早于开始时间';
|
this.showFormError('结束时间不能早于开始时间', 'time');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// 通过校验:清掉上一轮的错误标记
|
||||||
|
this.errorField = '';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,9 +1018,10 @@ struct EventEditPage {
|
|||||||
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
|
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
|
||||||
const book = this.chosenBook();
|
const book = this.chosenBook();
|
||||||
if (book === null) {
|
if (book === null) {
|
||||||
this.statusMsg = this.books.length === 0
|
// 这也是"必填项缺失",同样要弹 Toast + 滚到对应字段,不能只写底部状态行
|
||||||
|
this.showFormError(this.books.length === 0
|
||||||
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
||||||
: '请选择要同步到的 CalDAV 日历本';
|
: '请选择要同步到的 CalDAV 日历本', 'book');
|
||||||
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
|
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -726,19 +1034,20 @@ struct EventEditPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
|
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
|
||||||
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
|
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误用 showFormError 强提示)。
|
||||||
if (!this.validate()) {
|
if (!this.validate()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const book = this.chosenBook();
|
const book = this.chosenBook();
|
||||||
if (book === null) {
|
if (book === null) {
|
||||||
this.statusMsg = this.books.length === 0
|
this.showFormError(this.books.length === 0
|
||||||
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
||||||
: '请选择要同步到的 CalDAV 日历本';
|
: '请选择要同步到的 CalDAV 日历本', 'book');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.statusMsg = '';
|
this.statusMsg = '';
|
||||||
|
this.errorField = '';
|
||||||
try {
|
try {
|
||||||
const context = this.getUIContext().getHostContext();
|
const context = this.getUIContext().getHostContext();
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
@@ -798,6 +1107,16 @@ struct EventEditPage {
|
|||||||
const ex = err as BusinessError;
|
const ex = err as BusinessError;
|
||||||
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
|
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
|
||||||
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
|
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
|
||||||
|
// ⭐ 这里紧接着会 router.back(),底部状态行根本来不及被看到 → 必须用 Toast
|
||||||
|
try {
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: '已保存到本地,同步到服务器失败,稍后会自动重试',
|
||||||
|
duration: 3000,
|
||||||
|
bottom: '70%'
|
||||||
|
});
|
||||||
|
} catch (e2) {
|
||||||
|
// Toast 失败不影响返回
|
||||||
|
}
|
||||||
// 数据仍在本地且带 dirty 标记,不会丢
|
// 数据仍在本地且带 dirty 标记,不会丢
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
@@ -968,10 +1287,12 @@ struct EventEditPage {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: 16, right: 16 })
|
.padding({ left: 16, right: 16 })
|
||||||
|
|
||||||
Scroll() {
|
Scroll(this.formScroller) {
|
||||||
Column({ space: 14 }) {
|
Column({ space: 14 }) {
|
||||||
// 标题
|
// 标题(必填)
|
||||||
TextInput({ text: this.title, placeholder: '标题' })
|
// ⭐ 校验失败时:输入框描红 + 紧贴下方一条红字说明。
|
||||||
|
// 只在 errorField === 'title' 时出现,用户开始输入即自动清除。
|
||||||
|
TextInput({ text: this.title, placeholder: '标题(必填)' })
|
||||||
.height(46)
|
.height(46)
|
||||||
.fontSize(16)
|
.fontSize(16)
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
@@ -981,9 +1302,28 @@ struct EventEditPage {
|
|||||||
.fontColor($r('app.color.text_primary'))
|
.fontColor($r('app.color.text_primary'))
|
||||||
.placeholderColor($r('app.color.text_hint'))
|
.placeholderColor($r('app.color.text_hint'))
|
||||||
.caretColor($r('app.color.brand'))
|
.caretColor($r('app.color.brand'))
|
||||||
|
// 标红:用边框而不是背景,深色/浅色主题下都醒目且不难看
|
||||||
|
.border({
|
||||||
|
width: this.errorField === 'title' ? 1.5 : 0,
|
||||||
|
color: $r('app.color.error')
|
||||||
|
})
|
||||||
.onChange((v: string) => {
|
.onChange((v: string) => {
|
||||||
this.title = v;
|
this.title = v;
|
||||||
|
// 用户开始填写就把红框撤掉,避免"改了还一直红着"
|
||||||
|
if (v.trim() !== '' && this.errorField === 'title') {
|
||||||
|
this.errorField = '';
|
||||||
|
this.statusMsg = '';
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
.id('titleInput')
|
||||||
|
|
||||||
|
if (this.errorField === 'title') {
|
||||||
|
Text('请填写标题后再保存')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.error'))
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
// 全天
|
// 全天
|
||||||
Row() {
|
Row() {
|
||||||
@@ -1012,6 +1352,19 @@ struct EventEditPage {
|
|||||||
.padding(6)
|
.padding(6)
|
||||||
.borderRadius(12)
|
.borderRadius(12)
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
// 时间顺序错误时整卡描红,用户一眼能看到问题在这个卡片里
|
||||||
|
.border({
|
||||||
|
width: this.errorField === 'time' ? 1.5 : 0,
|
||||||
|
color: $r('app.color.error')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.errorField === 'time') {
|
||||||
|
Text('结束时间不能早于开始时间')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.error'))
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
// 重复
|
// 重复
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
@@ -1173,6 +1526,11 @@ struct EventEditPage {
|
|||||||
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
|
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
this.chosenKey = b.calKey;
|
this.chosenKey = b.calKey;
|
||||||
|
// 选了日历本就把红框撤掉
|
||||||
|
if (this.errorField === 'book') {
|
||||||
|
this.errorField = '';
|
||||||
|
this.statusMsg = '';
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, (b: BookChoice) => b.calKey)
|
}, (b: BookChoice) => b.calKey)
|
||||||
}
|
}
|
||||||
@@ -1183,20 +1541,90 @@ struct EventEditPage {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.borderRadius(12)
|
.borderRadius(12)
|
||||||
|
// 日历本未选且用户点了保存 → 描红,提示"必填"
|
||||||
|
.border({
|
||||||
|
width: this.errorField === 'book' ? 1.5 : 0,
|
||||||
|
color: $r('app.color.error')
|
||||||
|
})
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
|
||||||
// 地点
|
// 地点(输入几个字 → 系统地图服务补全候选)
|
||||||
TextInput({ text: this.location, placeholder: '地点(可选)' })
|
// ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
|
||||||
.height(44)
|
// 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
|
||||||
.fontSize(14)
|
Row({ space: 8 }) {
|
||||||
.backgroundColor($r('app.color.card_bg'))
|
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
|
||||||
.borderRadius(12)
|
.height(44)
|
||||||
.fontColor($r('app.color.text_primary'))
|
.layoutWeight(1)
|
||||||
.placeholderColor($r('app.color.text_hint'))
|
.fontSize(14)
|
||||||
.caretColor($r('app.color.brand'))
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
.onChange((v: string) => {
|
.borderRadius(12)
|
||||||
this.location = v;
|
.fontColor($r('app.color.text_primary'))
|
||||||
})
|
.placeholderColor($r('app.color.text_hint'))
|
||||||
|
.caretColor($r('app.color.brand'))
|
||||||
|
.onChange((v: string) => {
|
||||||
|
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
|
||||||
|
// 精确比对最后一次程序写入值:命中就不再发起搜索,
|
||||||
|
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
|
||||||
|
if (v === this.lastAppliedText) {
|
||||||
|
this.lastAppliedText = '';
|
||||||
|
this.location = v;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastAppliedText = '';
|
||||||
|
this.location = v;
|
||||||
|
// 用户重新输入时解除"已选中"状态,恢复自动补全
|
||||||
|
this.placePicked = false;
|
||||||
|
this.scheduleSuggest(v);
|
||||||
|
})
|
||||||
|
.onSubmit(() => {
|
||||||
|
// 回车:立刻搜一次(不等防抖)
|
||||||
|
this.cancelSuggestTimer();
|
||||||
|
this.runSuggest(this.location, true);
|
||||||
|
})
|
||||||
|
.onFocus(() => {
|
||||||
|
// 键盘避让交给系统(RESIZE 已保证光标可见);
|
||||||
|
// 候选是浮层,不需要再滚动页面去给它腾位置
|
||||||
|
this.placeFocused = true;
|
||||||
|
})
|
||||||
|
.onBlur(() => {
|
||||||
|
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
|
||||||
|
this.placeFocused = false;
|
||||||
|
this.closePlacePopup();
|
||||||
|
})
|
||||||
|
.bindPopup($$this.placeOpen, {
|
||||||
|
builder: (): void => {
|
||||||
|
this.placePopup()
|
||||||
|
},
|
||||||
|
placement: Placement.Top,
|
||||||
|
popupColor: $r('app.color.card_bg'),
|
||||||
|
enableArrow: false,
|
||||||
|
showInSubWindow: false,
|
||||||
|
focusable: false,
|
||||||
|
autoCancel: true,
|
||||||
|
radius: 12,
|
||||||
|
onStateChange: (e: PopupStateChangeParam) => {
|
||||||
|
// 用户点浮层外部/floating 自动关闭时,同步状态,避免下次不弹
|
||||||
|
if (!e.isVisible && this.placeOpen) {
|
||||||
|
this.placeOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.id('placeAnchor')
|
||||||
|
// 在地图上选点(系统地图界面,可选可搜可周边)
|
||||||
|
Text('地图')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.brand_text'))
|
||||||
|
.padding({ left: 12, right: 12 })
|
||||||
|
.height(44)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.pickOnMap();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.id('placeRow')
|
||||||
|
|
||||||
// 描述
|
// 描述
|
||||||
TextArea({ text: this.description, placeholder: '备注(可选)' })
|
TextArea({ text: this.description, placeholder: '备注(可选)' })
|
||||||
@@ -1211,7 +1639,10 @@ struct EventEditPage {
|
|||||||
this.description = v;
|
this.description = v;
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.statusMsg !== '') {
|
// 状态行:只用于"保存失败/账号未配置"这类**系统级**信息,且必须有 Toast 同时提示。
|
||||||
|
// ⚠️ 校验类错误不走这里 —— 它们已经在对应字段旁就地显示,
|
||||||
|
// 再在这里重复一遍只会让用户以为有两处不同的问题。
|
||||||
|
if (this.statusMsg !== '' && this.errorField === '') {
|
||||||
Text(this.statusMsg)
|
Text(this.statusMsg)
|
||||||
.fontSize(13)
|
.fontSize(13)
|
||||||
.fontColor($r('app.color.error'))
|
.fontColor($r('app.color.error'))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
|
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
|
||||||
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
|
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
|
||||||
import { curves, display, router, window } from '@kit.ArkUI';
|
import { curves, display, router, window } from '@kit.ArkUI';
|
||||||
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { geoLocationManager } from '@kit.LocationKit';
|
import { geoLocationManager } from '@kit.LocationKit';
|
||||||
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
||||||
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
|
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
|
||||||
@@ -399,7 +399,8 @@ struct Index {
|
|||||||
/** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */
|
/** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */
|
||||||
private startAutoSync(): void {
|
private startAutoSync(): void {
|
||||||
if (this.autoSyncTimer === -1) {
|
if (this.autoSyncTimer === -1) {
|
||||||
AppStorage.setOrCreate('syncIntervalMinutes', 1);
|
// ⭐ 0.0.8:默认间隔 1 分钟 → 15 分钟(见 AppSettings.getSyncIntervalMinutes 注释)
|
||||||
|
AppStorage.setOrCreate('syncIntervalMinutes', 15);
|
||||||
const ctx = this.getUIContext().getHostContext();
|
const ctx = this.getUIContext().getHostContext();
|
||||||
if (ctx !== undefined) {
|
if (ctx !== undefined) {
|
||||||
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
|
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
|
||||||
@@ -412,7 +413,7 @@ struct Index {
|
|||||||
if (tickCtx !== undefined && !this.syncing) {
|
if (tickCtx !== undefined && !this.syncing) {
|
||||||
ReminderService.tickReminders(tickCtx as common.UIAbilityContext);
|
ReminderService.tickReminders(tickCtx as common.UIAbilityContext);
|
||||||
}
|
}
|
||||||
const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 1;
|
const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 15;
|
||||||
if (Date.now() - this.lastSyncTime < minutes * 60000) {
|
if (Date.now() - this.lastSyncTime < minutes * 60000) {
|
||||||
return; // 未到设置的同步间隔
|
return; // 未到设置的同步间隔
|
||||||
}
|
}
|
||||||
@@ -755,11 +756,10 @@ struct Index {
|
|||||||
}
|
}
|
||||||
const ctx = context as common.UIAbilityContext;
|
const ctx = context as common.UIAbilityContext;
|
||||||
const enc: string = encodeURIComponent(address);
|
const enc: string = encodeURIComponent(address);
|
||||||
// 高德鸿蒙深链 dlat/dlon 为必填(dname 仅作名称显示),先地理编码拿坐标
|
// 高德鸿蒙深链 dlat/dlon 为必填(dname 仅作名称显示),先地理编码拿坐标。
|
||||||
let coords: number[] | null = null;
|
// 地理编码(getAddressesFromLocationName)在本 SDK 上不要求定位权限,
|
||||||
if (await this.ensureLocationPermission()) {
|
// 因此这里既不申请、也不使用定位权限 —— 应用自始至终不读取设备实时位置。
|
||||||
coords = await this.geocodeAddress(address);
|
const coords: number[] | null = await this.geocodeAddress(address);
|
||||||
}
|
|
||||||
const base: string = 'amapuri://route/plan/?sourceApplication=SyncCalendar';
|
const base: string = 'amapuri://route/plan/?sourceApplication=SyncCalendar';
|
||||||
let link: string;
|
let link: string;
|
||||||
if (coords !== null) {
|
if (coords !== null) {
|
||||||
@@ -781,33 +781,7 @@ struct Index {
|
|||||||
this.getUIContext().getPromptAction().showToast({ message: '未安装高德地图或无法打开' });
|
this.getUIContext().getPromptAction().showToast({ message: '未安装高德地图或无法打开' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确认定位权限(地理编码依赖),未授权时向用户申请一次 */
|
/** 正向地理编码:地址 → 坐标(系统服务,无需高德 key、无需定位权限) */
|
||||||
private async ensureLocationPermission(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const atManager = abilityAccessCtrl.createAtManager();
|
|
||||||
const bundleInfo = bundleManager.getBundleInfoForSelfSync(
|
|
||||||
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
|
|
||||||
const tokenId = bundleInfo.appInfo.accessTokenId;
|
|
||||||
const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.LOCATION');
|
|
||||||
if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// 未授权会走申请流程
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
|
||||||
const atManager = abilityAccessCtrl.createAtManager();
|
|
||||||
// LOCATION 与 APPROXIMATELY_LOCATION 必须一起申请
|
|
||||||
const result = await atManager.requestPermissionsFromUser(context,
|
|
||||||
['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']);
|
|
||||||
return result.authResults.every((r: number): boolean => r === 0);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 正向地理编码:地址 → 坐标(系统服务,无需高德 key) */
|
|
||||||
private async geocodeAddress(address: string): Promise<number[] | null> {
|
private async geocodeAddress(address: string): Promise<number[] | null> {
|
||||||
try {
|
try {
|
||||||
if (!geoLocationManager.isGeocoderAvailable()) {
|
if (!geoLocationManager.isGeocoderAvailable()) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class BackupTarget {
|
|||||||
@Component
|
@Component
|
||||||
struct SettingsPage {
|
struct SettingsPage {
|
||||||
@State showSystem: boolean = true;
|
@State showSystem: boolean = true;
|
||||||
@State intervalMinutes: number = 1;
|
@State intervalMinutes: number = 15; // ⭐ 0.0.8:默认 15 分钟(原 1 分钟会烧光短时任务配额)
|
||||||
@State backgroundSync: boolean = false;
|
@State backgroundSync: boolean = false;
|
||||||
@State sysMode: string = 'display'; // display | backup
|
@State sysMode: string = 'display'; // display | backup
|
||||||
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
|
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
|
||||||
@@ -293,6 +293,51 @@ struct SettingsPage {
|
|||||||
return this.allBooks.length - this.mirrorCandidates.length;
|
return this.allBooks.length - this.mirrorCandidates.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已勾选的可镜像本数量(mirrorKeys 里可能残留已不在候选里的 bookId,按候选口径统计) */
|
||||||
|
private mirrorSelectedCount(): number {
|
||||||
|
return this.mirrorCandidates.filter((b: BackupTarget): boolean =>
|
||||||
|
this.mirrorKeys.indexOf(b.bookId) >= 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「全选 / 全不选」要镜像的日历本。
|
||||||
|
* ⭐ 批量落盘只写一次 preferences、缓存只失效一次(与逐个 toggle 的关键区别);
|
||||||
|
* 全不选时逐本删除对应系统日历账户(与取消单个勾选同一语义)。
|
||||||
|
* 半选状态下点按 = 全选。
|
||||||
|
*/
|
||||||
|
private async toggleMirrorAll(): Promise<void> {
|
||||||
|
if (this.context === undefined || this.mirrorCandidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectAll: boolean = this.mirrorSelectedCount() < this.mirrorCandidates.length;
|
||||||
|
if (selectAll) {
|
||||||
|
const ids: string[] = this.mirrorCandidates.map((b: BackupTarget): string => b.bookId);
|
||||||
|
this.mirrorKeys = ids;
|
||||||
|
await AppSettings.setMirrorBookIds(this.context, ids);
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: `已全选 ${ids.length} 个日历本,点「立即执行镜像」写入系统日历`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 逐本删除系统日历账户;逐个独立 try,失败不影响其余
|
||||||
|
for (const k of this.mirrorKeys) {
|
||||||
|
try {
|
||||||
|
await SystemCalendarMirror.removeById(this.context, k);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`全不选:删除镜像账户失败 bookId=${k}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.mirrorKeys = [];
|
||||||
|
await AppSettings.setMirrorBookIds(this.context, []);
|
||||||
|
this.getUIContext().getPromptAction().showToast({
|
||||||
|
message: '已取消全部勾选,并清除对应的系统日历账户'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ⭐ 勾选本集合变化 → 提醒侧"已镜像本"缓存失效并重排提醒
|
||||||
|
ReminderService.invalidateMirrorCache();
|
||||||
|
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||||||
|
}
|
||||||
|
|
||||||
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
|
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
|
||||||
private isBookReadonly(b: BackupTarget): boolean {
|
private isBookReadonly(b: BackupTarget): boolean {
|
||||||
return !b.serverWritable || this.manualKeys.includes(b.calKey);
|
return !b.serverWritable || this.manualKeys.includes(b.calKey);
|
||||||
@@ -604,7 +649,7 @@ struct SettingsPage {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 「清理残留镜像账户(诊断)」按钮:
|
* 「清理残留镜像账户(诊断)」按钮:
|
||||||
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志;
|
* ① 读系统日历里**全部**账户(含 id / name / displayName / type)并显示在下方清单里;
|
||||||
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
|
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
|
||||||
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
|
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
|
||||||
*/
|
*/
|
||||||
@@ -623,7 +668,8 @@ struct SettingsPage {
|
|||||||
if (r.remaining > 0) {
|
if (r.remaining > 0) {
|
||||||
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
|
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
|
||||||
}
|
}
|
||||||
msg += '(完整账户清单见同步日志)';
|
// ⭐ 0.0.8:不再写同步日志 → 提示语改为指向页面下方那份清单(accountDump)
|
||||||
|
msg += '(完整账户清单见下方)';
|
||||||
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
|
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
@@ -977,6 +1023,35 @@ struct SettingsPage {
|
|||||||
.fontColor($r('app.color.text_hint'))
|
.fontColor($r('app.color.text_hint'))
|
||||||
.width('100%')
|
.width('100%')
|
||||||
}
|
}
|
||||||
|
// ⭐ 全选 / 全不选:半选状态下点按 = 全选
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Text(`全选(已选 ${this.mirrorSelectedCount()}/${this.mirrorCandidates.length})`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
Toggle({
|
||||||
|
type: ToggleType.Checkbox,
|
||||||
|
isOn: this.mirrorCandidates.length > 0
|
||||||
|
&& this.mirrorSelectedCount() === this.mirrorCandidates.length
|
||||||
|
})
|
||||||
|
.selectedColor($r('app.color.brand'))
|
||||||
|
.onChange((on: boolean): void => {
|
||||||
|
// 半选时勾选框显示为关,onChange(on=true) 走全选;全选时取消走全不选
|
||||||
|
if (on === (this.mirrorSelectedCount() === this.mirrorCandidates.length)) {
|
||||||
|
return; // 状态未变化(个别系统会重复回调),忽略
|
||||||
|
}
|
||||||
|
this.toggleMirrorAll();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 2, bottom: 2 })
|
||||||
|
.onClick(() => {
|
||||||
|
// 整行可点:直接按"是否已全选"翻转,比依赖 Checkbox 的 onChange 更可靠
|
||||||
|
this.toggleMirrorAll();
|
||||||
|
})
|
||||||
|
|
||||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||||
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
|
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
|
||||||
Row({ space: 6 }) {
|
Row({ space: 6 }) {
|
||||||
|
|||||||
@@ -44,26 +44,6 @@
|
|||||||
],
|
],
|
||||||
"when": "inuse"
|
"when": "inuse"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ohos.permission.LOCATION",
|
|
||||||
"reason": "$string:perm_location",
|
|
||||||
"usedScene": {
|
|
||||||
"abilities": [
|
|
||||||
"EntryAbility"
|
|
||||||
],
|
|
||||||
"when": "inuse"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ohos.permission.APPROXIMATELY_LOCATION",
|
|
||||||
"reason": "$string:perm_location_approx",
|
|
||||||
"usedScene": {
|
|
||||||
"abilities": [
|
|
||||||
"EntryAbility"
|
|
||||||
],
|
|
||||||
"when": "inuse"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"deliveryWithInstall": true,
|
"deliveryWithInstall": true,
|
||||||
|
|||||||
@@ -24,14 +24,6 @@
|
|||||||
"name": "perm_read_whole_calendar",
|
"name": "perm_read_whole_calendar",
|
||||||
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "perm_location",
|
|
||||||
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "perm_location_approx",
|
|
||||||
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "card_ability_desc",
|
"name": "card_ability_desc",
|
||||||
"value": "同步日历服务卡片"
|
"value": "同步日历服务卡片"
|
||||||
|
|||||||
Reference in New Issue
Block a user