From fd3ea5308697d7748ed52b8f726a0bf7932ec8a8 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 28 Oct 2026 23:53:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=EF=BC=9A=E7=82=B9=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=BF=9B=E5=8F=AA=E8=AF=BB=E8=AF=A6=E6=83=85=EF=BC=8C?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E5=8F=AF=E9=A2=84=E8=A7=88/=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=EF=BC=8C=E7=BC=96=E8=BE=91=E8=B5=B0=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=88=86=E7=A6=BB=20laoyang/FamilyBook#29?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话(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)。 --- entry/src/main/ets/common/FbAttachOpen.ets | 265 ++++++++++++ .../ets/components/FbResourceDetailSheet.ets | 409 ++++++++++++++++++ .../ets/pages/resources/FbResourcesPage.ets | 122 +++++- 3 files changed, 789 insertions(+), 7 deletions(-) create mode 100644 entry/src/main/ets/common/FbAttachOpen.ets create mode 100644 entry/src/main/ets/components/FbResourceDetailSheet.ets diff --git a/entry/src/main/ets/common/FbAttachOpen.ets b/entry/src/main/ets/common/FbAttachOpen.ets new file mode 100644 index 0000000..83292f3 --- /dev/null +++ b/entry/src/main/ets/common/FbAttachOpen.ets @@ -0,0 +1,265 @@ +/** + * 家书 FamilyBook · 附件的「打开 / 保存」(鸿蒙端) + * + * 对应服务端 `media.php` 那套附件出口 —— 服务端早已支持移动端用 + * `?k=&token=` 取附件(见 `FbHttp.fbImageUrl` 与 media.php 的注释)。 + * 所以本文件只做**端上**该做的三件事: + * + * · 下载:把附件明文拉到 `cacheDir` 下一个临时文件(用完即弃,系统会清); + * · 打开:非图片 → 系统「打开方式」(第三方阅读器);图片 → 由调用方全屏预览; + * · 保存:图片 → 存相册(系统弹窗);非图片 → 「另存为」(系统保存选择器)。 + * + * ⚠️ 为什么不复用 `FbAttachQueue`(它的 `fetchOne` 是**加密落盘** + 明文临时文件 + * 那套离线缓存链路)?本文件的诉求是「临时拿一份明文来做一次性动作」, + * 不落加密缓存、不计入同步队列,语义更轻,也避免与离线同步的目录/状态打架。 + * + * 🔴 上下文(ctx)从哪来:这些动作要 `UIAbilityContext`(相册弹窗 / startAbility / + * DocumentViewPicker 都在这个上下文上)。调用方(资源详情浮层)由宿主页 + * `getUIContext().getHostContext()` 取得后传入;拿不到就返回明确文案, + * **不静默假装成功**(用户点了没反应比报个错更糟)。 + */ + +import { http } from '@kit.NetworkKit'; +import { fileIo, fileUri, picker } from '@kit.CoreFileKit'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { common, wantConstant, Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { fbImageUrl } from '../data/FbHttp'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbAttachOpen'; + +/** 附件相对键 → 末尾扩展名(小写,如 `pdf` / `jpg`;没有返回 '') */ +export function fbAttachExt(key: string): string { + if (key === '') { + return ''; + } + const slash: number = key.lastIndexOf('/'); + const dot: number = key.lastIndexOf('.'); + if (dot <= slash) { + return ''; + } + const e: string = key.substring(dot + 1).toLowerCase(); + // 扩展名过长多半不是真后缀(key 里带了别的段),保守点不算 + return (e.length > 0 && e.length <= 6) ? e : ''; +} + +/** 原始文件名 → 去掉扩展名的标题(存相册 / 另存为的默认名用) */ +function baseTitle(name: string, key: string): string { + let t: string = name.trim(); + if (t === '') { + return '家书附件'; + } + const dot: number = t.lastIndexOf('.'); + if (dot > 0) { + t = t.substring(0, dot); + } + // 存相册标题对非法字符敏感(\ / : * ? " ' ` < > | { } [ ]),统一清洗 + const cleaned: string = t.replace(/[\\/:*?"'`<>|{}\[\]]/g, '_').trim(); + return cleaned !== '' ? cleaned : '家书附件'; +} + +/** + * 下载附件明文到一个 `cacheDir` 临时文件。 + * + * @returns 沙盒绝对路径;失败返回 ''。 + */ +export async function fbAttachFetchPlain(token: string, key: string, + ctx: common.Context): Promise { + if (key === '' || token === '') { + return ''; + } + const url: string = fbImageUrl(key, token); + if (url === '') { + return ''; + } + const req: http.HttpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await req.request(url, { + method: http.RequestMethod.GET, + connectTimeout: 15000, + readTimeout: 30000, + expectDataType: http.HttpDataType.ARRAY_BUFFER + }); + if (resp.responseCode !== 200) { + return ''; + } + const raw: ArrayBuffer | string = resp.result as ArrayBuffer | string; + if (typeof raw === 'string') { + return ''; + } + const buf: Uint8Array = new Uint8Array(raw); + if (buf.length === 0) { + return ''; + } + const ext: string = fbAttachExt(key); + const path: string = ctx.cacheDir + '/fb_attach_' + Date.now().toString() + + '_' + Math.floor(Math.random() * 100000).toString() + + (ext !== '' ? '.' + ext : ''); + const f: fileIo.File = fileIo.openSync(path, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC); + try { + fileIo.writeSync(f.fd, buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); + } finally { + fileIo.closeSync(f); + } + return path; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '附件 %{public}s 下载失败:%{public}s', key, JSON.stringify(e)); + return ''; + } finally { + req.destroy(); + } +} + +/** + * 保存附件到用户的位置。 + * + * · 图片 → 相册(`showAssetsCreationDialog`,系统弹窗授权,无需申请 WRITE 权限); + * · 非图片 → 「另存为」(`DocumentViewPicker.save`)。 + * + * @returns '' 表示成功;否则是一句给人看的错误/取消文案。 + */ +export async function fbAttachSave(token: string, key: string, name: string, + isImage: boolean, ctx: common.Context): Promise { + if (key === '' || token === '') { + return '这条附件拿不到,没法保存。'; + } + let plain: string = ''; + try { + plain = await fbAttachFetchPlain(token, key, ctx); + if (plain === '') { + return '附件没下载下来,请稍后再试。'; + } + if (isImage) { + return await saveToAlbum(ctx, plain, name, key); + } + return await saveFileAs(plain, name, key); + } finally { + // 临时明文用完即删(保存动作已经把字节写去了目标位置) + tryDelete(plain); + } +} + +/** + * 打开附件: + * + * · 图片 → 返回特殊标记 `#image`(由调用方走应用内全屏查看器,本工具不处理); + * · 非图片 → 下载明文 → 系统「打开方式」(第三方 PDF / Office 阅读器)。 + * + * @returns '' 表示成功;`#image` 表示"这是图片,请在应用内预览"; + * 其它字符串是一句给人看的错误文案。 + */ +export async function fbAttachOpen(token: string, key: string, isImage: boolean, + ctx: common.Context): Promise { + if (isImage) { + return '#image'; + } + if (key === '' || token === '') { + return '这条附件拿不到,没法打开。'; + } + const plain: string = await fbAttachFetchPlain(token, key, ctx); + if (plain === '') { + return '附件没下载下来,请稍后再试。'; + } + // ⚠️ 打开后第三方可能还在读这个文件,不能立刻删 —— 留在 cacheDir, + // 由系统按缓存策略回收(这也是为什么这里**不**走 finally 删)。 + return await openWithSystem(ctx, plain); +} + +// ==================== 内部 ==================== + +/** 存相册(系统弹窗,用户确认才写) */ +async function saveToAlbum(ctx: common.Context, plainPath: string, name: string, + key: string): Promise { + try { + const ph: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(ctx); + const srcUri: string = fileUri.getUriFromPath(plainPath); + const ext: string = fbAttachExt(key); + const cfg: photoAccessHelper.PhotoCreationConfig = { + title: baseTitle(name, key), + fileNameExtension: ext !== '' ? ext : 'jpg', + photoType: photoAccessHelper.PhotoType.IMAGE + }; + const uris: Array = await ph.showAssetsCreationDialog([srcUri], [cfg]); + return uris.length > 0 ? '' : '已取消保存。'; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '存相册失败:%{public}s', JSON.stringify(e)); + return '存相册失败,请再试一次。'; + } +} + +/** 另存为(系统保存选择器) */ +async function saveFileAs(plainPath: string, name: string, key: string): Promise { + try { + const docPicker: picker.DocumentViewPicker = new picker.DocumentViewPicker(); + const opts: picker.DocumentSaveOptions = new picker.DocumentSaveOptions(); + const ext: string = fbAttachExt(key); + const fn: string = name.trim() !== '' ? name.trim() : ('附件' + (ext !== '' ? '.' + ext : '')); + opts.newFileNames = [fn]; + const uris: Array = await docPicker.save(opts); + if (uris.length === 0) { + return '已取消保存。'; + } + // 把明文字节写进用户选的目标 uri(copyFile 的 dest 支持 uri,但用 open+write 更不挑环境) + await copyBytes(plainPath, uris[0]); + return ''; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '另存为失败:%{public}s', JSON.stringify(e)); + return '另存为失败,请再试一次。'; + } +} + +/** 把沙盒文件字节复制到目标 uri(目标可能是个已预创建的空文件) */ +async function copyBytes(srcPath: string, destUri: string): Promise { + const src: fileIo.File = fileIo.openSync(srcPath, fileIo.OpenMode.READ_ONLY); + const size: number = fileIo.statSync(src.fd).size; + const buf: ArrayBuffer = new ArrayBuffer(size); + try { + fileIo.readSync(src.fd, buf); + } finally { + fileIo.closeSync(src); + } + const dst: fileIo.File = await fileIo.open(destUri, + fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC); + try { + fileIo.writeSync(dst.fd, buf); + } finally { + fileIo.closeSync(dst); + } +} + +/** 非图片 → 系统「打开方式」(隐式 Want `viewData` + 读授权 flag) */ +async function openWithSystem(ctx: common.Context, plainPath: string): Promise { + const ability: common.UIAbilityContext = ctx as common.UIAbilityContext; + try { + const uri: string = fileUri.getUriFromPath(plainPath); + const want: Want = { + action: 'ohos.want.action.viewData', + uri: uri, + flags: wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION + }; + await ability.startAbility(want); + return ''; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '系统打开失败:%{public}s', JSON.stringify(e)); + return '本机没有能打开这种文件的应用。'; + } +} + +/** 尽力删一个临时文件(删不掉就算了,cacheDir 由系统回收) */ +function tryDelete(path: string): void { + if (path === '') { + return; + } + try { + fileIo.unlinkSync(path); + } catch (err) { + // 忽略 + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbResourceDetailSheet.ets b/entry/src/main/ets/components/FbResourceDetailSheet.ets new file mode 100644 index 0000000..e2c244c --- /dev/null +++ b/entry/src/main/ets/components/FbResourceDetailSheet.ets @@ -0,0 +1,409 @@ +/** + * 家书 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%') + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/resources/FbResourcesPage.ets b/entry/src/main/ets/pages/resources/FbResourcesPage.ets index 2bbebad..6b52c3c 100644 --- a/entry/src/main/ets/pages/resources/FbResourcesPage.ets +++ b/entry/src/main/ets/pages/resources/FbResourcesPage.ets @@ -20,6 +20,11 @@ import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FbResourceSheet } from '../../components/FbResourceSheet'; +import { FbResourceDetailSheet } from '../../components/FbResourceDetailSheet'; +import { fbAttachOpen, fbAttachSave } from '../../common/FbAttachOpen'; +import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; +import { common } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus'; @@ -64,6 +69,15 @@ export struct FbResourcesPage { @Local sheetOwner: string = ''; @Local sheetCanWrite: boolean = true; + // ---- 工单 #29:详情 / 编辑分离 ---- + /** 只读详情浮层是否打开 */ + @Local detailOpen: boolean = false; + @Local detailId: number = 0; + @Local detailTitle: string = ''; + @Local detailContent: string = ''; + @Local detailOwner: string = ''; + @Local detailCanWrite: boolean = false; + /** * 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。 * @@ -80,6 +94,10 @@ export struct FbResourcesPage { } private backOnce(): boolean { + if (this.detailOpen) { + this.detailOpen = false; + return true; + } if (this.sheetOpen) { this.sheetOpen = false; return true; @@ -127,6 +145,12 @@ export struct FbResourcesPage { fbAppState().sheetCount = this.sheetOpen ? 1 : 0; } + /* 🔴 工单 #29:详情浮层开着也要让位(与编辑浮层同规则) */ + @Monitor('detailOpen') + onResourceDetailChanged(): void { + fbAppState().sheetCount = (this.sheetOpen || this.detailOpen) ? 1 : 0; + } + /** 打开「新增资源」 */ private openNew(): void { this.sheetId = 0; @@ -139,16 +163,75 @@ export struct FbResourcesPage { /** 打开某条资源(只读共享进来的锁输入框,但内容照看) */ private openDetail(r: FbResourceItem): void { - this.sheetId = r.id; - this.sheetTitle = r.name; - this.sheetContent = r.content; - this.sheetOwner = r.ownerName; - /* `perm` 是服务端 `Resource::all` 里算好的:owner / write / read。 - ⚠️ 只有 owner 与 write 能改(服务端 `Resource::canWrite` 同一口径)。 */ - this.sheetCanWrite = r.perm === 'owner' || r.perm === 'write'; + /* 🔴 工单 #29:点列表 → **只读详情**(不再直接进可编辑框)。 + 详情里归属人 / 正文 / 共享 / 附件一览无余,下面一个「编辑」按钮, + 点了才进编辑浮层(`openEditFromDetail`)。 */ + this.detailId = r.id; + this.detailTitle = r.name; + this.detailContent = r.content; + this.detailOwner = r.ownerName; + this.detailCanWrite = r.perm === 'owner' || r.perm === 'write'; + this.detailOpen = true; + } + + /** 从详情点「编辑」→ 关详情、开编辑浮层 */ + private openEditFromDetail(): void { + this.sheetId = this.detailId; + this.sheetTitle = this.detailTitle; + this.sheetContent = this.detailContent; + this.sheetOwner = this.detailOwner; + this.sheetCanWrite = this.detailCanWrite; + this.detailOpen = false; this.sheetOpen = true; } + /** 拿稳妥的 UIAbilityContext(打开/保存附件要它;拿不到返回 undefined) */ + private abilityCtx(): common.UIAbilityContext | undefined { + try { + return this.getUIContext().getHostContext() as common.UIAbilityContext | undefined; + } catch (err) { + hilog.warn(0x0000, 'FamilyBook', '取 Ability Context 失败:%{public}s', JSON.stringify(err)); + return undefined; + } + } + + /** 打开一个非图片附件(调系统打开方式) */ + private async openFile(key: string, name: string): Promise { + void name; + const ctx: common.UIAbilityContext | undefined = this.abilityCtx(); + if (ctx === undefined) { + this.toast('打开的上下文不可用,请重进本页再试。'); + return; + } + const err: string = await fbAttachOpen(FB_TOKEN_STORE.get(), key, false, ctx); + if (err !== '') { + this.toast(err); + } + } + + /** 下载 / 保存一个附件(图片存相册、非图片另存为) */ + private async saveAttach(key: string, name: string, isImage: boolean): Promise { + const ctx: common.UIAbilityContext | undefined = this.abilityCtx(); + if (ctx === undefined) { + this.toast('保存的上下文不可用,请重进本页再试。'); + return; + } + const err: string = await fbAttachSave(FB_TOKEN_STORE.get(), key, name, isImage, ctx); + if (err !== '') { + this.toast(err); + } else { + this.toast('已保存。'); + } + } + + private toast(msg: string): void { + try { + this.getUIContext().getPromptAction().showToast({ message: msg, duration: 2600 }); + } catch (err) { + // 忽略 + } + } + build() { // 🔴 外层 Stack:**模块设置浮层**要浮在页面之上 // (本页原来是 Scroll 直接作根,没有 Stack,这里补一层) @@ -309,6 +392,31 @@ export struct FbResourcesPage { }) .transition(fbSheetTransition()) } + + // ---- 资源只读详情浮层(工单 #29:点列表进详情,编辑走底部按钮)---- + if (this.detailOpen) { + FbResourceDetailSheet({ + accent: this.accent, + editingId: this.detailId, + editingTitle: this.detailTitle, + editingContent: this.detailContent, + ownerName: this.detailOwner, + canWrite: this.detailCanWrite, + onClose: (): void => { + this.detailOpen = false; + }, + onEdit: (): void => { + this.openEditFromDetail(); + }, + onOpenFile: (key: string, name: string): void => { + this.openFile(key, name); + }, + onSaveAttach: (key: string, name: string, isImage: boolean): void => { + this.saveAttach(key, name, isImage); + } + }) + .transition(fbSheetTransition()) + } } .width('100%') .height('100%')