Files
shanxia2.0/docs/superpowers/plans/2026-11-12-doclib-batch-share.md
T
wuzilongandCursor bb75f98582 docs: 增加文档库多选批量分享实现计划(#25)。
按 TDD 拆分前后端任务,覆盖交集预选、batch-shares 事务与页面接入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:39:49 +08:00

375 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 文档库多选批量设置分享 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」/ 空测试步骤。