首次提交:SyncCalendar 项目

This commit is contained in:
2026-09-13 15:50:37 +08:00
commit bbb3ec5334
53 changed files with 7123 additions and 0 deletions
+384
View File
@@ -0,0 +1,384 @@
// entry/src/main/ets/pages/AccountsPage.ets
// DAV 账号管理(由原 CalDAVSync 首页移植)
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine';
import { EditNavParams } from './EditAccountPage';
import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
@Entry
@Component
struct AccountsPage {
@State accounts: DavAccount[] = [];
@State showTypeMenu: boolean = false;
@State syncing: boolean = false;
@State syncingId: string = '';
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
LogUtil.init(ctx);
LogUtil.write('---- 打开 DAV 账号页 ----');
}
this.reloadAccounts();
}
onPageShow(): void {
this.reloadAccounts();
}
private async reloadAccounts(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
this.accounts = await AccountStore.loadAll(context);
const pendingId: string | undefined = AppStorage.get<string>('pendingSyncAccountId');
if (pendingId !== undefined && pendingId !== '') {
AppStorage.setOrCreate<string>('pendingSyncAccountId', '');
const found = this.accounts.find((a: DavAccount): boolean => a.id === pendingId);
if (found !== undefined) {
this.syncSingleAccount(found);
}
}
}
private typeLabel(type: string): string {
if (type === TYPE_CALDAV) {
return 'CalDAV 日历';
}
if (type === TYPE_CARDDAV) {
return 'CardDAV 通讯录';
}
return 'WebDAV 文件';
}
private formatNow(): string {
const d = new Date();
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
private async syncSingleAccount(acc: DavAccount): Promise<void> {
if (this.syncing) {
return;
}
this.syncing = true;
this.syncingId = acc.id;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
if (acc.type === TYPE_CALDAV) {
await SyncEngine.withTimeout(
SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000);
}
acc.itemCount = acc.calendarHrefs.length;
acc.lastSyncTime = this.formatNow();
await AccountStore.saveAll(context, this.accounts);
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
} finally {
this.syncing = false;
this.syncingId = '';
}
}
private openAddPage(type: string): void {
this.showTypeMenu = false;
AppStorage.setOrCreate<string>('pendingAccountType', type);
router.pushUrl({ url: 'pages/AddAccountPage' });
}
/** 点击账号 → 进入编辑页(查看/重选日历本) */
private openEditPage(acc: DavAccount): void {
if (acc.type !== TYPE_CALDAV) {
this.getUIContext().getPromptAction()
.showToast({ message: '该类型账号暂不支持编辑日历本' });
return;
}
AppStorage.setOrCreate<string>('pendingEditAccountId', acc.id);
const params = new EditNavParams();
params.accId = acc.id;
router.pushUrl({ url: 'pages/EditAccountPage', params: params });
}
/** 长按账号 → 弹出删除确认 */
private askDeleteAccount(acc: DavAccount): void {
this.getUIContext().showAlertDialog({
title: '删除账号',
message: `确定删除「${acc.name}」吗?\n\n该账号下的所有日历本、日程与待办的本地数据将一并删除。\n服务器上的数据不受影响。`,
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {}
},
secondaryButton: {
value: '删除',
fontColor: $r('app.color.error'),
action: (): void => {
this.confirmDeleteAccount(acc);
}
}
});
}
/** 执行删除:账号配置 + 该账号全部本地日程/待办 */
private async confirmDeleteAccount(acc: DavAccount): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
try {
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
await AccountStore.saveAll(context, this.accounts);
await EventDb.deleteAccountEvents(context, acc.id);
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」已删除` });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction()
.showToast({ message: `删除失败:${e.message}` });
}
}
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
Column() {
// 顶部
Row({ space: 10 }) {
Text('←')
.fontSize(20)
.fontColor($r('app.color.brand'))
.onClick(() => {
router.back();
})
Text('DAV 账号')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
if (this.accounts.length === 0) {
this.emptyState()
} else {
Scroll() {
Column({ space: 16 }) {
ForEach(TYPE_KEYS, (type: string) => {
if (this.accounts.some((a: DavAccount): boolean => a.type === type)) {
Column({ space: 8 }) {
Text(this.typeLabel(type))
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
ForEach(this.accounts.filter((a: DavAccount): boolean => a.type === type),
(acc: DavAccount) => {
AccountRow({
acc: acc,
isSyncing: this.syncingId === acc.id,
onSelect: (selected: DavAccount): void => {
this.openEditPage(selected);
},
onLongPress: (selected: DavAccount): void => {
this.askDeleteAccount(selected);
}
})
}, (acc: DavAccount) => acc.id)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
}, (type: string) => type)
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 100 })
.constraintSize({ minHeight: '100%' })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_bg'))
if (this.showTypeMenu) {
Column()
.width('100%')
.height('100%')
.onClick(() => {
this.showTypeMenu = false;
})
}
if (this.showTypeMenu) {
Column({ space: 10 }) {
this.menuItem('日', 'CalDAV', '日历同步', TYPE_CALDAV)
this.menuItem('人', 'CardDAV', '通讯录同步', TYPE_CARDDAV)
this.menuItem('文', 'WebDAV', '文件访问', TYPE_WEBDAV)
}
.width(220)
.padding(10)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 16, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
.margin({ right: 24, bottom: 156 })
}
Button() {
Text('')
.fontSize(26)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor($r('app.color.brand'))
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
.margin(24)
.onClick(() => {
this.showTypeMenu = !this.showTypeMenu;
})
}
.width('100%')
.height('100%')
}
@Builder
emptyState() {
Column({ space: 12 }) {
Text('')
.fontSize(30)
.fontColor($r('app.color.text_hint'))
.width(72)
.height(72)
.textAlign(TextAlign.Center)
.borderRadius(20)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1.5, color: $r('app.color.shadow_color') })
Text('还没有任何 DAV 账号')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('点击右下角 + 添加 CalDAV / CardDAV / WebDAV 账号')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
@Builder
menuItem(badge: string, title: string, desc: string, type: string) {
Row({ space: 12 }) {
Text(badge)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.brand'))
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor($r('app.color.input_bg'))
Column({ space: 2 }) {
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(desc)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(8)
.borderRadius(10)
.onClick(() => {
this.openAddPage(type);
})
}
}
@Component
struct AccountRow {
@ObjectLink acc: DavAccount;
@Prop isSyncing: boolean = false;
onSelect: (acc: DavAccount) => void = (selected: DavAccount): void => {};
onLongPress: (acc: DavAccount) => void = (selected: DavAccount): void => {};
build() {
Row({ space: 12 }) {
Text(this.acc.name !== '' ? this.acc.name.substring(0, 1) : 'D')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.button_text'))
.width(40)
.height(40)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor($r('app.color.brand'))
Column({ space: 4 }) {
Text(this.acc.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.acc.serverUrl)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.acc.lastSyncTime === ''
? '尚未同步'
: `上次同步 ${this.acc.lastSyncTime} · ${this.acc.calendarHrefs.length} 个日历本`)
.fontSize(12)
.fontColor($r('app.color.text_hint'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.isSyncing) {
LoadingProgress()
.width(20)
.height(20)
.color($r('app.color.brand'))
} else {
Text('')
.fontSize(20)
.fontColor($r('app.color.text_hint'))
}
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
.onClick(() => {
this.onSelect(this.acc);
})
.gesture(LongPressGesture({ repeat: false })
.onAction((event: GestureEvent) => {
this.onLongPress(this.acc);
}))
}
}
+283
View File
@@ -0,0 +1,283 @@
// entry/src/main/ets/pages/AddAccountPage.ets
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS';
import url from '@ohos.url';
import { BusinessError } from '@kit.BasicServicesKit';
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
@Entry
@Component
struct AddAccountPage {
@State serverUrl: string = '';
@State username: string = '';
@State password: string = '';
@State isLoading: boolean = false;
@State statusMsg: string = '';
@State statusOk: boolean = false;
@State typeLabel: string = 'CalDAV';
private accountType: string = TYPE_CALDAV;
aboutToAppear(): void {
const t: string | undefined = AppStorage.get<string>('pendingAccountType');
this.accountType = (t === undefined || t === '') ? TYPE_CALDAV : t;
if (this.accountType === TYPE_CARDDAV) {
this.typeLabel = 'CardDAV';
} else if (this.accountType === TYPE_WEBDAV) {
this.typeLabel = 'WebDAV';
} else {
this.typeLabel = 'CalDAV';
}
}
private normalizeUrl(): string | null {
let rawUrl: string = this.serverUrl.trim();
if (rawUrl === '') {
return null;
}
if (!rawUrl.startsWith('http://') && !rawUrl.startsWith('https://')) {
rawUrl = 'https://' + rawUrl;
}
const hostPattern: RegExp =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$|^\d{1,3}(\.\d{1,3}){3}$|^\[[0-9A-Fa-f:]+\]$|^localhost$/;
try {
const parsed = url.URL.parseURL(rawUrl);
const hostname: string = parsed.hostname !== '' ? parsed.hostname : parsed.host;
if (hostname !== '' && hostPattern.test(hostname)) {
return rawUrl;
}
this.statusMsg = `URL 主机名无效:${rawUrl}`;
this.statusOk = false;
return null;
} catch (err) {
const e = err as BusinessError;
console.error(`URL 解析异常(${e.code}),使用正则兜底: ${e.message}`);
const fallbackPattern: RegExp =
/^https?:\/\/[^\s/:?#]+(:\d{1,5})?([/?#][^\s]*)?$/;
if (fallbackPattern.test(rawUrl)) {
return rawUrl;
}
this.statusMsg = `URL 解析失败:${e.message}`;
this.statusOk = false;
return null;
}
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async sendOnce(serverUrl: string, method: http.RequestMethod, authHeader: string): Promise<number> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
method: method,
header: {
'Authorization': authHeader,
'Accept': '*/*',
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 10000
});
return resp.responseCode;
} finally {
httpRequest.destroy();
}
}
private async probeServer(serverUrl: string): Promise<boolean> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
this.statusOk = false;
return false;
}
const authHeader: string = 'Basic ' + token;
try {
let code: number = await this.sendOnce(serverUrl, http.RequestMethod.OPTIONS, authHeader);
if (code === 401) {
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
}
if (code === 401) {
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
this.statusOk = false;
return false;
}
if (code >= 200 && code < 500) {
this.statusMsg = '服务器连接成功';
this.statusOk = true;
return true;
}
this.statusMsg = `服务器返回异常状态码:${code}`;
this.statusOk = false;
return false;
} catch (err) {
const e = err as BusinessError;
console.error(`连接失败: ${e.code} - ${e.message}`);
this.statusMsg = `无法连接服务器:${e.message}`;
this.statusOk = false;
return false;
}
}
private async onConnectAndSave(): Promise<void> {
if (this.isLoading) {
return;
}
const targetUrl: string | null = this.normalizeUrl();
if (targetUrl === null) {
if (this.statusMsg === '' || this.statusMsg === '正在连接服务器…' || this.statusOk) {
this.statusMsg = '请输入有效的服务器地址';
}
this.statusOk = false;
return;
}
if (!this.username.trim()) {
this.statusMsg = '请输入用户名';
this.statusOk = false;
return;
}
if (!this.password) {
this.statusMsg = '请输入密码';
this.statusOk = false;
return;
}
this.isLoading = true;
this.statusMsg = '正在连接服务器…';
this.statusOk = false;
const ok: boolean = await this.probeServer(targetUrl);
if (ok) {
AppStorage.setOrCreate<string>('pendingDavUrl', targetUrl);
AppStorage.setOrCreate<string>('pendingDavUsername', this.username.trim());
AppStorage.setOrCreate<string>('pendingDavPassword', this.password);
this.getUIContext().getPromptAction().showToast({ message: '连接成功' });
router.pushUrl({ url: 'pages/CalendarListPage' });
}
this.isLoading = false;
}
build() {
Column({ space: 24 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 8 }) {
Text(`添加${this.typeLabel}账号`)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('输入服务器账号信息')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 16 }) {
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl,
(value: string) => {
this.serverUrl = value;
}, false)
this.formField('用户名', '请输入用户名', this.username,
(value: string) => {
this.username = value;
}, false)
this.formField('密码', '请输入密码', this.password,
(value: string) => {
this.password = value;
}, true)
}
.width('100%')
.padding(20)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
Button() {
Row({ space: 8 }) {
if (this.isLoading) {
LoadingProgress()
.width(20)
.height(20)
.color($r('app.color.button_text'))
}
Text(this.isLoading ? '连接中…' : '连接并保存')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isLoading ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isLoading)
.onClick(() => {
this.onConnectAndSave();
})
if (this.statusMsg) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Blank()
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
.alignItems(HorizontalAlign.Center)
}
@Builder
formField(label: string, placeholder: string, value: string,
onChange: (value: string) => void, isPassword: boolean) {
Column({ space: 8 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
TextInput({ text: value, placeholder: placeholder })
.type(isPassword ? InputType.Password : InputType.Normal)
.showPasswordIcon(isPassword)
.height(44)
.fontSize(15)
.backgroundColor($r('app.color.input_bg'))
.borderRadius(8)
.onChange(onChange)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
}
@@ -0,0 +1,459 @@
// 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 { LogUtil } from '../common/LogUtil';
/**
* 日历本条目(@Observed 使勾选状态变化能刷新 UI)
*/
@Observed
export class CalendarItem {
href: string;
name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空
selected: boolean;
constructor(href: string, name: string, color: string) {
this.href = href;
this.name = name;
this.color = color;
this.selected = false;
}
}
@Entry
@Component
struct CalendarListPage {
@State calendarList: CalendarItem[] = [];
@State accountName: string = '';
@State isLoading: boolean = true;
@State statusMsg: string = '正在获取日历列表…';
@State statusOk: boolean = false;
@State isSaving: boolean = false;
@State selectedCount: number = 0;
@State allSelected: boolean = false;
private serverUrl: string = '';
private username: string = '';
private password: string = '';
aboutToAppear(): Promise<void> {
return this.initPage();
}
private async initPage(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
LogUtil.init(ctx);
}
this.serverUrl = AppStorage.get<string>('pendingDavUrl') ?? '';
this.username = AppStorage.get<string>('pendingDavUsername') ?? '';
this.password = AppStorage.get<string>('pendingDavPassword') ?? '';
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl} 用户名=${this.username}`);
if (this.serverUrl === '') {
this.isLoading = false;
this.statusMsg = '尚未连接服务器,请先返回重新连接';
this.statusOk = false;
return;
}
await this.fetchCalendars();
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async fetchCalendars(): Promise<void> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.isLoading = false;
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
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();
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;
this.statusMsg = '登录已失效,请返回重新连接';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`添加账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: CalendarItem[] = this.parseCalendarList(xml);
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;
} else {
this.calendarList = list;
this.updateSelectionState();
this.statusMsg = `发现 ${list.length} 个日历本,请勾选要同步的日历`;
this.statusOk = true;
}
} catch (err) {
const e = err as BusinessError;
console.error(`获取日历列表失败: ${e.code} - ${e.message}`);
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;
}
if (this.accountName.trim() === '') {
this.statusMsg = '请先给这个日历账户起一个名字';
this.statusOk = false;
return;
}
const selectedItems: CalendarItem[] = this.calendarList.filter((c: CalendarItem) => c.selected);
if (selectedItems.length === 0) {
this.statusMsg = '请至少勾选一个日历本';
this.statusOk = false;
return;
}
this.isSaving = true;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
this.statusMsg = '无法获取应用上下文';
this.statusOk = false;
this.isSaving = false;
return;
}
const acc = new DavAccount();
acc.id = String(Date.now());
const accType: string | undefined = AppStorage.get<string>('pendingAccountType');
acc.type = (accType === undefined || accType === '') ? TYPE_CALDAV : accType;
acc.name = this.accountName.trim();
acc.serverUrl = this.serverUrl;
acc.username = this.username;
acc.password = this.password;
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} 个日历本`);
await AccountStore.addAccount(context, acc);
AppStorage.setOrCreate<string>('pendingSyncAccountId', acc.id);
this.getUIContext().getPromptAction()
.showToast({ message: `账号已保存,共 ${selectedItems.length} 个日历本` });
this.statusMsg = '保存成功';
this.statusOk = true;
router.back({ url: 'pages/AccountsPage' });
} catch (err) {
const e = err as BusinessError;
console.error(`保存失败: ${e.code} - ${e.message}`);
this.statusMsg = `保存失败:${e.message}`;
this.statusOk = false;
}
this.isSaving = false;
}
private updateSelectionState(): void {
const count: number = this.calendarList.filter((c: CalendarItem): boolean => c.selected).length;
this.selectedCount = count;
this.allSelected = this.calendarList.length > 0 && count === this.calendarList.length;
}
private handleItemToggle(item: CalendarItem): void {
item.selected = !item.selected;
this.updateSelectionState();
}
private toggleAll(): void {
const target: boolean = !this.allSelected;
this.calendarList.forEach((c: CalendarItem) => {
c.selected = target;
});
this.updateSelectionState();
}
build() {
Column({ space: 14 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 6 }) {
Text('选择日历本')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('勾选需要同步的日历本,并为账户命名')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 8 }) {
Row({ space: 4 }) {
Text('日历账户名称')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
Text('*')
.fontSize(15)
.fontColor($r('app.color.error'))
}
TextInput({ text: this.accountName, placeholder: '例如:我的群晖日历' })
.height(46)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.backgroundColor($r('app.color.input_bg'))
.borderRadius(10)
.border({ width: 1.5, color: $r('app.color.brand') })
.padding({ left: 12, right: 12 })
.onChange((value: string) => {
this.accountName = value;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(14)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
Row({ space: 8 }) {
Text(this.calendarList.length > 0
? `已选 ${this.selectedCount} / ${this.calendarList.length}` : ' ')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
if (this.calendarList.length > 0) {
Button(this.allSelected ? '取消全选' : '全选')
.fontSize(13)
.fontColor($r('app.color.brand'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.brand'), radius: 14 })
.height(30)
.padding({ left: 14, right: 14 })
.onClick(() => {
this.toggleAll();
})
}
}
.width('100%')
Scroll() {
Column({ space: 4 }) {
if (this.isLoading) {
Column({ space: 12 }) {
LoadingProgress()
.width(36)
.height(36)
.color($r('app.color.brand'))
Text('正在从服务器获取日历列表…')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding(32)
} else if (this.calendarList.length === 0) {
Text(this.statusMsg)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.textAlign(TextAlign.Center)
} else {
ForEach(this.calendarList, (item: CalendarItem) => {
CalendarRow({
item: item,
onSelect: (selectedItem: CalendarItem): void => {
this.handleItemToggle(selectedItem);
}
})
}, (item: CalendarItem) => item.href)
}
}
.width('100%')
.padding(8)
.constraintSize({ minHeight: '100%' })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
if (this.statusMsg !== '' && !this.isLoading) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Button() {
Text(this.isSaving ? '保存中…' : '保存并完成')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isSaving ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isSaving && !this.isLoading)
.onClick(() => {
this.saveSelection();
})
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
.alignItems(HorizontalAlign.Center)
}
}
@Component
struct CalendarRow {
@ObjectLink item: CalendarItem;
onSelect: (item: CalendarItem) => void = (selectedItem: CalendarItem): void => {};
build() {
Row({ space: 12 }) {
Text(this.item.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
if (this.item.selected) {
Text('✓')
.fontSize(14)
.fontColor($r('app.color.button_text'))
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.brand'))
} else {
Text('')
.width(24)
.height(24)
.borderRadius(12)
.border({ width: 1.5, color: $r('app.color.text_hint') })
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 14, bottom: 14 })
.borderRadius(8)
.onClick(() => {
this.onSelect(this.item);
})
}
}
@@ -0,0 +1,530 @@
// 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 { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
/**
* 日历本条目(@Observed 使勾选状态变化能刷新 UI)
*/
@Observed
export class EditCalendarItem {
href: string;
name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空
selected: boolean;
constructor(href: string, name: string, color: string) {
this.href = href;
this.name = name;
this.color = color;
this.selected = false;
}
}
/**
* 路由参数(AppStorage 的兜底通道)
*/
export class EditNavParams {
accId: string = '';
}
@Entry
@Component
struct EditAccountPage {
@State accountName: string = '';
@State serverUrl: string = '';
@State username: string = '';
@State calendarList: EditCalendarItem[] = [];
@State isLoading: boolean = true;
@State statusMsg: string = '正在获取日历列表…';
@State statusOk: boolean = false;
@State isSaving: boolean = false;
@State selectedCount: number = 0;
@State allSelected: boolean = false;
private acc: DavAccount = new DavAccount();
private found: boolean = false;
aboutToAppear(): Promise<void> {
return this.initPage();
}
private async initPage(): Promise<void> {
try {
// 1) 读取目标账号 idAppStorage 为主,路由参数兜底)
let accId: string | undefined = AppStorage.get<string>('pendingEditAccountId');
if (accId === undefined || accId === '') {
const rawParams: Object | undefined = router.getParams();
if (rawParams instanceof EditNavParams && rawParams.accId !== '') {
accId = rawParams.accId;
}
}
console.info(`编辑账号 initPage: accId=${accId ?? '(undefined)'}`);
if (accId === undefined || accId === '') {
this.isLoading = false;
this.statusMsg = '未指定要编辑的账号,请返回账号列表重新点击';
this.statusOk = false;
return;
}
// 2) 获取应用上下文(getHostContext 过早调用可能为 undefinedgetContext 兜底)
let context: common.Context | undefined = undefined;
try {
context = this.getUIContext().getHostContext();
} catch (err) {
console.info('getHostContext 异常,使用 getContext 兜底');
}
if (context === undefined) {
context = getContext(this);
}
if (context === undefined) {
this.isLoading = false;
this.statusMsg = '无法获取应用上下文';
this.statusOk = false;
return;
}
// 3) 从账号列表里找到该账号
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const foundAcc = accounts.find((a: DavAccount): boolean => a.id === accId);
if (foundAcc === undefined) {
this.isLoading = false;
this.statusMsg = `账号不存在(id=${accId}),可能已被删除`;
this.statusOk = false;
return;
}
this.acc = foundAcc;
this.found = true;
this.accountName = foundAcc.name;
this.serverUrl = foundAcc.serverUrl;
this.username = foundAcc.username;
LogUtil.write(`编辑账号「${foundAcc.name}」: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]}`);
}
await this.fetchCalendars();
} catch (err) {
const e = err as BusinessError;
console.error(`编辑账号初始化失败: ${e.code} - ${e.message}`);
this.isLoading = false;
this.statusMsg = `页面初始化失败:${e.message}`;
this.statusOk = false;
}
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.acc.username}:${this.acc.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async fetchCalendars(): Promise<void> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.isLoading = false;
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
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();
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;
this.statusMsg = '登录已失效,请检查账号密码';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
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;
if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = '该路径下未发现日历本';
this.statusOk = false;
} else {
// 已勾选的日历本按账号当前配置预选
for (const item of list) {
item.selected = this.acc.calendarHrefs.includes(item.href);
LogUtil.write(`编辑账号发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color} 预选=${item.selected}`);
}
this.calendarList = list;
this.updateSelectionState();
this.statusMsg = `该账号共 ${list.length} 个日历本,当前已选 ${this.selectedCount} 个`;
this.statusOk = true;
}
} catch (err) {
const e = err as BusinessError;
console.error(`获取日历列表失败: ${e.code} - ${e.message}`);
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) {
return;
}
if (this.accountName.trim() === '') {
this.statusMsg = '账户名不能为空';
this.statusOk = false;
return;
}
const selectedItems: EditCalendarItem[] = this.calendarList.filter((c: EditCalendarItem) => c.selected);
if (selectedItems.length === 0) {
this.statusMsg = '请至少勾选一个日历本';
this.statusOk = false;
return;
}
this.isSaving = true;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
this.statusMsg = '无法获取应用上下文';
this.statusOk = false;
this.isSaving = false;
return;
}
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const target = accounts.find((a: DavAccount): boolean => a.id === this.acc.id);
if (target === undefined) {
this.statusMsg = '账号不存在,可能已被删除';
this.statusOk = false;
this.isSaving = false;
return;
}
target.name = this.accountName.trim();
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} 个日历本`);
await AccountStore.saveAll(context, accounts);
// 重选后 calKey(accId_序号)会变化,清理已取消勾选的日历本数据
const validKeys: string[] =
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
await EventDb.pruneAccountEvents(context, target.id, validKeys);
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
this.getUIContext().getPromptAction()
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` });
router.back();
} catch (err) {
const e = err as BusinessError;
console.error(`保存失败: ${e.code} - ${e.message}`);
this.statusMsg = `保存失败:${e.message}`;
this.statusOk = false;
}
this.isSaving = false;
}
private updateSelectionState(): void {
const count: number = this.calendarList.filter((c: EditCalendarItem): boolean => c.selected).length;
this.selectedCount = count;
this.allSelected = this.calendarList.length > 0 && count === this.calendarList.length;
}
private handleItemToggle(item: EditCalendarItem): void {
// 选中状态已在 EditCalendarRow 内部直接翻转,这里只刷新计数
this.updateSelectionState();
}
private toggleAll(): void {
const target: boolean = !this.allSelected;
this.calendarList.forEach((c: EditCalendarItem) => {
c.selected = target;
});
this.updateSelectionState();
}
build() {
Column({ space: 14 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 6 }) {
Text('编辑账号')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.username !== '' ? `${this.serverUrl} · ${this.username}` : this.serverUrl)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 8 }) {
Row({ space: 4 }) {
Text('日历账户名称')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
Text('*')
.fontSize(15)
.fontColor($r('app.color.error'))
}
TextInput({ text: this.accountName, placeholder: '例如:我的群晖日历' })
.height(46)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.backgroundColor($r('app.color.input_bg'))
.borderRadius(10)
.border({ width: 1.5, color: $r('app.color.brand') })
.padding({ left: 12, right: 12 })
.onChange((value: string) => {
this.accountName = value;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(14)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
Row({ space: 8 }) {
Text(this.calendarList.length > 0
? `已选 ${this.selectedCount} / ${this.calendarList.length}` : ' ')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
if (this.calendarList.length > 0) {
Button(this.allSelected ? '取消全选' : '全选')
.fontSize(13)
.fontColor($r('app.color.brand'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.brand'), radius: 14 })
.height(30)
.padding({ left: 14, right: 14 })
.onClick(() => {
this.toggleAll();
})
}
}
.width('100%')
Scroll() {
Column({ space: 4 }) {
if (this.isLoading) {
Column({ space: 12 }) {
LoadingProgress()
.width(36)
.height(36)
.color($r('app.color.brand'))
Text('正在从服务器获取日历列表…')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding(32)
} else if (this.calendarList.length === 0) {
Text(this.statusMsg)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.textAlign(TextAlign.Center)
} else {
ForEach(this.calendarList, (item: EditCalendarItem) => {
EditCalendarRow({
item: item,
onSelect: (selectedItem: EditCalendarItem): void => {
this.handleItemToggle(selectedItem);
}
})
}, (item: EditCalendarItem) => `${item.href}_${item.selected}`)
}
}
.width('100%')
.padding(8)
.constraintSize({ minHeight: '100%' })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
if (this.statusMsg !== '' && !this.isLoading) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Button() {
Text(this.isSaving ? '保存中…' : '保存并同步')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isSaving ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isSaving && !this.isLoading && this.found)
.onClick(() => {
this.saveSelection();
})
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
.alignItems(HorizontalAlign.Center)
}
}
@Component
struct EditCalendarRow {
@ObjectLink item: EditCalendarItem;
onSelect: (item: EditCalendarItem) => void = (selectedItem: EditCalendarItem): void => {};
build() {
Row({ space: 12 }) {
if (this.item.color !== '') {
Circle()
.width(12)
.height(12)
.fill(this.item.color)
}
Text(this.item.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
if (this.item.selected) {
Text('✓')
.fontSize(14)
.fontColor($r('app.color.button_text'))
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor($r('app.color.brand'))
} else {
Text('')
.width(24)
.height(24)
.borderRadius(12)
.border({ width: 1.5, color: $r('app.color.text_hint') })
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 14, bottom: 14 })
.borderRadius(8)
.onClick(() => {
// 直接翻转 @ObjectLink 属性,子组件自身即可触发刷新
this.item.selected = !this.item.selected;
this.onSelect(this.item);
})
}
}
+496
View File
@@ -0,0 +1,496 @@
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除本地(DAV 或本机)日程
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb';
import { DavClient } from '../common/DavClient';
import { SyncEngine } from '../common/SyncEngine';
/** 可选的日历本 */
class BookChoice {
calKey: string = '';
href: string = '';
name: string = '';
color: string = '#007DFF';
}
@Entry
@Component
struct EventEditPage {
@State title: string = '';
@State location: string = '';
@State description: string = '';
@State allDay: boolean = false;
@State startMs: number = 0;
@State endMs: number = 0;
@State books: BookChoice[] = [];
@State chosenKey: string = '';
@State isSaving: boolean = false;
@State statusMsg: string = '';
@State isExisting: boolean = false;
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
return this.initPage();
}
private async initPage(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
// 收集可写入的日历本(DAV + 本机)
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context);
const choices: BookChoice[] = [];
for (const s of sources) {
const b = new BookChoice();
b.calKey = s.calKey;
b.href = s.href;
b.name = s.name;
b.color = s.color;
choices.push(b);
}
this.books = choices;
// 编辑既有事件
const pendingId: number | undefined = AppStorage.get<number>('pendingEventId');
if (pendingId !== undefined && pendingId > 0) {
const loaded = await EventDb.getById(context, pendingId);
if (loaded !== null) {
this.event = loaded;
this.isExisting = true;
this.title = loaded.title;
this.location = loaded.location;
this.description = loaded.description;
this.allDay = loaded.isAllDay;
this.startMs = loaded.startTime;
this.endMs = loaded.endTime;
this.chosenKey = loaded.calKey;
return;
}
}
// 新建:默认时间 = 所选日期 9:00-10:00
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
new Date(base).getDate()).getTime();
this.startMs = dayStart + 9 * 3600000;
this.endMs = dayStart + 10 * 3600000;
if (choices.length > 0) {
this.chosenKey = choices[0].calKey;
}
}
private chosenBook(): BookChoice | null {
return this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey) ?? null;
}
private fmtDate(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
private fmtTime(ms: number): string {
const d = new Date(ms);
const p = (n: number): string => n < 10 ? '0' + n : String(n);
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
private pickStartDate(): void {
const cur = new Date(this.startMs);
DatePickerDialog.show({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: cur,
onDateAccept: (value: Date) => {
const keep = new Date(this.startMs);
const newStart: number = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
keep.getHours(), keep.getMinutes()).getTime();
const dur: number = this.endMs - this.startMs;
this.startMs = newStart;
this.endMs = this.allDay ? newStart + 86399999 : newStart + dur;
}
});
}
private pickStartTime(): void {
const cur = new Date(this.startMs);
TimePickerDialog.show({
selected: cur,
onAccept: (value: TimePickerResult) => {
const d = new Date(this.startMs);
const newStart: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
value.hour, value.minute).getTime();
const dur: number = this.endMs - this.startMs;
this.startMs = newStart;
this.endMs = newStart + dur;
}
});
}
private pickEndDate(): void {
const cur = new Date(this.endMs);
DatePickerDialog.show({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: cur,
onDateAccept: (value: Date) => {
const keep = new Date(this.endMs);
this.endMs = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
keep.getHours(), keep.getMinutes()).getTime();
}
});
}
private pickEndTime(): void {
const cur = new Date(this.endMs);
TimePickerDialog.show({
selected: cur,
onAccept: (value: TimePickerResult) => {
const d = new Date(this.endMs);
this.endMs = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
value.hour, value.minute).getTime();
}
});
}
private toggleAllDay(): void {
this.allDay = !this.allDay;
if (this.allDay) {
const s = new Date(this.startMs);
const dayStart: number = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime();
this.startMs = dayStart;
this.endMs = dayStart + 86399999;
} else {
const s = new Date(this.startMs);
this.startMs = s.getTime() + 9 * 3600000;
this.endMs = this.startMs + 3600000;
}
}
private validate(): boolean {
if (this.title.trim() === '') {
this.statusMsg = '请输入日程标题';
return false;
}
if (this.endMs < this.startMs) {
this.statusMsg = '结束时间不能早于开始时间';
return false;
}
return true;
}
private async save(): Promise<void> {
if (this.isSaving || !this.validate()) {
return;
}
if (this.event !== null && this.event.recurring) {
this.statusMsg = '重复日程(系列中的一天)暂不支持修改,请到服务器端调整重复规则';
return;
}
this.isSaving = true;
this.statusMsg = '';
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
const book = this.chosenBook();
const e = this.event ?? new LocalEvent();
const isNew: boolean = this.event === null;
e.title = this.title.trim();
e.location = this.location.trim();
e.description = this.description.trim();
e.startTime = this.startMs;
e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs;
e.isAllDay = this.allDay;
e.calKey = book !== null ? book.calKey : 'local';
e.href = book !== null ? book.href : '';
if (isNew) {
e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
e.remotePath = encodeURIComponent(e.uid) + '.ics';
await EventDb.insertLocal(context, e);
} else {
await EventDb.updateLocal(context, e);
}
// 立即推送(尽力而为,失败不打断,下次同步会再推)
if (e.href !== '') {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(e.href));
if (acc !== undefined) {
const auth: string = DavClient.authHeader(acc.username, acc.password);
await SyncEngine.pushDirtyForAccount(context, acc, auth);
}
} else {
await SyncEngine.settleLocalEvents(context);
}
this.getUIContext().getPromptAction().showToast({ message: '日程已保存' });
router.back();
} catch (err) {
const ex = err as BusinessError;
console.error(`保存日程失败: ${ex.message}`);
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
// 数据仍在本地且带 dirty 标记,不会丢
router.back();
}
this.isSaving = false;
}
private async removeEvent(): Promise<void> {
if (this.event === null || this.isSaving) {
return;
}
if (this.event.recurring) {
this.statusMsg = '重复日程(系列中的一天)暂不支持删除,请到服务器端调整重复规则';
return;
}
this.isSaving = true;
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
await EventDb.markDeleted(context, this.event.id);
if (this.event.href !== '') {
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(this.event?.href ?? ''));
if (acc !== undefined) {
const auth: string = DavClient.authHeader(acc.username, acc.password);
await SyncEngine.pushDirtyForAccount(context, acc, auth);
}
} else {
await SyncEngine.settleLocalEvents(context);
}
this.getUIContext().getPromptAction().showToast({ message: '日程已删除' });
router.back();
} catch (err) {
const ex = err as BusinessError;
this.statusMsg = `删除失败:${ex.message}`;
}
this.isSaving = false;
}
build() {
Column({ space: 14 }) {
// 顶部
Row({ space: 6 }) {
Text('取消')
.fontSize(16)
.fontColor($r('app.color.brand'))
.onClick(() => {
router.back();
})
Blank()
Text(this.isExisting ? '编辑日程' : '新建日程')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('保存')
.fontSize(16)
.fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand'))
.fontWeight(FontWeight.Medium)
.onClick(() => {
this.save();
})
}
.width('100%')
Scroll() {
Column({ space: 14 }) {
// 标题
TextInput({ text: this.title, placeholder: '标题' })
.height(46)
.fontSize(16)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.onChange((v: string) => {
this.title = v;
})
// 全天
Row() {
Text('全天')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.allDay })
.selectedColor($r('app.color.brand'))
.onChange(() => {
this.toggleAllDay();
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 时间卡片
Column({ space: 10 }) {
this.timeRow('开始', true)
Divider().color($r('app.color.shadow_color'))
this.timeRow('结束', false)
}
.width('100%')
.padding(6)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 日历本选择
Column({ space: 8 }) {
Text('日历本')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.books, (b: BookChoice) => {
Row({ space: 5 }) {
Circle().width(8).height(8).fill(b.color)
Text(b.name)
.fontSize(12)
.fontColor(this.chosenKey === b.calKey
? $r('app.color.button_text') : $r('app.color.text_primary'))
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.margin({ right: 8, bottom: 8 })
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
.onClick(() => {
this.chosenKey = b.calKey;
})
}, (b: BookChoice) => b.calKey)
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 地点
TextInput({ text: this.location, placeholder: '地点(可选)' })
.height(44)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.onChange((v: string) => {
this.location = v;
})
// 描述
TextArea({ text: this.description, placeholder: '备注(可选)' })
.height(90)
.fontSize(14)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.onChange((v: string) => {
this.description = v;
})
if (this.statusMsg !== '') {
Text(this.statusMsg)
.fontSize(13)
.fontColor($r('app.color.error'))
.width('100%')
}
// 删除
if (this.isExisting) {
Button('删除日程')
.fontSize(15)
.fontColor($r('app.color.error'))
.backgroundColor($r('app.color.error_bg'))
.width('100%')
.height(44)
.borderRadius(12)
.enabled(!this.isSaving)
.onClick(() => {
this.removeEvent();
})
}
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 30 })
.constraintSize({ minHeight: '100%' })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
}
.width('100%')
.height('100%')
.padding({ top: 12 })
.backgroundColor($r('app.color.page_bg'))
}
@Builder
timeRow(label: string, isStart: boolean) {
Row({ space: 8 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(36)
Text(this.fmtDate(isStart ? this.startMs : this.endMs))
.fontSize(15)
.fontColor($r('app.color.brand'))
.onClick(() => {
if (isStart) {
this.pickStartDate();
} else {
this.pickEndDate();
}
})
if (!this.allDay) {
Text(this.fmtTime(isStart ? this.startMs : this.endMs))
.fontSize(15)
.fontColor($r('app.color.brand'))
.onClick(() => {
if (isStart) {
this.pickStartTime();
} else {
this.pickEndTime();
}
})
}
Blank()
}
.width('100%')
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
}
}
/** 桥接:从账号存储拿可写来源(DAV 日历本 + 本机),附上 href */
class CalendarDataBridge {
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> {
const result: CalSourceWithHref[] = [];
const accounts: DavAccount[] = await AccountStore.loadAll(context);
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const s = new CalSourceWithHref();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
if (name === '') {
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
}
s.name = `${acc.name} · ${name}`;
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
s.color = color !== '' ? color : BookPalette.colorFor(i);
s.href = acc.calendarHrefs[i];
result.push(s);
}
}
const local = new CalSourceWithHref();
local.calKey = 'local';
local.name = '本机(不同步)';
local.color = '#5A6068';
result.push(local);
return result;
}
}
class CalSourceWithHref extends CalSource {
href: string = '';
}
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
// entry/src/main/ets/pages/SettingsPage.ets
// 设置页:系统日历混合显示开关 + 自动同步间隔
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { AppSettings } from '../common/AppSettings';
import { LogUtil } from '../common/LogUtil';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
@Entry
@Component
struct SettingsPage {
@State showSystem: boolean = true;
@State intervalMinutes: number = 1;
private context?: common.Context;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.context = ctx;
LogUtil.init(ctx);
AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => {
this.showSystem = v;
});
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
this.intervalMinutes = v;
});
}
private async saveShowSystem(value: boolean): Promise<void> {
if (this.context === undefined) {
return;
}
await AppSettings.setShowSystemCalendar(this.context, value);
this.getUIContext().getPromptAction()
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
}
private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) {
return;
}
await AppSettings.setSyncIntervalMinutes(this.context, minutes);
AppStorage.setOrCreate('syncIntervalMinutes', minutes);
this.getUIContext().getPromptAction()
.showToast({ message: `自动同步间隔已设为 ${minutes} 分钟` });
}
private intervalLabel(minutes: number): string {
return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`;
}
build() {
Column() {
// 顶部
Row({ space: 10 }) {
Text('←')
.fontSize(20)
.fontColor($r('app.color.brand'))
.onClick(() => {
router.back();
})
Text('设置')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
Scroll() {
Column({ space: 12 }) {
// 系统日历混合显示
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('混合显示系统日历')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('关闭后只显示 DAV 账号的日程')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.showSystem })
.selectedColor($r('app.color.brand'))
.onChange((isOn: boolean) => {
if (isOn !== this.showSystem) {
this.showSystem = isOn;
this.saveShowSystem(isOn);
}
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 自动同步间隔
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)
Select([{ value: '1 分钟' }, { value: '5 分钟' }, { value: '15 分钟' },
{ value: '30 分钟' }, { value: '1 小时' }] as SelectOption[])
.selected(INTERVAL_OPTIONS.indexOf(this.intervalMinutes))
.value(this.intervalLabel(this.intervalMinutes))
.fontColor($r('app.color.text_primary'))
.font({ size: 14 })
.optionFont({ size: 14 })
.selectedOptionFont({ size: 14 })
.onSelect((index: number) => {
if (index >= 0 && index < INTERVAL_OPTIONS.length) {
this.intervalMinutes = INTERVAL_OPTIONS[index];
this.saveInterval(INTERVAL_OPTIONS[index]);
}
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
}
.width('100%')
.padding({ left: 20, right: 20, top: 8, bottom: 20 })
}
.layoutWeight(1)
.align(Alignment.Top)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_bg'))
}
}
@@ -0,0 +1,113 @@
// entry/src/main/ets/pages/widget/Widget2x2.ets
// 2x2 服务卡片:日期 + 农历 + 下一条日程
let storage2x2 = new LocalStorage();
class CardItem2x2 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage2x2)
@Component
struct Widget2x2Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem2x2[] {
try {
return JSON.parse(this.eventsJson) as CardItem2x2[];
} catch (err) {
return [];
}
}
build() {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Blank()
Text(this.lunarText)
.fontSize(11)
.fontColor('#8A8A8A')
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseItems().length === 0) {
Column({ space: 4 }) {
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Column({ space: 4 }) {
Row({ space: 6 }) {
Circle().width(6).height(6).fill(this.parseItems()[0].color)
Text(this.parseItems()[0].title)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (this.parseItems()[0].calName !== '') {
Text(this.parseItems()[0].calName)
.fontSize(9)
.fontColor(this.parseItems()[0].color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '30%' })
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Blank()
Row({ space: 6 }) {
Text(this.parseItems()[0].date)
.fontSize(10)
.fontColor('#8A8A8A')
Text(this.parseItems()[0].time === '全天'
? '全天'
: `${this.parseItems()[0].time} - ${this.parseItems()[0].endTime}`)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#007DFF')
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
@@ -0,0 +1,184 @@
// entry/src/main/ets/pages/widget/Widget4x2.ets
// 2x4 服务卡片:日期 + 农历 + 未来几条日程(时间轴样式,按天分组)
let storage2x4 = new LocalStorage();
class CardItem2x4 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage2x4)
@Component
struct Widget4x2Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem2x4[] {
try {
const all: CardItem2x4[] = JSON.parse(this.eventsJson) as CardItem2x4[];
return all.slice(0, 4);
} catch (err) {
return [];
}
}
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
@Builder
buildAllDayRow(item: CardItem2x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
@Builder
buildTimedRow(item: CardItem2x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(36)
.borderRadius(2)
.backgroundColor(item.color)
// 时间列:开始时间在上、结束时间在下、中间竖线连接
Column({ space: 2 }) {
Text(item.time)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column()
.width(1.5)
.layoutWeight(1)
.backgroundColor('#D8D8D8')
.borderRadius(1)
Text(item.endTime)
.fontSize(10)
.fontColor('#999999')
}
.width(38)
.alignItems(HorizontalAlign.Center)
.height(36)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
build() {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Text(this.lunarText)
.fontSize(11)
.fontColor('#8A8A8A')
.maxLines(1)
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.parseItems().length === 0) {
Column() {
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 4 }) {
ForEach(this.parseItems(), (item: CardItem2x4, idx: number) => {
ListItem() {
Column({ space: 3 }) {
if (item.showDate) {
Text(item.date)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
.width('100%')
}
if (item.time === '全天') {
this.buildAllDayRow(item)
} else {
this.buildTimedRow(item)
}
}
.width('100%')
}
}, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.cachedCount(4)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
@@ -0,0 +1,187 @@
// entry/src/main/ets/pages/widget/Widget4x4.ets
// 4x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,按天分组,可滑动)
let storage4x4 = new LocalStorage();
class CardItem4x4 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage4x4)
@Component
struct Widget4x4Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem4x4[] {
try {
return JSON.parse(this.eventsJson) as CardItem4x4[];
} catch (err) {
return [];
}
}
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
@Builder
buildAllDayRow(item: CardItem4x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
@Builder
buildTimedRow(item: CardItem4x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor(item.color)
// 时间列:开始时间在上、结束时间在下、中间竖线连接
Column({ space: 2 }) {
Text(item.time)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column()
.width(1.5)
.layoutWeight(1)
.backgroundColor('#D8D8D8')
.borderRadius(1)
Text(item.endTime)
.fontSize(10)
.fontColor('#999999')
}
.width(38)
.alignItems(HorizontalAlign.Center)
.height(38)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
build() {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(this.lunarText)
.fontSize(12)
.fontColor('#8A8A8A')
Blank()
Text('同步日历')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.parseItems().length === 0) {
Column({ space: 6 }) {
Text('📅')
.fontSize(24)
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 4 }) {
ForEach(this.parseItems(), (item: CardItem4x4, idx: number) => {
ListItem() {
Column({ space: 3 }) {
if (item.showDate) {
Text(item.date)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
.width('100%')
}
if (item.time === '全天') {
this.buildAllDayRow(item)
} else {
this.buildTimedRow(item)
}
}
.width('100%')
}
}, (item: CardItem4x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(8)
}
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}
@@ -0,0 +1,187 @@
// entry/src/main/ets/pages/widget/Widget6x4.ets
// 6x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,比 4x4 显示更多)
let storage6x4 = new LocalStorage();
class CardItem6x4 {
title: string = '';
time: string = '';
endTime: string = '';
date: string = '';
showDate: boolean = false;
calName: string = '';
color: string = '#007DFF';
}
@Entry(storage6x4)
@Component
struct Widget6x4Card {
@LocalStorageProp('eventsJson') eventsJson: string = '[]';
@LocalStorageProp('dateText') dateText: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
private parseItems(): CardItem6x4[] {
try {
return JSON.parse(this.eventsJson) as CardItem6x4[];
} catch (err) {
return [];
}
}
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
@Builder
buildAllDayRow(item: CardItem6x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('全天')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor(item.color)
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */
@Builder
buildTimedRow(item: CardItem6x4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor(item.color)
// 时间列:开始时间在上、结束时间在下、中间竖线连接
Column({ space: 2 }) {
Text(item.time)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column()
.width(1.5)
.layoutWeight(1)
.backgroundColor('#D8D8D8')
.borderRadius(1)
Text(item.endTime)
.fontSize(10)
.fontColor('#999999')
}
.width(38)
.alignItems(HorizontalAlign.Center)
.height(38)
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(9)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '25%' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
build() {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(this.dateText)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(this.lunarText)
.fontSize(12)
.fontColor('#8A8A8A')
Blank()
Text('同步日历')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
Divider().strokeWidth(0.5).color('#E5E5E5')
if (this.parseItems().length === 0) {
Column({ space: 6 }) {
Text('📅')
.fontSize(24)
Text('暂无日程')
.fontSize(13)
.fontColor('#8A8A8A')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 4 }) {
ForEach(this.parseItems(), (item: CardItem6x4, idx: number) => {
ListItem() {
Column({ space: 3 }) {
if (item.showDate) {
Text(item.date)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#666666')
.width('100%')
}
if (item.time === '全天') {
this.buildAllDayRow(item)
} else {
this.buildTimedRow(item)
}
}
.width('100%')
}
}, (item: CardItem6x4, idx: number) => `${idx}_${item.title}_${item.time}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.cachedCount(12)
}
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {}
});
})
}
}