@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%')
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%')
|
||||
|
||||
@@ -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%')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user