From bf89a1f3016b56356662640186e9ffe643cba729 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 17:36:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=BA=93=E5=A4=9A=E9=80=89=E6=89=B9=E9=87=8F=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E8=AE=BE=E8=AE=A1=EF=BC=88#25=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确整份覆盖、交集预选、batch-shares 事务与 Web 弹窗扩展范围,供实现对照。 Co-authored-by: Cursor --- .../2026-11-12-doclib-batch-share-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md diff --git a/docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md b/docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md new file mode 100644 index 000000000..858e2875a --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md @@ -0,0 +1,115 @@ +# 文档库多选批量设置分享(工单 #25) + +工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/25 + +## 背景与问题 + +Web 文档库管理(`/#/doclib/enter`)工具栏「设置分享」当前仅在恰好选中 1 份且可管理的文档时可用(`actionsFor.share`),弹窗与接口均为单文档。用户需要多选文档后统一设置分享人员。 + +## 已确认决策 + +| 项 | 结论 | +| --- | --- | +| 保存语义 | 整份覆盖:弹窗所选 `userIds` 写入每一份所选文档,替换原分享人;空列表表示全部收回 | +| 弹窗初始勾选 | 所选文档现有分享人的交集;无共同人员则为空 | +| 修改端 | Web 管理端 + 后端批量 API | +| 失败策略 | 整批事务:全部成功或全部回滚 | +| 实现方案 | 新增 `batch-shares` + 扩展现有 `ShareDialog`(方案 A) | + +## 范围 + +**包含** + +- `web-admin`:`doclib/enter` 列表工具栏、`ShareDialog`、`actionsFor`、API 封装与单测 +- `backend`:批量分享路由、schema、服务层事务与单文件写入逻辑复用 + +**不包含** + +- 小程序 / 移动 H5 +- 「只追加不收回」、部分成功返回清单 +- 文件夹级分享 + +## 后端设计 + +### 接口 + +`POST /web/doclib/files/batch-shares` + +请求体(`extra=forbid`): + +```json +{ + "ids": ["file-id-1", "file-id-2"], + "userIds": ["user-id-a", "user-id-b"] +} +``` + +约束: + +- `ids`:`min_length=1`,`max_length=100`(对齐 `DeleteFilesBody`) +- `userIds`:`max_length=500`(对齐 `ShareBody`) +- 去重后的 `ids` 均须当前用户可管理;任一份不可管理则 4xx,整批不写 +- 人员须同租户且启用;自动排除各文档上传人(与现 `set_shares` 一致:`requested - {create_by}`) +- 非法 / 停用 / 跨租户人员 → 400,整批回滚 + +响应:成功后返回更新后的文档列表(字段与现有单文档分享成功返回一致的序列化)。 + +### 事务与复用 + +- 现有 `DoclibService.set_shares` 在单文件路径末尾 `commit`。批量路径须在**全部文件写入完成前不 commit**。 +- 抽取内部方法(名称示例:`_apply_shares(file_id, user_ids)`)完成单文件差量更新(含 lock);`set_shares` = `_apply_shares` + `commit` + 返回序列化;`set_shares_batch` = 校验全部可管理 → 校验人员 → 对每个 id 调用 `_apply_shares` → 一次 `commit` → 返回列表。 +- 任一步抛错依赖会话回滚,不留下部分文档已改的状态。 + +路由注册位置:与现有 `PUT /web/doclib/files/{file_id}/shares`、`POST .../batch-delete` 同模块(`backend/app/api/doclib.py`)。 + +## 前端设计 + +### 工具栏与权限 + +- `actionsFor.share`:`rows.length > 0 && rows.every((row) => row.canManage)`(与删除多选门槛一致,取消「仅 1 条」限制)。 +- 工具栏「设置分享」对 `selected` 全量打开弹窗,不再只取 `selected[0]`。 +- 详情抽屉单文档「设置分享」仍传入单元素数组或单文档,行为不变。 + +### ShareDialog + +- Props 改为支持多文档(例如 `documents: DoclibDocument[]`);打开时: + - 摘要列出所选文档名称(数量较多时可截断并注明总数) + - `userIds` 初始 = 分享人 id **交集** + - 候选人员:交集中的人员 ∪ 远程搜索结果;排除各文档上传人(多文档时排除所有相关 `ownerId`) +- 保存:统一调用批量接口 `saveDoclibBatchShares(ids, userIds)`(单文档也走批量,少分支);成功提示后关闭并刷新列表 / 更新详情。 + +### 纯函数(便于单测) + +- `shareUserIntersection(documents): string[]` — 交集;空文档列表返回 `[]` +- 可选:`canShareSelection(rows)` 与 `actionsFor` 共用语义 + +### API + +在 `web-admin/src/api/doclib.ts` 增加: + +`saveDoclibBatchShares(ids: string[], userIds: string[])` → `POST .../files/batch-shares` + +单文档 `saveDoclibShares` 可保留供兼容/测试,页面路径以批量为准。 + +## UI 规范 + +- 沿用现有 `doclib-dialog dialog-form-layout`、Fresh Mint、P12 类弹窗;不新造布局与主色。 +- 按钮图标规则与现页一致(Close / Check 等已有用法)。 + +## 验收 + +1. 多选可管理文档后可打开「设置分享」;含不可管理项时按钮禁用。 +2. 打开时勾选为交集;无共同分享人时为空。 +3. 保存后所选文档分享人员均等于弹窗所选集合(含清空)。 +4. 故意混入无权限文档(若后端被直接调用)→ 整批不改。 +5. 详情页单文档分享仍可用。 +6. 单测覆盖交集函数与 `actionsFor.share`;后端/服务层覆盖批量成功与权限失败回滚(按项目既有测试方式)。 + +## 风险 + +- `set_shares` 重构时须保证单文档路径回归(commit 时机、上传人排除、软删恢复历史 share 行)。 +- 批量上限 100 与删除一致;超大选区依赖现有表格多选,不另做分片 UI。 + +## 状态 + +设计已获用户确认(会话中「按照推荐」方案 A,「OK,请继续」批准落盘)。待用户审阅本文件后进入实现计划。 From bb75f98582c4857ef3a28869e46b67320d6ab300 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 17:39:49 +0800 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20=E5=A2=9E=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=BA=93=E5=A4=9A=E9=80=89=E6=89=B9=E9=87=8F=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=88#25?= =?UTF-8?q?=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 TDD 拆分前后端任务,覆盖交集预选、batch-shares 事务与页面接入。 Co-authored-by: Cursor --- .../plans/2026-11-12-doclib-batch-share.md | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-12-doclib-batch-share.md diff --git a/docs/superpowers/plans/2026-11-12-doclib-batch-share.md b/docs/superpowers/plans/2026-11-12-doclib-batch-share.md new file mode 100644 index 000000000..8e7d05939 --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-doclib-batch-share.md @@ -0,0 +1,374 @@ +# 文档库多选批量设置分享 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」/ 空测试步骤。 From 522ef36e7a5ddf24fb0fd36a5cb35d54f406f9fc Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 17:41:48 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(doclib):=20=E5=85=81=E8=AE=B8=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E5=8F=AF=E7=AE=A1=E7=90=86=E6=96=87=E6=A1=A3=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E5=B9=B6=E8=AE=A1=E7=AE=97=E5=88=86=E4=BA=AB=E4=BA=BA?= =?UTF-8?q?=E4=BA=A4=E9=9B=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/views/doclib/enter/model.test.ts | 24 +++++++++++++++++-- web-admin/src/views/doclib/enter/model.ts | 19 ++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/web-admin/src/views/doclib/enter/model.test.ts b/web-admin/src/views/doclib/enter/model.test.ts index 34d038057..68247aabd 100644 --- a/web-admin/src/views/doclib/enter/model.test.ts +++ b/web-admin/src/views/doclib/enter/model.test.ts @@ -14,7 +14,7 @@ const node = (id: string, scope: 'mine' | 'shared', children: any[] = []) => ({ children }) -test('分享接收人只能下载,混合选择不能批量删除', () => { +test('可管理文档可多选分享,混合选择不能分享', () => { assert.deepEqual(actionsFor([{ canManage: false }]), { share: false, download: true, @@ -25,10 +25,30 @@ test('分享接收人只能下载,混合选择不能批量删除', () => { download: true, delete: false }) - assert.deepEqual(actionsFor([{ canManage: true }]), { share: true, download: true, delete: true }) + 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([]), []) +}) + test('同一个目录在不同范围有独立树节点,已删除目录不再命中', () => { const mine = node('root', 'mine', [node('child', 'mine')]) const shared = node('root', 'shared') diff --git a/web-admin/src/views/doclib/enter/model.ts b/web-admin/src/views/doclib/enter/model.ts index 3ac2a58e8..7ce8c2048 100644 --- a/web-admin/src/views/doclib/enter/model.ts +++ b/web-admin/src/views/doclib/enter/model.ts @@ -2,12 +2,29 @@ import type { DoclibFolder } from '@/api/doclib' export function actionsFor(rows: Array<{ canManage: boolean }>) { return { - share: rows.length === 1 && rows[0].canManage, + 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] +} + export function findFolder(nodes: DoclibFolder[], id: string): DoclibFolder | undefined { for (const node of nodes) { if (node.id === id) return node From 92a8b3f6dad10b8d4997c8ed70a23fe28140ee50 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 17:42:06 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(doclib):=20=E5=A2=9E=E5=8A=A0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=AE=BE=E7=BD=AE=E5=88=86=E4=BA=AB=20API=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- web-admin/src/api/doclib.ts | 5 +++++ web-admin/src/views/doclib/enter/api.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/web-admin/src/api/doclib.ts b/web-admin/src/api/doclib.ts index f26bad8ca..9ef0aa8fc 100644 --- a/web-admin/src/api/doclib.ts +++ b/web-admin/src/api/doclib.ts @@ -67,6 +67,11 @@ export const saveDoclibShares = (id: string, userIds: string[]) => url: `${base}/files/${encodeURIComponent(id)}/shares`, data: { userIds } }) +export const saveDoclibBatchShares = (ids: string[], userIds: string[]) => + request.post({ + url: `${base}/files/batch-shares`, + data: { ids, userIds } + }) export const deleteDoclibFiles = (ids: string[]) => request.post({ url: `${base}/files/batch-delete`, data: { ids } }) export const downloadDoclibFile = (id: string) => diff --git a/web-admin/src/views/doclib/enter/api.test.ts b/web-admin/src/views/doclib/enter/api.test.ts index 6db3262b3..d4f09ca19 100644 --- a/web-admin/src/views/doclib/enter/api.test.ts +++ b/web-admin/src/views/doclib/enter/api.test.ts @@ -44,3 +44,11 @@ test('专用上传请求提交原文件及目标目录,清空分享提交空 assert.equal(calls[1].method, 'put') assert.deepEqual(calls[1].config.data, { userIds: [] }) }) + +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'] }) +}) From dd4fdf1e6e1ab970dda616ec649376a84a454432 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 17:42:34 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(doclib):=20=E5=A2=9E=E5=8A=A0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=AE=BE=E7=BD=AE=E5=88=86=E4=BA=AB=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=E5=8D=95=E6=96=87=E6=A1=A3=E5=86=99?= =?UTF-8?q?=E5=85=A5=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/app/api/doclib.py | 13 ++++++++++++- backend/app/schemas/doclib.py | 5 +++++ backend/app/services/doclib_service.py | 25 ++++++++++++++++++++----- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/app/api/doclib.py b/backend/app/api/doclib.py index d7b187107..629a81698 100644 --- a/backend/app/api/doclib.py +++ b/backend/app/api/doclib.py @@ -4,7 +4,13 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.core.doclib_upload_route import DoclibRoute -from app.schemas.doclib import DeleteFilesBody, FolderCreateBody, FolderUpdateBody, ShareBody +from app.schemas.doclib import ( + BatchShareBody, + DeleteFilesBody, + FolderCreateBody, + FolderUpdateBody, + ShareBody, +) from app.services.doclib_service import DoclibService from app.services.doclib_storage import download_document, upload_document @@ -87,6 +93,11 @@ def delete_document_files(body: DeleteFilesBody, svc: DoclibService = Depends(se return success_response() +@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)) + + @router.get('/web/doclib/files/{file_id}') def document_detail(file_id: str, svc: DoclibService = Depends(service)): return success_response(svc.get_file(file_id)) diff --git a/backend/app/schemas/doclib.py b/backend/app/schemas/doclib.py index cccf13e4d..93474978a 100644 --- a/backend/app/schemas/doclib.py +++ b/backend/app/schemas/doclib.py @@ -28,5 +28,10 @@ class ShareBody(_Body): userIds: List[str] = Field(max_length=500) +class BatchShareBody(_Body): + ids: List[str] = Field(min_length=1, max_length=100) + userIds: List[str] = Field(max_length=500) + + class DeleteFilesBody(_Body): ids: List[str] = Field(min_length=1, max_length=100) diff --git a/backend/app/services/doclib_service.py b/backend/app/services/doclib_service.py index 0a578a98f..b241f93c9 100644 --- a/backend/app/services/doclib_service.py +++ b/backend/app/services/doclib_service.py @@ -228,23 +228,38 @@ class DoclibService: self.db.commit() return result - def set_shares(self, file_id, user_ids): + def _apply_shares(self, file_id, user_ids): row = self._file(file_id, manage=True, lock=True) requested = set(user_ids) - {row.create_by} - valid = {u.id for u in self._base(SysUser).filter(SysUser.id.in_(requested), SysUser.status == '1').all()} - if requested != valid: - raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户') + 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, '分享人员不存在、已停用或不属于当前租户') 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()} + 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) + def delete_files(self, ids): rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))] for row in rows: From 0baa6880f7a8fbef4f4a5da436e4a187074caa28 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 17:43:16 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat(doclib):=20=E6=96=87=E6=A1=A3=E5=BA=93?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E6=96=87=E6=A1=A3=E5=90=8E?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E8=AE=BE=E7=BD=AE=E5=88=86=E4=BA=AB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/views/doclib/enter/ShareDialog.vue | 45 ++++++++++++++----- .../src/views/doclib/enter/browser.test.mjs | 6 +++ web-admin/src/views/doclib/enter/index.vue | 24 ++++++---- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/web-admin/src/views/doclib/enter/ShareDialog.vue b/web-admin/src/views/doclib/enter/ShareDialog.vue index a92ccde68..c61ba47b7 100644 --- a/web-admin/src/views/doclib/enter/ShareDialog.vue +++ b/web-admin/src/views/doclib/enter/ShareDialog.vue @@ -11,11 +11,13 @@ :close-on-press-escape="!saving" :show-close="!saving" > -

