增强了安全配置。

Signed-off-by: Yang Yongquan <i@yangyq.net>
This commit is contained in:
2026-09-15 12:51:58 +08:00
parent 088f7b3773
commit 5eaeeb0f4c
19 changed files with 1062 additions and 267 deletions
@@ -151,6 +151,41 @@ struct AddAccountPage {
return;
}
// 明文 HTTP 属于"已知不安全"的传输方式:Basic 凭据(用户名/密码)会以未加密形式
// 在网络上传输,可被同网络中间人直接读取。
// 不直接拒绝(局域网自建 NAS/测试环境常用 http),但必须让用户明确知情并二次确认,
// 避免用户在不知情下把凭据发到明文信道。
if (targetUrl.startsWith('http://')) {
this.getUIContext().showAlertDialog({
title: '不安全连接',
message: '该地址使用 http:// 明文连接,用户名与密码将以未加密方式在网络中传输,存在被窃听的风险。\n\n建议改用 https://。是否仍要继续?',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {
this.statusMsg = '已取消:建议改用 https:// 地址';
this.statusOk = false;
}
},
secondaryButton: {
value: '仍要继续',
fontColor: $r('app.color.error'),
action: (): void => {
this.doConnect(targetUrl);
}
}
});
return;
}
await this.doConnect(targetUrl);
}
/** 实际连接并(成功时)把凭据经 AppStorage 交给日历本选择页 */
private async doConnect(targetUrl: string): Promise<void> {
if (this.isLoading) {
return;
}
this.isLoading = true;
this.statusMsg = '正在连接服务器…';
this.statusOk = false;
+30 -81
View File
@@ -1,10 +1,10 @@
// entry/src/main/ets/pages/CalendarListPage.ets
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { LogUtil } from '../common/LogUtil';
/**
@@ -52,7 +52,8 @@ struct CalendarListPage {
this.serverUrl = AppStorage.get<string>('pendingDavUrl') ?? '';
this.username = AppStorage.get<string>('pendingDavUsername') ?? '';
this.password = AppStorage.get<string>('pendingDavPassword') ?? '';
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl} 用户名=${this.username}`);
// 日志不记录用户名(可能是邮箱等个人标识),只留服务器地址以便定位问题
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl}`);
if (this.serverUrl === '') {
this.isLoading = false;
this.statusMsg = '尚未连接服务器,请先返回重新连接';
@@ -62,6 +63,23 @@ struct CalendarListPage {
await this.fetchCalendars();
}
/**
* 清除跨页传递的明文凭据。
* 这些值原本通过 AppStorage 在页面间传递,AppStorage 是全局单例,若不清除,
* 明文密码会在**整个进程生命周期**内一直可读 —— 属于不必要的凭据驻留。
*/
private clearPendingCredentials(): void {
AppStorage.setOrCreate<string>('pendingDavPassword', '');
AppStorage.setOrCreate<string>('pendingDavUsername', '');
AppStorage.setOrCreate<string>('pendingDavUrl', '');
this.password = '';
}
/** 离开页面即清理凭据(覆盖用户按返回键放弃添加这条路径) */
aboutToDisappear(): void {
this.clearPendingCredentials();
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
@@ -80,45 +98,25 @@ struct CalendarListPage {
this.statusOk = false;
return;
}
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
try {
const resp: http.HttpResponse = await httpRequest.request(this.serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': 'Basic ' + token,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
console.info(`PROPFIND 响应码: ${resp.responseCode}`);
if (resp.responseCode === 401) {
this.isLoading = false;
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token);
this.isLoading = false;
if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请返回重新连接';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`添加账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: CalendarItem[] = this.parseCalendarList(xml);
const list: CalendarItem[] = disc.entries.map((e: DavCalendarEntry): CalendarItem =>
new CalendarItem(e.href, e.displayName, e.color));
for (const item of list) {
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
}
this.isLoading = false;
if (list.length === 0) {
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
this.statusOk = false;
@@ -134,59 +132,9 @@ struct CalendarListPage {
this.isLoading = false;
this.statusMsg = `获取日历列表失败:${e.message}`;
this.statusOk = false;
} finally {
httpRequest.destroy();
}
}
private extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
private parseCalendarList(xml: string): CalendarItem[] {
const items: CalendarItem[] = [];
const originMatch = /https?:\/\/[^/]+/i.exec(this.serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = this.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = this.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = this.extractTag(block, 'displayname');
if (name === '') {
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
try {
name = decodeURIComponent(segs[segs.length - 1]);
} catch (err) {
name = segs[segs.length - 1];
}
} else {
name = href;
}
}
// 服务器端颜色:cs:getcolor 或 ical:calendar-color,带 Alpha 时转成 #RRGGBB
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
if (color === '') {
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
}
const fullHref: string = href.startsWith('http') ? href : origin + href;
items.push(new CalendarItem(fullHref, name, color));
}
return items;
}
private async saveSelection(): Promise<void> {
if (this.isSaving) {
return;
@@ -222,13 +170,14 @@ struct CalendarListPage {
acc.calendarHrefs = selectedItems.map((c: CalendarItem): string => c.href);
acc.calendarNames = selectedItems.map((c: CalendarItem): string => c.name);
acc.calendarColors = selectedItems.map((c: CalendarItem): string => c.color);
LogUtil.write(`保存账号「${acc.name}」:id=${acc.id}勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
LogUtil.write(`保存账号 id=${acc.id}勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
await AccountStore.addAccount(context, acc);
AppStorage.setOrCreate<string>('pendingSyncAccountId', acc.id);
this.getUIContext().getPromptAction()
.showToast({ message: `账号已保存,共 ${selectedItems.length} 个日历本` });
this.statusMsg = '保存成功';
this.statusOk = true;
this.clearPendingCredentials();
router.back({ url: 'pages/AccountsPage' });
} catch (err) {
const e = err as BusinessError;
+13 -81
View File
@@ -1,12 +1,12 @@
// entry/src/main/ets/pages/EditAccountPage.ets
// 编辑账号:查看/重选该账号下的日历本、修改账户名
// 保存后清理失效日历本的本地数据,并触发一次重新同步
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore } from '../common/AccountStore';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
@@ -102,7 +102,7 @@ struct EditAccountPage {
this.accountName = foundAcc.name;
this.serverUrl = foundAcc.serverUrl;
this.username = foundAcc.username;
LogUtil.write(`编辑账号「${foundAcc.name}」:id=${foundAcc.id}当前已选 ${foundAcc.calendarHrefs.length} 个日历本`);
LogUtil.write(`编辑账号 id=${foundAcc.id}当前已选 ${foundAcc.calendarHrefs.length} 个日历本`);
for (let i = 0; i < foundAcc.calendarHrefs.length; i++) {
const nm: string = i < foundAcc.calendarNames.length ? foundAcc.calendarNames[i] : '';
LogUtil.write(` 已选日历本[${i}]「${nm}」${foundAcc.calendarHrefs[i]}`);
@@ -135,42 +135,22 @@ struct EditAccountPage {
this.statusOk = false;
return;
}
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
try {
const resp: http.HttpResponse = await httpRequest.request(this.acc.serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': 'Basic ' + token,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
console.info(`编辑账号 PROPFIND 响应码: ${resp.responseCode}`);
if (resp.responseCode === 401) {
this.isLoading = false;
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token);
this.isLoading = false;
if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请检查账号密码';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`编辑账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: EditCalendarItem[] = this.parseCalendarList(xml);
this.isLoading = false;
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
new EditCalendarItem(e.href, e.displayName, e.color));
if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = '该路径下未发现日历本';
@@ -192,58 +172,9 @@ struct EditAccountPage {
this.isLoading = false;
this.statusMsg = `获取日历列表失败:${e.message}`;
this.statusOk = false;
} finally {
httpRequest.destroy();
}
}
private extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
private parseCalendarList(xml: string): EditCalendarItem[] {
const items: EditCalendarItem[] = [];
const originMatch = /https?:\/\/[^/]+/i.exec(this.acc.serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = this.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = this.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = this.extractTag(block, 'displayname');
if (name === '') {
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
try {
name = decodeURIComponent(segs[segs.length - 1]);
} catch (err) {
name = segs[segs.length - 1];
}
} else {
name = href;
}
}
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
if (color === '') {
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
}
const fullHref: string = href.startsWith('http') ? href : origin + href;
items.push(new EditCalendarItem(fullHref, name, color));
}
return items;
}
/** 保存:更新账号的日历本选择与名称,清理失效数据,触发重新同步 */
private async saveSelection(): Promise<void> {
if (this.isSaving || !this.found) {
@@ -281,7 +212,7 @@ struct EditAccountPage {
target.calendarHrefs = selectedItems.map((c: EditCalendarItem): string => c.href);
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
LogUtil.write(`编辑账号保存:「${target.name}」id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
await AccountStore.saveAll(context, accounts);
// 重选后 calKey(accId_序号)会变化,清理已取消勾选的日历本数据
const validKeys: string[] =
@@ -428,7 +359,8 @@ struct EditAccountPage {
this.handleItemToggle(selectedItem);
}
})
}, (item: EditCalendarItem) => `${item.href}_${item.selected}`)
// key 只用 href(稳定标识):若把选中态也拼进 key,勾选一次就会导致整行销毁重建
}, (item: EditCalendarItem) => item.href)
}
}
.width('100%')
+35 -3
View File
@@ -270,7 +270,8 @@ struct EventEditPage {
} else {
await SyncEngine.settleLocalEvents(context);
}
LogUtil.write(`本地保存日程「${e.title}」提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效
try {
await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -289,6 +290,35 @@ struct EventEditPage {
this.isSaving = false;
}
/**
* 删除前二次确认。
* 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认,
* 避免"删除日程"按钮点击即删的误触。
*/
private askRemoveEvent(): void {
if (this.event === null || this.isSaving) {
return;
}
const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`;
this.getUIContext().showAlertDialog({
title: '删除日程',
message: `确定删除${label}吗?\n\n删除后该日程将从本地与服务器日历中一并移除,且不可撤销。`,
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {}
},
secondaryButton: {
value: '删除',
fontColor: $r('app.color.error'),
action: (): void => {
this.removeEvent();
}
}
});
}
private async removeEvent(): Promise<void> {
if (this.event === null || this.isSaving) {
return;
@@ -314,7 +344,8 @@ struct EventEditPage {
} else {
await SyncEngine.settleLocalEvents(context);
}
LogUtil.write(`本地删除日程「${this.event?.title ?? ''}」`);
// 日志不落盘日程标题,仅以 uid 追踪
LogUtil.write(`本地删除日程(标题已脱敏)uid=${this.event?.uid ?? ''}`);
// 立即刷新提醒(取消已发布但日程已删的提醒)
try {
await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -515,7 +546,8 @@ struct EventEditPage {
.borderRadius(12)
.enabled(!this.isSaving)
.onClick(() => {
this.removeEvent();
// 二次确认后再执行删除
this.askRemoveEvent();
})
}
}
+157 -22
View File
@@ -681,7 +681,8 @@ struct Index {
}
try {
await ctx.openLink(link);
LogUtil.write(`已拉起高德导航:${address}(坐标=${coords !== null ? '有' : '无'}`);
// 日志不落盘地点文本(可能包含家庭/公司等敏感地址),仅记录是否拿到坐标
LogUtil.write(`已拉起高德导航(地点已脱敏,坐标=${coords !== null ? '有' : '无'}`);
return;
} catch (err) {
LogUtil.write(`高德深链打开失败:${(err as BusinessError).message}`);
@@ -728,7 +729,8 @@ struct Index {
if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) {
return [list[0].latitude, list[0].longitude];
}
LogUtil.write(`地理编码无结果:${address}`);
// 日志不落盘地点文本(可能含家庭/公司等敏感地址)
LogUtil.write('地理编码无结果(地点已脱敏)');
} catch (err) {
LogUtil.write(`地理编码失败:${(err as BusinessError).message}`);
}
@@ -2351,6 +2353,9 @@ struct DayTimelineView {
@Watch('onShowNowLine') @Prop showNowLine: boolean = false; // 仅"今天"显示红线 + 触发自动定位
@Prop scrollable: boolean = false; // 是否自带可滚动容器(月/周视图日时间轴=true;列表视图卡片=false
hourUnit: number = TimelineUtil.HOUR_UNIT;
private minBlockVp: number = 16; // 极短日程色块的可读高度下限(一行"标题 + 时间"
private twoLineVp: number = 30; // 能放下"标题 + 时间/地点"两行的高度门槛
private threeLineVp: number = 46; // 能放下"标题 / 时间 / 地址"三行的高度门槛
onPick: (e: DisplayEvent) => void = (e: DisplayEvent): void => {};
private scroller: Scroller = new Scroller();
@State private viewportH: number = 0; // 滚动视口高度(由 Scroll 实测,用于把"当前时刻"居中)
@@ -2483,10 +2488,62 @@ struct DayTimelineView {
const d: number = (top - prevEnd) * this.totalH();
return d < 0 ? 0 : d;
}
/** 色块高度(vp):按实时长换算,至少 14vp 保证短日程也可见(超出组行的部分由列裁切) */
private blockH(b: TimelineBlock): number {
const h: number = b.heightRatio * this.totalH();
return h < 14 ? 14 : h;
/** 色块实际渲染高度(vp):按实时长换算
* "特别短"的日程(如 15 分钟)原始高度只有几 vp,放不下一行字 → 在不越过**同列下一个色块**的前提下
* 尽量垫到 minBlockVp,保证至少能显示一行"时刻 + 题目"。垫不满时按实际可用量给,绝不重叠。 */
private blockVp(r: TimeRow, lane: TimelineBlock[], index: number): number {
const arr: TimelineBlock[] = this.laneBlocks(r, lane);
const b: TimelineBlock = arr[index];
const real: number = b.heightRatio * this.totalH();
if (real >= this.minBlockVp) {
return real;
}
let room: number = 0; // 可以借用的下方空隙(同列下一块之前 / 本行末尾)
if (index + 1 < arr.length) {
room = this.lanePadH(r, lane, index + 1);
} else {
const tail: number = (r.botRatio - (b.topRatio + b.heightRatio)) * this.totalH();
room = tail > 0 ? tail : 0;
}
const h: number = real + room;
return h > this.minBlockVp ? this.minBlockVp : h;
}
/** 色块信息行数(按**渲染高度**判定,标题永远独占第一行的开头):
* 1 = 一行放下 → 标题 · 时间 · 地址(时间用短格式);
* 2 = 标题一行 + "时间 · 地点"一行;
* 3 = 标题 / 时间 / 地址 各一行(没有地址时退化为 2 行,不空占一行)。 */
private blockLines(r: TimeRow, lane: TimelineBlock[], index: number, b: TimelineBlock): number {
const h: number = this.blockVp(r, lane, index);
if (h >= this.threeLineVp && b.location !== '') {
return 3;
}
if (h >= this.twoLineVp) {
return 2;
}
return 1;
}
/** 两行色块的第二行:'09:00 - 10:30 · 地点'(无地点时只剩时间) */
private blockMeta(b: TimelineBlock): string {
if (b.location !== '') {
return `${b.timeText} · ${b.location}`;
}
return b.timeText;
}
/** 色块 ForEach key 的内容签名:只读标记 / 地点 / 渲染高度 / 标题长度任一变化都必须强制重建,
* 否则 ArkUI 会按旧 key 复用子组件 → "改了样式界面不更新"。 */
private blockSig(r: TimeRow, lane: TimelineBlock[], index: number, b: TimelineBlock): string {
return `${b.writable ? 1 : 0}_${b.location.length}_${Math.round(this.blockVp(r, lane, index))}_${b.title.length}`;
}
/** 「只读」小标:圆角矩形白框 + 白字,紧跟在日程标题后面,字号比标题小一号 */
@Builder
private roTag() {
Text('只读')
.fontSize(9)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
.maxLines(1)
}
// 整点灰线由第 1 层(网格层)画、红线由第 3 层画 → 日程层不再为它们切段。
/** 左侧刻度:当前小时是否高亮(仅今天) */
@@ -2588,6 +2645,15 @@ struct DayTimelineView {
.backgroundColor('#26000000')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (!e.writable) {
// 只读标记:圆角矩形白框 + 白字(与色块上的「只读」小标同一套样式)
Text('只读')
.fontSize(9)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
}
Text(e.title === '' ? '(无标题)' : e.title)
.fontSize(12)
.fontWeight(FontWeight.Medium)
@@ -2595,15 +2661,24 @@ struct DayTimelineView {
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (e.location !== '') {
Text(e.location)
.fontSize(10)
.fontColor('#D9FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 120 })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(26)
.padding({ left: 8, right: 8 })
.borderRadius(8)
// 只读不再用灰蒙版(已撤),只靠标题后的「只读」白框小标区分
.backgroundColor(e.color)
.onClick(() => this.onPick(e))
}, (e: DisplayEvent) => `al_${this.timeline.dateKey}_${TimelineUtil.keyOf(e)}`)
}, (e: DisplayEvent) => `al_${this.timeline.dateKey}_${TimelineUtil.keyOf(e)}_${e.writable ? 1 : 0}_${e.location.length}`)
}
.width('100%')
.padding({ bottom: 6 })
@@ -2683,20 +2758,80 @@ struct DayTimelineView {
ForEach(this.laneBlocks(r, lane), (b: TimelineBlock, index: number) => {
Blank().height(this.lanePadH(r, lane, index))
Column({ space: 1 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.blockLines(r, lane, index, b) === 1) {
// 一行:标题 · [只读] · 时间 · 地址
// 时间用**完整起止**09:00 - 10:30):宽度够就整段显示,不够由 maxWidth + 省略号自然截断;
// 三者的让位顺序是"地址先没 → 时间省略 → 标题只保 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) {
this.roTag()
}
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '55%' })
.lineHeight(12)
if (b.location !== '') {
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(12)
.layoutWeight(1)
}
}
.width('100%')
if (b.heightRatio * 86400000 >= 40 * 60000) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.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) {
this.roTag()
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.blockLines(r, lane, index, 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.blockMeta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
@@ -2707,13 +2842,13 @@ struct DayTimelineView {
.opacity(b.isNow ? 1 : 0.92)
.clip(true)
.width('100%')
.height(this.blockH(b))
.height(this.blockVp(r, lane, index))
.onClick(() => {
if (b.ev !== null) {
this.onPick(b.ev);
}
})
}, (b: TimelineBlock) => `blk_${r.key}_${b.eventKey}`)
}, (b: TimelineBlock, index: number) => `blk_${r.key}_${b.eventKey}_${this.blockSig(r, lane, index, b)}`)
}
.layoutWeight(1)
.height('100%')
+94 -4
View File
@@ -104,6 +104,11 @@ struct SettingsPage {
this.allBooks = books;
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey);
}
/** 手动标记/取消只读 */
private async toggleManualBook(b: BackupTarget): Promise<void> {
if (this.context === undefined) {
@@ -300,6 +305,44 @@ struct SettingsPage {
});
}
/** 确认后清空本地账号库(密码一并删除不可恢复;服务器日程不受影响) */
private askResetVault(): void {
this.getUIContext().showAlertDialog({
title: '重置账号库',
message: '将删除本机保存的全部账号、服务器地址与密码,回到"还没添加过账号"的状态。\n\n'
+ '日历数据保存在你的 CalDAV 服务器上,不受影响:重新添加账号并同步一次即可恢复。\n\n'
+ '本操作不可撤销(本地密码会一并删除),仅在账号读不出来、也无法新增账号时使用。',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {}
},
secondaryButton: {
value: '确认重置',
action: (): void => {
this.doResetVault();
}
}
});
}
private async doResetVault(): Promise<void> {
if (this.context === undefined) {
return;
}
try {
await AccountStore.resetVault(this.context);
this.getUIContext().getPromptAction().showToast({
message: '账号库已重置,请重新添加账号'
});
} catch (err) {
this.getUIContext().getPromptAction().showToast({
message: '重置失败,请稍后重试'
});
}
}
/** 去系统设置开启本应用通知 */
private async openNotifySettings(): Promise<void> {
if (this.context === undefined) {
@@ -313,12 +356,20 @@ struct SettingsPage {
}
}
/** 打开外部链接(如开源仓库) */
/**
* 打开外部链接(隐私政策 / 用户服务协议 / 开源仓库)。
* 仅放行 http(s):避免 URL 被篡改或误传为 file://、自定义 scheme、intent 类 URI 时,
* 拉起本地文件或任意第三方应用 —— 把"打开网页"严格限定在预期范围内。
*/
private async openUrl(url: string): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
this.getUIContext().getPromptAction().showToast({ message: '仅支持打开 http(s) 链接' });
return;
}
try {
await (context as common.UIAbilityContext).openLink(url);
} catch (err) {
@@ -479,6 +530,36 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 逃生舱:账号库读不出来(密钥丢失/失效)时,只清账号,不动设置与日程
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('重置账号库')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('清空本机保存的账号、服务器地址与密码。服务器上的日程不受影响,重新添加账号并同步即可恢复。仅在账号读不出来、也无法新增账号时使用')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Button('重置')
.fontSize(13)
.backgroundColor($r('app.color.error'))
.onClick(() => {
this.askResetVault();
})
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 混合显示系统日历
Column({ space: 8 }) {
Row({ space: 10 }) {
@@ -791,9 +872,17 @@ struct SettingsPage {
}
ForEach(this.allBooks, (b: BackupTarget) => {
Row({ space: 8 }) {
// 日历本色点:只读本变中性灰 —— 相当于给这个本盖了一层灰蒙板
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(this.isBookReadonly(b)
? '#B0B4BA' : $r('app.color.brand'))
Text(b.label)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.fontColor(this.isBookReadonly(b)
? $r('app.color.text_hint') : $r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
@@ -827,8 +916,9 @@ struct SettingsPage {
})
}
.width('100%')
.padding({ top: 6, bottom: 6 })
}, (b: BackupTarget) => `book_${b.calKey}_r${this.manualKeys.includes(b.calKey) ? 1 : 0}_m${this.mutedKeys.includes(b.calKey) ? 1 : 0}`)
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
.borderRadius(8)
}, (b: BackupTarget) => `book_${b.calKey}_r${this.manualKeys.includes(b.calKey) ? 1 : 0}_m${this.mutedKeys.includes(b.calKey) ? 1 : 0}_w${b.serverWritable ? 1 : 0}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
+101 -12
View File
@@ -14,6 +14,8 @@ class TBlock {
eventKey: string = '';
title: string = '';
timeText: string = '';
location: string = ''; // 地点(第 2/3 行显示)
writable: boolean = true; // false → 标题后挂白框「只读」小标
color: string = '#007DFF';
topRatio: number = 0;
heightRatio: number = 0;
@@ -143,9 +145,11 @@ struct Widget4x4Card {
rg.e = e;
return rg;
}
/** 视窗起始小时(取 w4Range 的起点,向下取整) */
private w4StartHour(): number {
return this.w4Range().s;
}
/** 视窗结束小时(取 w4Range 的终点,向上取整) */
private w4EndHour(): number {
return this.w4Range().e;
}
@@ -306,6 +310,21 @@ struct Widget4x4Card {
private w4BlockVp(b: TBlock): number {
return b.heightRatio * 24 * W4_HOUR;
}
/** 色块信息行数(标题永远打头):1=标题+时间+地址同行;2=标题 + 时间·地点;3=标题 / 时间 / 地址 */
private w4Lines(b: TBlock): number {
const h: number = this.w4BlockVp(b);
if (h >= 40 && b.location !== '') {
return 3;
}
if (h >= 26) {
return 2;
}
return 1;
}
/** 两行色块的第二行:'09:00 - 10:30 · 地点'(无地点时只剩时间) */
private w4Meta(b: TBlock): string {
return b.location !== '' ? `${b.timeText} · ${b.location}` : b.timeText;
}
/** 今天定时日程是否已全部结束(最晚结束比例 <= 当前时刻比例);只剩全天 / 无定时日程也算已结束。
* 非今天(r<0)返回 false,交由原条件判断(非今天本来就不显示红线)。 */
private w4AllTimedEnded(): boolean {
@@ -585,19 +604,89 @@ struct Widget4x4Card {
ForEach(lane, (b: TBlock, index: number) => {
Blank().height(this.w4LanePadVp(r.group, lane, index))
Column({ space: 1 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.w4Lines(b) === 1) {
// 一行:标题 · [只读] · 时间 · 地址(时间用完整起止,宽度不够自然省略;
// 让位顺序:地址先没 → 时间省略 → 标题只保 50%)
Row({ space: 3 }) {
Text(b.title)
.fontSize(9)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
.lineHeight(11)
if (!b.writable) {
Text('只读')
.fontSize(8)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(3)
.padding({ left: 3, right: 3, top: 0, bottom: 0 })
.maxLines(1)
}
Text(b.timeText)
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '56%' })
.lineHeight(11)
if (b.location !== '') {
Text(b.location)
.fontSize(8)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(11)
.layoutWeight(1)
}
}
.width('100%')
if (b.heightRatio * 86400000 >= 60 * 60000) {
Text(b.timeText)
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题 · [只读]
Row({ space: 3 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '70%' })
if (!b.writable) {
Text('只读')
.fontSize(8)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(3)
.padding({ left: 3, right: 3, top: 0, bottom: 0 })
.maxLines(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.w4Lines(b) >= 3) {
Text(b.timeText)
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.location)
.fontSize(8)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.w4Meta(b))
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
+101 -12
View File
@@ -14,6 +14,8 @@ 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;
@@ -142,9 +144,11 @@ struct Widget6x4Card {
rg.e = e;
return rg;
}
/** 视窗起始小时(取 w6Range 的起点,向下取整) */
private w6StartHour(): number {
return this.w6Range().s;
}
/** 视窗结束小时(取 w6Range 的终点,向上取整) */
private w6EndHour(): number {
return this.w6Range().e;
}
@@ -305,6 +309,21 @@ struct Widget6x4Card {
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 {
@@ -584,19 +603,89 @@ struct Widget6x4Card {
ForEach(lane, (b: TBlock6, index: number) => {
Blank().height(this.w6LanePadVp(r.group, lane, index))
Column({ space: 1 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
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%')
if (b.heightRatio * 86400000 >= 60 * 60000) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(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)