重复日程支持单次/后续/全部范围;时间轴刻度改为 8-18 固定

重复日程编辑与删除:
- 编辑或删除重复日程前先让用户选范围:仅这一次 / 此次及以后 / 全部
- 仅这一次:主事件加 EXDATE 排除该次,另插一条带 RECURRENCE-ID 的覆盖行
- 此次及以后:主事件 RRULE 加 UNTIL 截断,剩余部分拆成新 uid 的新系列
- 全部:删除/改写整个系列
- IcsUtil 新增 vevent / buildSeries / rruleWithUntil / fmtIcsTime:
  同一个 .ics 资源内写主事件 + 全部覆盖实例(标准写法),
  此前对覆盖实例直接跳过推送、导致单次改动无法上传服务器
- SyncEngine.pushSeries:按 uid 取全系列行重建整份 .ics 后一次性 PUT
- EventDb 新增 queryByUid / purgeByUid / purgeByUidFrom / reassignOverridesFrom /
  setExdate / setRruleForUid

时间轴刻度(App + 4x4/6x6 卡片同口径):
- 8:00~18:00 恒定显示;仅当 8 点前 / 18 点后有日程时才向该侧扩展,
  其余时刻不再出现'只有一条日程就只剩一小段刻度'的观感
This commit is contained in:
2026-10-09 21:40:53 +08:00
parent d5e5d54d35
commit 5c80c0b773
7 changed files with 677 additions and 85 deletions
+110
View File
@@ -624,6 +624,116 @@ export class EventDb {
await store.delete(predicates); await store.delete(predicates);
} }
/**
* ⭐ 取同一个 UID 在同一个日历本下的**全部行**(主事件 + 覆盖实例)。
* 用于"重复日程的单次/后续/全部"编辑与删除:重建整个系列后一次性推送。
* 只返回未删除的行。
*/
static async queryByUid(context: common.Context, calKey: string, uid: string): Promise<LocalEvent[]> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
predicates.orderByAsc('id');
const rs = await store.query(predicates);
const list: LocalEvent[] = [];
try {
while (rs.goToNextRow()) {
const e = EventDb.fromRow(rs);
if (!e.deleted) {
list.push(e);
}
}
} finally {
rs.close();
}
return list;
}
/** 物理删除某 uid 在某个日历本下的全部行("删除全部"用) */
static async purgeByUid(context: common.Context, calKey: string, uid: string): Promise<number> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid);
return await store.delete(predicates);
}
/** 物理删除某 uid 在某个日历本下、开始时间 >= from 的全部行("此次及以后"删除用) */
static async purgeByUidFrom(context: common.Context, calKey: string, uid: string,
from: number): Promise<number> {
const store = await EventDb.getDb(context);
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
.and().greaterThanOrEqualTo('start_time', from);
return await store.delete(predicates);
}
/**
* ⭐ 把某个 uid 系列下、开始时间 >= from 的行搬到另一个 uid("此次及以后"改期的实现):
* 主事件被截断(加 UNTIL)后,剩余部分作为**新的独立系列**存在,否则会被原系列覆盖。
* 返回被搬迁的行数。
*/
static async reassignUidFrom(context: common.Context, calKey: string, oldUid: string,
newUid: string, newPath: string, from: number): Promise<number> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'uid': newUid,
'remote_path': newPath,
'etag': '',
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
.and().greaterThanOrEqualTo('start_time', from);
return await store.update(bucket, predicates);
}
/** 给单行写入 EXDATE("仅删除这一次"→ 排除主事件的该次发生) */
static async setExdate(context: common.Context, id: number, exdate: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'exdate': exdate, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/**
* ⭐ 把某 uid 系列下、**RECURRENCE-ID >= from** 的覆盖实例行搬到新 uid
* ("此次及以后"拆分系列时用;主事件行 recurrenceId=0,天然不会被命中)。
*/
static async reassignOverridesFrom(context: common.Context, calKey: string, oldUid: string,
newUid: string, newPath: string, from: number): Promise<number> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'uid': newUid,
'remote_path': newPath,
'etag': '',
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', oldUid)
.and().greaterThanOrEqualTo('recurrence_id', from);
return await store.update(bucket, predicates);
}
/**
* ⭐ 给某个 uid 系列(指定日历本)的主事件写入 RRULE("此次及以后"截断/改期时用)。
* 只作用于有 RRULE 的主事件行。
*/
static async setRruleForUid(context: common.Context, calKey: string, uid: string,
rrule: string): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = { 'rrule': rrule, 'dirty': 1 };
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('cal_key', calKey).and().equalTo('uid', uid)
.and().notEqualTo('rrule', '');
await store.update(bucket, predicates);
}
/** 新增一行(供拆分系列时插入新主事件;已置 dirty) */
static async insertRow(context: common.Context, e: LocalEvent): Promise<number> {
return await EventDb.insertLocal(context, e);
}
/** /**
* 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。 * 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。 * 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
+108 -8
View File
@@ -236,6 +236,42 @@ export class IcsUtil {
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。 * 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
*/ */
static build(e: LocalEvent): string { static build(e: LocalEvent): string {
const now: string = IcsUtil.fmtUtcNow();
return 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
'CALSCALE:GREGORIAN\r\n' +
IcsUtil.vevent(e, now) +
'END:VCALENDAR\r\n';
}
/**
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
*
* 为什么必须合在一起:CalDAV 里"重复日程的某一次发生被单独修改"的标准表示法是
* 在**同一个 .ics 资源**里放第二个 VEVENT,带 `RECURRENCE-ID` 指向被覆盖的那一次。
* 早先 App 直接把每个实例当独立资源 PUT,会破坏服务器上的整个系列,所以那时干脆
* 禁止了单次修改(见旧版"重复日程的单次修改暂不支持")。现在改为重建整份系列后推送。
*
* @param master 主事件行(含 RRULE / EXDATE)
* @param overrides 覆盖实例行(rrule 为空、recurrenceId > 0),已排除 deleted
*/
static buildSeries(master: LocalEvent, overrides: LocalEvent[]): string {
const now: string = IcsUtil.fmtUtcNow();
let out: string = 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
'CALSCALE:GREGORIAN\r\n' +
IcsUtil.vevent(master, now);
for (const ov of overrides) {
out += IcsUtil.vevent(ov, now);
}
out += 'END:VCALENDAR\r\n';
return out;
}
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
private static vevent(e: LocalEvent, now: string): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n); const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const fmtUtc = (ms: number): string => { const fmtUtc = (ms: number): string => {
const d = new Date(ms); const d = new Date(ms);
@@ -256,24 +292,88 @@ export class IcsUtil {
dtstart = 'DTSTART:' + fmtUtc(e.startTime); dtstart = 'DTSTART:' + fmtUtc(e.startTime);
dtend = 'DTEND:' + fmtUtc(e.endTime); dtend = 'DTEND:' + fmtUtc(e.endTime);
} }
const now: string = fmtUtc(Date.now()); // ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
return 'BEGIN:VCALENDAR\r\n' + // 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
'VERSION:2.0\r\n' + let recId: string = '';
'PRODID:-//SyncCalendar//Calendar//CN\r\n' + if (e.rrule === '' && e.recurrenceId > 0) {
'CALSCALE:GREGORIAN\r\n' + recId = e.isAllDay
'BEGIN:VEVENT\r\n' + ? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
: `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`;
}
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
let exdate: string = '';
if (e.exdate !== '') {
const vals: string[] = [];
for (const raw of e.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
vals.push(t.allDay ? fmtDate(t.time) : fmtUtc(t.time));
}
}
if (vals.length > 0) {
exdate = e.isAllDay
? `EXDATE;VALUE=DATE:${vals.join(',')}\r\n`
: `EXDATE:${vals.join(',')}\r\n`;
}
}
return 'BEGIN:VEVENT\r\n' +
`UID:${e.uid}\r\n` + `UID:${e.uid}\r\n` +
`DTSTAMP:${now}\r\n` + `DTSTAMP:${now}\r\n` +
`${dtstart}\r\n` + `${dtstart}\r\n` +
`${dtend}\r\n` + `${dtend}\r\n` +
recId +
(e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') + (e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') +
exdate +
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` + `SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') + (e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') + (e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
IcsUtil.valarms(e.reminders.length > 0 ? e.reminders IcsUtil.valarms(e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : [])) + : (e.reminder > 0 ? [e.reminder] : [])) +
'END:VEVENT\r\n' + 'END:VEVENT\r\n';
'END:VCALENDAR\r\n'; }
/** 当前时刻的 UTC 形式(DTSTAMP) */
private static fmtUtcNow(): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date();
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ`
*/
static fmtIcsTime(ms: number, dateOnly: boolean): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date(ms);
if (dateOnly) {
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
/**
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
* - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒"
*/
static rruleWithUntil(rrule: string, untilMs: number): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const d = new Date(untilMs);
const until: string = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
const kept: string[] = [];
for (const part of rrule.split(';')) {
const k: string = part.split('=')[0].trim().toUpperCase();
if (k === 'UNTIL' || k === 'COUNT' || k === '') {
continue;
}
kept.push(part);
}
kept.push(`UNTIL=${until}`);
return kept.join(';');
} }
/** 由提醒分钟数组生成多个 VALARM(已规范化去重) */ /** 由提醒分钟数组生成多个 VALARM(已规范化去重) */
+81 -7
View File
@@ -404,6 +404,9 @@ export class SyncEngine {
const dirty: LocalEvent[] = await EventDb.getDirty(context); const dirty: LocalEvent[] = await EventDb.getDirty(context);
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href)); const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`); LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
// ⭐ 同一个重复系列(uid + 日历本)在一次推送里只处理一次:整份 .ics(主事件 + 各覆盖实例)
// 一起重建后推送,避免"先 PUT 覆盖实例、后 PUT 主事件"互相覆盖。
const pushedSeries: Set<string> = new Set<string>();
for (const e of mine) { for (const e of mine) {
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推) // 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
const bookIdx: number = acc.calendarHrefs.indexOf(e.href); const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
@@ -417,15 +420,17 @@ export class SyncEngine {
await EventDb.clearDirty(context, e.id, e.etag); await EventDb.clearDirty(context, e.id, e.etag);
continue; continue;
} }
if (e.recurring && e.rrule === '') { // ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持 // 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器;
await EventDb.clearDirty(context, e.id, e.etag); // 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`); const seriesKey: string = `${e.calKey}|${e.uid}`;
if ((e.recurring || e.rrule !== '') && !pushedSeries.has(seriesKey)) {
pushedSeries.add(seriesKey);
await SyncEngine.pushSeries(context, acc, auth, e);
continue; continue;
} }
if (e.recurring) { if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) {
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送 continue; // 该系列已在上面整体推送过
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}`;
try { try {
@@ -451,6 +456,75 @@ export class SyncEngine {
} }
} }
/**
* ⭐ 推送一个"重复系列":取该 uid 下的主事件与全部覆盖实例,合成整份 .ics 后 PUT。
*
* - 主事件行若被标记删除(deleted)→ 视为"整个系列删除",DELETE 整个资源并清掉本地该 uid 全部行。
* - 否则重建(主事件可能带上了 EXDATE / 被截断的 UNTIL,覆盖实例带 RECURRENCE-ID),
* 一次性 PUT 覆盖服务器上的同一资源;成功后清掉本系列全部行的 dirty。
*/
private static async pushSeries(context: common.Context, acc: DavAccount, auth: string,
trigger: LocalEvent): Promise<void> {
const calKey: string = trigger.calKey;
const uid: string = trigger.uid;
const rows: LocalEvent[] = await EventDb.queryByUid(context, calKey, uid);
// 主事件:优先取带 RRULE 的行;退而取最早一行
let master: LocalEvent | null = null;
for (const r of rows) {
if (r.rrule !== '') {
master = r;
break;
}
}
if (master === null && rows.length > 0) {
master = rows[0];
}
if (master === null) {
return;
}
const href: string = master.href;
const path: string = master.remotePath !== '' ? master.remotePath : encodeURIComponent(uid) + '.ics';
const url: string = href.endsWith('/') ? href + path : `${href}/${path}`;
// 整个系列被删除(主事件行 deleted)→ DELETE 资源 + 清掉该 uid 全部本地行
if (master.deleted) {
try {
await DavClient.deleteRemote(url, auth);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
LogUtil.write(`推送删除重复系列失败:uid=${uid} → ${(err as Error).message}`);
return;
}
await EventDb.purgeByUid(context, calKey, uid);
LogUtil.write(`推送删除重复系列(标题已脱敏)uid=${uid} → ${url}`);
return;
}
const overrides: LocalEvent[] = [];
for (const r of rows) {
if (r.id !== master.id && r.rrule === '' && r.recurrenceId > 0) {
overrides.push(r);
}
}
overrides.sort((a: LocalEvent, b: LocalEvent): number => a.recurrenceId - b.recurrenceId);
const ics: string = IcsUtil.buildSeries(master, overrides);
try {
const etag: string = await DavClient.putEvent(url, auth, ics);
// 清掉本系列全部行的 dirty(第一条写回 etag)
let first: boolean = true;
for (const r of rows) {
await EventDb.clearDirty(context, r.id, first ? etag : '');
first = false;
}
LogUtil.write(`推送重复系列(标题已脱敏)uid=${uid} → ${url}(${ics.length} 字节,覆盖实例=${overrides.length})`);
} catch (err) {
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
throw err as Error;
}
LogUtil.write(`推送重复系列失败(保留待推送):uid=${uid} → ${(err as Error).message}`);
}
}
/** /**
* 清理本机虚拟日历(calKey=local)的待推送状态: * 清理本机虚拟日历(calKey=local)的待推送状态:
* 本机事件不参与 DAV 同步,直接落地 * 本机事件不参与 DAV 同步,直接落地
+20 -15
View File
@@ -272,38 +272,43 @@ export class TimelineUtil {
return out; return out;
} }
/** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */
static readonly BASE_START_HOUR: number = 8;
static readonly BASE_END_HOUR: number = 18;
/** /**
* 计算"需要显示的小时区间"(视窗截断): * 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认):
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点) * - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见 * 就只显示那 1 小时"的奇怪观感);
* - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示;
* - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的
* "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩,
* 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。 * 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
*/ */
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange { static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
const rg = new ViewRange(); const rg = new ViewRange();
let s: number = -1; // 基准:8~18 恒显示
let e: number = -1; let s: number = TimelineUtil.BASE_START_HOUR;
let e: number = TimelineUtil.BASE_END_HOUR;
for (const b of blocks) { for (const b of blocks) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
if (s < 0) {
s = 8;
}
if (e < 0) {
e = 20;
}
if (includeNowHour) { if (includeNowHour) {
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24); const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
if (nh < s) { // 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示")
if (nh < s && s < TimelineUtil.BASE_START_HOUR) {
s = nh; s = nh;
} }
if (nh + 1 > e) { if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) {
e = nh + 1; e = nh + 1;
} }
} }
+336 -17
View File
@@ -11,6 +11,15 @@ import { DavClient } from '../common/DavClient';
import { SyncEngine } from '../common/SyncEngine'; import { SyncEngine } from '../common/SyncEngine';
import { ReminderService } from '../common/ReminderService'; import { ReminderService } from '../common/ReminderService';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds {
thisOcc: number = 0; // 本次目标的(原始)发生时刻
beforeThis: number = 0; // 此次之前一点(截断 UNTIL 用),= 上一次与本次的中点或本次-1s
nextOcc: number = 0; // 下一次发生时刻(0 = 已是最后一次)
}
/** 可选的日历本 */ /** 可选的日历本 */
class BookChoice { class BookChoice {
@@ -41,6 +50,12 @@ struct EventEditPage {
@State pickDate: Date = new Date(); @State pickDate: Date = new Date();
@State pickHour: number = 9; @State pickHour: number = 9;
@State pickMin: number = 0; @State pickMin: number = 0;
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
@State scope: string = 'all';
@State scopeConfirmShow: boolean = false; // 保存重复日程时的范围选择弹层
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
private occStartMs: number = 0;
private event: LocalEvent | null = null; private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> { aboutToAppear(): Promise<void> {
@@ -92,6 +107,8 @@ struct EventEditPage {
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
: (loaded.reminder > 0 ? [loaded.reminder] : []); : (loaded.reminder > 0 ? [loaded.reminder] : []);
// ⭐ 目标发生时刻:优先用 RECURRENCE-ID(覆盖行=被改写的原始时刻),否则用行本身开始时间
this.occStartMs = loaded.recurrenceId > 0 ? loaded.recurrenceId : loaded.startTime;
return; return;
} }
} }
@@ -226,13 +243,182 @@ struct EventEditPage {
return true; return true;
} }
private async save(): Promise<void> { /**
if (this.isSaving || !this.validate()) { * 当前编辑的是不是一个"重复系列中的某一次发生"?
* - 主事件行(rrule 非空):用户点开的可能就是第一次发生或其展开实例 → 需要询问范围
* - 覆盖实例行(recurring && rrule==''):明确就是被单独改写过的那一次 → 需要询问范围
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
*/
private isSeriesOccurrence(): boolean {
if (this.event === null) {
return false;
}
return this.event.recurring || this.event.rrule !== '';
}
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行) */
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
if (this.event === null) {
return null;
}
const rows: LocalEvent[] = await EventDb.queryByUid(context, this.event.calKey, this.event.uid);
for (const r of rows) {
if (r.rrule !== '') {
return r;
}
}
return this.event;
}
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */
private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> {
const b = new SeriesBounds();
const occ: number = this.occStartMs;
b.thisOcc = occ;
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
b.beforeThis = occ - 1000;
b.nextOcc = 0;
return b;
}
const exNums: number[] = [];
if (master.exdate !== '') {
for (const raw of master.exdate.split(';')) {
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
if (t !== null) {
exNums.push(t.time);
}
}
}
// 展开窗口:主事件开始 ~ "该次"之后一年的发生,用于找"前后相邻"的两次
const winEnd: number = occ + 366 * 86400000;
const occs: number[] = RruleUtil.expand(master.rrule, master.startTime,
master.startTime, winEnd, exNums, 3000);
let prev: number = 0;
let next: number = 0;
for (const o of occs) {
if (o < occ) {
prev = o;
} else if (o > occ) {
next = o;
break;
}
}
b.beforeThis = prev > 0 ? (prev + occ) / 2 : (occ - 1000);
b.nextOcc = next;
return b;
}
/**
* ⭐ 按用户选择的"重复日程编辑范围"落库:
* - all :原逻辑,直接改这一行(若是覆盖实例则改覆盖行),整系列由 pushSeries 重建
* - one :单次改写 ——
* · 原行是覆盖实例 → 直接更新它
* · 原行是主事件(用户点开了系列)→ 为主事件加 EXDATE 排除该次,另插一条覆盖行
* - future :此次及以后 —— 先把主事件的 RRULE 截断到"此次之前"(加 UNTIL),
* 再把"此次及以后"的内容作为**新的独立系列**(新 uid)插入。
*/
private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean,
scope: string): Promise<void> {
const target: LocalEvent = e;
if (isNew || !this.isSeriesOccurrence() || this.event === null) {
if (isNew) {
await EventDb.insertLocal(context, target);
} else {
await EventDb.updateLocal(context, target);
}
return; return;
} }
// 仅阻止"单次覆盖实例"(RECURRENCE-ID)的修改;重复主事件(含 RRULE)允许编辑 const cur: LocalEvent = this.event;
if (this.event !== null && this.event.recurring && this.event.rrule === '') { const bounds: SeriesBounds = await this.seriesBoundaries(context);
this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则'; if (scope === 'all') {
// 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容;
// 若当前行是主事件 → 更新主事件(DTSTART 若被改 → 整系列平移,符合"修改全部"直觉)
await EventDb.updateLocal(context, target);
return;
}
if (scope === 'one') {
if (cur.rrule === '' && cur.recurrenceId > 0) {
// 已经是覆盖实例 → 直接改它
await EventDb.updateLocal(context, target);
return;
}
// 主事件上的"仅此次":加 EXDATE 排除该次 + 插入覆盖行
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
return;
}
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';');
const occIcs: string = IcsUtil.fmtIcsTime(bounds.thisOcc, master.isAllDay);
if (!exList.includes(occIcs)) {
exList.push(occIcs);
}
await EventDb.setExdate(context, master.id, exList.join(';'));
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
const ov = new LocalEvent();
ov.uid = master.uid;
ov.calKey = master.calKey;
ov.href = master.href;
ov.remotePath = master.remotePath;
ov.title = target.title;
ov.description = target.description;
ov.location = target.location;
ov.startTime = target.startTime;
ov.endTime = target.endTime;
ov.isAllDay = target.isAllDay;
ov.recurring = true;
ov.rrule = '';
ov.recurrenceId = bounds.thisOcc;
ov.reminders = target.reminders;
ov.reminder = target.reminder;
await EventDb.insertLocal(context, ov);
return;
}
// scope === 'future'
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
return;
}
// 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身,
// 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。
const startedAtThis: boolean =
(cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000);
// 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响)
const newUid: string = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
const newPath: string = encodeURIComponent(newUid) + '.ics';
await EventDb.reassignOverridesFrom(context, master.calKey, master.uid,
newUid, newPath, bounds.thisOcc);
// 2) 创建新系列主事件(本次编辑内容)
const newMaster = new LocalEvent();
newMaster.uid = newUid;
newMaster.calKey = master.calKey;
newMaster.href = master.href;
newMaster.remotePath = newPath;
newMaster.title = target.title;
newMaster.description = target.description;
newMaster.location = target.location;
newMaster.startTime = target.startTime;
newMaster.endTime = target.endTime;
newMaster.isAllDay = target.isAllDay;
newMaster.recurring = true;
// 新的重复规则:沿用原系列(用户没显式改规则时),保持"此次及以后"的重复节奏
newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule;
newMaster.reminders = target.reminders;
newMaster.reminder = target.reminder;
await EventDb.insertLocal(context, newMaster);
// 3) 原主事件系列截断到"此次之前"。
// 若主事件本身就代表"此次"(第一次发生且被改期)→ 原系列整个让位给新系列,
// 直接标记原主事件删除(pushSeries 会 DELETE 原资源),避免旧时间残留成幽灵日程。
if (startedAtThis) {
await EventDb.markDeleted(context, master.id);
} else {
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
}
}
private async save(): Promise<void> {
if (this.isSaving || !this.validate()) {
return; return;
} }
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下 // 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
@@ -243,6 +429,24 @@ struct EventEditPage {
: '请选择要同步到的 CalDAV 日历本'; : '请选择要同步到的 CalDAV 日历本';
return; return;
} }
// ⭐ 重复日程:先让用户选择"改这一次 / 此次及以后 / 全部"
if (this.isSeriesOccurrence()) {
this.pendingDelete = false;
this.scopeConfirmShow = true;
return;
}
await this.doSave('all');
}
/** 真正执行保存(scope 已被用户确认) */
private async doSave(scope: string): Promise<void> {
if (this.isSaving) {
return;
}
const book = this.chosenBook();
if (book === null) {
return;
}
this.isSaving = true; this.isSaving = true;
this.statusMsg = ''; this.statusMsg = '';
try { try {
@@ -276,7 +480,8 @@ struct EventEditPage {
e.remotePath = encodeURIComponent(e.uid) + '.ics'; e.remotePath = encodeURIComponent(e.uid) + '.ics';
await EventDb.insertLocal(context, e); await EventDb.insertLocal(context, e);
} else { } else {
await EventDb.updateLocal(context, e); // 重复系列的"仅此次/此次及以后"需要特殊落库(EXDATE / UNTIL / 拆分)
await this.applySeriesEdit(context, e, false, scope);
} }
// 立即推送(尽力而为,失败不打断,下次同步会再推) // 立即推送(尽力而为,失败不打断,下次同步会再推)
if (e.href !== '') { if (e.href !== '') {
@@ -290,7 +495,7 @@ struct EventEditPage {
await SyncEngine.settleLocalEvents(context); await SyncEngine.settleLocalEvents(context);
} }
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log) // 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 范围=${scope} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能, // 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能,
// 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗) // 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗)
if (e.reminders.length > 0) { if (e.reminders.length > 0) {
@@ -344,6 +549,12 @@ struct EventEditPage {
if (this.event === null || this.isSaving) { if (this.event === null || this.isSaving) {
return; return;
} }
// ⭐ 重复日程:先让用户选择"删这一次 / 此次及以后 / 全部"
if (this.isSeriesOccurrence()) {
this.pendingDelete = true;
this.scopeConfirmShow = true;
return;
}
const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`; const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`;
this.getUIContext().showAlertDialog({ this.getUIContext().showAlertDialog({
title: '删除日程', title: '删除日程',
@@ -358,30 +569,66 @@ struct EventEditPage {
value: '删除', value: '删除',
fontColor: $r('app.color.error'), fontColor: $r('app.color.error'),
action: (): void => { action: (): void => {
this.removeEvent(); this.removeEvent('all');
} }
} }
}); });
} }
private async removeEvent(): Promise<void> { /**
* ⭐ 按范围删除:one = 仅此次(EXDATE 排除该次发生)/ future = 此次及以后(截断 RRULE)/
* all = 删除全部(DELETE 整个系列资源)。
*/
private async removeEvent(scope: string): Promise<void> {
if (this.event === null || this.isSaving) { if (this.event === null || this.isSaving) {
return; return;
} }
if (this.event !== null && this.event.recurring && this.event.rrule === '') {
this.statusMsg = '重复日程的单次删除暂不支持,请到服务器端调整重复规则';
return;
}
this.isSaving = true; this.isSaving = true;
try { try {
const context = this.getUIContext().getHostContext(); const context = this.getUIContext().getHostContext();
if (context === undefined) { if (context === undefined) {
return; return;
} }
await EventDb.markDeleted(context, this.event.id); const cur: LocalEvent = this.event;
if (this.event.href !== '') { const isSeries: boolean = this.isSeriesOccurrence();
if (!isSeries || scope === 'all') {
// 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源)
await EventDb.markDeleted(context, cur.id);
} else if (scope === 'one') {
// 仅此次:主事件加 EXDATE 排除该次;若该行本身就是覆盖实例 → 直接删掉这一行
if (cur.rrule === '' && cur.recurrenceId > 0) {
await EventDb.markDeleted(context, cur.id);
} else {
const master: LocalEvent | null = await this.loadMaster(context);
if (master !== null) {
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';');
const occIcs: string = IcsUtil.fmtIcsTime(this.occStartMs, master.isAllDay);
if (!exList.includes(occIcs)) {
exList.push(occIcs);
}
await EventDb.setExdate(context, master.id, exList.join(';'));
// 若该次此前被单独改写过(存在覆盖行),一并清掉
const rows: LocalEvent[] = await EventDb.queryByUid(context, cur.calKey, cur.uid);
for (const r of rows) {
if (r.rrule === '' && r.recurrenceId > 0) {
await EventDb.markDeleted(context, r.id);
}
}
}
}
} else {
// 此次及以后:主事件 RRULE 截断到"此次之前";该次及以后的本地行物理删除
const master: LocalEvent | null = await this.loadMaster(context);
if (master !== null) {
const bounds: SeriesBounds = await this.seriesBoundaries(context);
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
await EventDb.purgeByUidFrom(context, master.calKey, master.uid, bounds.thisOcc);
}
}
if (cur.href !== '') {
const accounts: DavAccount[] = await AccountStore.loadAll(context); const accounts: DavAccount[] = await AccountStore.loadAll(context);
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(this.event?.href ?? '')); const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(cur.href));
if (acc !== undefined) { if (acc !== undefined) {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
@@ -390,7 +637,7 @@ struct EventEditPage {
await SyncEngine.settleLocalEvents(context); await SyncEngine.settleLocalEvents(context);
} }
// 日志不落盘日程标题,仅以 uid 追踪 // 日志不落盘日程标题,仅以 uid 追踪
LogUtil.write(`本地删除日程(标题已脱敏)uid=${this.event?.uid ?? ''}`); LogUtil.write(`本地删除日程(标题已脱敏)uid=${cur.uid} 范围=${scope}`);
// 立即刷新提醒(取消已发布但日程已删的提醒) // 立即刷新提醒(取消已发布但日程已删的提醒)
try { try {
await ReminderService.refreshReminders(context as common.UIAbilityContext); await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -657,6 +904,78 @@ struct EventEditPage {
dragBar: true, dragBar: true,
backgroundColor: $r('app.color.card_bg') backgroundColor: $r('app.color.card_bg')
}) })
.bindSheet($$this.scopeConfirmShow, this.scopeSheet(), {
height: 300,
showClose: false,
dragBar: true,
backgroundColor: $r('app.color.card_bg')
})
}
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
@Builder
scopeSheet() {
Column({ space: 8 }) {
Text(this.pendingDelete ? '删除重复日程' : '修改重复日程')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 14, bottom: 4 })
Text('这是一个重复日程,请选择作用的范围')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ bottom: 6 })
this.scopeOption('one', this.pendingDelete ? '仅删除这一次' : '仅修改这一次',
'只影响所选日期的这一次,其余保持不变')
this.scopeOption('future', this.pendingDelete ? '删除此次及以后' : '修改此次及以后',
'从这一次开始到最后一次都' + (this.pendingDelete ? '删除' : '修改'))
this.scopeOption('all', this.pendingDelete ? '删除全部' : '修改全部',
'整条重复日程的所有发生都' + (this.pendingDelete ? '删除' : '修改'))
Text('取消')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12, bottom: 16 })
.onClick(() => {
this.scopeConfirmShow = false;
})
}
.width('100%')
.padding({ left: 20, right: 20 })
}
@Builder
scopeOption(mode: string, title: string, desc: string) {
Column({ space: 3 }) {
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Start)
Text(desc)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Start)
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor($r('app.color.chip_off_bg'))
.onClick(() => {
this.scopeConfirmShow = false;
if (this.pendingDelete) {
this.removeEvent(mode);
} else {
this.doSave(mode);
}
})
} }
/** /**
+11 -19
View File
@@ -110,40 +110,32 @@ struct Widget4x4Card {
return r >= 0 && Math.floor(r * 24) === h; return r >= 0 && Math.floor(r * 24) === h;
} }
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w4Range(): W4Range { private w4Range(): W4Range {
const rg = new W4Range(); const rg = new W4Range();
const bs: TBlock[] = this.parseBlocks(); const bs: TBlock[] = this.parseBlocks();
let s: number = -1; let s: number = 8;
let e: number = -1; let e: number = 18;
for (const b of bs) { for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
const r: number = this.w4NowR(); const r: number = this.w4NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1; const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (s < 0) { if (nh >= 0 && nh < s && s < 8) {
s = nh >= 0 ? nh : 8; s = nh;
} }
if (e < 0) { if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh >= 0 ? nh + 1 : 20; e = nh + 1;
}
if (nh >= 0) {
if (nh < s) {
s = nh;
}
if (nh + 1 > e) {
e = nh + 1;
}
} }
if (s < 0) { if (s < 0) {
s = 0; s = 0;
+11 -19
View File
@@ -109,40 +109,32 @@ struct Widget6x4Card {
return r >= 0 && Math.floor(r * 24) === h; return r >= 0 && Math.floor(r * 24) === h;
} }
/** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 /** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ * (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w6Range(): W6Range { private w6Range(): W6Range {
const rg = new W6Range(); const rg = new W6Range();
const bs: TBlock6[] = this.parseBlocks(); const bs: TBlock6[] = this.parseBlocks();
let s: number = -1; let s: number = 8;
let e: number = -1; let e: number = 18;
for (const b of bs) { for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24); const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (s < 0 || bh < s) { if (bh < s) {
s = bh; s = bh;
} }
if (e < 0 || eh > e) { if (eh > e) {
e = eh; e = eh;
} }
} }
const r: number = this.w6NowR(); const r: number = this.w6NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 // 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1; const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (s < 0) { if (nh >= 0 && nh < s && s < 8) {
s = nh >= 0 ? nh : 8; s = nh;
} }
if (e < 0) { if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh >= 0 ? nh + 1 : 20; e = nh + 1;
}
if (nh >= 0) {
if (nh < s) {
s = nh;
}
if (nh + 1 > e) {
e = nh + 1;
}
} }
if (s < 0) { if (s < 0) {
s = 0; s = 0;