3 Commits
Author SHA1 Message Date
laoyang 32162b8f04 修复折叠屏合盖旋转不再切换布局的回归
上一版为满足"接近1:1屏幕横竖屏布局一致",把宽屏判据改成纯短边≥840vp,
导致长条形屏幕(普通手机、折叠屏合上后的外屏)旋转横屏也不出双栏。
现在两条判据满足其一即双栏:
① 短边≥840vp(展开态/平板)——旋转不变,维持商店合规要求;
② 横屏且宽高比≥1.5(长条形屏幕)——旋转正常切换,恢复 0.0.2 行为。
判据实现由 mediaquery 改为 windowSizeChange(px→vp),旋转/折叠展开均实时生效,
并记录窗口尺寸日志便于真机排查。
2026-09-29 09:09:06 +08:00
laoyang b83ce83d43 版本号升至 0.0.4(versionCode 400) 2026-09-29 08:46:48 +08:00
laoyang d0b8720c41 同步容错:服务器删除日历本后 404 不再导致整个账号同步失败
- DavClient 新增 DavHttpError(带 HTTP 状态码),REPORT/PUT/DELETE 统一抛出
- 同步引擎对单本 404 跳过并自动从账号移除(先按 href 清行再迁移序号),
  全部日历本均 404 时保留账号配置并给出明确提示
- 推送阶段单条失败不再中断整个账号(401 凭据失效仍立即抛出)
- 编辑账号保存与自动移除都在序号迁移前先删被移除日历本的行,
  修复行混入其它日历本 calKey 的老问题(实测「读书」988 行混进「轻舟」)
- applyRemote 刷新行的 href:日历本被删除重建后集合 URL 变化,
  旧行不再残留死地址(编辑推送 404 / 永久卡待推送)
