fix(卡片/粘贴): 5 项反馈修复 —— 粘贴按钮、卡片数据、卡片加号、卡片滚动、列表统一

## 1. 粘贴按钮文字看不清(EventEditPage)

根因不是"忘了设文字"——空参时系统本就给默认样式(LINES 图标 + PASTE 文案)。
真正原因是我**给了一个和系统文字颜色太接近的背景色**(card_bg 近乎白)。

查 SDK 类型定义 `paste_button.d.ts`,错误码 2 明确列出:
  「字体或图标颜色与组件背景颜色相近」「文字超出组件背景区域」等
安全组件创建时会做样式校验,对比度不足会让文字灰淡难辨、甚至授权失败。

同时发现 icon/text/buttonType **只能走构造参数**,不是链式属性
(`.text()` 会编译报 "Property 'text' does not exist")。

改为:
```
PasteButton({
  icon: PasteIconStyle.LINES,      // 该枚举只有 LINES
  text: PasteDescription.PASTE,    // 该枚举只有 PASTE
  buttonType: ButtonType.Capsule
})
```
并**去掉自定义背景色**,交给系统默认样式,只保留尺寸与圆角。

## 2. 待办卡片显示 0 条(CardDataService)

`pushToAllForms()` 是同步完成后刷新所有卡片的主路径,
但它只调 `buildCardData()`(日程),**从未调 `buildTodoCardData()`** ⇒
推给卡片的 binding 里 todoCount / todoListJson 全是默认值。

而 EntryFormAbility.pushData() 里合并了待办 —— 两条路径不一致,
所以表现成"有时对、有时 0"。

修:抽出 `mergeTodoInto()` 供两处共用;待办数据与 dayOffset 无关,
整个 pushToAllForms 只构建一次。

真机日志:`待办卡片数据刷新:未完成 5 条…列表输出 5 条`(原为 0)。

## 3. 待办卡片右上角 + 点了没反应(EntryAbility + Index)

待办卡片发的是 `widgetAction: 'addTodo'` / `'openTodo'`,
但 EntryAbility 只判断 `raw === 'add'`,Index 也只处理 `'add'`
⇒ 参数被静默丢弃。

修:两端都补上 addTodo / openTodo。
  addTodo  → 切待办 Tab + 打开新建待办
  openTodo → 切待办 Tab

## 4. 日程 2×4 卡片右侧可滚动,15 条(CardDataService + Widget4x2)

数据侧 `slice(0, 2)` → `slice(0, 15)`
视图侧 `Column + ForEach` → `List + ListItem`(虚拟化 + 滚动惯性 + 回弹)

真机日志:`进行中 15 条`,确认上限生效。

## 5. 待办 4×4 / 6×4 列表样式统一,全部可滚动

按用户选择,**统一成 4×4 样式**(勾选框 + 色条 + 标题 + 副行「截止时间 · 日历本」)。

6×4 原来是「标题 + 独立截止时间列 + 独立日历本列」,窄卡片里会把标题挤没,
改为与 4×4 完全相同的结构。已用逐行 diff 校验:两卡 todoRow **36 行完全一致**。

`MAX_TODO_ITEMS` 20 → 15,与日程卡片口径一致(所有待办卡片都已可滚动,
不必再按卡片尺寸给不同上限)。

## 编译
`BUILD SUCCESSFUL`

