按 TDD 拆分前后端任务,覆盖交集预选、batch-shares 事务与页面接入。 Co-authored-by: Cursor <cursoragent@cursor.com>
375 lines
12 KiB
Markdown
375 lines
12 KiB
Markdown
# 文档库多选批量设置分享 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」/ 空测试步骤。
|