docs: 增加文档库多选批量分享实现计划(#25)。

按 TDD 拆分前后端任务,覆盖交集预选、batch-shares 事务与页面接入。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-12 17:39:49 +08:00
1 parent bf89a1f301
commit bb75f98582
1 file changed
+374
@@ -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<DoclibDocument[]>({
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<DoclibDocument[]>`)。
- `openShare(rows: DoclibDocument | DoclibDocument[])` 规范化为数组。
- 工具栏:`@click="openShare(selected)"`。
- 详情:`@click="openShare(detail)"`。
- `<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />`
- `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」/ 空测试步骤。