资源附件+共享:浮层补「附件」「共享」两个真功能 laoyang/FamilyBook#25

用户原话(2026-10-28):「鸿蒙端对齐网页端」。资源浮层原来「附件与共享
还在网页端,App 暂未开放」,现在服务端补齐 API 后:

1) FbResourceRepo 补 attach/share 写操作(上传走 fbUploadOne 字段 file;
   删除走 query 传 attachment_id / person_id —— DELETE 带 JSON body 服务端读不到)
2) FbResourceSheet:附件区块(列表 + 缩略图/图标 + 「+传附件」+ 逐条删)+
   共享区块(列表 + 「共享给…」复用选人组件 + 逐条取消)
   · 只读共享来的不摆入口(与 canWrite 对齐)

编译 BUILD SUCCESSFUL;服务端冒烟 _i25_resource_api.py 21/0 + 反证命中。
This commit is contained in:
2026-10-28 22:00:15 +08:00
parent 6f360fd6a1
commit f93dd92cdb
2 changed files with 372 additions and 23 deletions
+319 -10
View File
@@ -9,18 +9,54 @@
* ⇒ 端上原来既没有新建、也没有详情:`FbPrimaryButton` 无 `onTap`、
* 列表项无 `onClick`。这个浮层把两件事一次补齐。
*
* ⚠️⚠️ 服务端只有 title / content(详见 `data/FbResourceRepo` 文件头):
* 归属人固定是当前登录的人、附件与共享**没有 API**。
* ⇒ 这里**故意不摆**「选归属人」「传附件」「共享给谁」这些入口 ——
* 摆了也存不进去,用户会以为存上了。
* 🔴 工单 #25(2026-10-28):附件与共享原来「故意不摆假入口」(服务端没 API)。
* 服务端补齐 `attach` / `shares` 子端点后,这里补上两个真功能:
* · 附件:列表 + 「+ 传附件」+ 逐条删除
* · 共享:列表 + 「共享给…」+ 逐条取消(复用选人组件)
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbPersonPickSheet } from './FbPersonPickSheet';
import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createResource, updateResource, deleteResource } from '../data/FbResourceRepo';
import {
createResource, updateResource, deleteResource,
fetchResourceAttach, delResourceAttach,
fetchResourceShares, shareResource, unshareResource,
} from '../data/FbResourceRepo';
import { fbUploadOne } from '../data/FbUpload';
import { fbImageSrc } from '../data/FbAttachQueue';
import { FbApiResult } from '../data/FbHttp';
import { picker } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 一条附件(服务端 `Attachment::list`) */
interface FbResourceAttach {
id: number;
name: string;
key: string;
isImage: boolean;
}
/** 一条共享(服务端 `Resource::shares()`) */
interface FbResourceShareRow {
personId: number;
name: string;
perm: string;
}
/** 附件原始行 */
interface RawAttach {
id?: number; key?: string | null; name?: string | null; is_image?: number;
}
/** 共享原始行 */
interface RawShareRow {
person_id?: number; name?: string | null; perm?: string | null;
}
@ComponentV2
export struct FbResourceSheet {
@@ -45,10 +81,22 @@ export struct FbResourceSheet {
/** 删除的二次确认(网页也是先点一次再确认,避免误删证件) */
@Local confirmingDelete: boolean = false;
@Local note: string = '';
// ---- 附件(工单 #25)----
@Local attachs: FbResourceAttach[] = [];
@Local uploading: boolean = false;
// ---- 共享(工单 #25)----
@Local shares: FbResourceShareRow[] = [];
@Local roster: FbRosterPerson[] = [];
@Local picking: boolean = false;
aboutToAppear(): void {
this.title = this.editingTitle;
this.content = this.editingContent;
/* 已有资源才拉附件与共享(新建时还没有 id) */
if (this.editingId > 0) {
this.reloadAttach();
this.reloadShares();
}
}
build() {
@@ -153,13 +201,128 @@ export struct FbResourceSheet {
.maxLines(3)
}
// 附件 / 共享:服务端没有接口,照实说明(不摆假入口)
// ---- 附件(工单 #25)----
if (this.editingId > 0) {
Text('附件与共享还在网页端「资源」里管 —— App 这边暂未开放。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('附件')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
Text(this.attachs.length > 0 ? this.attachs.length.toString() + ' 个' : '还没有附件')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.width('100%')
.maxLines(3)
.alignItems(VerticalAlign.Center)
if (this.attachs.length > 0) {
ForEach(this.attachs, (a: FbResourceAttach) => {
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 })
if (this.canWrite) {
Text('删')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.padding({ left: 6, right: 2, top: 2, bottom: 2 })
.onClick((): void => {
this.dropAttach(a);
})
}
}
.width('100%')
.padding({ top: 4, bottom: 4 })
.alignItems(VerticalAlign.Center)
}, (a: FbResourceAttach): string => 'ratt_' + a.id.toString())
}
if (this.canWrite) {
Text(this.uploading ? '正在上传…' : '+ 传附件')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, 0.08))
.onClick((): void => {
this.pickAttach();
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
// ---- 共享(工单 #25)----
if (this.editingId > 0) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('共享')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(34)
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: FbResourceShareRow) => {
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)
if (this.canWrite) {
Text('取消')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.padding({ left: 6, right: 2, top: 2, bottom: 2 })
.onClick((): void => {
this.dropShare(s);
})
}
}
.width('100%')
.padding({ top: 4, bottom: 4 })
.alignItems(VerticalAlign.Center)
}, (s: FbResourceShareRow): string => 'rsh_' + s.personId.toString())
}
if (this.canWrite) {
Text('共享给…')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, 0.08))
.onClick((): void => {
this.openPicker();
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
@@ -240,6 +403,22 @@ export struct FbResourceSheet {
}
}
.width('92%')
/* 选人浮层:共享给谁(工单 #25,复用名册多选组件) */
if (this.picking) {
FbPersonPickSheet({
people: this.roster,
selected: [],
title: '共享给谁',
accent: this.accent,
onConfirm: (ids: number[]): void => {
this.addShares(ids);
},
onClose: (): void => {
this.picking = false;
}
})
}
}
.width('100%')
.height('100%')
@@ -251,6 +430,136 @@ export struct FbResourceSheet {
return this.canWrite;
}
// ==================== 附件(工单 #25)====================
private async reloadAttach(): Promise<void> {
const res: FbApiResult = await fetchResourceAttach(FB_TOKEN_STORE.get(), this.editingId);
if (!res.ok) {
this.note = res.error !== '' ? res.error : '附件列表没拉到。';
return;
}
const out: FbResourceAttach[] = [];
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
const items: Object | undefined = o['items'];
const rows: RawAttach[] = (items !== undefined && items !== null)
? (items as RawAttach[]) : [];
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 pickAttach(): Promise<void> {
if (this.uploading) {
return;
}
try {
const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions();
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
opts.maxSelectNumber = 1;
const p: picker.PhotoViewPicker = new picker.PhotoViewPicker();
const sel: picker.PhotoSelectResult = await p.select(opts);
if (sel.photoUris.length === 0) {
return;
}
this.uploading = true;
this.note = '';
// 资源附件字段名 `file`,与任务附件同
const err: string = await fbUploadOne('/api/v1/resources/' + this.editingId.toString() + '/attach',
'file', sel.photoUris[0], FB_TOKEN_STORE.get());
this.uploading = false;
if (err !== '') {
this.note = err;
return;
}
await this.reloadAttach();
} catch (e) {
const be: BusinessError = e as BusinessError;
hilog.warn(0x0000, 'FamilyBook', '资源传附件失败:%{public}s', JSON.stringify(be));
this.uploading = false;
this.note = '传附件失败,请再试一次。';
}
}
private async dropAttach(a: FbResourceAttach): Promise<void> {
const res: FbApiResult = await delResourceAttach(FB_TOKEN_STORE.get(), this.editingId, a.id);
if (!res.ok) {
this.note = res.error !== '' ? res.error : '附件没删掉。';
return;
}
await this.reloadAttach();
}
// ==================== 共享(工单 #25)====================
private async reloadShares(): Promise<void> {
const res: FbApiResult = await fetchResourceShares(FB_TOKEN_STORE.get(), this.editingId);
if (!res.ok) {
this.note = res.error !== '' ? res.error : '共享列表没拉到。';
return;
}
const out: FbResourceShareRow[] = [];
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
const items: Object | undefined = o['items'];
const rows: RawShareRow[] = (items !== undefined && items !== null)
? (items as RawShareRow[]) : [];
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;
}
private async openPicker(): Promise<void> {
if (this.roster.length === 0) {
try {
this.roster = await FbPersonRepo.inst().list();
} catch (e) {
this.roster = [];
}
}
this.picking = true;
}
private async addShares(ids: number[]): Promise<void> {
this.picking = false;
if (ids.length === 0) {
return;
}
let bad: string = '';
for (const pid of ids) {
const res: FbApiResult = await shareResource(FB_TOKEN_STORE.get(), this.editingId, pid, 'read');
if (!res.ok && bad === '') {
bad = res.error;
}
}
this.note = bad !== '' ? bad : '';
await this.reloadShares();
}
private async dropShare(s: FbResourceShareRow): Promise<void> {
const res: FbApiResult = await unshareResource(FB_TOKEN_STORE.get(), this.editingId, s.personId);
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没取消成。';
return;
}
await this.reloadShares();
}
private async save(): Promise<void> {
if (this.saving) {
return;
+53 -13
View File
@@ -2,22 +2,24 @@
* 家书 FamilyBook · 资源的**写操作**(鸿蒙端)
*
* 对应服务端 `app/ApiHandler.php` 的 `resources()`:
* POST /api/v1/resources 新建(title / content)
* PATCH /api/v1/resources/<id> 改(title / content / category)
* DELETE /api/v1/resources/<id> 删
* POST /api/v1/resources 新建(title / content)
* PATCH /api/v1/resources/<id> 改(title / content / category)
* DELETE /api/v1/resources/<id> 删
* GET /api/v1/resources/<id>/attach 附件列表(工单 #25)
* POST /api/v1/resources/<id>/attach 传附件(multipart,字段 `file`)
* DELETE /api/v1/resources/<id>/attach 删附件(attachment_id)
* GET /api/v1/resources/<id>/shares 共享给了谁
* POST /api/v1/resources/<id>/shares 共享给某人(person_id / perm)
* DELETE /api/v1/resources/<id>/shares 取消共享(person_id)
*
* 🔴 用户 2026-10-20:「资源也是啊,新增加资源,资源也点不进去。
* 设置也没有任何的东西。」
* ⇒ 端上「添加」按钮**没有 `onTap`**、列表项**没有 `onClick`** —— 两件都没接。
* 设置也没有任何的东西。」⇒ 端上「添加」按钮**没有 onTap**、列表项**没有 onClick**。
*
* ⚠️⚠️ **服务端只认 title / content 两个字段**(其它字段会被静默忽略):
* · `Resource::create()` 的 INSERT 只有 `title` / `content`;
* · `resources` 表**没有 category 列**(见 `app/Schema.php`),
* 接口里那个 `category` 参数是历史遗留,写下去不会落库;
* · 归属人(`owner_id`)由接口固定成**当前登录的人**,没法从端上指定;
* · **附件与共享没有 API**(`Attachment::list/save` 只有网页在用)。
* ⇒ 端上就只做「标题 + 正文」,别在界面上摆出家能选归属人/传附件的假入口 ——
* 用户填了、保存了、却没落库,比没做更糟。
* 🔴 工单 #25(2026-10-28):原来这里只有 title/content(附件与共享没 API,
* 端上「故意不摆假入口」)。现在服务端补齐了 attach + shares 子端点,
* 这里也把三组写操作补上,端上终于能管附件与共享。
*
* ⚠️ 归属人(owner_id)仍由接口固定成当前登录的人,端上不选。
*/
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
@@ -48,3 +50,41 @@ export async function deleteResource(token: string, id: number): Promise<FbApiRe
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/resources/' + id.toString(), opts);
}
/** 附件列表(工单 #25) */
export async function fetchResourceAttach(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { token: token, authProbe: true };
return await fbApi('/api/v1/resources/' + id.toString() + '/attach', opts);
}
/** 删附件(⚠️ 附件 id 走 query,DELET 带 JSON body 服务端读不到 —— 见任务删附件同款注释) */
export async function delResourceAttach(token: string, id: number, aid: number): Promise<FbApiResult> {
const q: Record<string, string> = {};
q['attachment_id'] = aid.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/resources/' + id.toString() + '/attach', opts);
}
/** 共享给了谁(工单 #25) */
export async function fetchResourceShares(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { token: token, authProbe: true };
return await fbApi('/api/v1/resources/' + id.toString() + '/shares', opts);
}
/** 共享给某人(perm = read / write) */
export async function shareResource(token: string, id: number, personId: number,
perm: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['person_id'] = personId;
body['perm'] = perm;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/resources/' + id.toString() + '/shares', opts);
}
/** 取消某人的共享(person_id 走 query,DELETE 带 body 服务端读不到) */
export async function unshareResource(token: string, id: number, personId: number): Promise<FbApiResult> {
const q: Record<string, string> = {};
q['person_id'] = personId.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/resources/' + id.toString() + '/shares', opts);
}