diff --git a/entry/src/main/ets/components/FbResourceSheet.ets b/entry/src/main/ets/components/FbResourceSheet.ets index 9e6c022..9f54fe1 100644 --- a/entry/src/main/ets/components/FbResourceSheet.ets +++ b/entry/src/main/ets/components/FbResourceSheet.ets @@ -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 { + 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 = JSON.parse(res.data) as Record; + 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 { + 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 { + 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 { + 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 = JSON.parse(res.data) as Record; + 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 { + 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 { + 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 { + 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 { if (this.saving) { return; diff --git a/entry/src/main/ets/data/FbResourceRepo.ets b/entry/src/main/ets/data/FbResourceRepo.ets index ab58c8b..39feeda 100644 --- a/entry/src/main/ets/data/FbResourceRepo.ets +++ b/entry/src/main/ets/data/FbResourceRepo.ets @@ -2,22 +2,24 @@ * 家书 FamilyBook · 资源的**写操作**(鸿蒙端) * * 对应服务端 `app/ApiHandler.php` 的 `resources()`: - * POST /api/v1/resources 新建(title / content) - * PATCH /api/v1/resources/ 改(title / content / category) - * DELETE /api/v1/resources/ 删 + * POST /api/v1/resources 新建(title / content) + * PATCH /api/v1/resources/ 改(title / content / category) + * DELETE /api/v1/resources/ 删 + * GET /api/v1/resources//attach 附件列表(工单 #25) + * POST /api/v1/resources//attach 传附件(multipart,字段 `file`) + * DELETE /api/v1/resources//attach 删附件(attachment_id) + * GET /api/v1/resources//shares 共享给了谁 + * POST /api/v1/resources//shares 共享给某人(person_id / perm) + * DELETE /api/v1/resources//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 { + 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 { + const q: Record = {}; + 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 { + 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 { + const body: Record = {}; + 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 { + const q: Record = {}; + q['person_id'] = personId.toString(); + const opts: FbApiOptions = { method: 'DELETE', token: token, query: q }; + return await fbApi('/api/v1/resources/' + id.toString() + '/shares', opts); +}