Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbResourceDetailSheet.ets
T
laoyang fd3ea53086 资源:点列表进只读详情,附件可预览/下载,编辑走底部按钮分离 laoyang/FamilyBook#29
用户原话(2026-10-28):「资源模块,点击资源后是编辑,那怎么下载资源
里面的文件呢?应该和名册一样,点击后是详情,属于谁、共享给谁、附件,
可以打开预览然后下载。下面有按钮可以编辑,点编辑之后才是编辑功能。」

原来 FbResourceSheet 是「查看=编辑」合体:点列表直接进可编辑框,附件区
点不动、没有预览/下载。这次拆成两级:

1) 新增 FbResourceDetailSheet(只读详情):标题/正文纯文本、归属人、
   共享列表、附件列表(图片点开全屏预览、非图片点开交给系统「打开方式」、
   每条附件可「下载」)。底部「编辑」按钮(仅 owner/write)→ 切编辑浮层。
2) FbResourcesPage 编排:点列表 → 详情;详情「编辑」→ 原 FbResourceSheet;
   编辑保存/删除回列表。 + 新建仍直接进编辑浮层。
3) 新增 common/FbAttachOpen.ets:附件「下载明文 / 打开发 / 存相册 / 另存为」
   的端上工具。走服务端 media.php 的移动端出口(能力早已就绪,纯端上改动)。
   · 存相册:photoAccessHelper.showAssetsCreationDialog(系统弹窗,无需 WRITE 权限)
   · 另存为:picker.DocumentViewPicker.save
   · 系统打开:隐式 Want viewData + 读授权 flag

编译 BUILD SUCCESSFUL;守卫 _fb_arkts_guard.js 1324/0(浮层计数清单已同步
resources 1→3 模板、books 3→4 补 #28 存量、cardN 42→44)。
2026-10-28 23:53:53 +08:00

409 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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<void> {
const res: FbApiResult = await fetchResourceAttach(FB_TOKEN_STORE.get(), this.editingId);
const out: FbResAttachView[] = [];
if (res.ok) {
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
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<void> {
const res: FbApiResult = await fetchResourceShares(FB_TOKEN_STORE.get(), this.editingId);
const out: FbResShareView[] = [];
if (res.ok) {
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
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%')
}
}