重复日程支持单次/后续/全部范围;时间轴刻度改为 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:
@@ -624,6 +624,116 @@ export class EventDb {
|
||||
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(如 `_13`、`acc1789274273124_0_13`)。
|
||||
|
||||
@@ -236,6 +236,42 @@ export class IcsUtil {
|
||||
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
|
||||
*/
|
||||
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 fmtUtc = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
@@ -256,24 +292,88 @@ export class IcsUtil {
|
||||
dtstart = 'DTSTART:' + fmtUtc(e.startTime);
|
||||
dtend = 'DTEND:' + fmtUtc(e.endTime);
|
||||
}
|
||||
const now: string = fmtUtc(Date.now());
|
||||
return 'BEGIN:VCALENDAR\r\n' +
|
||||
'VERSION:2.0\r\n' +
|
||||
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
|
||||
'CALSCALE:GREGORIAN\r\n' +
|
||||
'BEGIN:VEVENT\r\n' +
|
||||
// ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
|
||||
// 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
|
||||
let recId: string = '';
|
||||
if (e.rrule === '' && e.recurrenceId > 0) {
|
||||
recId = e.isAllDay
|
||||
? `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` +
|
||||
`DTSTAMP:${now}\r\n` +
|
||||
`${dtstart}\r\n` +
|
||||
`${dtend}\r\n` +
|
||||
recId +
|
||||
(e.rrule !== '' ? `RRULE:${e.rrule}\r\n` : '') +
|
||||
exdate +
|
||||
`SUMMARY:${IcsUtil.escape(e.title)}\r\n` +
|
||||
(e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') +
|
||||
(e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') +
|
||||
IcsUtil.valarms(e.reminders.length > 0 ? e.reminders
|
||||
: (e.reminder > 0 ? [e.reminder] : [])) +
|
||||
'END:VEVENT\r\n' +
|
||||
'END:VCALENDAR\r\n';
|
||||
'END:VEVENT\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(已规范化去重) */
|
||||
|
||||
@@ -404,6 +404,9 @@ export class SyncEngine {
|
||||
const dirty: LocalEvent[] = await EventDb.getDirty(context);
|
||||
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
|
||||
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) {
|
||||
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
|
||||
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
|
||||
@@ -417,15 +420,17 @@ export class SyncEngine {
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
continue;
|
||||
}
|
||||
if (e.recurring && e.rrule === '') {
|
||||
// 重复日程的"单次覆盖实例"(RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持
|
||||
await EventDb.clearDirty(context, e.id, e.etag);
|
||||
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`);
|
||||
// ⭐ 重复系列(含覆盖实例):按 uid 取全系列行,重建整份 .ics 后一次性 PUT。
|
||||
// 旧版对"覆盖实例"直接跳过(不推送),导致 App 里的单次改期/删除无法上传到服务器;
|
||||
// 现在支持标准写法(同资源内多 VEVENT + RECURRENCE-ID)。
|
||||
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;
|
||||
}
|
||||
if (e.recurring) {
|
||||
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送
|
||||
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
|
||||
if ((e.recurring || e.rrule !== '') && pushedSeries.has(seriesKey)) {
|
||||
continue; // 该系列已在上面整体推送过
|
||||
}
|
||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||
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)的待推送状态:
|
||||
* 本机事件不参与 DAV 同步,直接落地
|
||||
|
||||
@@ -272,38 +272,43 @@ export class TimelineUtil {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 时间轴"基准可视区"的起止小时:8:00 ~ 18:00 **恒定显示**(与用户确认)。 */
|
||||
static readonly BASE_START_HOUR: number = 8;
|
||||
static readonly BASE_END_HOUR: number = 18;
|
||||
|
||||
/**
|
||||
* 计算"需要显示的小时区间"(视窗截断):
|
||||
* - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点)
|
||||
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见
|
||||
* 计算"需要显示的小时区间"(视窗截断)。规则(与用户确认):
|
||||
* - **8:00 ~ 18:00 恒定显示**(即使当天没有任何日程,也至少显示这一段,避免"只有一条日程
|
||||
* 就只显示那 1 小时"的奇怪观感);
|
||||
* - 若某天在 **8 点之前**有日程 → 起点向前扩到最早日程所在整点;否则 8 点之前的刻度不显示;
|
||||
* - 若某天在 **18 点之后**有日程 → 终点向后扩到最晚日程结束时刻向上取整;否则 18 点之后的刻度不显示。
|
||||
* - includeNowHour=true(今天)时,把当前时刻所在小时并进来以保证红线可见 —— 但**仍遵守上面的
|
||||
* "无日程则不显示"约束**:只有当该侧本来就有日程(起点已 <8 / 终点已 >18)时才允许继续向外扩,
|
||||
* 否则不会因为"当前时刻"而把 8 点前 / 18 点后的空档重新显示出来。
|
||||
* 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。
|
||||
*/
|
||||
static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange {
|
||||
const rg = new ViewRange();
|
||||
let s: number = -1;
|
||||
let e: number = -1;
|
||||
// 基准:8~18 恒显示
|
||||
let s: number = TimelineUtil.BASE_START_HOUR;
|
||||
let e: number = TimelineUtil.BASE_END_HOUR;
|
||||
for (const b of blocks) {
|
||||
const bh: number = Math.floor(b.topRatio * 24);
|
||||
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
|
||||
if (s < 0 || bh < s) {
|
||||
if (bh < s) {
|
||||
s = bh;
|
||||
}
|
||||
if (e < 0 || eh > e) {
|
||||
if (eh > e) {
|
||||
e = eh;
|
||||
}
|
||||
}
|
||||
if (s < 0) {
|
||||
s = 8;
|
||||
}
|
||||
if (e < 0) {
|
||||
e = 20;
|
||||
}
|
||||
if (includeNowHour) {
|
||||
const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24);
|
||||
if (nh < s) {
|
||||
// 只有"该侧本来就有日程"时才允许把当前小时并进来(否则会违反"8 点前/18 点后无日程则不显示")
|
||||
if (nh < s && s < TimelineUtil.BASE_START_HOUR) {
|
||||
s = nh;
|
||||
}
|
||||
if (nh + 1 > e) {
|
||||
if (nh + 1 > e && e > TimelineUtil.BASE_END_HOUR) {
|
||||
e = nh + 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user