{{ document?.name }}

+

{{ summaryText }}

分享给可多选,取消选择即收回访问权限;保存后对所选文档整份覆盖 () + import { createLatestRequest, shareUserIntersection } from './model' + const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] - saved: [document: DoclibDocument] + saved: [documents: DoclibDocument[]] }>() const visible = computed({ get: () => props.modelValue, @@ -77,17 +79,31 @@ const userIds = ref([]) const people = ref([]) const requests = createLatestRequest() + const ownerIds = computed( + () => new Set(props.documents.map((doc) => doc.ownerId).filter(Boolean)) + ) + const summaryText = computed(() => { + const names = props.documents.map((doc) => doc.name).filter(Boolean) + if (!names.length) return '未选择文档' + if (names.length === 1) return names[0] + const shown = names.slice(0, 5) + const more = names.length > 5 ? `等 ${names.length} 个` : '' + return `已选 ${names.length} 个文档:${shown.join('、')}${more ? ` ${more}` : ''}` + }) const options = computed(() => { - const candidates = new Map((props.document?.shares || []).map((person) => [person.id, person])) + const candidates = new Map() + for (const doc of props.documents) { + for (const person of doc.shares || []) candidates.set(person.id, person) + } people.value.forEach((person) => candidates.set(person.id, person)) - return [...candidates.values()].filter((person) => person.id !== props.document?.ownerId) + return [...candidates.values()].filter((person) => !ownerIds.value.has(person.id)) }) watch( () => props.modelValue, (value) => { requests.invalidate() if (!value) return - userIds.value = (props.document?.shares || []).map((person) => person.id) + userIds.value = shareUserIntersection(props.documents) people.value = [] void searchUsers('') } @@ -109,12 +125,17 @@ } } async function save() { - if (!props.document?.canManage || saving.value) return + if (!props.documents.length || props.documents.some((doc) => !doc.canManage) || saving.value) { + return + } saving.value = true try { - const updated = await saveDoclibShares(props.document.id, userIds.value) + const updated = await saveDoclibBatchShares( + props.documents.map((doc) => doc.id), + userIds.value + ) ElMessage.success('分享设置已保存') - emit('saved', updated) + emit('saved', updated || []) emit('update:modelValue', false) } catch { /* 请求层统一展示错误 */ diff --git a/web-admin/src/views/doclib/enter/browser.test.mjs b/web-admin/src/views/doclib/enter/browser.test.mjs index 5d5f17934..334f3b313 100644 --- a/web-admin/src/views/doclib/enter/browser.test.mjs +++ b/web-admin/src/views/doclib/enter/browser.test.mjs @@ -95,6 +95,12 @@ await context.route('**/api/**', async (route) => { return ok({ list, total: list.length }) } if (path === '/web/doclib/users') return ok([{ id: 'other', name: '同事', deptName: '生产部' }]) + if (path === '/web/doclib/files/batch-shares' && req.method() === 'POST') { + const body = req.postDataJSON() + sharedUsers = body.userIds + file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' })) + return ok([file]) + } if (path.endsWith('/shares')) { sharedUsers = req.postDataJSON().userIds file.shares = sharedUsers.map((id) => ({ id, name: '同事', deptName: '生产部' })) diff --git a/web-admin/src/views/doclib/enter/index.vue b/web-admin/src/views/doclib/enter/index.vue index b76f38c31..302a7f1ff 100644 --- a/web-admin/src/views/doclib/enter/index.vue +++ b/web-admin/src/views/doclib/enter/index.vue @@ -78,7 +78,7 @@ > 设置分享 - + (null) - function openShare(row: DoclibDocument) { - if (!row?.canManage) return - shareDocument.value = row + const shareDocuments = ref([]) + function openShare(rows: DoclibDocument | DoclibDocument[] | null | undefined) { + const list = (Array.isArray(rows) ? rows : rows ? [rows] : []).filter( + (row): row is DoclibDocument => !!row?.id && row.canManage + ) + if (!list.length) return + shareDocuments.value = list shareVisible.value = true } - function shareSaved(row: DoclibDocument) { - if (detail.value?.id === row.id) detail.value = row + function shareSaved(rows: DoclibDocument[]) { + const updated = Array.isArray(rows) ? rows : [] + const current = detail.value + if (current) { + const match = updated.find((row) => row.id === current.id) + if (match) detail.value = match + } void refresh() } const detailVisible = ref(false)