/** * 家书 FamilyBook · 资源「只读详情」浮层(鸿蒙端) * * 🔴 工单 #29(用户 2026-10-28): * 原 `FbResourceSheet` 是「查看 = 编辑」合体 —— 点列表直接进**可编辑**框, * 标题/正文是输入框,附件点不动、没有预览/下载。 * 用户要求「和名册一样」:点列表 → **详情**(归属谁 / 共享给谁 / 附件), * 附件能预览、能下载;下面一个「编辑」按钮,点了才进编辑。 * * 所以这个浮层: * · **只读**:标题 / 正文是纯文本,不摆输入框; * · 展示:归属人、共享给了谁(可改 / 只看)、附件列表; * · 附件:图片点开 → 应用内全屏预览;非图片点开 → 交给系统「打开方式」; * 每条附件都能「下载 / 保存」; * · 底部「编辑」按钮(仅 owner / write 可见)→ 发 `onEdit`,由宿主切编辑浮层。 * * ⚠️ 本组件**不自己拿 ctx**(相册 / startAbility / DocumentViewPicker 都需要 * `UIAbilityContext`,而组件里强转可能在浮层重建时拿到 undefined —— * 见 `FbEventSheet` 文件头同一结论)。所以「打开非图片」「保存附件」两件事 * 通过 `@Event` 抛给宿主页做;只有图片全屏预览是纯 UI,留在本组件内。 */ import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme'; import { FbSheetClose } from './FbSheetClose'; import { FbSheetRing } from './FbSheetRing'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { fbImageSrc } from '../data/FbAttachQueue'; import { fetchResourceAttach, fetchResourceShares } from '../data/FbResourceRepo'; import { FbApiResult } from '../data/FbHttp'; /** 一条附件(服务端 `Attachment::list`,本文件只读展示) */ interface FbResAttachView { id: number; name: string; key: string; isImage: boolean; } /** 一条共享(服务端 `Resource::shares()`) */ interface FbResShareView { personId: number; name: string; perm: string; } /** 附件原始行 */ interface RawAttachView { id?: number; key?: string | null; name?: string | null; is_image?: number; } /** 共享原始行 */ interface RawShareView { person_id?: number; name?: string | null; perm?: string | null; } @ComponentV2 export struct FbResourceDetailSheet { @Param accent: string = '#5566A8'; @Param editingId: number = 0; @Param editingTitle: string = ''; @Param editingContent: string = ''; @Param ownerName: string = ''; /** 我能不能改(owner / write);决定「编辑」按钮出不出 */ @Param canWrite: boolean = true; @Event onClose: () => void = (): void => { }; /** 点「编辑」—— 宿主关详情、开编辑浮层 */ @Event onEdit: () => void = (): void => { }; /** 打开一个**非图片**附件(宿主拿 ctx 调系统打开) */ @Event onOpenFile: (key: string, name: string) => void = (): void => { }; /** 下载 / 保存一个附件(宿主拿 ctx 走存相册 / 另存为) */ @Event onSaveAttach: (key: string, name: string, isImage: boolean) => void = (): void => { }; @Local attachs: FbResAttachView[] = []; @Local shares: FbResShareView[] = []; @Local loading: boolean = true; // ---- 图片全屏查看器 ---- @Local viewerOpen: boolean = false; @Local viewerIndex: number = 0; aboutToAppear(): void { this.reloadAttach(); this.reloadShares(); } /** 附件里的图片(供查看器左右滑) */ private imageKeys(): string[] { const keys: string[] = []; for (const a of this.attachs) { if (a.isImage) { keys.push(a.key); } } return keys; } private openImage(key: string): void { const keys: string[] = this.imageKeys(); const idx: number = keys.indexOf(key); if (idx < 0) { return; } this.viewerIndex = idx; this.viewerOpen = true; } private viewerStep(delta: number): void { const keys: string[] = this.imageKeys(); if (keys.length === 0) { return; } let next: number = this.viewerIndex + delta; if (next < 0) { next = keys.length - 1; } if (next >= keys.length) { next = 0; } this.viewerIndex = next; } private async reloadAttach(): Promise { const res: FbApiResult = await fetchResourceAttach(FB_TOKEN_STORE.get(), this.editingId); const out: FbResAttachView[] = []; if (res.ok) { try { const o: Record = JSON.parse(res.data) as Record; const items: Object | undefined = o['items']; const rows: RawAttachView[] = (items !== undefined && items !== null) ? (items as RawAttachView[]) : []; for (const a of rows) { out.push({ id: a.id !== undefined ? a.id : 0, name: a.name !== undefined && a.name !== null ? a.name : '附件', key: a.key !== undefined && a.key !== null ? a.key : '', isImage: a.is_image !== undefined ? a.is_image !== 0 : false, }); } } catch (e) { // 解析失败就当空列表 } } this.attachs = out; } private async reloadShares(): Promise { const res: FbApiResult = await fetchResourceShares(FB_TOKEN_STORE.get(), this.editingId); const out: FbResShareView[] = []; if (res.ok) { try { const o: Record = JSON.parse(res.data) as Record; const items: Object | undefined = o['items']; const rows: RawShareView[] = (items !== undefined && items !== null) ? (items as RawShareView[]) : []; for (const s of rows) { out.push({ personId: s.person_id !== undefined ? s.person_id : 0, name: s.name !== undefined && s.name !== null ? s.name : '(无名)', perm: s.perm !== undefined && s.perm !== null ? s.perm : 'read', }); } } catch (e) { // 解析失败就当空列表 } } this.shares = out; this.loading = false; } 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(this.editingTitle !== '' ? this.editingTitle : '资源') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) Text('归属 ' + this.ownerName) .fontSize(FbMetric.fontTiny) .fontColor(this.accent) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accent }) } .width('100%') .alignItems(VerticalAlign.Center) Scroll() { Column({ space: 12 }) { // ---- 正文(只读)---- Text(this.editingContent !== '' ? this.editingContent : '(没有正文)') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .width('100%') .textAlign(TextAlign.Start) Divider().color(FbColors.line) // ---- 附件 ---- Column({ space: 6 }) { Row({ space: 8 }) { Text('附件') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(this.attachs.length > 0 ? this.attachs.length.toString() + ' 个' : '没有附件') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) } .width('100%') .alignItems(VerticalAlign.Center) if (this.attachs.length > 0) { ForEach(this.attachs, (a: FbResAttachView) => { Row({ space: 8 }) { if (a.isImage) { Image(fbImageSrc(a.key, FB_TOKEN_STORE.get())) .width(40).height(40) .objectFit(ImageFit.Cover) .borderRadius(FbMetric.radiusS) .backgroundColor(FbColors.inputBg) } else { Text('📎') .fontSize(20) } Text(a.name) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 下载 / 保存 Text('下载') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) .padding({ left: 6, right: 6, top: 4, bottom: 4 }) .onClick((): void => { this.onSaveAttach(a.key, a.name, a.isImage); }) } .width('100%') .padding({ top: 6, bottom: 6 }) .alignItems(VerticalAlign.Center) .onClick((): void => { /* 点整行 = 「打开」: 图片 → 应用内全屏预览;非图片 → 抛给宿主调系统打开。 */ if (a.isImage) { this.openImage(a.key); } else { this.onOpenFile(a.key, a.name); } }) }, (a: FbResAttachView): string => 'rd_' + a.id.toString()) } } .width('100%') .alignItems(HorizontalAlign.Start) Divider().color(FbColors.line) // ---- 共享 ---- Column({ space: 6 }) { Row({ space: 8 }) { Text('共享给') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(this.shares.length > 0 ? this.shares.length.toString() + ' 人' : '还没有共享') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) } .width('100%') .alignItems(VerticalAlign.Center) if (this.shares.length > 0) { ForEach(this.shares, (s: FbResShareView) => { Row({ space: 8 }) { Text(s.name) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .layoutWeight(1) .maxLines(1) Text(s.perm === 'write' ? '可改' : '只看') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .width('100%') .padding({ top: 4, bottom: 4 }) .alignItems(VerticalAlign.Center) }, (s: FbResShareView): string => 'rdsh_' + s.personId.toString()) } } .width('100%') .alignItems(HorizontalAlign.Start) } .width('100%') .alignItems(HorizontalAlign.Start) } .constraintSize({ maxHeight: 420 }) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) // ---- 底部:编辑(仅可写者)---- if (this.canWrite) { Text('编辑') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor('#FFFFFF') .textAlign(TextAlign.Center) .width('100%') .height(44) .borderRadius(FbMetric.radiusS) .backgroundColor(fbAlpha(this.accent, 0.98)) .onClick((): void => { this.onEdit(); }) } } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') // ---- 全屏图片查看器(图片附件点开)---- if (this.viewerOpen) { this.viewer() } } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } /** 全屏图片查看器(单张 / 左右滑,参照观影详情浮层的 viewer 精简) */ @Builder viewer() { Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor('#E6000000') .onClick((): void => { this.viewerOpen = false; }) Stack({ alignContent: Alignment.Center }) { Image(fbImageSrc(this.imageKeys()[this.viewerIndex], FB_TOKEN_STORE.get())) .width('100%') .height('76%') .objectFit(ImageFit.Contain) } .width('100%') .height('76%') Row({ space: 18 }) { Text('‹ 上一张') .fontSize(FbMetric.fontSmall) .fontColor('#FFFFFFCC') .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .onClick((): void => { this.viewerStep(-1); }) Text((this.viewerIndex + 1).toString() + ' / ' + this.imageKeys().length.toString()) .fontSize(FbMetric.fontTiny) .fontColor('#FFFFFF99') Text('下一张 ›') .fontSize(FbMetric.fontSmall) .fontColor('#FFFFFFCC') .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .onClick((): void => { this.viewerStep(1); }) } .width('100%') .justifyContent(FlexAlign.Center) .align(Alignment.Bottom) } .width('100%') .height('100%') } }