资源:点列表进只读详情,附件可预览/下载,编辑走底部按钮分离 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)。
This commit is contained in:
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* 家书 FamilyBook · 附件的「打开 / 保存」(鸿蒙端)
|
||||||
|
*
|
||||||
|
* 对应服务端 `media.php` 那套附件出口 —— 服务端早已支持移动端用
|
||||||
|
* `?k=<key>&token=<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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> = 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<string> {
|
||||||
|
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<string> = 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<void> {
|
||||||
|
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<string> {
|
||||||
|
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) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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%')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
|
|||||||
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
|
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
|
||||||
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
|
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
|
||||||
import { FbResourceSheet } from '../../components/FbResourceSheet';
|
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_BACK } from '../../common/FbBack';
|
||||||
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
|
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
|
||||||
|
|
||||||
@@ -64,6 +69,15 @@ export struct FbResourcesPage {
|
|||||||
@Local sheetOwner: string = '';
|
@Local sheetOwner: string = '';
|
||||||
@Local sheetCanWrite: boolean = true;
|
@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 的说明)。
|
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
|
||||||
*
|
*
|
||||||
@@ -80,6 +94,10 @@ export struct FbResourcesPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private backOnce(): boolean {
|
private backOnce(): boolean {
|
||||||
|
if (this.detailOpen) {
|
||||||
|
this.detailOpen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (this.sheetOpen) {
|
if (this.sheetOpen) {
|
||||||
this.sheetOpen = false;
|
this.sheetOpen = false;
|
||||||
return true;
|
return true;
|
||||||
@@ -127,6 +145,12 @@ export struct FbResourcesPage {
|
|||||||
fbAppState().sheetCount = this.sheetOpen ? 1 : 0;
|
fbAppState().sheetCount = this.sheetOpen ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 🔴 工单 #29:详情浮层开着也要让位(与编辑浮层同规则) */
|
||||||
|
@Monitor('detailOpen')
|
||||||
|
onResourceDetailChanged(): void {
|
||||||
|
fbAppState().sheetCount = (this.sheetOpen || this.detailOpen) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开「新增资源」 */
|
/** 打开「新增资源」 */
|
||||||
private openNew(): void {
|
private openNew(): void {
|
||||||
this.sheetId = 0;
|
this.sheetId = 0;
|
||||||
@@ -139,16 +163,75 @@ export struct FbResourcesPage {
|
|||||||
|
|
||||||
/** 打开某条资源(只读共享进来的锁输入框,但内容照看) */
|
/** 打开某条资源(只读共享进来的锁输入框,但内容照看) */
|
||||||
private openDetail(r: FbResourceItem): void {
|
private openDetail(r: FbResourceItem): void {
|
||||||
this.sheetId = r.id;
|
/* 🔴 工单 #29:点列表 → **只读详情**(不再直接进可编辑框)。
|
||||||
this.sheetTitle = r.name;
|
详情里归属人 / 正文 / 共享 / 附件一览无余,下面一个「编辑」按钮,
|
||||||
this.sheetContent = r.content;
|
点了才进编辑浮层(`openEditFromDetail`)。 */
|
||||||
this.sheetOwner = r.ownerName;
|
this.detailId = r.id;
|
||||||
/* `perm` 是服务端 `Resource::all` 里算好的:owner / write / read。
|
this.detailTitle = r.name;
|
||||||
⚠️ 只有 owner 与 write 能改(服务端 `Resource::canWrite` 同一口径)。 */
|
this.detailContent = r.content;
|
||||||
this.sheetCanWrite = r.perm === 'owner' || r.perm === 'write';
|
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;
|
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> {
|
||||||
|
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<void> {
|
||||||
|
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() {
|
build() {
|
||||||
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
|
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
|
||||||
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
|
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
|
||||||
@@ -309,6 +392,31 @@ export struct FbResourcesPage {
|
|||||||
})
|
})
|
||||||
.transition(fbSheetTransition())
|
.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%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
|
|||||||
Reference in New Issue
Block a user