v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据
从「演示数据壳子」走到「真的能连家里的服务器用」。
登录(用户 2026-10-17「完整的登录功能」)
· 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
· 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
· FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
设备名取机型(marketName),并上报 platform / app_version
设备会话(v57,用户 2026-10-17)
· 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
· 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
(离线超 24h → 只锁界面、不清数据、联网自动恢复)——
混成一种就会违背用户要的"一直联网就不用每天输密码"
· 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
让登录/校验类请求跳过,避免"两个地方同时跳登录页")
· 退出登录:先清本地令牌(主防线),再尽力通知服务端
· 设置页显示版本号与「这台设备」
数据层
· FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
· 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
· 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致
验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
This commit is contained in:
@@ -0,0 +1,908 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 日程新建 / 编辑浮层(鸿蒙端)
|
||||
*
|
||||
* 用户 2026-10-15:「我们家书的日程功能模块……跟 SyncCalendar 做得一样」。
|
||||
*
|
||||
* 字段对齐 SyncCalendar 的 `EventEditPage`:
|
||||
* **标题 / 全天 / 日期与时间(同一行)/ 重复 / 提醒 / 日历本 / 地点 / 备注**,
|
||||
* 编辑既有日程时底部还有一颗**删除**。
|
||||
*
|
||||
* 🔴 用户 2026-10-16 的两条修正:
|
||||
* 1. 「**重复和提醒都挤在一块了**……而且**重复和提醒什么的都点不了**。」
|
||||
* · "点不了" = chip 曾是 `@Builder` 值参数(不参与观察)⇒ 换成 `FbEventChip` 组件;
|
||||
* · "挤在一块" = 裸 `Flex({wrap})` 里的 chip **没有 margin** ⇒
|
||||
* 间距放进 `FbEventChip` 组件内部(项目别处的 wrap 也都写了 margin)。
|
||||
* 2. 「时间和日期……**放在一行上吧**,别放上下了。」
|
||||
* · 原来开始/结束各占一整行(`日期 时间` 拼成一串)。
|
||||
* 3. **第二轮**:「你参考一下 SyncCalendar 的做法,实际上就是
|
||||
* **把日期和时间放在一个控件里面**。」
|
||||
* · 🔴 第一版我只做到"日期块 + 时间块并排一行",**但仍是两个可点区**、
|
||||
* 点开的是**两个独立选择器** —— 不是 SyncCalendar 的形态。
|
||||
* · SyncCalendar(`EventEditPage.dateTimePickerSheet`)是:
|
||||
* **一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**,
|
||||
* 选完点「确定」**一次回填**。
|
||||
* · ⇒ 改成:控件是**一整块可点的框**(显示 `10月16日 周五 20:00`),
|
||||
* 点它开弹层、里面两个滚轮并排、点「确定」写回;
|
||||
* 弹层内部用**草稿**(`pickDate`/`pickHour`/`pickMin`)—— 取消可丢弃。
|
||||
*
|
||||
* 🔴 为什么用**浮层**而不是像 SyncCalendar 那样跳新页面:
|
||||
* 本项目的浮层范式已经统一(见 `FbPersonEditSheet` / `FbMovieDetailSheet`)——
|
||||
* 都是页面内的 `Stack` 覆盖层。跳页面会丢列表滚动位置、还要处理返回栈;
|
||||
* 浮层状态完全由父页面掌控(一个 `@Param`),更简单。
|
||||
* ⚠️ 与 SyncCalendar 的**功能对齐**是有意的;**形态不同**是有意的。
|
||||
*
|
||||
* ⚠️ 服务端 `POST/PATCH /api/v1/events` 的字段名是**下划线**(`starts_at` 等),
|
||||
* 且"没传的字段沿用旧值" ⇒ 编辑时只需发改动的那些(见 `FbCalRepo.toBody`)。
|
||||
*/
|
||||
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { FbColors, FbMetric } from '../common/Theme';
|
||||
import { FbCalEventFull, FbCalEventInput, FbCalRepo } from '../data/FbCalRepo';
|
||||
import { FbCalBook } from '../model/Types';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
|
||||
import { FbEventChip } from './FbEventChip';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbEventEdit';
|
||||
|
||||
/** 贴底卡片的手势区缓冲(同其他浮层) */
|
||||
const FB_SHEET_BOTTOM_GAP: number = 16;
|
||||
|
||||
/**
|
||||
* 提醒选项(分钟 → 文案)。
|
||||
*
|
||||
* 🔴 与 SyncCalendar 的 `reminderChip` 同一组值(0/5/10/15/30/60/1440)——
|
||||
* 两边口径一致,用户在两个 App 里看到的选项不会不一样。
|
||||
*/
|
||||
interface FbRemindOpt {
|
||||
min: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const REMIND_OPTS: FbRemindOpt[] = [
|
||||
{ min: 0, label: '不提醒' },
|
||||
{ min: 5, label: '提前5分钟' },
|
||||
{ min: 10, label: '提前10分钟' },
|
||||
{ min: 15, label: '提前15分钟' },
|
||||
{ min: 30, label: '提前30分钟' },
|
||||
{ min: 60, label: '提前1小时' },
|
||||
{ min: 1440, label: '提前1天' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 重复选项(key → 文案 + RRULE)。
|
||||
*
|
||||
* ⚠️ 只做**最常见的那几种**(对齐 SyncCalendar 的 chip 组),
|
||||
* "每两周/自定义"这类留给网页端 —— 手机上点五层下拉去拼 RRULE 得不偿失。
|
||||
* ⚠️ 端上拼的是**完整 RRULE 串**,服务端 `rruleFromBody()` 也认这个口径。
|
||||
*/
|
||||
interface FbRepeatOpt {
|
||||
key: string;
|
||||
label: string;
|
||||
rule: string;
|
||||
}
|
||||
|
||||
const REPEAT_OPTS: FbRepeatOpt[] = [
|
||||
{ key: 'none', label: '不重复', rule: '' },
|
||||
{ key: 'daily', label: '每天', rule: 'FREQ=DAILY' },
|
||||
{ key: 'weekday', label: '工作日', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
|
||||
{ key: 'weekly', label: '每周', rule: 'FREQ=WEEKLY' },
|
||||
{ key: 'monthly', label: '每月', rule: 'FREQ=MONTHLY' },
|
||||
{ key: 'yearly', label: '每年', rule: 'FREQ=YEARLY' }
|
||||
];
|
||||
|
||||
@ComponentV2
|
||||
export struct FbEventEditSheet {
|
||||
/** 要编辑的日程(null + isCreate=false 表示没打开;isCreate=true 时是"新建") */
|
||||
@Param event: FbCalEventFull | null = null;
|
||||
/** 新建模式 */
|
||||
@Param isCreate: boolean = false;
|
||||
/** 新建时预填的日期(`YYYY-MM-DD`,从"点哪天的格子"来) */
|
||||
@Param presetDate: string = '';
|
||||
/** 可选的日历本(新建时要选落到哪本) */
|
||||
@Param books: FbCalBook[] = [];
|
||||
/** 取消 */
|
||||
@Event onCancel: () => void = (): void => {
|
||||
};
|
||||
/** 保存成功 */
|
||||
@Event onSaved: () => void = (): void => {
|
||||
};
|
||||
/** 删除成功 */
|
||||
@Event onDeleted: () => void = (): void => {
|
||||
};
|
||||
|
||||
// ---- 表单状态 ----
|
||||
@Local fTitle: string = '';
|
||||
@Local fAllDay: boolean = false;
|
||||
/** `YYYY-MM-DD` */
|
||||
@Local fStartDate: string = '';
|
||||
/** `HH:mm` */
|
||||
@Local fStartTime: string = '09:00';
|
||||
@Local fEndDate: string = '';
|
||||
@Local fEndTime: string = '10:00';
|
||||
@Local fPlace: string = '';
|
||||
@Local fNote: string = '';
|
||||
/** 重复的 key(REPEAT_OPTS 里那个) */
|
||||
@Local fRepeat: string = 'none';
|
||||
/** 提醒分钟(可多选;空数组 = 不提醒) */
|
||||
@Local fRemind: number[] = [];
|
||||
/** 选中的日历本 id */
|
||||
@Local fBookId: number = 0;
|
||||
|
||||
@Local busy: boolean = false;
|
||||
@Local err: string = '';
|
||||
@Local confirmDel: boolean = false;
|
||||
@Local deleting: boolean = false;
|
||||
|
||||
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar 的 `dateTimePickerSheet`)----
|
||||
/**
|
||||
* 弹层开着吗。
|
||||
*
|
||||
* 🔴 **为什么不是"就地展开"**(这是用户 2026-10-16 的第二条反馈):
|
||||
* SyncCalendar 的做法是**一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**
|
||||
* (见其 `EventEditPage.dateTimePickerSheet`),选完点「确定」一次回填。
|
||||
* 我第一版做成了"点日期块就地展开一个 DatePicker、点时间块再展开一个 TimePicker"
|
||||
* —— 那是两个控件、分两次选,不是它的做法。
|
||||
*/
|
||||
@Local pickOpen: boolean = false;
|
||||
/** 正在选的是"结束"(false = 开始) */
|
||||
@Local pickIsEnd: boolean = false;
|
||||
/** 弹层里的**草稿**值:点「确定」才写回 `fStartXxx`/`fEndXxx`(取消则丢弃) */
|
||||
@Local pickDate: Date = new Date();
|
||||
@Local pickHour: number = 9;
|
||||
@Local pickMin: number = 0;
|
||||
|
||||
aboutToAppear(): void {
|
||||
const e: FbCalEventFull | null = this.event;
|
||||
if (e !== null) {
|
||||
// ---- 编辑:回填 ----
|
||||
this.fTitle = e.title;
|
||||
this.fAllDay = e.all_day;
|
||||
this.fStartDate = this.dateOf(e.starts_at);
|
||||
this.fStartTime = this.timeOf(e.starts_at);
|
||||
this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at);
|
||||
this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at);
|
||||
this.fPlace = e.location;
|
||||
this.fNote = e.note;
|
||||
this.fRepeat = this.repeatKeyOf(e.rrule);
|
||||
this.fRemind = this.minsOf(e.reminder_minutes);
|
||||
this.fBookId = e.calendar_id;
|
||||
return;
|
||||
}
|
||||
// ---- 新建:预填 ----
|
||||
const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr();
|
||||
this.fStartDate = d;
|
||||
this.fEndDate = d;
|
||||
// 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 ——
|
||||
// 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。
|
||||
const now: Date = new Date();
|
||||
const nh: number = Math.min(now.getHours() + 1, 22);
|
||||
this.fStartTime = this.pad(nh) + ':00';
|
||||
this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00';
|
||||
this.fBookId = this.defaultBookId();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 取值 / 换算
|
||||
// ============================================================
|
||||
|
||||
private todayStr(): string {
|
||||
const n: Date = new Date();
|
||||
return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate());
|
||||
}
|
||||
|
||||
private pad(n: number): string {
|
||||
return n < 10 ? ('0' + n.toString()) : n.toString();
|
||||
}
|
||||
|
||||
/** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */
|
||||
private dateOf(dt: string): string {
|
||||
return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr();
|
||||
}
|
||||
|
||||
/** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */
|
||||
private timeOf(dt: string): string {
|
||||
return dt.length >= 16 ? dt.substring(11, 16) : '09:00';
|
||||
}
|
||||
|
||||
/** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */
|
||||
private addHour(dt: string): string {
|
||||
const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9;
|
||||
const h2: number = Math.min(hh + 1, 23);
|
||||
return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00');
|
||||
}
|
||||
|
||||
/** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */
|
||||
private defaultBookId(): number {
|
||||
const want: number = FB_CAL_SETTINGS.getBookId();
|
||||
for (const b of this.books) {
|
||||
if (b.id === want && b.canWrite) {
|
||||
return b.id;
|
||||
}
|
||||
}
|
||||
for (const b of this.books) {
|
||||
if (b.canWrite) {
|
||||
return b.id;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 重复:从 RRULE 串反推是哪个 chip(认不出就归"不重复") */
|
||||
private repeatKeyOf(rrule: string): string {
|
||||
if (rrule === '') {
|
||||
return 'none';
|
||||
}
|
||||
for (const o of REPEAT_OPTS) {
|
||||
if (o.rule !== '' && rrule.indexOf(o.rule) === 0) {
|
||||
return o.key;
|
||||
}
|
||||
}
|
||||
// 认不出的复杂规则:给"不重复"更危险(会覆盖掉原规则),
|
||||
// 但端上确实没有编辑自定义规则的能力 ⇒ 保留原文不覆盖(见 save() 的处理)
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** 重复 chip → RRULE 串 */
|
||||
private ruleOfRepeat(): string {
|
||||
for (const o of REPEAT_OPTS) {
|
||||
if (o.key === this.fRepeat) {
|
||||
return o.rule;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 提醒串 → 分钟数组 */
|
||||
private minsOf(s: string): number[] {
|
||||
const out: number[] = [];
|
||||
if (s === '') {
|
||||
return out;
|
||||
}
|
||||
for (const x of s.split(',')) {
|
||||
const n: number = parseInt(x.trim(), 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 登录令牌 */
|
||||
private token(): string {
|
||||
return FB_TOKEN_STORE.get();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 小件
|
||||
// ============================================================
|
||||
|
||||
@Builder
|
||||
field(label: string, value: string, hint: string, onChange: (v: string) => void) {
|
||||
Column({ space: 4 }) {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
TextInput({ text: value, placeholder: hint })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(40)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.onChange((v: string): void => {
|
||||
onChange(v);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
area(label: string, value: string, hint: string, onChange: (v: string) => void) {
|
||||
Column({ space: 4 }) {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
TextArea({ text: value, placeholder: hint })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(72)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.onChange((v: string): void => {
|
||||
onChange(v);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/** 小标题(把一大串字段分开) */
|
||||
@Builder
|
||||
section(title: string) {
|
||||
Text(title)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 这里删掉过两个 `@Builder`,都**不要**加回来:
|
||||
* · `chip(label, on: boolean, onTap)` —— "重复/提醒点不了"的根因
|
||||
* (@Builder 值参数不参与观察)⇒ 已换成 `FbEventChip` 组件。
|
||||
* · `pickRow(label, value, onTap)` —— 被"日期时间并排一行"取代
|
||||
* (用户要求时间和日期同一行,不再上下堆)⇒ 见 `dateTimeRow`。
|
||||
*/
|
||||
|
||||
build() {
|
||||
// 🔴 外层 `Stack` 是**必需**的:本组件有**三个**要显示的层
|
||||
// (编辑卡片 + 日期时间弹层 + 删除确认遮罩),而 build() 只能有一个根节点。
|
||||
// ⚠️ `alignContent: Bottom` 让卡片/弹层都贴底(同一形态)。
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
this.editCard()
|
||||
|
||||
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar)----
|
||||
if (this.pickOpen) {
|
||||
this.dateTimePickerSheet()
|
||||
}
|
||||
|
||||
if (this.confirmDel) {
|
||||
this.deleteConfirm()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
editCard() {
|
||||
Column({ space: 0 }) {
|
||||
// ---- 标题栏 ----
|
||||
Row({ space: 12 }) {
|
||||
Text(this.isCreate ? '新建日程' : '编辑日程')
|
||||
.fontSize(FbMetric.fontTitle)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
Blank()
|
||||
Text('取消')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.muted)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.onClick((): void => {
|
||||
this.onCancel();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
this.field('标题', this.fTitle, '日程得有个标题', (v: string): void => {
|
||||
this.fTitle = v;
|
||||
})
|
||||
|
||||
// ---- 全天 ----
|
||||
Row() {
|
||||
Text('全天')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.fAllDay })
|
||||
.selectedColor('#4A7C82')
|
||||
.onChange((on: boolean): void => {
|
||||
this.fAllDay = on;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
|
||||
// ---- 日期与时间 ----
|
||||
// 🔴 照 SyncCalendar:**一行一个"日期 + 时间"控件**(点开一个弹层、
|
||||
// 里面 DatePicker 与 TimePicker 并排,选完点「确定」)。
|
||||
// 用户 2026-10-16:「实际上就是把日期和时间放在一个控件里面。」
|
||||
this.section('日期与时间')
|
||||
this.dateTimeRow('开始', true)
|
||||
if (!this.fAllDay) {
|
||||
this.dateTimeRow('结束', false)
|
||||
}
|
||||
|
||||
// ---- 重复 ----
|
||||
// 🔴 用 `FbEventChip` **组件**而不是 @Builder ——
|
||||
// @Builder 的值类型参数(on: boolean)变化**不触发重建**,
|
||||
// 这正是"重复/提醒点不了"的根因(见该组件文件头)。
|
||||
this.section('重复')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(REPEAT_OPTS, (o: FbRepeatOpt) => {
|
||||
FbEventChip({
|
||||
label: o.label,
|
||||
active: this.fRepeat === o.key,
|
||||
onPick: (): void => {
|
||||
this.fRepeat = o.key;
|
||||
}
|
||||
})
|
||||
}, (o: FbRepeatOpt): string => 'rp_' + o.key)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2 })
|
||||
|
||||
// ---- 提醒 ----
|
||||
this.section('提醒(可多选)')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(REMIND_OPTS, (o: FbRemindOpt) => {
|
||||
FbEventChip({
|
||||
label: o.label,
|
||||
active: this.hasRemind(o.min),
|
||||
onPick: (): void => {
|
||||
this.toggleRemind(o.min);
|
||||
}
|
||||
})
|
||||
}, (o: FbRemindOpt): string => 'rm_' + o.min.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2 })
|
||||
|
||||
// ---- 日历本 ----
|
||||
if (this.books.length > 0) {
|
||||
this.section('加入哪本日历')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.books, (b: FbCalBook) => {
|
||||
FbEventChip({
|
||||
label: b.name,
|
||||
active: this.fBookId === b.id,
|
||||
// 选中时用**这本日历自己的颜色**(一眼看出落到哪本)
|
||||
activeBg: b.color,
|
||||
onPick: (): void => {
|
||||
this.fBookId = b.id;
|
||||
}
|
||||
})
|
||||
}, (b: FbCalBook): string => 'bk_' + b.id.toString())
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// ---- 地点 / 备注 ----
|
||||
this.field('地点', this.fPlace, '地点(可选)', (v: string): void => {
|
||||
this.fPlace = v;
|
||||
})
|
||||
this.area('备注', this.fNote, '备注(可选)', (v: string): void => {
|
||||
this.fNote = v;
|
||||
})
|
||||
|
||||
if (this.err !== '') {
|
||||
Text(this.err)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor('#A32D2D')
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 14 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.TopStart)
|
||||
|
||||
// ---- 底部按钮:删除 / 保存 ----
|
||||
if (this.deleting) {
|
||||
Text('删除中…')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.faint)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.margin({ top: 16 })
|
||||
} else {
|
||||
Row({ space: 10 }) {
|
||||
if (!this.isCreate) {
|
||||
Text('删除')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor('#C0533F')
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(42)
|
||||
.padding({ left: 18, right: 18 })
|
||||
.border({ width: 1, color: '#C0533F' })
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.onClick((): void => {
|
||||
if (!this.busy) {
|
||||
this.confirmDel = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
Text(this.busy ? '保存中…' : (this.isCreate ? '创建' : '保存'))
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(Color.White)
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(42)
|
||||
.backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82')
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.onClick((): void => {
|
||||
if (!this.busy) {
|
||||
this.save();
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 16 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.constraintSize({ maxHeight: '86%' })
|
||||
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
|
||||
.backgroundColor(FbColors.surface)
|
||||
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/**
|
||||
* **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。
|
||||
*
|
||||
* 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是
|
||||
* **把日期和时间放在一个控件里面**。」
|
||||
* 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与
|
||||
* `TimePicker` 并排**,选完点「确定」一次回填。
|
||||
* ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker"
|
||||
* —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。
|
||||
*
|
||||
* 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`;
|
||||
* 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。
|
||||
*/
|
||||
@Builder
|
||||
dateTimeRow(label: string, isStart: boolean) {
|
||||
Row({ space: 10 }) {
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.width(28)
|
||||
// 整块可点:日期与时间在**同一个控件**里,中间不再分两块
|
||||
Row() {
|
||||
Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate))
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
if (!this.fAllDay) {
|
||||
Text(' ' + (isStart ? this.fStartTime : this.fEndTime))
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height(42)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick((): void => {
|
||||
this.openPicker(isStart);
|
||||
})
|
||||
// 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致)
|
||||
Text('›')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontColor(FbColors.faint)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开「日期 + 时间」弹层(把当前值装进草稿)。
|
||||
*
|
||||
* ⚠️ 草稿的意义:用户滚了一半又点「取消」⇒ **不写回**(`fStartXxx` 保持原样)。
|
||||
* 若直接改 `fStartXxx`,"取消"就没法撤销了。
|
||||
*/
|
||||
private openPicker(isEnd: boolean): void {
|
||||
this.pickIsEnd = isEnd;
|
||||
const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate);
|
||||
this.pickDate = d;
|
||||
const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime);
|
||||
this.pickHour = tm.getHours();
|
||||
this.pickMin = tm.getMinutes();
|
||||
this.pickOpen = true;
|
||||
}
|
||||
|
||||
/** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */
|
||||
private applyPicker(): void {
|
||||
const iso: string = this.pad(this.pickDate.getFullYear()) + '-'
|
||||
+ this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate());
|
||||
const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin);
|
||||
if (this.pickIsEnd) {
|
||||
this.fEndDate = iso;
|
||||
this.fEndTime = hm;
|
||||
} else {
|
||||
this.fStartDate = iso;
|
||||
this.fStartTime = hm;
|
||||
// ⚠️ 开始改到"结束之后" ⇒ 把结束顺延到同一时刻(服务端不校验,但负时长是坏的)
|
||||
if (this.fEndDate < iso || (this.fEndDate === iso && this.fEndTime < hm)) {
|
||||
this.fEndDate = iso;
|
||||
this.fEndTime = hm;
|
||||
}
|
||||
}
|
||||
this.pickOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。
|
||||
*
|
||||
* 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。
|
||||
* ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**:
|
||||
* 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。
|
||||
* ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。
|
||||
*/
|
||||
@Builder
|
||||
dateTimePickerSheet() {
|
||||
Column({ space: 14 }) {
|
||||
Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间')
|
||||
.fontSize(FbMetric.fontTitle)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 12 })
|
||||
|
||||
Row({ space: 6 }) {
|
||||
DatePicker({
|
||||
start: new Date('2000-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: this.pickDate
|
||||
})
|
||||
.onDateChange((v: Date): void => {
|
||||
this.pickDate = v;
|
||||
})
|
||||
.textStyle({ color: FbColors.faint, font: { size: 14 } })
|
||||
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
|
||||
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
|
||||
if (!this.fAllDay) {
|
||||
TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) })
|
||||
.useMilitaryTime(true)
|
||||
.onChange((v: TimePickerResult): void => {
|
||||
// ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零
|
||||
this.pickHour = v.hour;
|
||||
this.pickMin = v.minute;
|
||||
})
|
||||
.textStyle({ color: FbColors.faint, font: { size: 14 } })
|
||||
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
|
||||
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Button(this.fAllDay ? '确定日期' : '确定')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.backgroundColor('#4A7C82')
|
||||
.fontColor(Color.White)
|
||||
.onClick((): void => {
|
||||
this.applyPicker();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(FbColors.surface)
|
||||
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
|
||||
.padding({ left: 18, right: 18, top: 8, bottom: 20 + FB_SHEET_BOTTOM_GAP })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */
|
||||
private cnDate(iso: string): string {
|
||||
if (iso.length < 10) {
|
||||
return iso;
|
||||
}
|
||||
const y: number = parseInt(iso.substring(0, 4), 10);
|
||||
const m: number = parseInt(iso.substring(5, 7), 10);
|
||||
const d: number = parseInt(iso.substring(8, 10), 10);
|
||||
const w: string[] = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()];
|
||||
}
|
||||
|
||||
private parseDate(s: string): Date {
|
||||
if (s.length < 10) {
|
||||
return new Date();
|
||||
}
|
||||
const y: number = parseInt(s.substring(0, 4), 10);
|
||||
const m: number = parseInt(s.substring(5, 7), 10) - 1;
|
||||
const d: number = parseInt(s.substring(8, 10), 10);
|
||||
return new Date(y, m, d);
|
||||
}
|
||||
|
||||
private parseTime(s: string): Date {
|
||||
const out: Date = new Date();
|
||||
if (s.length >= 5) {
|
||||
out.setHours(parseInt(s.substring(0, 2), 10));
|
||||
out.setMinutes(parseInt(s.substring(3, 5), 10));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Builder
|
||||
deleteConfirm() {
|
||||
Column() {
|
||||
Column({ space: 8 }) {
|
||||
Text('删除这条日程?')
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
Text('删掉之后不能撤销。若这条日程是重复的,这一次和以后的都会不见。')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.maxLines(3)
|
||||
Row({ space: 10 }) {
|
||||
Text('取消')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.muted)
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(42)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.onClick((): void => {
|
||||
this.confirmDel = false;
|
||||
})
|
||||
Text('删除')
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(Color.White)
|
||||
.textAlign(TextAlign.Center)
|
||||
.layoutWeight(1)
|
||||
.height(42)
|
||||
.backgroundColor('#C0533F')
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.onClick((): void => {
|
||||
this.confirmDel = false;
|
||||
this.doDelete();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.backgroundColor(FbColors.surface)
|
||||
.borderRadius(FbMetric.radius)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 32, right: 32 })
|
||||
.backgroundColor('#00000066')
|
||||
.onClick((): void => {
|
||||
this.confirmDel = false;
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 提醒多选
|
||||
// ============================================================
|
||||
|
||||
private hasRemind(min: number): boolean {
|
||||
// 0 = "不提醒":选中它等价于"没有任何提醒"
|
||||
if (min === 0) {
|
||||
return this.fRemind.length === 0;
|
||||
}
|
||||
return this.fRemind.indexOf(min) >= 0;
|
||||
}
|
||||
|
||||
private toggleRemind(min: number): void {
|
||||
if (min === 0) {
|
||||
this.fRemind = [];
|
||||
return;
|
||||
}
|
||||
const at: number = this.fRemind.indexOf(min);
|
||||
if (at >= 0) {
|
||||
this.fRemind.splice(at, 1);
|
||||
} else {
|
||||
this.fRemind.push(min);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 网络
|
||||
// ============================================================
|
||||
|
||||
private async doDelete(): Promise<void> {
|
||||
const e: FbCalEventFull | null = this.event;
|
||||
if (e === null) {
|
||||
return;
|
||||
}
|
||||
const token: string = this.token();
|
||||
if (token === '') {
|
||||
this.err = '未登录,无法删除';
|
||||
return;
|
||||
}
|
||||
this.deleting = true;
|
||||
this.err = '';
|
||||
const res = await FbCalRepo.inst().remove(e.id, token);
|
||||
this.deleting = false;
|
||||
if (!res.ok) {
|
||||
hilog.warn(DOMAIN, TAG, '删除日程失败:%{public}s', res.error);
|
||||
this.err = '删除失败:' + res.error;
|
||||
return;
|
||||
}
|
||||
this.onDeleted();
|
||||
}
|
||||
|
||||
/** 保存(新建走 POST、编辑走 PATCH) */
|
||||
private async save(): Promise<void> {
|
||||
const title: string = this.fTitle.trim();
|
||||
if (title === '') {
|
||||
this.err = '日程得有个标题';
|
||||
return;
|
||||
}
|
||||
const token: string = this.token();
|
||||
if (token === '') {
|
||||
this.err = this.isCreate ? '未登录,无法创建' : '未登录,无法保存';
|
||||
return;
|
||||
}
|
||||
const isNew: boolean = this.isCreate || this.event === null;
|
||||
if (isNew && this.fBookId <= 0) {
|
||||
this.err = '请选一本日历';
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 时间:全天归零到 00:00;否则拼上时分 ----
|
||||
const startAt: string = this.fAllDay
|
||||
? (this.fStartDate + ' 00:00:00')
|
||||
: (this.fStartDate + ' ' + this.fStartTime + ':00');
|
||||
let endAt: string = this.fAllDay
|
||||
? (this.fEndDate + ' 00:00:00')
|
||||
: (this.fEndDate + ' ' + this.fEndTime + ':00');
|
||||
// ⚠️ 结束早于开始 ⇒ 补成同一时刻(服务端不校验时长,但"负时长"看着是坏的)
|
||||
if (endAt < startAt) {
|
||||
endAt = startAt;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.err = '';
|
||||
const input: FbCalEventInput = {
|
||||
title: title,
|
||||
location: this.fPlace.trim(),
|
||||
starts_at: startAt,
|
||||
ends_at: endAt,
|
||||
all_day: this.fAllDay,
|
||||
note: this.fNote,
|
||||
reminder_minutes: this.fRemind
|
||||
};
|
||||
// ⚠️ 重复:只在用户**确实选过**时才发。
|
||||
// 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"),
|
||||
// 若照样发个空 rrule,就会把人家原来的规则**清掉**。
|
||||
// ⇒ 规则原文认不出(非空且 chip 是 none)⇒ 这一项**不发**,保持原样。
|
||||
const recognize: boolean = this.fRepeat !== 'none'
|
||||
|| (this.event !== null && this.event.rrule === '');
|
||||
if (recognize) {
|
||||
input.rrule = this.ruleOfRepeat();
|
||||
}
|
||||
if (isNew) {
|
||||
input.calendar_id = this.fBookId;
|
||||
}
|
||||
|
||||
const repo: FbCalRepo = FbCalRepo.inst();
|
||||
const res = isNew ? await repo.create(input, token) : await repo.update(this.event!.id, input, token);
|
||||
this.busy = false;
|
||||
if (!res.ok) {
|
||||
hilog.warn(DOMAIN, TAG, '保存日程失败:%{public}s', res.error);
|
||||
this.err = (isNew ? '创建失败:' : '保存失败:') + res.error;
|
||||
return;
|
||||
}
|
||||
// 新建时把选的日历本记成"以后默认用它"(与 SyncCalendar 的默认本同一体验)
|
||||
if (isNew) {
|
||||
FB_CAL_SETTINGS.setBookId(this.fBookId);
|
||||
}
|
||||
this.onSaved();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user