/** * 家书 FamilyBook · 账本设置的两个浮层(鸿蒙端,v68) * * 对应 `FB_SET_LEDGER` 里原来那两条**灰条**: * 「账本名字」→ `FbLedgerRenameSheet`(PATCH /ledger/) * 「共享给谁」→ `FbLedgerShareSheet`(GET/POST/DELETE /ledger//shares) * * 🔴 用户 2026-10-20:「财务的显示也有问题」,v65 修的是**显示口径** * (端上按正负号重加 vs 服务端负债取绝对值);这一批补的是**动作**: * 模型 `Ledger::rename/setShare/unshare/shares` 一直都有,只是没有路由, * 端上就只能把这两条画成灰的。 * * ⚠️ 只有**账本主人**能改共享(服务端也是这个判断,403 会说原话)—— * 界面先把"只有主人"写出来,别让被共享的人点了才收到 403。 */ import { FbColors, FbMetric, FB_MASK } from '../common/Theme'; import { FbSheetClose } from './FbSheetClose'; import { FbSheetRing } from './FbSheetRing'; import { FbPersonPickSheet } from './FbPersonPickSheet'; import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbApiResult } from '../data/FbHttp'; import { renameLedger, fetchLedgerShares, shareLedger, unshareLedger } from '../data/FbLedgerRepo'; /** 一条共享记录(服务端 `Ledger::shares()`:s.* + p.name / avatar) */ interface FbLedgerShareRow { personId: number; name: string; perm: string; } /** 服务端原始行(`Api::ok(['items' => …])`)—— 字段名照抄数据库列名 */ interface RawShareRow { person_id?: number; name?: string | null; perm?: string | null; } interface RawShareList { items?: RawShareRow[]; } @ComponentV2 export struct FbLedgerRenameSheet { @Param accent: string = '#5E7A3C'; @Param ledgerId: number = 0; @Param currentName: string = ''; @Event onClose: () => void = (): void => {}; @Event onSaved: () => void = (): void => {}; @Local name: string = ''; @Local saving: boolean = false; @Local err: string = ''; aboutToAppear(): void { this.name = this.currentName; } build() { Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor(FB_MASK) .onClick((): void => { this.onClose(); }) Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { Column({ space: 12 }) { Row({ space: 8 }) { Column({ space: 2 }) { Text('账本名字') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Text('只改名字,账目不动') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accent }) } .width('100%') .alignItems(VerticalAlign.Center) TextInput({ text: this.name, placeholder: '如:家里的账 / 装修账' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(44) .width('100%') .padding({ left: 10, right: 10 }) .onChange((v: string): void => { this.name = v; this.err = ''; }) if (this.err !== '') { Text(this.err) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') .maxLines(3) } Text(this.saving ? '保存中…' : '保存') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(this.saving ? FbColors.faint : '#FFFFFF') .textAlign(TextAlign.Center) .width('100%') .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(this.accent) .onClick((): void => { this.save(); }) } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } private async save(): Promise { if (this.saving) { return; } const nm: string = this.name.trim(); if (nm === '') { this.err = '名字不能空着。'; return; } this.saving = true; const res: FbApiResult = await renameLedger(FB_TOKEN_STORE.get(), this.ledgerId, nm); this.saving = false; if (!res.ok) { this.err = res.error !== '' ? res.error : '没改成,请稍后再试。'; return; } this.onSaved(); } } @ComponentV2 export struct FbLedgerShareSheet { @Param accent: string = '#5E7A3C'; @Param ledgerId: number = 0; @Param ledgerName: string = ''; /** 只有主人能改 —— 服务端也是这个判断 */ @Param isOwner: boolean = true; @Event onClose: () => void = (): void => {}; /** 共享关系变了(页面可以提示一句) */ @Event onChanged: () => void = (): void => {}; @Local rows: FbLedgerShareRow[] = []; @Local roster: FbRosterPerson[] = []; @Local picking: boolean = false; @Local busy: boolean = false; @Local note: string = ''; aboutToAppear(): void { this.reload(); } private async reload(): Promise { const res: FbApiResult = await fetchLedgerShares(FB_TOKEN_STORE.get(), this.ledgerId); if (!res.ok) { this.note = res.error !== '' ? res.error : '共享列表没拉到。'; return; } /* ⚠️ `FbApiResult.data` 是**原始 JSON 片段**(不是已解析的对象), 而且列表在信封里是 `{items:[…]}` —— 两层都要拆。 */ let parsed: RawShareList = {}; try { parsed = JSON.parse(res.data) as RawShareList; } catch (e) { this.note = '共享列表格式看不懂。'; return; } const rawRows: RawShareRow[] = parsed.items !== undefined ? parsed.items : []; const raw: FbLedgerShareRow[] = []; for (const it of rawRows) { raw.push({ personId: it.person_id !== undefined ? it.person_id : 0, name: it.name !== undefined && it.name !== null ? it.name : '(无名)', perm: it.perm !== undefined && it.perm !== null ? it.perm : 'read' }); } this.rows = raw; this.note = ''; } private async openPicker(): Promise { if (this.roster.length === 0) { try { this.roster = await FbPersonRepo.inst().list(); } catch (e) { this.roster = []; } } this.picking = true; } private async addShares(ids: number[]): Promise { this.picking = false; if (ids.length === 0) { return; } this.busy = true; let bad: string = ''; for (const id of ids) { const res: FbApiResult = await shareLedger(FB_TOKEN_STORE.get(), this.ledgerId, id, 'read'); if (!res.ok && bad === '') { bad = res.error; } } this.busy = false; this.note = bad !== '' ? bad : ''; await this.reload(); this.onChanged(); } private async drop(personId: number): Promise { this.busy = true; const res: FbApiResult = await unshareLedger(FB_TOKEN_STORE.get(), this.ledgerId, personId); this.busy = false; if (!res.ok) { this.note = res.error !== '' ? res.error : '没取消成。'; return; } await this.reload(); this.onChanged(); } build() { Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor(FB_MASK) .onClick((): void => { this.onClose(); }) Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) { Column({ space: 10 }) { Row({ space: 8 }) { Column({ space: 2 }) { Text('共享给谁') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Text('「' + this.ledgerName + '」· 被共享的人只能看') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accent }) } .width('100%') .alignItems(VerticalAlign.Center) if (!this.isOwner) { Text('这本账不是你建的 —— 只有账本主人能改共享关系。') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .width('100%') .maxLines(3) } if (this.rows.length === 0) { Text('还没共享给任何人') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width('100%') } else { ForEach(this.rows, (r: FbLedgerShareRow) => { Row({ space: 8 }) { Text(r.name) .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .layoutWeight(1) .maxLines(1) Text(r.perm === 'write' ? '可改' : '只看') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) if (this.isOwner) { Text('取消') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .padding({ left: 8, right: 4, top: 4, bottom: 4 }) .onClick((): void => { this.drop(r.personId); }) } } .width('100%') .padding({ top: 8, bottom: 8 }) .alignItems(VerticalAlign.Center) }, (r: FbLedgerShareRow): string => 'lsh_' + r.personId.toString()) } if (this.note !== '') { Text(this.note) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .width('100%') .maxLines(3) } if (this.isOwner) { Text(this.busy ? '处理中…' : '共享给…') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(this.busy ? FbColors.faint : '#FFFFFF') .textAlign(TextAlign.Center) .width('100%') .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(this.accent) .onClick((): void => { this.openPicker(); }) } } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') /* 选人浮层:复用名册那颗(多选 + 确定),选完逐个人 POST */ if (this.picking) { FbPersonPickSheet({ people: this.roster, selected: [], title: '共享给谁', accent: this.accent, onConfirm: (ids: number[]): void => { this.addShares(ids); }, onClose: (): void => { this.picking = false; } }) } } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } }