完成了用户账户加密,更改了只读日程的显示样式;日程中的地址,可以通过高德地图导航了。
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
// entry/src/main/ets/common/AccountStore.ets
|
// entry/src/main/ets/common/AccountStore.ets
|
||||||
|
// DAV 账号持久化(加密版):
|
||||||
|
// - 账号列表(含密码)序列化为 JSON 后用 AES-256-GCM 整体加密,
|
||||||
|
// 存储为 caldav_vault 偏好中的 data_b64(iv:密文+tag);
|
||||||
|
// - AES 密钥首次随机生成后存于同一偏好(key_b64);
|
||||||
|
// - 旧版明文存储(caldav_account 的 acc_0..n)在首次 loadAll 时自动迁移并清除。
|
||||||
import { preferences } from '@kit.ArkData';
|
import { preferences } from '@kit.ArkData';
|
||||||
|
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
|
||||||
|
import { util } from '@kit.ArkTS';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
|
||||||
@@ -11,6 +18,15 @@ export const TYPE_KEYS: string[] = [TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV];
|
|||||||
/** 本机事件所属的虚拟日历 key */
|
/** 本机事件所属的虚拟日历 key */
|
||||||
export const LOCAL_CAL_KEY: string = 'local';
|
export const LOCAL_CAL_KEY: string = 'local';
|
||||||
|
|
||||||
|
/** 旧版明文存储(迁移后清除) */
|
||||||
|
const LEGACY_STORE: string = 'caldav_account';
|
||||||
|
const LEGACY_COUNT_KEY: string = 'accountCount';
|
||||||
|
|
||||||
|
/** 加密存储 */
|
||||||
|
const VAULT_STORE: string = 'caldav_vault';
|
||||||
|
const VAULT_KEY: string = 'key_b64';
|
||||||
|
const VAULT_DATA: string = 'data_b64';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DAV 账号(@Observed 使同步状态变化能刷新列表 UI)
|
* DAV 账号(@Observed 使同步状态变化能刷新列表 UI)
|
||||||
*/
|
*/
|
||||||
@@ -52,13 +68,100 @@ export class BookPalette {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bytesToB64(u: Uint8Array): string {
|
||||||
|
return new util.Base64Helper().encodeToStringSync(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64ToBytes(s: string): Uint8Array {
|
||||||
|
return new util.Base64Helper().decodeSync(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strToBytes(s: string): Uint8Array {
|
||||||
|
return new util.TextEncoder().encodeInto(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToStr(u: Uint8Array): string {
|
||||||
|
return new util.TextDecoder('utf-8').decodeToString(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号加密存储实现(AES-256-GCM,密钥持久化) */
|
||||||
|
class Vault {
|
||||||
|
private static keyB64: string = ''; // 进程内缓存,避免每轮同步重复读偏好
|
||||||
|
|
||||||
|
static async prefs(context: common.Context): Promise<preferences.Preferences> {
|
||||||
|
return preferences.getPreferences(context, VAULT_STORE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取(或首次生成)AES-256 密钥 */
|
||||||
|
private static async aesKey(context: common.Context): Promise<cryptoFramework.SymKey> {
|
||||||
|
if (Vault.keyB64 === '') {
|
||||||
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
|
Vault.keyB64 = await store.get(VAULT_KEY, '') as string;
|
||||||
|
}
|
||||||
|
const gen: cryptoFramework.SymKeyGenerator =
|
||||||
|
cryptoFramework.createSymKeyGenerator('AES256');
|
||||||
|
if (Vault.keyB64 !== '') {
|
||||||
|
return gen.convertKey({ data: b64ToBytes(Vault.keyB64) });
|
||||||
|
}
|
||||||
|
const key: cryptoFramework.SymKey = await gen.generateSymKey();
|
||||||
|
const raw: cryptoFramework.DataBlob = key.getEncoded();
|
||||||
|
Vault.keyB64 = bytesToB64(raw.data);
|
||||||
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
|
await store.put(VAULT_KEY, Vault.keyB64);
|
||||||
|
await store.flush();
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static gcmParams(iv: Uint8Array, authTag: Uint8Array): cryptoFramework.GcmParamsSpec {
|
||||||
|
return {
|
||||||
|
algName: 'GcmParamsSpec',
|
||||||
|
iv: { data: iv },
|
||||||
|
aad: { data: new Uint8Array(0) },
|
||||||
|
authTag: { data: authTag }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加密:返回 base64(iv) + ':' + base64(密文+authTag) */
|
||||||
|
static async encrypt(context: common.Context, plain: string): Promise<string> {
|
||||||
|
const key: cryptoFramework.SymKey = await Vault.aesKey(context);
|
||||||
|
const iv: Uint8Array =
|
||||||
|
cryptoFramework.createRandom().generateRandomSync(12).data;
|
||||||
|
const cipher: cryptoFramework.Cipher =
|
||||||
|
cryptoFramework.createCipher('AES256|GCM|NoPadding');
|
||||||
|
await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, key,
|
||||||
|
Vault.gcmParams(iv, new Uint8Array(16)));
|
||||||
|
const out: cryptoFramework.DataBlob =
|
||||||
|
await cipher.doFinal({ data: strToBytes(plain) });
|
||||||
|
return `${bytesToB64(iv)}:${bytesToB64(out.data)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解密:失败返回 null(调用方按"无账号"或迁移路径处理) */
|
||||||
|
static async decrypt(context: common.Context, stored: string): Promise<string | null> {
|
||||||
|
const sep: number = stored.indexOf(':');
|
||||||
|
if (sep <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const iv: Uint8Array = b64ToBytes(stored.substring(0, sep));
|
||||||
|
const blob: Uint8Array = b64ToBytes(stored.substring(sep + 1));
|
||||||
|
if (blob.length <= 16) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tag: Uint8Array = blob.slice(blob.length - 16);
|
||||||
|
const ct: Uint8Array = blob.slice(0, blob.length - 16);
|
||||||
|
const key: cryptoFramework.SymKey = await Vault.aesKey(context);
|
||||||
|
const cipher: cryptoFramework.Cipher =
|
||||||
|
cryptoFramework.createCipher('AES256|GCM|NoPadding');
|
||||||
|
await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, key,
|
||||||
|
Vault.gcmParams(iv, tag));
|
||||||
|
const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: ct });
|
||||||
|
return bytesToStr(out.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账号持久化:每个账号编码为一个分隔符字符串存储(acc_0、acc_1…),避免 JSON 结构化类型问题
|
* 账号持久化:加密 JSON 存储 + 旧版明文自动迁移
|
||||||
*/
|
*/
|
||||||
export class AccountStore {
|
export class AccountStore {
|
||||||
private static readonly STORE: string = 'caldav_account';
|
|
||||||
private static readonly COUNT_KEY: string = 'accountCount';
|
|
||||||
|
|
||||||
/** 规范化颜色:#RRGGBBAA → #RRGGBB;非法返回空串 */
|
/** 规范化颜色:#RRGGBBAA → #RRGGBB;非法返回空串 */
|
||||||
static normalizeColor(raw: string): string {
|
static normalizeColor(raw: string): string {
|
||||||
const v: string = raw.trim();
|
const v: string = raw.trim();
|
||||||
@@ -71,25 +174,6 @@ export class AccountStore {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static encodeAccount(acc: DavAccount): string {
|
|
||||||
const safe = (s: string): string => s.split('|').join('∥');
|
|
||||||
const parts: string[] = [
|
|
||||||
safe(acc.type),
|
|
||||||
safe(acc.name),
|
|
||||||
safe(acc.serverUrl),
|
|
||||||
safe(acc.username),
|
|
||||||
safe(acc.password),
|
|
||||||
String(acc.itemCount),
|
|
||||||
safe(acc.lastSyncTime),
|
|
||||||
acc.calendarHrefs.join(';'),
|
|
||||||
acc.calendarNames.join(';'),
|
|
||||||
acc.calendarColors.join(';'),
|
|
||||||
safe(acc.id),
|
|
||||||
acc.calendarWritable.join(';')
|
|
||||||
];
|
|
||||||
return parts.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static decodeAccount(raw: string): DavAccount | null {
|
private static decodeAccount(raw: string): DavAccount | null {
|
||||||
const parts: string[] = raw.split('|');
|
const parts: string[] = raw.split('|');
|
||||||
if (parts.length < 9) {
|
if (parts.length < 9) {
|
||||||
@@ -119,69 +203,153 @@ export class AccountStore {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async loadAll(context: common.Context): Promise<DavAccount[]> {
|
/** 旧版明文账号编码(迁移旧数据时使用) */
|
||||||
const result: DavAccount[] = [];
|
private static encodeLegacyAccount(acc: DavAccount): string {
|
||||||
let migrated: boolean = false;
|
const safe = (s: string): string => s.split('|').join('∥');
|
||||||
|
const parts: string[] = [
|
||||||
|
safe(acc.type),
|
||||||
|
safe(acc.name),
|
||||||
|
safe(acc.serverUrl),
|
||||||
|
safe(acc.username),
|
||||||
|
safe(acc.password),
|
||||||
|
String(acc.itemCount),
|
||||||
|
safe(acc.lastSyncTime),
|
||||||
|
acc.calendarHrefs.join(';'),
|
||||||
|
acc.calendarNames.join(';'),
|
||||||
|
acc.calendarColors.join(';'),
|
||||||
|
safe(acc.id),
|
||||||
|
acc.calendarWritable.join(';')
|
||||||
|
];
|
||||||
|
return parts.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从旧版明文存储读取(无则返回 null) */
|
||||||
|
private static async loadLegacy(context: common.Context): Promise<DavAccount[] | null> {
|
||||||
try {
|
try {
|
||||||
const store: preferences.Preferences =
|
const store: preferences.Preferences =
|
||||||
await preferences.getPreferences(context, AccountStore.STORE);
|
await preferences.getPreferences(context, LEGACY_STORE);
|
||||||
const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
const count: number = await store.get(LEGACY_COUNT_KEY, 0) as number;
|
||||||
console.info(`[AccountStore] loadAll: count=${count}`);
|
if (count <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const result: DavAccount[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const raw = await store.get(`acc_${i}`, '') as string;
|
const raw = await store.get(`acc_${i}`, '') as string;
|
||||||
if (raw === '') {
|
if (raw === '') {
|
||||||
console.warn(`[AccountStore] loadAll: acc_${i} 为空`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const acc = AccountStore.decodeAccount(raw);
|
const acc = AccountStore.decodeAccount(raw);
|
||||||
if (acc !== null) {
|
if (acc !== null) {
|
||||||
if (acc.id === '') {
|
if (acc.id === '') {
|
||||||
// 旧版本存储没有 id:补发一个并标记需要回写,保证 id 跨启动稳定
|
|
||||||
acc.id = `acc${Date.now()}_${i}`;
|
acc.id = `acc${Date.now()}_${i}`;
|
||||||
migrated = true;
|
|
||||||
}
|
}
|
||||||
result.push(acc);
|
result.push(acc);
|
||||||
} else {
|
|
||||||
console.error(`[AccountStore] loadAll: acc_${i} 解码失败,raw 长度=${raw.length}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除旧版明文存储(迁移完成后调用) */
|
||||||
|
private static async clearLegacy(context: common.Context): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences =
|
||||||
|
await preferences.getPreferences(context, LEGACY_STORE);
|
||||||
|
const count: number = await store.get(LEGACY_COUNT_KEY, 0) as number;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
store.delete(`acc_${i}`);
|
||||||
|
}
|
||||||
|
store.delete(LEGACY_COUNT_KEY);
|
||||||
|
await store.flush();
|
||||||
|
} catch (err) {
|
||||||
|
// 清理失败不影响使用,下次迁移再试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async loadAll(context: common.Context): Promise<DavAccount[]> {
|
||||||
|
// 1) 新加密存储
|
||||||
|
try {
|
||||||
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
|
const data: string = await store.get(VAULT_DATA, '') as string;
|
||||||
|
if (data !== '') {
|
||||||
|
const plain: string | null = await Vault.decrypt(context, data);
|
||||||
|
if (plain !== null) {
|
||||||
|
const arr: Array<Record<string, Object>> =
|
||||||
|
JSON.parse(plain) as Array<Record<string, Object>>;
|
||||||
|
const result: DavAccount[] = [];
|
||||||
|
for (const o of arr) {
|
||||||
|
const a = new DavAccount();
|
||||||
|
a.id = o['id'] !== undefined ? o['id'] as string : '';
|
||||||
|
a.type = o['type'] !== undefined ? o['type'] as string : TYPE_CALDAV;
|
||||||
|
a.name = o['name'] !== undefined ? o['name'] as string : '';
|
||||||
|
a.serverUrl = o['serverUrl'] !== undefined ? o['serverUrl'] as string : '';
|
||||||
|
a.username = o['username'] !== undefined ? o['username'] as string : '';
|
||||||
|
a.password = o['password'] !== undefined ? o['password'] as string : '';
|
||||||
|
a.calendarHrefs = o['calendarHrefs'] !== undefined ? o['calendarHrefs'] as string[] : [];
|
||||||
|
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
|
||||||
|
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
|
||||||
|
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : [];
|
||||||
|
a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
|
||||||
|
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
|
||||||
|
if (a.id === '') {
|
||||||
|
a.id = `acc${Date.now()}_${result.length}`;
|
||||||
|
}
|
||||||
|
result.push(a);
|
||||||
|
}
|
||||||
|
console.info(`[AccountStore] loadAll: ${result.length} 个账号(加密存储)`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
console.error('[AccountStore] 加密账号数据解密失败');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as BusinessError;
|
const e = err as BusinessError;
|
||||||
console.error(`读取账号失败: ${e.message}`);
|
console.error(`[AccountStore] 读取加密账号失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
if (migrated) {
|
|
||||||
try {
|
// 2) 旧版明文存储 → 迁移
|
||||||
await AccountStore.saveAll(context, result);
|
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
|
||||||
console.info('旧版账号数据已迁移:补充持久化账号 id');
|
if (legacy !== null) {
|
||||||
} catch (err) {
|
console.info(`[AccountStore] 迁移 ${legacy.length} 个明文账号到加密存储`);
|
||||||
const e = err as BusinessError;
|
await AccountStore.saveAll(context, legacy, true);
|
||||||
console.error(`账号 id 迁移回写失败: ${e.message}`);
|
await AccountStore.clearLegacy(context);
|
||||||
}
|
return legacy;
|
||||||
}
|
}
|
||||||
return result;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存全部账号。
|
* 保存全部账号(整体加密写入)。
|
||||||
* 防御:存储里已有账号时,禁止用空列表覆盖(调用方若因读取异常拿到空列表再回写,
|
* 防御:当前已有账号时,禁止用空列表覆盖(调用方因读取异常拿到空列表再回写,
|
||||||
* 会把所有账号抹掉)。仅删除账号的合法场景通过 force=true 放行。
|
* 会把所有账号抹掉)。仅删除账号的合法场景通过 force=true 放行。
|
||||||
*/
|
*/
|
||||||
static async saveAll(context: common.Context, accounts: DavAccount[], force: boolean = false): Promise<void> {
|
static async saveAll(context: common.Context, accounts: DavAccount[], force: boolean = false): Promise<void> {
|
||||||
const store: preferences.Preferences =
|
// 防御:先看加密存储里现有数量(空列表覆盖保护)
|
||||||
await preferences.getPreferences(context, AccountStore.STORE);
|
let existing: number = -1;
|
||||||
const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number;
|
try {
|
||||||
if (accounts.length === 0 && oldCount > 0 && !force) {
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${oldCount} 个账号)`);
|
const data: string = await store.get(VAULT_DATA, '') as string;
|
||||||
|
if (data !== '') {
|
||||||
|
const plain: string | null = await Vault.decrypt(context, data);
|
||||||
|
if (plain !== null) {
|
||||||
|
existing = (JSON.parse(plain) as Array<Record<string, Object>>).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 读取失败按"未知"处理,不拦截
|
||||||
|
existing = -1;
|
||||||
|
}
|
||||||
|
if (accounts.length === 0 && existing > 0 && !force) {
|
||||||
|
console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${existing} 个账号)`);
|
||||||
throw new Error('账号列表为空,已阻止覆盖存储(保护原有账号数据)');
|
throw new Error('账号列表为空,已阻止覆盖存储(保护原有账号数据)');
|
||||||
}
|
}
|
||||||
for (let i = 0; i < oldCount; i++) {
|
const plain: string = JSON.stringify(accounts);
|
||||||
store.delete(`acc_${i}`);
|
const enc: string = await Vault.encrypt(context, plain);
|
||||||
}
|
const store: preferences.Preferences = await Vault.prefs(context);
|
||||||
for (let i = 0; i < accounts.length; i++) {
|
await store.put(VAULT_DATA, enc);
|
||||||
await store.put(`acc_${i}`, AccountStore.encodeAccount(accounts[i]));
|
|
||||||
}
|
|
||||||
await store.put(AccountStore.COUNT_KEY, accounts.length);
|
|
||||||
await store.flush();
|
await store.flush();
|
||||||
|
console.info(`[AccountStore] saveAll: ${accounts.length} 个账号已加密写入`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async addAccount(context: common.Context, acc: DavAccount): Promise<void> {
|
static async addAccount(context: common.Context, acc: DavAccount): Promise<void> {
|
||||||
@@ -189,4 +357,4 @@ export class AccountStore {
|
|||||||
list.push(acc);
|
list.push(acc);
|
||||||
await AccountStore.saveAll(context, list);
|
await AccountStore.saveAll(context, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,32 @@ export class AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除账号后的设置清理:移除该账号名下所有日历本的
|
||||||
|
* 手动只读标记、提醒静音标记,并清空指向它的备份目标。
|
||||||
|
*/
|
||||||
|
static async cleanupAccountSettings(context: common.Context, accId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const manual: string[] = await AppSettings.getManualReadonlyKeys(context);
|
||||||
|
const manual2: string[] = manual.filter((k: string): boolean => !k.startsWith(`${accId}_`));
|
||||||
|
if (manual2.length !== manual.length) {
|
||||||
|
await AppSettings.setManualReadonlyKeys(context, manual2);
|
||||||
|
}
|
||||||
|
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||||
|
const muted2: string[] = muted.filter((k: string): boolean => !k.startsWith(`${accId}_`));
|
||||||
|
if (muted2.length !== muted.length) {
|
||||||
|
await AppSettings.setMutedReminderKeys(context, muted2);
|
||||||
|
}
|
||||||
|
const backupKey: string = await AppSettings.getBackupCalKey(context);
|
||||||
|
if (backupKey.startsWith(`${accId}_`)) {
|
||||||
|
await AppSettings.setBackupCalKey(context, '');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as BusinessError;
|
||||||
|
console.error(`清理账号设置失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 系统日历模式:display = 仅混合显示;backup = 本地系统日程备份到选定 CalDAV 日历本 */
|
/** 系统日历模式:display = 仅混合显示;backup = 本地系统日程备份到选定 CalDAV 日历本 */
|
||||||
static async getSysCalMode(context: common.Context): Promise<string> {
|
static async getSysCalMode(context: common.Context): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SyncEngine } from '../common/SyncEngine';
|
|||||||
import { EditNavParams } from './EditAccountPage';
|
import { EditNavParams } from './EditAccountPage';
|
||||||
import { EventDb } from '../common/EventDb';
|
import { EventDb } from '../common/EventDb';
|
||||||
import { LogUtil } from '../common/LogUtil';
|
import { LogUtil } from '../common/LogUtil';
|
||||||
|
import { AppSettings } from '../common/AppSettings';
|
||||||
import { ScreenKeeper } from '../common/ScreenKeeper';
|
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||||
|
|
||||||
@Entry
|
@Entry
|
||||||
@@ -150,8 +151,11 @@ struct AccountsPage {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
|
this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id);
|
||||||
await AccountStore.saveAll(context, this.accounts);
|
// force=true:删除最后一个账号时列表为空是合法场景,允许覆盖
|
||||||
|
await AccountStore.saveAll(context, this.accounts, true);
|
||||||
|
// 删除该账号全部本地日程/待办,并清理只读/静音/备份目标等关联设置
|
||||||
await EventDb.deleteAccountEvents(context, acc.id);
|
await EventDb.deleteAccountEvents(context, acc.id);
|
||||||
|
await AppSettings.cleanupAccountSettings(context, acc.id);
|
||||||
this.getUIContext().getPromptAction()
|
this.getUIContext().getPromptAction()
|
||||||
.showToast({ message: `「${acc.name}」已删除` });
|
.showToast({ message: `「${acc.name}」已删除` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
|
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
|
||||||
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
|
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
|
||||||
import { mediaquery, router } from '@kit.ArkUI';
|
import { mediaquery, router } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';
|
||||||
|
import { geoLocationManager } from '@kit.LocationKit';
|
||||||
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
||||||
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
|
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
|
||||||
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
|
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
|
||||||
@@ -55,6 +56,8 @@ struct Index {
|
|||||||
private autoSyncTimer: number = -1;
|
private autoSyncTimer: number = -1;
|
||||||
private lastSyncTime: number = 0;
|
private lastSyncTime: number = 0;
|
||||||
private permissionAsked: boolean = false;
|
private permissionAsked: boolean = false;
|
||||||
|
@State detailShow: boolean = false; // 只读日程详情半屏弹层
|
||||||
|
@State detailEvent: DisplayEvent | null = null;
|
||||||
|
|
||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
const ctx = this.getUIContext().getHostContext();
|
const ctx = this.getUIContext().getHostContext();
|
||||||
@@ -433,42 +436,304 @@ struct Index {
|
|||||||
router.pushUrl({ url: 'pages/EventEditPage' });
|
router.pushUrl({ url: 'pages/EventEditPage' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只读日程详情弹窗 */
|
/** 只读日程详情:改为卡片式半屏弹层(与编辑页风格一致) */
|
||||||
private showEventDetail(e: DisplayEvent): void {
|
private showEventDetail(e: DisplayEvent): void {
|
||||||
const lines: string[] = [];
|
this.detailEvent = e;
|
||||||
|
this.detailShow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detailStartText(e: DisplayEvent): string {
|
||||||
if (e.isAllDay) {
|
if (e.isAllDay) {
|
||||||
lines.push(`时间:全天 ${this.fmtDateCn(e.startTime)}`);
|
return `全天 ${this.fmtDateCn(e.startTime)}`;
|
||||||
if (this.spansDays(e)) {
|
}
|
||||||
lines.push(` ~ ${this.fmtDateCn(e.endTime)}`);
|
return `${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`;
|
||||||
}
|
}
|
||||||
} else if (this.spansDays(e)) {
|
|
||||||
lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`);
|
private detailEndText(e: DisplayEvent): string {
|
||||||
lines.push(` ~ ${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`);
|
const sameDay: boolean = this.startOfDay(e.startTime) === this.startOfDay(e.endTime);
|
||||||
|
if (e.isAllDay) {
|
||||||
|
return `全天 ${this.fmtDateCn(e.endTime)}`;
|
||||||
|
}
|
||||||
|
if (sameDay) {
|
||||||
|
return this.fmtTime(e.endTime);
|
||||||
|
}
|
||||||
|
return `${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击地点:拉起高德 App 路线规划,地址经系统地理编码转为坐标后传入目的地 */
|
||||||
|
private async openInAmap(address: string): Promise<void> {
|
||||||
|
const context = this.getUIContext().getHostContext();
|
||||||
|
if (context === undefined || address === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = context as common.UIAbilityContext;
|
||||||
|
const enc: string = encodeURIComponent(address);
|
||||||
|
// 高德鸿蒙深链 dlat/dlon 为必填(dname 仅作名称显示),先地理编码拿坐标
|
||||||
|
let coords: number[] | null = null;
|
||||||
|
if (await this.ensureLocationPermission()) {
|
||||||
|
coords = await this.geocodeAddress(address);
|
||||||
|
}
|
||||||
|
const base: string = 'amapuri://route/plan/?sourceApplication=SyncCalendar';
|
||||||
|
let link: string;
|
||||||
|
if (coords !== null) {
|
||||||
|
// 系统地理编码返回 WGS84,高德要求 GCJ02(dev=0),做本地转换
|
||||||
|
const gcj: number[] = this.wgs84ToGcj02(coords[0], coords[1]);
|
||||||
|
link = `${base}&dlat=${gcj[0].toFixed(6)}&dlon=${gcj[1].toFixed(6)}&dname=${enc}&dev=0&t=0`;
|
||||||
} else {
|
} else {
|
||||||
lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)} ~ ${this.fmtTime(e.endTime)}`);
|
// 无坐标时尽力而为:部分版本只认坐标,目的地可能为空
|
||||||
|
link = `${base}&dname=${enc}&dev=0&t=0`;
|
||||||
}
|
}
|
||||||
if (e.location !== '') {
|
try {
|
||||||
lines.push(`地点:${e.location}`);
|
await ctx.openLink(link);
|
||||||
|
LogUtil.write(`已拉起高德导航:${address}(坐标=${coords !== null ? '有' : '无'})`);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`高德深链打开失败:${(err as BusinessError).message}`);
|
||||||
}
|
}
|
||||||
if (e.recurring) {
|
this.getUIContext().getPromptAction().showToast({ message: '未安装高德地图或无法打开' });
|
||||||
lines.push('重复:是');
|
}
|
||||||
}
|
|
||||||
if (e.calName !== '') {
|
/** 确认定位权限(地理编码依赖),未授权时向用户申请一次 */
|
||||||
lines.push(`日历本:${e.calName}${e.isSystem ? '' : '(只读)'}`);
|
private async ensureLocationPermission(): Promise<boolean> {
|
||||||
}
|
try {
|
||||||
if (e.description !== '') {
|
const atManager = abilityAccessCtrl.createAtManager();
|
||||||
lines.push(`备注:${e.description}`);
|
const bundleInfo = bundleManager.getBundleInfoForSelfSync(
|
||||||
}
|
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
|
||||||
this.getUIContext().showAlertDialog({
|
const tokenId = bundleInfo.appInfo.accessTokenId;
|
||||||
title: e.title === '' ? '(无标题)' : e.title,
|
const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.LOCATION');
|
||||||
message: lines.join('\n'),
|
if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||||
autoCancel: true,
|
return true;
|
||||||
alignment: DialogAlignment.Center,
|
|
||||||
primaryButton: {
|
|
||||||
value: '关闭',
|
|
||||||
action: (): void => {}
|
|
||||||
}
|
}
|
||||||
});
|
} catch (err) {
|
||||||
|
// 未授权会走申请流程
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||||
|
const atManager = abilityAccessCtrl.createAtManager();
|
||||||
|
// LOCATION 与 APPROXIMATELY_LOCATION 必须一起申请
|
||||||
|
const result = await atManager.requestPermissionsFromUser(context,
|
||||||
|
['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']);
|
||||||
|
return result.authResults.every((r: number): boolean => r === 0);
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正向地理编码:地址 → 坐标(系统服务,无需高德 key) */
|
||||||
|
private async geocodeAddress(address: string): Promise<number[] | null> {
|
||||||
|
try {
|
||||||
|
if (!geoLocationManager.isGeocoderAvailable()) {
|
||||||
|
LogUtil.write('系统地理编码服务不可用');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const req: geoLocationManager.GeoCodeRequest = { description: address, maxItems: 1 };
|
||||||
|
const list: geoLocationManager.GeoAddress[] =
|
||||||
|
await geoLocationManager.getAddressesFromLocationName(req);
|
||||||
|
if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) {
|
||||||
|
return [list[0].latitude, list[0].longitude];
|
||||||
|
}
|
||||||
|
LogUtil.write(`地理编码无结果:${address}`);
|
||||||
|
} catch (err) {
|
||||||
|
LogUtil.write(`地理编码失败:${(err as BusinessError).message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WGS84 → GCJ02 火星坐标转换(中国境内;标准偏移算法) */
|
||||||
|
private wgs84ToGcj02(wlat: number, wlon: number): number[] {
|
||||||
|
const a: number = 6378245.0;
|
||||||
|
const ee: number = 0.00669342162296594323;
|
||||||
|
const transformLat = (x: number, y: number): number => {
|
||||||
|
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y
|
||||||
|
+ 0.2 * Math.sqrt(Math.abs(x));
|
||||||
|
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
|
||||||
|
ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0;
|
||||||
|
ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320.0 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
const transformLon = (x: number, y: number): number => {
|
||||||
|
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||||
|
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
|
||||||
|
ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0;
|
||||||
|
ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
if (wlon < 72.004 || wlon > 137.8347 || wlat < 0.8293 || wlat > 55.8271) {
|
||||||
|
return [wlat, wlon]; // 中国境外无偏移
|
||||||
|
}
|
||||||
|
let dLat: number = transformLat(wlon - 105.0, wlat - 35.0);
|
||||||
|
let dLon: number = transformLon(wlon - 105.0, wlat - 35.0);
|
||||||
|
const radLat: number = wlat / 180.0 * Math.PI;
|
||||||
|
let magic: number = Math.sin(radLat);
|
||||||
|
magic = 1 - ee * magic * magic;
|
||||||
|
const sqrtMagic: number = Math.sqrt(magic);
|
||||||
|
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI);
|
||||||
|
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI);
|
||||||
|
return [wlat + dLat, wlon + dLon];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
detailTag(text: string) {
|
||||||
|
Text(text)
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.brand'))
|
||||||
|
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||||
|
.borderRadius(9)
|
||||||
|
.border({ width: 1, color: $r('app.color.brand') })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
detailRow(label: string, value: string) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(56)
|
||||||
|
Text(value)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.textAlign(TextAlign.End)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
detailSheet() {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.detailEvent !== null) {
|
||||||
|
// 标题区:色点 + 标题 + 徽标紧跟标题 + 自绘关闭按钮(与标题同行)
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Circle()
|
||||||
|
.fill(this.detailEvent.color)
|
||||||
|
.width(12)
|
||||||
|
.height(12)
|
||||||
|
Text(this.detailEvent.title === '' ? '(无标题)' : this.detailEvent.title)
|
||||||
|
.fontSize(19)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.constraintSize({ maxWidth: '58%' })
|
||||||
|
if (this.detailEvent.isSystem) {
|
||||||
|
this.detailTag('系统')
|
||||||
|
} else if (!this.detailEvent.writable) {
|
||||||
|
this.detailTag('只读')
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
Text('✕')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(30)
|
||||||
|
.height(30)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.borderRadius(15)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.detailShow = false;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
// 时间卡片
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.detailEvent.isAllDay && !this.spansDays(this.detailEvent)) {
|
||||||
|
this.detailRow('时间', `全天 ${this.fmtDateCn(this.detailEvent.startTime)}`)
|
||||||
|
} else {
|
||||||
|
this.detailRow('开始', this.detailStartText(this.detailEvent))
|
||||||
|
Divider().color($r('app.color.shadow_color'))
|
||||||
|
this.detailRow('结束', this.detailEndText(this.detailEvent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
|
||||||
|
// 日历本 / 重复
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text('日历本')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(56)
|
||||||
|
Text(this.detailEvent.calName === '' ? '-' : this.detailEvent.calName)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.textAlign(TextAlign.End)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
if (this.detailEvent.recurring) {
|
||||||
|
Divider().color($r('app.color.shadow_color'))
|
||||||
|
this.detailRow('重复', '重复日程')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
|
||||||
|
// 地点:点击打开高德地图导航
|
||||||
|
if (this.detailEvent.location !== '') {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text('地点')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(this.detailEvent.location)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.brand'))
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.layoutWeight(1)
|
||||||
|
Text('导航 ›')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.brand'))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
.onClick(() => {
|
||||||
|
if (this.detailEvent !== null) {
|
||||||
|
this.openInAmap(this.detailEvent.location);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备注
|
||||||
|
if (this.detailEvent.description !== '') {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('备注')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(this.detailEvent.description)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.borderRadius(12)
|
||||||
|
.backgroundColor($r('app.color.card_bg'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
}
|
}
|
||||||
|
|
||||||
private addEvent(): void {
|
private addEvent(): void {
|
||||||
@@ -520,6 +785,12 @@ struct Index {
|
|||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
|
.bindSheet($$this.detailShow, this.detailSheet(), {
|
||||||
|
height: 540,
|
||||||
|
dragBar: true,
|
||||||
|
showClose: false, // 关闭按钮自绘在标题行右侧,避免系统按钮压住徽标
|
||||||
|
backgroundColor: $r('app.color.page_bg')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
|
|||||||
@@ -47,6 +47,26 @@
|
|||||||
],
|
],
|
||||||
"when": "inuse"
|
"when": "inuse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.LOCATION",
|
||||||
|
"reason": "$string:perm_location",
|
||||||
|
"usedScene": {
|
||||||
|
"abilities": [
|
||||||
|
"EntryAbility"
|
||||||
|
],
|
||||||
|
"when": "inuse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.APPROXIMATELY_LOCATION",
|
||||||
|
"reason": "$string:perm_location_approx",
|
||||||
|
"usedScene": {
|
||||||
|
"abilities": [
|
||||||
|
"EntryAbility"
|
||||||
|
],
|
||||||
|
"when": "inuse"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"deliveryWithInstall": true,
|
"deliveryWithInstall": true,
|
||||||
|
|||||||
@@ -24,6 +24,14 @@
|
|||||||
"name": "perm_read_whole_calendar",
|
"name": "perm_read_whole_calendar",
|
||||||
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
"value": "读取所有日历账户的日程,用于在日历中统一展示"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "perm_location",
|
||||||
|
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "perm_location_approx",
|
||||||
|
"value": "将日程中的地址转换为坐标,用于发起高德地图导航"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "card_ability_desc",
|
"name": "card_ability_desc",
|
||||||
"value": "同步日历服务卡片"
|
"value": "同步日历服务卡片"
|
||||||
|
|||||||
Reference in New Issue
Block a user