跨端: 鸿蒙日历补 .ics 导入导出(WebUI 有、鸿蒙一直没有)
WebUI 工具条那两个入口(`CalendarView` 的导入/导出图标)鸿蒙侧一直缺,
`CalendarPage` 的文件头也一直诚实写着"没做:…….ics 导入导出"。
## 为什么不能照抄 WebUI 的做法
WebUI 在浏览器里:导出是 `Blob` + `<a download>`、导入是 `<input type=file>`,
两条都由浏览器提供。鸿蒙**没有"下载目录"这个概念**,必须显式走系统
`DocumentViewPicker` —— 这不是多此一举,是这个平台上唯一能让用户拿到/指定文件的路。
新增 `common/IcsFile.ets` 封装选/读/写(`pickIcsText` / `saveIcsText`)。
## ApiClient 加两个方法(不能复用 `request<T>`)
`request<T>` 无条件 `JSON.parse(response.result)`,而 iCalendar 不是 JSON
(与 `getBytes` 取壁纸二进制同一个理由)。所以:
- `getText()`:`expectDataType: STRING` 取原文;
- `postText()`:raw body + **显式** `Content-Type: text/calendar`
—— 服务端是**按 Content-Type 分流**的(`strings.HasPrefix(ct, "multipart/")`,
否则当 raw text)。写错成 `application/json` 会走对分支但语义不对。
## 三处对齐 WebUI 的细节
- **区间用屏幕上正在看的那段**(`rangeFrom/rangeTo`),不写死 ±1 年 ——
服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。
- **文件名带日期**:`agentmail-<selectedIso>.ics`(WebUI 是
`agentmail-${dayKey(anchor)}.ics`)。固定名的问题是连导两次就分不清哪份是哪份,
而导出天然会被重复做(看一个月导一份)。实测截图里默认文件名
`agentmail-2026-09-18.ics`。
- **`imported` 与 `skipped` 两个数都报**:只说"导入成功"会把
"20 条里跳过了 18 条"读成一切正常,而"跳过"正是用户需要知道的那部分。
## 结果要分三种说(取消不是失败)
导出成功(给出落盘路径)/ 用户取消(**什么都不说**)/ 真失败(说原因)。
把"取消"弹成"导出失败"是"用户什么也没做却被骂一句"。
导入成功后**重拉当前区间** —— 不重拉用户看不到刚导进来的东西,会以为失败。
## 验证
- 服务端两端点实测(curl):导出 8 个 VEVENT、Content-Type/Disposition 正确;
把导出的原样导回去 `{"imported":8,"skipped":0,"total":8}`。
- ★ 这次验证**污染了生产库**(那 8 条真写进去了):已按 event_id 逐条删除
(47 → 39),删除前备份 `/tmp/db-before-cleanup.db`。
教训:拿生产实例做写侧验证要先想清楚怎么回滚 —— 我这次是先写后想。
- 设备侧:导出选择器实测打开(系统 filemanager 的 `PathPicker`),
默认文件名正确、目录可选;选中保存后回到日历。
This commit is contained in:
@ -5,7 +5,7 @@
|
||||
*
|
||||
* 做了:月网格(可翻月、可回"今天")、点某一天看当天日程、事件点标记、
|
||||
* 今天/选中两态、加载失败**说出来**。
|
||||
* 没做:新建/编辑/删除事件(写侧)、农历重复、.ics 导入导出、左右滑动翻页(P6 第 3 步)。
|
||||
* 没做:农历重复事件、左右滑动翻页(P6 第 3 步)。
|
||||
* 滑动那条还牵着 `docs/DEBTS.json` 的 `gesture-semantics`("鸿蒙侧出现滑动手势代码时立即建判据")
|
||||
* —— 没写手势就不该先建那条假判据,所以这一版用按钮翻月。
|
||||
*
|
||||
@ -49,6 +49,7 @@ import {
|
||||
} from '../model/Calendar';
|
||||
import { LIST_FADE_LENGTH } from '../model/NavItems';
|
||||
import { LengthMetrics } from '@kit.ArkUI';
|
||||
import { pickIcsText, saveIcsText } from '../common/IcsFile';
|
||||
|
||||
/** 一格的高度(vp)。7 列等分 + 固定高度才画得成整齐的网格 */
|
||||
const CELL_HEIGHT: number = 52;
|
||||
@ -143,6 +144,8 @@ export struct CalendarPage {
|
||||
* 只有要改的人才点开 —— 点一下展开,再点一下收起。
|
||||
*/
|
||||
@State pickersOpen: boolean = false;
|
||||
/** .ics 导入/导出在飞:两个动作都碰文件与网络,防连点 */
|
||||
@State icsBusy: boolean = false;
|
||||
|
||||
private api: CalendarApi | null = null;
|
||||
private offsetMinutes: number = 0;
|
||||
@ -371,6 +374,99 @@ export struct CalendarPage {
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 .ics(对齐 WebUI `CalendarView` 的导出图标)。
|
||||
*
|
||||
* ★ 区间用**屏幕上正在看的那段**(`rangeFrom/rangeTo`),不是写死 ±1 年 ——
|
||||
* 服务端注释里写着同一条理由:「写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件」。
|
||||
*
|
||||
* 三条结果必须分开说:导出成功(给出落盘路径)/ 用户取消(**什么都不说**)/
|
||||
* 真失败(说出原因)。把"取消"弹成"导出失败"是"用户什么也没做却被骂一句"。
|
||||
*/
|
||||
private async exportIcs(): Promise<void> {
|
||||
const a: CalendarApi | null = this.api;
|
||||
if (a === null || this.icsBusy) {
|
||||
return;
|
||||
}
|
||||
this.icsBusy = true;
|
||||
const prompt = this.getUIContext().getPromptAction();
|
||||
try {
|
||||
const text: string = await a.exportIcs(this.rangeFrom(), this.rangeTo());
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* 文件名带上"导的是哪一天那一屏"(对齐 WebUI `agentmail-${dayKey(anchor)}.ics`)。
|
||||
* 固定名 `agentmail-calendar.ics` 的问题是连导两次就分不清哪份是哪份 ——
|
||||
* 而导出这个动作天然会被重复做(看一个月导一份)。
|
||||
* 这里用 `selectedIso`:它就是这个视图的锚点(WebUI 的 `anchor` 同义)。
|
||||
*/
|
||||
const saved = await saveIcsText(ctx, text, 'agentmail-' + this.selectedIso + '.ics');
|
||||
if (saved.ok) {
|
||||
prompt.showToast({ message: '已导出:' + saved.path, duration: 6000 });
|
||||
} else if (saved.message.length > 0) {
|
||||
prompt.showToast({ message: '导出失败:' + saved.message, duration: 6000 });
|
||||
}
|
||||
// saved.message 为空 = 用户取消 ⇒ 刻意什么都不说
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
prompt.showToast({
|
||||
message: ae.message.length > 0 ? ae.message : '导出失败',
|
||||
duration: 6000
|
||||
});
|
||||
} finally {
|
||||
this.icsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 .ics(对齐 WebUI 的导入图标)。
|
||||
*
|
||||
* ★ `imported` 与 `skipped` **两个数都要说**:只说"导入成功"会把
|
||||
* "20 条里跳过了 18 条"读成一切正常,而跳过的原因(重复 / 缺 DTSTART)
|
||||
* 正是用户需要知道的那部分。服务端返回的就是这两个计数。
|
||||
*
|
||||
* 导入成功后**重拉当前区间** —— 不重拉的话用户看不到刚导进来的东西,
|
||||
* 会以为导入失败(而它其实成功了)。
|
||||
*/
|
||||
private async importIcs(): Promise<void> {
|
||||
const a: CalendarApi | null = this.api;
|
||||
if (a === null || this.icsBusy) {
|
||||
return;
|
||||
}
|
||||
this.icsBusy = true;
|
||||
const prompt = this.getUIContext().getPromptAction();
|
||||
try {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
const picked = await pickIcsText(ctx);
|
||||
if (!picked.ok) {
|
||||
// 取消(message 空)⇒ 不说话;读文件失败 ⇒ 说原因
|
||||
if (picked.message.length > 0) {
|
||||
prompt.showToast({ message: '导入失败:' + picked.message, duration: 6000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resp = await a.importIcs(picked.text);
|
||||
prompt.showToast({
|
||||
message: `已导入 ${resp.imported} 条,跳过 ${resp.skipped} 条`,
|
||||
duration: 6000
|
||||
});
|
||||
await this.loadEvents();
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
prompt.showToast({
|
||||
message: ae.message.length > 0 ? ae.message : '导入失败',
|
||||
duration: 6000
|
||||
});
|
||||
} finally {
|
||||
this.icsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 要显示哪些格子 —— **由视图档决定**(月:整月网格;周:一行 7 天;日:单天)。
|
||||
*
|
||||
@ -963,8 +1059,9 @@ export struct CalendarPage {
|
||||
*
|
||||
* 对齐 WebUI `CalendarView.tsx:293-370` 那条工具条的分组与顺序:
|
||||
* ‹ › 今天 | 标题 | 月/周/日 | 新建
|
||||
* WebUI 还有导入/导出 .ics 两个图标,鸿蒙这边暂未做(那两个要文件选择器 + .ics 解析,
|
||||
* 与"月份/星期"这类算术不同,属于一个独立的工作量)。
|
||||
* WebUI 工具条还有导入/导出 .ics 两个入口,鸿蒙这边 2026-09-18 补上
|
||||
* (走系统文件选择器 —— 鸿蒙没有"下载目录",`DocumentViewPicker` 是
|
||||
* 这个平台上唯一能让用户拿到/指定文件的路)。
|
||||
* 这里是**窄屏**,四段挤一行会溢出 —— 拆成两行:
|
||||
* 上行 = 翻页 + 标题;下行 = 今天 + 视图档 + 新建。
|
||||
*/
|
||||
@ -1013,6 +1110,23 @@ export struct CalendarPage {
|
||||
.margin({ left: 4 })
|
||||
.onClick(() => this.setScale(s))
|
||||
}, (s: CalendarScale) => `scale${s}`)
|
||||
/*
|
||||
* .ics 导入 / 导出(对齐 WebUI 工具条里那两个图标)。
|
||||
*
|
||||
* 用文字而不是图标:窄屏下这两个动作**低频且后果不可见**
|
||||
* (导出了什么、导进来什么,光看按钮看不出来),文字比一个需要猜的
|
||||
* 小图标省一次试错。WebUI 有 title 提示可悬停,手指没有悬停。
|
||||
*/
|
||||
Text('导入')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => { this.importIcs(); })
|
||||
Text('导出')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => { this.exportIcs(); })
|
||||
Button('+')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Theme.accent)
|
||||
|
||||
Reference in New Issue
Block a user