- pruneOrphanRows 追加 href 维度兜底清扫(跳过 dirty 行)
2026-09-29 08:46:01 +08:00
6 changed files with 281 additions and 60 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 300, "versionCode": 400,
"versionName": "0.0.3", "versionName": "0.0.4",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+19 -5
View File
@@ -36,6 +36,20 @@ export class DavCalendarDiscovery {
entries: DavCalendarEntry[] = []; entries: DavCalendarEntry[] = [];
} }
/**
* 带 HTTP 状态码的 DAV 请求错误。
* 同步引擎据此区分「404 日历本/资源在服务器上已不存在」(可降级处理:
* 单本跳过、自动从账号移除)与其它网络/服务器故障(整个账号同步失败)。
*/
export class DavHttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export class DavClient { export class DavClient {
/** /**
* PROPFIND 拉取某路径下所有集合的 calendar-color * PROPFIND 拉取某路径下所有集合的 calendar-color
@@ -346,7 +360,7 @@ export class DavClient {
}); });
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new Error(`服务器返回状态码 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
} }
const xml: string = resp.result as string; const xml: string = resp.result as string;
const items: RemoteItem[] = []; const items: RemoteItem[] = [];
@@ -403,11 +417,11 @@ export class DavClient {
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
if (resp.responseCode === 401) { if (resp.responseCode === 401) {
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`); LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
throw new Error('服务器拒绝凭据(401)'); throw new DavHttpError(401, '服务器拒绝凭据(401)');
} }
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
throw new Error(`服务器返回状态码 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
} }
const xml: string = resp.result as string; const xml: string = resp.result as string;
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`); LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
@@ -459,7 +473,7 @@ export class DavClient {
console.info(`PUT ${url} 响应码: ${resp.responseCode}`); console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`); LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
if (resp.responseCode < 200 || resp.responseCode >= 300) { if (resp.responseCode < 200 || resp.responseCode >= 300) {
throw new Error(`推送失败,服务器返回 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `推送失败,服务器返回 ${resp.responseCode}`);
} }
const headers = resp.header as Record<string, string>; const headers = resp.header as Record<string, string>;
if (headers !== undefined && headers !== null) { if (headers !== undefined && headers !== null) {
@@ -489,7 +503,7 @@ export class DavClient {
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`); LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
// 404 视为已删除,成功 // 404 视为已删除,成功
if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) { if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) {
throw new Error(`删除失败,服务器返回 ${resp.responseCode}`); throw new DavHttpError(resp.responseCode, `删除失败,服务器返回 ${resp.responseCode}`);
} }
} finally { } finally {
httpRequest.destroy(); httpRequest.destroy();
+56 -2
View File
@@ -470,8 +470,13 @@ export class EventDb {
added++; added++;
} else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate
|| found.reminder !== r.reminder || found.recurring !== r.recurring || found.reminder !== r.reminder || found.recurring !== r.recurring
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) { || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填) || found.href !== href) {
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
found.href = href;
found.title = r.title; found.title = r.title;
found.description = r.description; found.description = r.description;
found.location = r.location; found.location = r.location;
@@ -495,6 +500,15 @@ export class EventDb {
updated++; updated++;
} else { } else {
unchanged++; unchanged++;
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中,
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释)
if (found.href !== href) {
found.href = href;
const hp = new relationalStore.RdbPredicates('events');
hp.equalTo('id', found.id);
const hb: relationalStore.ValuesBucket = { 'href': href };
await store.update(hb, hp);
}
} }
} }
// 删除远端已不存在的(排除本地修改未推送的) // 删除远端已不存在的(排除本地修改未推送的)
@@ -645,6 +659,46 @@ export class EventDb {
await store.delete(predicates); await store.delete(predicates);
} }
/**
* 删除某账号下、属于指定日历本集合(按 href 匹配)的全部本地日程/待办。
*
* 用途:日历本在服务器上被删除(同步 REPORT 返回 404)或用户取消勾选时,
* 必须在 calKey 序号迁移**之前**先删掉这些行——否则序号收窄后,被移除日历本的
* 旧行会与新搬到该序号下的其它日历本行混在同一个 calKey 里(2026-09-29 实测:
* 「读书」被服务器删除后重选,其 988 行混进「轻舟」的 calKey)。
* 行上自带 href(日历本集合 URL),按 href 删比按 calKey 删更稳——
* 历史原因(旧版迁移缺陷)行可能挂在错误的 calKey 下,href 才是行的真实归属。
*/
static async pruneByHrefs(context: common.Context, accId: string, hrefs: string[]): Promise<number> {
if (hrefs.length === 0) {
return 0;
}
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.like('cal_key', `${accId}_%`);
predicates.and().in('href', hrefs);
return await store.delete(predicates);
}
/**
* 清理「href 已不属于任何已选日历本」的残留行(跨账号兜底清扫)。
* 典型:服务器删除日历本后,旧版把其残留行混进其它日历本的 calKey(见 pruneByHrefs 注释),
* 这类行 calKey 看似有效、pruneOrphanCalKeys 抓不到,但 href 暴露了真实归属。
* ⚠️ 跳过 dirty 行(用户未推送的本地修改绝不能被清扫掉)与 local 行。
*/
static async pruneStaleHrefs(context: common.Context, validHrefs: string[]): Promise<number> {
if (validHrefs.length === 0) {
return 0;
}
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.notEqualTo('cal_key', 'local');
predicates.and().notEqualTo('href', '');
predicates.and().notIn('href', validHrefs);
predicates.and().equalTo('dirty', 0);
return await store.delete(predicates);
}
/** /**
* 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。 * 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。
* *
+134 -12
View File
@@ -2,12 +2,13 @@
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量) // 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount } from './AccountStore'; import { DavAccount, AccountStore } from './AccountStore';
import { EventDb, LocalEvent, RemoteEvent } from './EventDb'; import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
import { IcsUtil } from './IcsUtil'; import { IcsUtil } from './IcsUtil';
import { DavClient, DavColorEntry, RemoteItem } from './DavClient'; import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { SystemCalendarMirror } from './SystemCalendarMirror';
export class SyncEngine { export class SyncEngine {
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */ /** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
@@ -91,13 +92,28 @@ export class SyncEngine {
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS'); LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
} }
let changed: number = 0; let changed: number = 0;
// 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本,
// 循环结束后统一从账号移除(见 removeDeadBooks)。
// 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 →
// 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。
const deadIdxs: number[] = [];
for (let i = 0; i < acc.calendarHrefs.length; i++) { for (let i = 0; i < acc.calendarHrefs.length; i++) {
const href: string = acc.calendarHrefs[i]; const href: string = acc.calendarHrefs[i];
const calKey: string = `${acc.id}_${i}`; const calKey: string = `${acc.id}_${i}`;
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`); LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
const t1: number = Date.now(); const t1: number = Date.now();
const items: RemoteItem[] = await DavClient.reportEtags(href, auth); let items: RemoteItem[] = [];
try {
items = await DavClient.reportEtags(href, auth);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 404) {
LogUtil.write(`日历本[${i}]「${calName}」在服务器上已不存在(404),本轮跳过,收尾时自动从账号移除`);
deadIdxs.push(i);
continue;
}
throw err as Error;
}
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`); LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
@@ -197,9 +213,92 @@ export class SyncEngine {
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`); LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
} }
} }
// 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。
// 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。
if (deadIdxs.length > 0) {
await SyncEngine.removeDeadBooks(context, acc, deadIdxs);
}
return changed; return changed;
} }
/**
* 从账号移除「服务器上已不存在(404)」的日历本:收窄配置、迁移序号、清理本地数据并持久化。
* 只要有至少一个日历本保留就自动移除;若全部失效则不动配置(避免把账号清空),
* 抛错交给上层提示用户去服务器/编辑账号处理。
*/
private static async removeDeadBooks(context: common.Context, acc: DavAccount,
deadIdxs: number[]): Promise<void> {
if (deadIdxs.length >= acc.calendarHrefs.length) {
throw new Error('该账号下所有日历本在服务器上均已不存在(404),请检查服务器或重新编辑账号');
}
const deadSet: Set<number> = new Set<number>();
for (const d of deadIdxs) {
deadSet.add(d);
}
const removedHrefs: string[] = [];
const newHrefs: string[] = [];
const newNames: string[] = [];
const newColors: string[] = [];
const newWritable: string[] = [];
for (let i = 0; i < acc.calendarHrefs.length; i++) {
if (deadSet.has(i)) {
removedHrefs.push(acc.calendarHrefs[i]);
continue;
}
newHrefs.push(acc.calendarHrefs[i]);
newNames.push(i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`);
newColors.push(i < acc.calendarColors.length ? acc.calendarColors[i] : '');
newWritable.push(i < acc.calendarWritable.length ? acc.calendarWritable[i] : '1');
}
LogUtil.write(`自动移除 ${removedHrefs.length} 个在服务器上不存在的日历本,保留 ${newHrefs.length} 个`);
// 1) 先按 href 删掉被移除日历本的本地行——必须在序号迁移之前,
// 否则序号收窄后这些行会混进接替该序号的其它日历本(pruneByHrefs 注释详述)
await EventDb.pruneByHrefs(context, acc.id, removedHrefs);
// 2) 序号迁移:保留的日历本按原顺序收紧(旧序号 → 新序号)
const oldHrefs: string[] = acc.calendarHrefs.slice();
const pairs: Array<[string, string]> = [];
let ni: number = 0;
for (let oi = 0; oi < oldHrefs.length; oi++) {
if (deadSet.has(oi)) {
continue;
}
if (ni !== oi) {
pairs.push([`${acc.id}_${oi}`, `${acc.id}_${ni}`]);
}
ni++;
}
if (pairs.length > 0) {
const moved: number = await EventDb.remapCalKeys(context, pairs);
LogUtil.write(`移除失效日历本后迁移日程 ${moved} 行(${pairs.length} 个日历本序号前移)`);
}
// 3) 收窄账号配置并持久化
acc.calendarHrefs = newHrefs;
acc.calendarNames = newNames;
acc.calendarColors = newColors;
acc.calendarWritable = newWritable;
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);
}
// 4) 兜底:该账号下 calKey 不在有效列表的行全部清掉
const validKeys: string[] = [];
for (let i = 0; i < newHrefs.length; i++) {
validKeys.push(`${acc.id}_${i}`);
}
await EventDb.pruneAccountEvents(context, acc.id, validKeys);
// 5) 若被移除的日历本正是「系统日历 → CalDAV」的备份目标,当场判失效并关闭备份
try {
const backupCheck = await SystemCalendarMirror.checkBackupTarget(context);
if (backupCheck.closed) {
LogUtil.write('备份目标日历本已在服务器上删除,备份功能已自动关闭');
}
} catch (err) {
// 检查失败不影响主流程
}
}
/** /**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
@@ -329,15 +428,25 @@ export class SyncEngine {
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`); LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
} }
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`; const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
if (e.deleted) { try {
await DavClient.deleteRemote(url, auth); if (e.deleted) {
await EventDb.purge(context, e.id); await DavClient.deleteRemote(url, auth);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`); await EventDb.purge(context, e.id);
} else { LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
const ics: string = IcsUtil.build(e); } else {
const etag: string = await DavClient.putEvent(url, auth, ics); const ics: string = IcsUtil.build(e);
await EventDb.clearDirty(context, e.id, etag); const etag: string = await DavClient.putEvent(url, auth, ics);
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`); await EventDb.clearDirty(context, e.id, etag);
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`);
}
} catch (err) {
// 单条推送失败不再中断整个账号(旧行为:一条 404 就让整个账号同步失败)。
// 401(凭据失效)例外:后续每条也必然失败,直接抛出让上层提示。
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
const pe = err as Error;
LogUtil.write(`推送单条失败(跳过,保留待推送):uid=${e.uid} → ${pe.message}`);
} }
} }
} }
@@ -388,6 +497,19 @@ export class SyncEngine {
if (removed > 0) { if (removed > 0) {
LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`); LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`);
} }
// 兜底清扫:行上 href 已不属于任何已选日历本的残留行(calKey 可能因历史迁移
// 缺陷混在有效日历本名下,calKey 维度抓不到,href 才暴露真实归属)。
// 典型如服务器删除日历本后残留的"幽灵"数据——不清掉的话镜像会一直把它写进系统日历。
const validHrefs: string[] = [];
for (const a of accounts) {
for (const h of a.calendarHrefs) {
validHrefs.push(h);
}
}
const stale: number = await EventDb.pruneStaleHrefs(context, validHrefs);
if (stale > 0) {
LogUtil.write(`清理失效日历本残留行:${stale} 条(href 已不属于任何已选日历本)`);
}
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`); LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
@@ -224,6 +224,24 @@ struct EditAccountPage {
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`); LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey // 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
// 序号收窄后,被取消本的旧 calKey 会被前面位移下来的本"接管",
// 不先删的话旧行会与新主的行混在同一个 calKey 里(2026-09-29 实测:
// 服务器删除「读书」后重选,其 988 行混进「轻舟」的 calKey,镜像一直复活幽灵数据)。
const newHrefSet: Set<string> = new Set<string>();
for (const c of newHrefs) {
newHrefSet.add(c);
}
const removedHrefs: string[] = [];
for (const h of oldHrefs) {
if (!newHrefSet.has(h)) {
removedHrefs.push(h);
}
}
if (removedHrefs.length > 0) {
const pruned: number = await EventDb.pruneByHrefs(context, target.id, removedHrefs);
LogUtil.write(`编辑账号保存:取消勾选/已失效 ${removedHrefs.length} 个日历本,清理本地日程 ${pruned} 行`);
}
const remapPairs: Array<[string, string]> = []; const remapPairs: Array<[string, string]> = [];
for (let oi: number = 0; oi < oldHrefs.length; oi++) { for (let oi: number = 0; oi < oldHrefs.length; oi++) {
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]); const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
+52 -39
View File
@@ -1,7 +1,7 @@
// entry/src/main/ets/pages/Index.ets // entry/src/main/ets/pages/Index.ets
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 // 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
import { curves, mediaquery, router } from '@kit.ArkUI'; import { curves, display, router, window } from '@kit.ArkUI';
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager } 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';
@@ -54,13 +54,16 @@ struct Index {
@State syncing: boolean = false; @State syncing: boolean = false;
@State loading: boolean = true; @State loading: boolean = true;
@State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单 @State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单
// 宽屏(平板 / 折叠屏展开态):月视图切左右双栏(左月历 / 右当日日程)。 // 宽屏(双栏:月视图左月历 / 右当日日程,周视图同理)判据,两条满足其一即双栏:
// 判据是窗口**短边**是否达到大屏断点 840vp:短边在旋转时不变(宽高互换), // ① 窗口短边 ≥ 840vp —— 折叠屏展开态 / 平板这类"大屏"。短边在旋转时不变,
// 所以横竖屏布局天然保持一致 —— 满足"屏幕比例接近 1:1 的设备,横竖屏布局需保持一致"; // 故横竖屏布局天然一致,满足"屏幕比例接近 1:1 的设备,横竖屏布局需保持一致";
// 同时不锁死屏幕方向,折叠屏展开态与平板仍可自由横竖屏切换。 // ② 横屏且宽高比 ≥ 1.5 —— 长条形屏幕(普通手机、折叠屏**合上后的外屏**)。
// 这类设备比例远离 1:1,旋转应当切换布局(0.0.2 起即如此,用户已习惯)。
// 加比例下限是为了兜底"短边不足 840 但比例接近 1:1"的小方屏:旋转依旧不变。
// 2026-09-29 修复:上一版只留了判据 ①,导致 Mate X7 合盖后旋转屏幕布局不再变化。
@State wideLayout: boolean = false; @State wideLayout: boolean = false;
private widthListener: mediaquery.MediaQueryListener | null = null; private winObj: window.Window | null = null;
private heightListener: mediaquery.MediaQueryListener | null = null; private winSizeCallback: ((size: window.Size) => void) | null = null;
private accounts: DavAccount[] = []; private accounts: DavAccount[] = [];
private sources: CalSource[] = []; private sources: CalSource[] = [];
private swiperController: SwiperController = new SwiperController(); private swiperController: SwiperController = new SwiperController();
@@ -147,47 +150,57 @@ struct Index {
clearInterval(this.nowTimer); clearInterval(this.nowTimer);
this.nowTimer = -1; this.nowTimer = -1;
} }
if (this.widthListener !== null) { if (this.winObj !== null && this.winSizeCallback !== null) {
this.widthListener.off('change'); try {
this.widthListener = null; this.winObj.off('windowSizeChange', this.winSizeCallback);
} } catch (err) {
if (this.heightListener !== null) { // 窗口可能已销毁(页面退出),忽略
this.heightListener.off('change'); }
this.heightListener = null; this.winSizeCallback = null;
this.winObj = null;
} }
} }
/** /**
* 宽屏判据:窗口短边 min(宽, 高) 是否达到大屏断点 840vp。 * 由窗口实际尺寸(px → vp)计算单栏/双栏。判据见 wideLayout 字段注释:
* min(w,h) >= 840vp 等价于 (width >= 840vp) && (height >= 840vp),用两条 mediaquery 合成。 * 短边 ≥ 840vp(大屏,旋转不变)或 横屏且宽高比 ≥ 1.5(长条形屏幕,旋转切换)。
*
* 为什么用"短边"而不是"是否横屏":
* 1) 旋转时短边不变 → 横竖屏布局自动保持一致。屏幕比例接近 1:1 的设备(折叠屏展开态、
* 平板)在旋转时不会出现"单栏 ⇄ 双栏"的布局跳变;
* 2) 不锁死屏幕方向,仍支持折叠屏展开态与平板的横竖屏自由切换显示。
*/ */
private applyLayoutMetrics(): void { private applyLayoutFromWindow(win: window.Window): void {
let wOk: boolean = false; try {
let hOk: boolean = false; const rect = win.getWindowProperties().windowRect;
if (this.widthListener !== null) { const density: number = display.getDefaultDisplaySync().densityPixels;
wOk = this.widthListener.matches; if (density <= 0) {
return;
}
const w: number = rect.width / density;
const h: number = rect.height / density;
const shortEdge: number = Math.min(w, h);
const ratio: number = h > 0 ? w / h : 1;
const wide: boolean = shortEdge >= 840 || (w > h && ratio >= 1.5);
LogUtil.write(`窗口尺寸 ${Math.round(w)}x${Math.round(h)}vp(短边 ${Math.round(shortEdge)},宽高比 ${ratio.toFixed(2)})→ ${wide ? '双栏' : '单栏'}`);
if (wide !== this.wideLayout) {
this.wideLayout = wide;
}
} catch (err) {
// 取窗口信息失败时保持现状(罕见:窗口已销毁)
} }
if (this.heightListener !== null) {
hOk = this.heightListener.matches;
}
this.wideLayout = wOk && hOk;
} }
private initLayoutListener(): void { private initLayoutListener(): void {
const mq = this.getUIContext().getMediaQuery(); const ctx = this.getUIContext().getHostContext();
this.widthListener = mq.matchMediaSync('(width >= 840vp)'); if (ctx === undefined) {
this.heightListener = mq.matchMediaSync('(height >= 840vp)'); return;
this.applyLayoutMetrics(); }
this.widthListener.on('change', (): void => { window.getLastWindow(ctx).then((win: window.Window): void => {
this.applyLayoutMetrics(); this.winObj = win;
}); this.applyLayoutFromWindow(win);
this.heightListener.on('change', (): void => { this.winSizeCallback = (_size: window.Size): void => {
this.applyLayoutMetrics(); // size 参数为 px;重新读取属性并换算(折叠/展开、旋转都会触发)
this.applyLayoutFromWindow(win);
};
win.on('windowSizeChange', this.winSizeCallback);
}).catch((err: BusinessError): void => {
LogUtil.write(`窗口尺寸监听初始化失败(保持默认单栏):${err.message}`);
}); });
} }