## 未验证
真机仅验证了「待办卡片数据不再为 0」与「日程 15 条上限」两项。
粘贴按钮视觉、卡片内滚动、卡片 + 跳转、两卡样式一致性需上机目视确认。
This commit is contained in:
2026-11-03 23:14:43 +08:00
parent 2bb620a6ed
commit b8d5aa630c
6 changed files with 123 additions and 46 deletions
+41 -7
View File
@@ -149,25 +149,53 @@ export class CardDataService {
} }
/** App 内同步完成后调用:刷新所有已添加的卡片 */ /** App 内同步完成后调用:刷新所有已添加的卡片 */
/**
* 把待办数据合并进日程数据对象。
*
* ⭐ 抽出来的原因: EntryFormAbility.pushData 与 CardDataService.pushToAllForms
* 是两条独立的推送路径,之前只有前者做了合并,后者漏了 ⇒
* 待办卡片永远显示 0 条(todoCount 停在默认值)。
* 两处共用同一段逻辑,避免再出现"改一处忘一处"。
*/
private static mergeTodoInto(ev: CardData, td: CardData): void {
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
}
static async pushToAllForms(context: common.Context): Promise<void> { static async pushToAllForms(context: common.Context): Promise<void> {
// 先恢复持久化的 formId(App 重启后内存注册表为空) // 先恢复持久化的 formId(App 重启后内存注册表为空)
await CardDataService.loadPersistedFormIds(context); await CardDataService.loadPersistedFormIds(context);
if (CardDataService.formIds.length === 0) { if (CardDataService.formIds.length === 0) {
return; return;
} }
// ⭐ 待办数据与 dayOffset 无关(待办不翻页),只构建一次
let todoData: CardData | null = null;
try {
todoData = await CardDataService.buildTodoCardData(context);
} catch (err) {
console.error(`待办卡片数据构建失败: ${(err as BusinessError).message}`);
}
// 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天 // 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天
const cache: Map<number, formBindingData.FormBindingData> = const cache: Map<number, formBindingData.FormBindingData> =
new Map<number, formBindingData.FormBindingData>(); new Map<number, formBindingData.FormBindingData>();
const fallbackData: CardData = await CardDataService.buildCardData(context, 0);
if (todoData !== null) {
CardDataService.mergeTodoInto(fallbackData, todoData);
}
const fallback: formBindingData.FormBindingData = const fallback: formBindingData.FormBindingData =
formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0)); formBindingData.createFormBindingData(fallbackData);
cache.set(0, fallback); cache.set(0, fallback);
const stale: string[] = []; const stale: string[] = [];
for (const formId of CardDataService.formIds) { for (const formId of CardDataService.formIds) {
const off: number = CardDataService.getDayOffset(formId); const off: number = CardDataService.getDayOffset(formId);
let binding: formBindingData.FormBindingData | undefined = cache.get(off); let binding: formBindingData.FormBindingData | undefined = cache.get(off);
if (binding === undefined) { if (binding === undefined) {
binding = formBindingData.createFormBindingData( const d: CardData = await CardDataService.buildCardData(context, off);
await CardDataService.buildCardData(context, off)); if (todoData !== null) {
CardDataService.mergeTodoInto(d, todoData);
}
binding = formBindingData.createFormBindingData(d);
cache.set(off, binding); cache.set(off, binding);
} }
try { try {
@@ -327,14 +355,16 @@ export class CardDataService {
// 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中) // 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中)
data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length; data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length;
// ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 ===== // ===== 2x4 卡片右侧:**当前时间之后最近的若干条**日程(卡片可滚动,最多 15 条)=====
// 规则:只取"还没开始"的有时间日程(startTime >= now), // 规则:只取"还没开始"的有时间日程(startTime >= now),
// **排除全天 / 跨天**,按开始时间升序取前 2 条。 // **排除全天 / 跨天**,按开始时间升序取前 2 条。
// 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。 // 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。
const upcomingNext: DisplayEvent[] = upcoming const upcomingNext: DisplayEvent[] = upcoming
.filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs) .filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime); .sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime);
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 2); // ⭐ 上限 15 条:卡片可滚动,不必再为了"放得下"而截断到 2 条。
// 15 是经验值 —— 再多则用户更该进 App 看,而不是在卡片里翻。
const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 15);
const ongoingItems: CardItem[] = []; const ongoingItems: CardItem[] = [];
for (const e of ongoingPick) { for (const e of ongoingPick) {
const dayLong: boolean = isDayLong(e); const dayLong: boolean = isDayLong(e);
@@ -404,8 +434,12 @@ export class CardDataService {
return data; return data;
} }
/** 卡片列表最多渲染多少条待办(4x4 用前 6 条、6x4 用前 10 条,卡片侧自行截断) */ /**
private static readonly MAX_TODO_ITEMS: number = 20; * 卡片列表最多输出多少条待办。
* ⭐ 统一为 15:所有待办卡片都已改为可滚动(List + 虚拟化),
* 不必再为了"一屏放得下"而按卡片尺寸给不同上限。
*/
private static readonly MAX_TODO_ITEMS: number = 15;
/** /**
* 构建**待办卡片**数据。 * 构建**待办卡片**数据。
@@ -42,10 +42,12 @@ export default class EntryAbility extends UIAbility {
private handleWidgetParam(want: Want): void { private handleWidgetParam(want: Want): void {
const params = want.parameters; const params = want.parameters;
if (params !== undefined) { if (params !== undefined) {
// ⭐ 白名单:日程卡片发 'add',待办卡片发 'addTodo'(新建待办)/ 'openTodo'(进待办页)。
// 之前只认 'add',待办卡片点 + 毫无反应 —— 参数被静默丢弃。
const raw = params['widgetAction']; const raw = params['widgetAction'];
if (typeof raw === 'string' && raw === 'add') { if (typeof raw === 'string' && (raw === 'add' || raw === 'addTodo' || raw === 'openTodo')) {
AppStorage.setOrCreate<string>('widgetAction', 'add'); AppStorage.setOrCreate<string>('widgetAction', raw);
LogUtil.write('卡片添加入口:已设置 widgetAction=add'); LogUtil.write(`卡片入口:已设置 widgetAction=${raw}`);
} }
// 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期) // 卡片点击进入日历时,定位到卡片当前显示的那一天(用户可能已翻到其他日期)
const od = params['openDate']; const od = params['openDate'];
+13 -3
View File
@@ -1746,11 +1746,21 @@ struct EventEditPage {
// 用它而不是自绘按钮 + pasteboard.getData(),原因见 pastePlace() 注释: // 用它而不是自绘按钮 + pasteboard.getData(),原因见 pastePlace() 注释:
// READ_PASTEBOARD 是受限权限,普通应用申请会被拒绝。 // READ_PASTEBOARD 是受限权限,普通应用申请会被拒绝。
// PasteButton 无需声明权限、无需弹窗,权限仅在 onClick 内有效。 // PasteButton 无需声明权限、无需弹窗,权限仅在 onClick 内有效。
PasteButton() // ⭐ PasteButton:安全控件,点击时临时获得剪贴板读权限
// (无需申请 READ_PASTEBOARD —— 那是受限权限,普通应用申请会被拒)
//
// ⚠️ icon/text/buttonType **只能走构造参数**,写成链式属性会编译报错。
// ⚠️ 不要自定义背景色:SDK 明确把"字体颜色与背景相近"列为样式违规
// (错误码 2),会让人字形灰淡难辨、甚至授权失败。
// 交给系统默认样式即可,只调尺寸与圆角。
PasteButton({
icon: PasteIconStyle.LINES,
text: PasteDescription.PASTE,
buttonType: ButtonType.Capsule
})
.height(44) .height(44)
.padding({ left: 8, right: 8 }) .padding({ left: 10, right: 14 })
.borderRadius(12) .borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => { .onClick(() => {
this.pastePlace(); this.pastePlace();
}) })
+18 -2
View File
@@ -291,12 +291,28 @@ struct Index {
} }
} }
/** 消费卡片"添加"深链:拉起新建日程页并立即清除标记,避免重复触发 */ /**
* 消费卡片"添加"深链。
*
* 三种来源:
* add 日程卡片 + → 新建日程
* addTodo 待办卡片 + → 新建待办
* openTodo 待办卡片整卡点击 → 切到待办 Tab
* 处理完立刻清标记,否则页面重建会重复触发。
*/
private consumeWidgetAction(): void { private consumeWidgetAction(): void {
const action: string = AppStorage.get<string>('widgetAction') ?? ''; const action: string = AppStorage.get<string>('widgetAction') ?? '';
if (action === '') {
return;
}
AppStorage.setOrCreate<string>('widgetAction', '');
if (action === 'add') { if (action === 'add') {
AppStorage.setOrCreate<string>('widgetAction', '');
this.addEvent(); this.addEvent();
} else if (action === 'addTodo') {
this.mode = 'todo';
this.openTodoCreator();
} else if (action === 'openTodo') {
this.mode = 'todo';
} }
} }
@@ -83,39 +83,50 @@ struct TodoWidget6x4Card {
.onClick(() => this.checkTodo(id)) .onClick(() => this.checkTodo(id))
} }
/** 单条待办(宽版):勾选框 + 色条 + 标题 + 截止时间列 + 日历本名列 */ /**
* 单条待办:勾选框 + 色条 + 标题 + 副行(截止时间 · 日历本)。
*
* ⚠️ 与 TodoWidget4x4 的 todoRow **保持完全一致**(用户要求两个卡片显示效果一模一样)。
* 改这里必须同步改那边,否则又会不一致。
* 选这个结构而不是"独立列 + 固定宽度":标题 layoutWeight(1) 能吃满剩余宽度,
* 在窄卡片里也不会被挤出屏幕。
*/
@Builder @Builder
todoRow(item: TodoItem6x4) { todoRow(item: TodoItem6x4) {
Row({ space: 8 }) { Row({ space: 8 }) {
this.checkbox(item.id) this.checkbox(item.id)
Column() Column()
.width(3) .width(3)
.height(34) .height(32)
.borderRadius(2) .borderRadius(2)
.backgroundColor(item.color) .backgroundColor(item.color)
Text(item.title) Column({ space: 3 }) {
.fontSize(13) Text(item.title)
.fontColor('#1A1A1A') .fontSize(13)
.maxLines(2) .fontColor('#1A1A1A')
.textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(2)
.layoutWeight(1) .textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.dueText) Row({ space: 6 }) {
.fontSize(11) Text(item.dueText)
.fontColor(item.dueText.indexOf('逾期') >= 0 ? '#FF3B30' : '#8A8A8A') .fontSize(10)
.maxLines(1) .fontColor(item.dueText.indexOf('逾期') >= 0 ? '#FF3B30' : '#8A8A8A')
.width(88) .maxLines(1)
.textAlign(TextAlign.End) if (item.calName !== '') {
Text(item.calName) Text(item.calName)
.fontSize(10) .fontSize(10)
.fontColor(item.color) .fontColor(item.color)
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.width(80) .constraintSize({ maxWidth: '55%' })
.textAlign(TextAlign.End) }
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
} }
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.width('100%') .width('100%')
.padding({ left: 4, right: 12, top: 6, bottom: 6 }) .padding({ left: 4, right: 10, top: 6, bottom: 6 })
.borderRadius(10) .borderRadius(10)
.backgroundColor('#F5F7FA') .backgroundColor('#F5F7FA')
} }
+13 -9
View File
@@ -26,11 +26,10 @@ struct Widget4x2Card {
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */ /** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
@LocalStorageProp('selectedDate') selectedDate: number = 0; @LocalStorageProp('selectedDate') selectedDate: number = 0;
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */ /** 右侧列表数据(可滚动;数据侧已按时间升序并限制在 15 条内) */
private parseOngoing(): CardItem2x4[] { private parseOngoing(): CardItem2x4[] {
try { try {
const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[]; return JSON.parse(this.ongoingJson) as CardItem2x4[];
return all.slice(0, 2);
} catch (err) { } catch (err) {
return []; return [];
} }
@@ -102,7 +101,7 @@ struct Widget4x2Card {
} }
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.width('100%') .width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }) .padding({ left: 6, right: 8, top: 4, bottom: 4 })
.borderRadius(8) .borderRadius(8)
.backgroundColor('#F5F7FA') .backgroundColor('#F5F7FA')
} }
@@ -183,19 +182,24 @@ struct Widget4x2Card {
.strokeWidth(0.5) .strokeWidth(0.5)
.color('#E5E5E5') .color('#E5E5E5')
// ===== 右半:当前时间之后最近的两条日程(不滚动)===== // ===== 右半:未来日程列表(可滚动,最多 15 条)=====
// ⭐ 原来固定 Column + 只取 2 条,用户想看后天的安排就看不到。
// 改为 List:自带虚拟化与滚动惯性,条目多也不占内存。
Column() { Column() {
if (this.parseOngoing().length === 0) { if (this.parseOngoing().length === 0) {
this.emptyHint() this.emptyHint()
} else { } else {
Column({ space: 6 }) { List({ space: 4 }) {
ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => { ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => {
this.ongoingRow(item) ListItem() {
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`) this.ongoingRow(item)
}
}, (item: CardItem2x4, idx: number) => `o${idx}_${item.time}_${item.title}`)
} }
.width('100%') .width('100%')
.layoutWeight(1) .layoutWeight(1)
.justifyContent(FlexAlign.Center) .scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
} }
} }
.layoutWeight(1) .layoutWeight(1)