Files
SyncCalendar/entry/src/main/ets/pages/widget/Widget6x4.ets
T
laoyang 5c80c0b773 重复日程支持单次/后续/全部范围;时间轴刻度改为 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 点后有日程时才向该侧扩展,
  其余时刻不再出现'只有一条日程就只剩一小段刻度'的观感
2026-10-09 21:40:53 +08:00

865 lines
29 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// entry/src/main/ets/pages/widget/Widget6x4.ets
// 6x4 服务卡片:今日时间轴 —— 与"列表视图"完全同一套逻辑,并且**可以上下滑动**:
// · 用 List 包一个"很高的 ListItem"(整日时间轴按真实 vp 高度撑开)
// —— 官方卡片能力清单里 List / ListItem 是支持的,Scroll 不支持,所以只能走 List。
// · 视窗截断:只渲染"最早日程 ~ 最晚日程(+ 当前时刻)"这一段,避免上下大片空白
// · 三层堆叠:网格层 / 日程层 / 红线层,色块永不被切割
// · 贪心分列:列数 = 最大同时重叠数
// · 全天日程同样用色块展示
// ListItem 内必须用**确定 vp 高度**(不能再用百分比,否则撑不开 → 无法滚动)。
let storage6x4 = new LocalStorage();
/** 时间轴色块(与 common/TimelineUtil.TimelineBlock 结构一致) */
class TBlock6 {
eventKey: string = '';
title: string = '';
timeText: string = '';
location: string = ''; // 地点(第 2/3 行显示)
writable: boolean = true; // false → 标题后挂白框「只读」小标
color: string = '#007DFF';
topRatio: number = 0;
heightRatio: number = 0;
leftRatio: number = 0;
widthRatio: number = 1;
groupIndex: number = 0;
isNow: boolean = false;
}
/** 全天日程(eventKey 由 CardDataService 写入,用于 ForEach 唯一标识) */
class TAllDay6 {
eventKey: string = '';
title: string = '';
color: string = '#007DFF';
isAllDay: boolean = true;
}
/** 列表模式单条日程(解析 listJson) */
class LItem6 {
title: string = '';
time: string = '';
endTime: string = '';
color: string = '#007DFF';
calName: string = '';
isNow: boolean = false;
}
/** 冲突组:组内**贪心分列**,lanes[i] = 第 i 列(同列互不重叠) */
class TGroup6 {
startRatio: number = 0;
endRatio: number = 0;
laneCount: number = 1;
blocks: TBlock6[] = [];
lanes: TBlock6[][] = [];
}
/** 日程层纵向序列中的一行:空隙 / 冲突组(线都在其它两层) */
class T6Row {
kind: number = 0; // 0=空隙 1=冲突组
from: number = 0;
to: number = 0;
group: TGroup6 = new TGroup6();
key: string = '';
}
/** 视窗范围(小时) */
class W6Range {
s: number = 0;
e: number = 0;
}
/** 每 1 小时的高度(vp)。整日 24h × 40 = 960vp,远超卡片高度 → 可以滑动 */
const W6_HOUR: number = 40;
/** 全天日程最多显示条数(超出折叠为"还有 N 个") */
const W6_ALLDAY_MAX: number = 2;
/** 左侧刻度占宽 */
const W6_GUTTER: number = 36;
/** 全天色块单条高度 / 间距 */
const W6_ALLDAY_H: number = 20;
@Entry(storage6x4)
@Component
struct Widget6x4Card {
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('timelineJson') timelineJson: string = '[]';
@LocalStorageProp('allDayJson') allDayJson: string = '[]';
@LocalStorageProp('nowRatio') nowRatio: number = 0;
@LocalStorageProp('nowLabel') nowLabel: string = '';
@LocalStorageProp('todayCount') todayCount: number = 0;
@LocalStorageProp('isToday') isToday: boolean = true;
@LocalStorageProp('dayCount') dayCount: number = 0;
@LocalStorageProp('listJson') listJson: string = '[]';
@LocalStorageProp('displayStyle') displayStyle: string = 'timeline';
@LocalStorageProp('selectedDate') selectedDate: number = 0; // 卡片当前显示日 0 点毫秒
private parseBlocks(): TBlock6[] {
try {
return JSON.parse(this.timelineJson) as TBlock6[];
} catch (err) {
return [];
}
}
/** 当前时间比例(无效返回 -1) */
private w6NowR(): number {
return (this.nowRatio > 0.001 && this.nowRatio < 0.999) ? this.nowRatio : -1;
}
private w6IsNowHour(h: number): boolean {
const r: number = this.w6NowR();
return r >= 0 && Math.floor(r * 24) === h;
}
/** 视窗范围:**8:00~18:00 恒显示**;仅当 8 点前 / 18 点后有日程时才向该侧扩展。
* (与 App 内时间轴 TimelineUtil.viewRange 同一口径,避免两处漂移。) */
private w6Range(): W6Range {
const rg = new W6Range();
const bs: TBlock6[] = this.parseBlocks();
let s: number = 8;
let e: number = 18;
for (const b of bs) {
const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (bh < s) {
s = bh;
}
if (eh > e) {
e = eh;
}
}
const r: number = this.w6NowR();
// 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示);
// 且仅在该侧本来就有日程时才允许继续向外扩(不因当前时刻把 8 点前/18 点后空档重新显示)
const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1;
if (nh >= 0 && nh < s && s < 8) {
s = nh;
}
if (nh >= 0 && nh + 1 > e && e > 18) {
e = nh + 1;
}
if (s < 0) {
s = 0;
}
if (e > 24) {
e = 24;
}
if (e <= s) {
e = s + 1 > 24 ? 24 : s + 1;
}
rg.s = s;
rg.e = e;
return rg;
}
/** 视窗起始小时(取 w6Range 的起点,向下取整) */
private w6StartHour(): number {
return this.w6Range().s;
}
/** 视窗结束小时(取 w6Range 的终点,向上取整) */
private w6EndHour(): number {
return this.w6Range().e;
}
/** 视窗跨度(小时) */
private w6Span(): number {
return this.w6EndHour() - this.w6StartHour();
}
/** 时间轴内容总高(vp)= 跨度 × 每小时高度 */
private w6ContentH(): number {
return this.w6Span() * W6_HOUR;
}
/** 一段(起止为当日比例)换算成 vp 高度 */
private w6SpanVp(from: number, to: number): number {
const v: number = (to - from) * 24 * W6_HOUR;
return v > 0 ? v : 0;
}
/** 全天区高度(vp),没有全天日程时为 0 */
private w6AllDayH(): number {
const n: number = this.w6AllDay().length;
if (n === 0) {
return 0;
}
let h: number = n * W6_ALLDAY_H;
if (this.w6AllDayRest() > 0) {
h += 14;
}
return h + 4;
}
/** ListItem 总高(vp)= 全天区 + 时间轴 */
private w6ItemH(): number {
return this.w6AllDayH() + this.w6ContentH();
}
/** 由色块重建冲突分组:组内按开始时间升序做**贪心分列**(列数 = 最大同时重叠数) */
private w6Groups(): TGroup6[] {
const map: Map<number, TBlock6[]> = new Map<number, TBlock6[]>();
for (const b of this.parseBlocks()) {
const arr: TBlock6[] | undefined = map.get(b.groupIndex);
if (arr === undefined) {
map.set(b.groupIndex, [b]);
} else {
arr.push(b);
}
}
const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b);
const out: TGroup6[] = [];
for (const gi of idxs) {
const bs: TBlock6[] = map.get(gi) ?? [];
bs.sort((a: TBlock6, b: TBlock6): number => {
if (a.topRatio !== b.topRatio) {
return a.topRatio - b.topRatio;
}
return b.heightRatio - a.heightRatio;
});
const lanes: TBlock6[][] = [];
const laneEnd: number[] = [];
for (const b of bs) {
let li: number = -1;
for (let i = 0; i < laneEnd.length; i++) {
if (laneEnd[i] <= b.topRatio + 0.0000001) {
li = i;
break;
}
}
const be: number = b.topRatio + b.heightRatio;
if (li < 0) {
li = lanes.length;
lanes.push([b]);
laneEnd.push(be);
} else {
lanes[li].push(b);
laneEnd[li] = be;
}
}
const g = new TGroup6();
g.blocks = bs;
g.lanes = lanes;
g.laneCount = lanes.length > 0 ? lanes.length : 1;
let s: number = 1;
let e: number = 0;
for (const b of bs) {
if (b.topRatio < s) {
s = b.topRatio;
}
const be: number = b.topRatio + b.heightRatio;
if (be > e) {
e = be;
}
}
g.startRatio = s;
g.endRatio = e;
out.push(g);
}
return out;
}
/** 日程层纵向序列:空隙 / 冲突组(不含任何"线") */
private w6Rows(): T6Row[] {
const rows: T6Row[] = [];
const gs: TGroup6[] = this.w6Groups();
const top: number = this.w6StartHour() / 24;
const bot: number = this.w6EndHour() / 24;
let cursor: number = top;
for (const g of gs) {
const gsx: number = g.startRatio > top ? g.startRatio : top;
const gex: number = g.endRatio < bot ? g.endRatio : bot;
if (gex - gsx <= 0.0002) {
continue;
}
this.w6Push(rows, cursor, gsx, null);
this.w6Push(rows, gsx, gex, g);
cursor = gex;
}
this.w6Push(rows, cursor, bot, null);
return rows;
}
private w6Push(rows: T6Row[], from: number, to: number, g: TGroup6 | null): void {
if (to - from <= 0.0002) {
return;
}
const r = new T6Row();
r.from = from;
r.to = to;
if (g === null) {
r.kind = 0;
r.key = `g_${rows.length}_${Math.round((to - from) * 100000)}`;
} else {
r.kind = 1;
r.group = g;
// key 必须绑定"内容":翻页后行序号不变、只有内容变了;
// 若只用序号,ArkUI 会认为是同一批元素而不重绘 → 残留上一天的数据
r.key = `b_${rows.length}_${this.w6GroupKey(g, from, to)}`;
}
rows.push(r);
}
/** 冲突组的内容签名(时间窗 + 各块 eventKey),供 ForEach key 使用 */
private w6GroupKey(g: TGroup6, from: number, to: number): string {
let sig: string = `${Math.round(from * 100000)}_${Math.round(to * 100000)}`;
for (const b of g.blocks) {
sig = `${sig}_${b.eventKey}`;
}
return sig;
}
/** 列内第 index 个色块之前的空隙(vp)= 本块顶 − 上一块底 */
private w6LanePadVp(g: TGroup6, lane: TBlock6[], index: number): number {
let prevEnd: number = g.startRatio;
if (index > 0) {
const p: TBlock6 = lane[index - 1];
const pe: number = p.topRatio + p.heightRatio;
prevEnd = pe > g.startRatio ? pe : g.startRatio;
}
return this.w6SpanVp(prevEnd, lane[index].topRatio);
}
/** 色块高度(vp) */
private w6BlockVp(b: TBlock6): number {
return b.heightRatio * 24 * W6_HOUR;
}
/** 色块信息行数(标题永远打头):1=标题+时间+地址同行;2=标题 + 时间·地点;3=标题 / 时间 / 地址 */
private w6Lines(b: TBlock6): number {
const h: number = this.w6BlockVp(b);
if (h >= 44 && b.location !== '') {
return 3;
}
if (h >= 30) {
return 2;
}
return 1;
}
/** 两行色块的第二行:'09:00 - 10:30 · 地点'(无地点时只剩时间) */
private w6Meta(b: TBlock6): string {
return b.location !== '' ? `${b.timeText} · ${b.location}` : b.timeText;
}
/** 今天定时日程是否已全部结束(最晚结束比例 <= 当前时刻比例);只剩全天 / 无定时日程也算已结束。
* 非今天(r<0)返回 false,交由原条件判断(非今天本来就不显示红线)。 */
private w6AllTimedEnded(): boolean {
const r: number = this.w6NowR();
if (r < 0) {
return false;
}
let maxEnd: number = 0;
for (const b of this.parseBlocks()) {
const e: number = b.topRatio + b.heightRatio;
if (e > maxEnd) {
maxEnd = e;
}
}
return r >= maxEnd - 0.0001;
}
/** 红线是否显示(当前时刻落在视窗内 + 今天还有未结束的定时日程) */
private w6NowVisible(): boolean {
const r: number = this.w6NowR();
return r >= 0 && !this.w6AllTimedEnded()
&& r >= this.w6StartHour() / 24 - 0.0001 && r <= this.w6EndHour() / 24 + 0.0001;
}
/** 红线距内容顶部的偏移(vp) */
private w6NowPadVp(): number {
const v: number = (this.w6NowR() - this.w6StartHour() / 24) * 24 * W6_HOUR;
return v > 0 ? v : 0;
}
private parseAllDay(): TAllDay6[] {
try {
return JSON.parse(this.allDayJson) as TAllDay6[];
} catch (err) {
return [];
}
}
private w6AllDay(): TAllDay6[] {
return this.parseAllDay().slice(0, W6_ALLDAY_MAX);
}
private w6AllDayRest(): number {
const n: number = this.parseAllDay().length - W6_ALLDAY_MAX;
return n > 0 ? n : 0;
}
private hourLabels(): number[] {
const arr: number[] = [];
for (let h = this.w6StartHour(); h < this.w6EndHour(); h++) {
arr.push(h);
}
return arr;
}
private hourText(h: number): string {
return h < 10 ? `0${h}:00` : `${h}:00`;
}
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */
@Builder
addButton() {
Button() {
Text('+')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#007DFF')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'add' }
});
})
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
// 携带卡片当前显示日(用户可能已翻到其他日期),进入日历时定位到这一天。
// 必须转成字符串:param 的数值按 int32 传递,13 位毫秒时间戳会溢出成 2147483647(曾导致定位到 1970)
params: { openDate: `${this.selectedDate}` }
});
}
/** 翻页:交给 FormExtensionAbility.onFormEvent 重新取数并推送(跨天查看) */
private page(act: string): void {
postCardAction(this, {
action: 'message',
params: { pageAction: act }
});
}
/** 上一天 / 下一天 圆按钮 */
@Builder
private pageBtn(label: string, act: string) {
Button() {
Text(label)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
}
.width(22)
.height(22)
.borderRadius(11)
.padding(0)
.backgroundColor('#F2F3F5')
.onClick(() => this.page(act))
}
/** 非今天时额外给一个"回到今天"按钮 */
@Builder
private sideBtn() {
if (!this.isToday) {
Button() {
Text('今')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(22)
.height(22)
.borderRadius(11)
.padding(0)
.backgroundColor('#007DFF')
.onClick(() => this.page('today'))
}
}
/** 列表模式单条日程(解析 listJson) */
private parseList(): LItem6[] {
try {
return JSON.parse(this.listJson) as LItem6[];
} catch (err) {
return [];
}
}
/** 列表模式单条行:左侧日历色条(进行中变红)+ 时间 + 标题 + 所属日历本;不画红线 */
@Builder
listRow(item: LItem6) {
Row({ space: 8 }) {
Column()
.width(4)
.height(item.time === '全天' ? 18 : 42)
.borderRadius(2)
.backgroundColor(item.isNow ? '#FF3B30' : item.color)
if (item.time === '全天') {
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
} else {
Column({ space: 2 }) {
Text(item.time)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(item.isNow ? '#FF3B30' : '#333333')
Text(item.endTime)
.fontSize(11)
.fontColor('#999999')
}
.width(42)
.alignItems(HorizontalAlign.Center)
Text(item.title)
.fontSize(13)
.fontColor(item.isNow ? '#FF3B30' : '#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.isNow) {
Text('● 进行中')
.fontSize(9)
.fontColor('#FF3B30')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.borderRadius(4)
.backgroundColor('#FFECEA')
}
}
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.borderRadius(8)
.backgroundColor(item.isNow ? '#FFF1F0' : '#F5F7FA')
}
/** 列表模式主体:空态 / 可滚动的"行"列表(无时间轴、无红线) */
@Builder
listBody() {
if (this.parseList().length === 0) {
Column({ space: 6 }) {
Text('📅')
.fontSize(24)
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => this.openApp())
} else {
List({ space: 4 }) {
ForEach(this.parseList(), (item: LItem6) => {
ListItem() {
this.listRow(item)
}
.width('100%')
}, (item: LItem6) => `${item.time}_${item.title}_${item.color}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(8)
.onClick(() => this.openApp())
}
}
build() {
Column({ space: 6 }) {
Row({ space: 6 }) {
this.pageBtn('‹', 'prev')
Column() {
Text(this.dateText)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(this.isToday ? '#007DFF' : '#1A1A1A')
.maxLines(1)
Text(this.lunarText)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.onClick(() => this.openApp())
Text(`${this.dayCount} 条`)
.fontSize(10)
.fontColor('#B0B0B0')
this.pageBtn('›', 'next')
this.sideBtn()
this.addButton()
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.displayStyle === 'list') {
this.listBody()
} else {
// 卡片不支持 Scroll,但支持 List / ListItem:
// 把"整日时间轴"作为**一个很高的 ListItem**,超出卡片窗口的部分靠上下滑动查看
List() {
ListItem() {
Column() {
// 全天 / 跨天:同样用**色块**展示(与列表视图一致),随内容一起滚动
if (this.w6AllDay().length > 0) {
Column({ space: 2 }) {
ForEach(this.w6AllDay(), (a: TAllDay6) => {
Row({ space: 5 }) {
Text(a.isAllDay ? '全天' : '跨天')
.fontSize(8)
.fontColor('#FFFFFF')
.backgroundColor('#26000000')
.borderRadius(5)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Text(a.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(W6_ALLDAY_H - 2)
.padding({ left: 6, right: 6 })
.borderRadius(6)
.backgroundColor(a.color)
.onClick(() => this.openApp())
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
if (this.w6AllDayRest() > 0) {
Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
.fontSize(9)
.fontColor('#8A8A8A')
.width('100%')
.padding({ left: 2 })
}
}
.width('100%')
.padding({ bottom: 2 })
}
// 时间轴主体:左侧小时刻度(放在 Stack 外,避免被色块盖住)+ 右侧三层堆叠
Row() {
Column() {
ForEach(this.hourLabels(), (h: number) => {
Text(this.hourText(h))
.fontSize(9)
.fontColor(this.w6IsNowHour(h) ? '#FF3B30' : '#9AA0A6')
.width(W6_GUTTER)
.height(W6_HOUR)
.textAlign(TextAlign.End)
.padding({ right: 3 })
.border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } })
}, (h: number) => `h${h}`)
}
.width(W6_GUTTER)
.height(this.w6ContentH())
Stack() {
this.w6GridLayer() // 第 1 层:整点网格线
this.w6EventLayer() // 第 2 层:日程色块
this.w6NowLayer() // 第 3 层:当前时间红线
}
.layoutWeight(1)
.height(this.w6ContentH())
.alignContent(Alignment.TopStart)
.border({ width: { left: 0.5 }, color: { left: '#14000000' } })
}
.width('100%')
.height(this.w6ContentH())
.alignItems(VerticalAlign.Top)
}
.width('100%')
.height(this.w6ItemH())
}
.width('100%')
.height(this.w6ItemH())
}
.layoutWeight(1)
.width('100%')
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@Builder
private w6GridLayer() {
Column() {
ForEach(this.hourLabels(), (h: number) => {
Column()
.width('100%')
.height(W6_HOUR)
.border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } })
.hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试
}, (h: number) => `gd_${h}`)
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None) // 装饰层:不参与命中测试,触碰事件穿透到下层色块
}
/** 第 2 层:日程色块(冲突组分行 + 组内 lane 分列) */
@Builder
private w6EventLayer() {
Column() {
ForEach(this.w6Rows(), (r: T6Row) => {
if (r.kind === 1) {
Row() {
ForEach(r.group.lanes, (lane: TBlock6[], li: number) => {
Column() {
ForEach(lane, (b: TBlock6, index: number) => {
Blank().height(this.w6LanePadVp(r.group, lane, index))
Column({ space: 1 }) {
if (this.w6Lines(b) === 1) {
// 一行:标题 · [只读] · 时间 · 地址(时间用完整起止,宽度不够自然省略;
// 让位顺序:地址先没 → 时间省略 → 标题只保 50%)
Row({ space: 4 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
.lineHeight(12)
if (!b.writable) {
Text('只读')
.fontSize(8)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(3)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
.maxLines(1)
}
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '56%' })
.lineHeight(12)
if (b.location !== '') {
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(12)
.layoutWeight(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题 · [只读]
Row({ space: 4 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '70%' })
if (!b.writable) {
Text('只读')
.fontSize(9)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
.maxLines(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.w6Lines(b) >= 3) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.w6Meta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 5, right: 3, top: 1, bottom: 1 })
.borderRadius(4)
.backgroundColor(b.color)
.opacity(b.isNow ? 1 : 0.92)
.clip(true)
.width('100%')
.height(this.w6BlockVp(b))
.constraintSize({ minHeight: 16 })
.onClick(() => this.openApp())
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
}
.layoutWeight(1)
.height('100%')
.padding({ right: 2 })
.clip(true)
}, (lane: TBlock6[], li: number) => `ln_${r.key}_${li}`)
}
.width('100%')
.height(this.w6SpanVp(r.from, r.to))
.alignItems(VerticalAlign.Top)
} else {
Blank().height(this.w6SpanVp(r.from, r.to))
}
}, (r: T6Row) => r.key)
}
.width('100%')
.height('100%')
}
/** 第 3 层:当前时间红线(浮在最上层) */
@Builder
private w6NowLayer() {
Column() {
if (this.w6NowVisible()) {
Blank().height(this.w6NowPadVp()).hitTestBehavior(HitTestMode.None) // 占位块:不拦截点击
Row() {
Column()
.width(5)
.height(5)
.borderRadius(3)
.backgroundColor('#FF3B30')
Column()
.height(2)
.layoutWeight(1)
.backgroundColor('#FF3B30')
.borderRadius(1)
}
.width('100%')
.hitTestBehavior(HitTestMode.None) // 红线本身也不拦截点击,穿透到色块层
}
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None) // 装饰层:整层不参与命中测试,触碰事件穿透到下层色块
}
}