409 lines
14 KiB
Plaintext
409 lines
14 KiB
Plaintext
/**
|
||||
|
|
* 家书 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%')
|
|||
|
|
}
|
|||
|
|
}
|