# 文档库多选批量设置分享 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 让 Web 文档库 `/#/doclib/enter` 支持多选文档后一次设置分享人员(整份覆盖、交集预选、整批事务)。 **Architecture:** 后端新增 `POST /web/doclib/files/batch-shares`,从现有 `set_shares` 抽出无 commit 的 `_apply_shares`,批量一次 commit;前端放宽 `actionsFor.share`、扩展 `ShareDialog` 为多文档并用 `shareUserIntersection` 预选,统一调用批量 API。 **Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / TypeScript / Element Plus;`node:test` + `tsx`(web-admin)。 **Spec:** `docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md`(工单 #25) --- ## File map | File | Responsibility | | --- | --- | | `backend/app/schemas/doclib.py` | 新增 `BatchShareBody` | | `backend/app/services/doclib_service.py` | `_apply_shares`、`set_shares` 复用、`set_shares_batch` | | `backend/app/api/doclib.py` | 注册 `POST .../batch-shares` | | `web-admin/src/api/doclib.ts` | `saveDoclibBatchShares` | | `web-admin/src/views/doclib/enter/model.ts` | `actionsFor.share` 多选 + `shareUserIntersection` | | `web-admin/src/views/doclib/enter/model.test.ts` | 前端纯函数回归 | | `web-admin/src/views/doclib/enter/api.test.ts` | 批量 API 适配器断言 | | `web-admin/src/views/doclib/enter/ShareDialog.vue` | 多文档摘要、交集预选、批量保存 | | `web-admin/src/views/doclib/enter/index.vue` | 工具栏传入全部 `selected` | 说明:仓库内暂无 backend doclib pytest;本计划以服务层行为对照现有 `set_shares`/`delete_files` 实现,并用前端单测锁定契约。若执行时已有可用测试 DB,可追加服务层用例,但不阻塞前端交付。 --- ### Task 1: 前端纯函数 — 多选可分享 + 分享人交集 **Files:** - Modify: `web-admin/src/views/doclib/enter/model.ts` - Modify: `web-admin/src/views/doclib/enter/model.test.ts` - [ ] **Step 1: 写失败测试(更新 actionsFor + 新增交集)** 在 `model.test.ts` 将首个测试改为多选可分享语义,并新增交集测试: ```ts test('可管理文档可多选分享,混合选择不能分享', () => { assert.deepEqual(actionsFor([{ canManage: false }]), { share: false, download: true, delete: false }) assert.deepEqual(actionsFor([{ canManage: true }, { canManage: false }]), { share: false, download: true, delete: false }) assert.deepEqual(actionsFor([{ canManage: true }]), { share: true, download: true, delete: true }) assert.deepEqual(actionsFor([{ canManage: true }, { canManage: true }]), { share: true, download: true, delete: true }) assert.deepEqual(actionsFor([]), { share: false, download: false, delete: false }) }) test('分享人初始勾选取所选文档交集', async () => { const { shareUserIntersection } = await import('./model') const docs = [ { shares: [{ id: 'a' }, { id: 'b' }] }, { shares: [{ id: 'b' }, { id: 'c' }] } ] assert.deepEqual(shareUserIntersection(docs), ['b']) assert.deepEqual(shareUserIntersection([{ shares: [{ id: 'a' }] }, { shares: [] }]), []) assert.deepEqual(shareUserIntersection([]), []) }) ``` - [ ] **Step 2: 跑测试确认失败** Run(在 `web-admin/`,使用本仓 `node_modules`): ```bash ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts ``` Expected: FAIL(`share` 仍要求 `length === 1`,且无 `shareUserIntersection`) - [ ] **Step 3: 最小实现** `model.ts`: ```ts export function actionsFor(rows: Array<{ canManage: boolean }>) { return { share: rows.length > 0 && rows.every((row) => row.canManage), download: rows.length > 0, delete: rows.length > 0 && rows.every((row) => row.canManage) } } export function shareUserIntersection( documents: Array<{ shares?: Array<{ id: string }> | null }> ): string[] { if (!documents.length) return [] let ids = new Set( (documents[0].shares || []).map((person) => String(person.id || '').trim()).filter(Boolean) ) for (const doc of documents.slice(1)) { const next = new Set( (doc.shares || []).map((person) => String(person.id || '').trim()).filter(Boolean) ) ids = new Set([...ids].filter((id) => next.has(id))) } return [...ids] } ``` - [ ] **Step 4: 跑测试确认通过** ```bash ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add web-admin/src/views/doclib/enter/model.ts web-admin/src/views/doclib/enter/model.test.ts git commit -m "feat(doclib): 允许多选可管理文档分享并计算分享人交集。" ``` --- ### Task 2: 前端 API — batch-shares 适配器 **Files:** - Modify: `web-admin/src/api/doclib.ts` - Modify: `web-admin/src/views/doclib/enter/api.test.ts` - [ ] **Step 1: 写失败测试** 在 `api.test.ts` 追加: ```ts test('批量分享提交文档 id 列表与人员 id', async () => { const { api, calls } = apiAdapter() await api.saveDoclibBatchShares(['id/a', 'id/b'], ['u1', 'u2']) assert.equal(calls[0].method, 'post') assert.equal(calls[0].config.url, '/web/doclib/files/batch-shares') assert.deepEqual(calls[0].config.data, { ids: ['id/a', 'id/b'], userIds: ['u1', 'u2'] }) }) ``` - [ ] **Step 2: 跑测试确认失败** ```bash ./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts ``` Expected: FAIL(无 `saveDoclibBatchShares`) - [ ] **Step 3: 最小实现** 在 `doclib.ts` 的 `saveDoclibShares` 旁增加: ```ts export const saveDoclibBatchShares = (ids: string[], userIds: string[]) => request.post({ url: `${base}/files/batch-shares`, data: { ids, userIds } }) ``` - [ ] **Step 4: 跑测试确认通过** ```bash ./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add web-admin/src/api/doclib.ts web-admin/src/views/doclib/enter/api.test.ts git commit -m "feat(doclib): 增加批量设置分享 API 客户端。" ``` --- ### Task 3: 后端 — BatchShareBody + set_shares_batch **Files:** - Modify: `backend/app/schemas/doclib.py` - Modify: `backend/app/services/doclib_service.py` - Modify: `backend/app/api/doclib.py` - [ ] **Step 1: 增加 schema** 在 `ShareBody` 后增加: ```python class BatchShareBody(_Body): ids: List[str] = Field(min_length=1, max_length=100) userIds: List[str] = Field(max_length=500) ``` - [ ] **Step 2: 重构服务层** 将 `set_shares` 拆为 `_apply_shares`(不 commit)与包装方法;新增 `set_shares_batch`: ```python def _apply_shares(self, file_id, user_ids): row = self._file(file_id, manage=True, lock=True) requested = set(user_ids) - {row.create_by} if requested: valid = {u.id for u in self._base(SysUser).filter( SysUser.id.in_(requested), SysUser.status == '1').all()} if requested != valid: raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户') else: valid = set() history = {s.user_id: s for s in self.db.query(DoclibShare).filter( DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id ).populate_existing().with_for_update().all()} for uid, share in history.items(): share.del_flag = 0 if uid in requested else 1 share.update_by = self.uid for uid in requested - set(history): self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public())) row.update_by, row.update_time = self.uid, datetime.now() return row def set_shares(self, file_id, user_ids): row = self._apply_shares(file_id, user_ids) self.db.commit() return self._serialize_files([row])[0] def set_shares_batch(self, ids, user_ids): file_ids = sorted({str(i).strip() for i in ids if str(i).strip()}) if not file_ids: raise HTTPException(400, '请选择要设置分享的文档') rows = [self._apply_shares(fid, user_ids) for fid in file_ids] self.db.commit() return self._serialize_files(rows) ``` 注意:空 `requested` 时跳过用户表查询(避免 `IN ()`),与「清空分享」语义一致。原 `set_shares` 在 `requested` 为空时 `SysUser.id.in_(set())` 行为依赖方言,重构时一并修掉。 - [ ] **Step 3: 注册路由** `doclib.py` 增加 import `BatchShareBody`,并在 `batch-delete` 旁: ```python @router.post('/web/doclib/files/batch-shares') def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)): return success_response(svc.set_shares_batch(body.ids, body.userIds)) ``` - [ ] **Step 4: 静态核对** 确认: - 单文档 `PUT .../shares` 仍指向 `set_shares` - 批量路径仅一次 `commit` - `_apply_shares` 任一步抛错时不调用 `commit` 可选:在已激活 venv 下对改动文件做语法检查 ```bash cd backend && source venv/bin/activate && python -c "from app.schemas.doclib import BatchShareBody; from app.services.doclib_service import DoclibService; print('ok')" ``` - [ ] **Step 5: Commit** ```bash git add backend/app/schemas/doclib.py backend/app/services/doclib_service.py backend/app/api/doclib.py git commit -m "feat(doclib): 增加批量设置分享接口并复用单文档写入逻辑。" ``` --- ### Task 4: ShareDialog + 列表页接入 **Files:** - Modify: `web-admin/src/views/doclib/enter/ShareDialog.vue` - Modify: `web-admin/src/views/doclib/enter/index.vue` - [ ] **Step 1: 改 ShareDialog props 与保存** - Props:`documents: DoclibDocument[]`(由页面传入;可去掉单 `document` 或保留 computed 取 `documents[0]` 兼容内部逻辑)。 - 打开时:`userIds = shareUserIntersection(documents)`;摘要展示名称列表(超过 5 个截断并显示「等 N 个」)。 - `options` 排除所有 `documents` 的 `ownerId`。 - `save`:若无文档或存在 `!canManage` 则 return;调用 `saveDoclibBatchShares(ids, userIds)`;`emit('saved', updated)`(`updated` 为数组时由父组件刷新)。 示意: ```ts const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>() // watch open: userIds.value = shareUserIntersection(props.documents) // save: const ids = props.documents.map((d) => d.id) const updated = await saveDoclibBatchShares(ids, userIds.value) emit('saved', updated) ``` - [ ] **Step 2: 改 index.vue** - `shareDocuments` 替代 `shareDocument`(`ref`)。 - `openShare(rows: DoclibDocument | DoclibDocument[])` 规范化为数组。 - 工具栏:`@click="openShare(selected)"`。 - 详情:`@click="openShare(detail)"`。 - `` - `shareSaved`:若返回数组则按 id 更新详情;始终 `refresh()`。 - [ ] **Step 3: 跑前端相关测试** ```bash cd web-admin ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts ``` Expected: PASS - [ ] **Step 4: Commit** ```bash git add web-admin/src/views/doclib/enter/ShareDialog.vue web-admin/src/views/doclib/enter/index.vue git commit -m "feat(doclib): 文档库支持多选文档后批量设置分享。" ``` --- ### Task 5: 回归核对与交付准备 - [ ] **Step 1: 再跑前端测试套件** ```bash cd web-admin && ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts ``` - [ ] **Step 2: 对照验收清单(手工或浏览器)** 1. 多选可管理 → 可开分享;混入不可管理 → 按钮禁用 2. 初始勾选为交集 3. 保存后各文档分享人一致 4. 详情单文档分享仍可用 - [ ] **Step 3: 更新工单 #25 评论**:分支、提交、测试命令与结果、未跑项(真机) - [ ] **Step 4: 按用户授权 push 任务分支**(`push_task_branch` 已开;`create_pr` 未开则不建 PR) --- ## Spec coverage self-check | Spec 要求 | Task | | --- | --- | | `actionsFor` 多选可管理可分享 | Task 1 | | 交集预选 | Task 1 + 4 | | `batch-shares` API + 事务 | Task 3 | | `_apply_shares` 复用单路径 | Task 3 | | 前端批量客户端 | Task 2 | | ShareDialog / 工具栏 | Task 4 | | 单测 | Task 1–2、5 | | 整份覆盖 / 空列表收回 | Task 3–4(沿用 `_apply_shares`) | | 非目标:小程序 / 只追加 / 部分成功 | 无任务(刻意不做) | ## Placeholder scan 无 TBD /「类似 Task N」/ 空测试步骤。