merge: 合入文档库多选批量设置分享(#25)。
This commit is contained in:
commit
71927bedef
12 files changed
+634
-29
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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」/ 空测试步骤。
|
||||
@@ -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,请继续」批准落盘)。待用户审阅本文件后进入实现计划。
|
||||
@@ -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<DoclibDocument[]>({
|
||||
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) =>
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
>
|
||||
<p class="doclib-summary">{{ document?.name }}</p>
|
||||
<p class="doclib-summary">{{ summaryText }}</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item>
|
||||
<template #label
|
||||
>分享给<span class="form-label-tip">可多选,取消选择即收回访问权限</span></template
|
||||
>分享给<span class="form-label-tip"
|
||||
>可多选,取消选择即收回访问权限;保存后对所选文档整份覆盖</span
|
||||
></template
|
||||
>
|
||||
<el-select
|
||||
v-model="userIds"
|
||||
@@ -56,15 +58,15 @@
|
||||
import { Close, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchDoclibUsers,
|
||||
saveDoclibShares,
|
||||
saveDoclibBatchShares,
|
||||
type DoclibDocument,
|
||||
type DoclibPerson
|
||||
} from '@/api/doclib'
|
||||
import { createLatestRequest } from './model'
|
||||
const props = defineProps<{ modelValue: boolean; document: DoclibDocument | null }>()
|
||||
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<string[]>([])
|
||||
const people = ref<DoclibPerson[]>([])
|
||||
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<string, DoclibPerson>()
|
||||
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 {
|
||||
/* 请求层统一展示错误 */
|
||||
|
||||
@@ -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'] })
|
||||
})
|
||||
@@ -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: '生产部' }))
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
>
|
||||
<el-button
|
||||
:disabled="!actions.share || loading || busy"
|
||||
@click="openShare(selected[0])"
|
||||
@click="openShare(selected)"
|
||||
><el-icon><Share /></el-icon>设置分享</el-button
|
||||
>
|
||||
<el-button
|
||||
@@ -195,7 +195,7 @@
|
||||
@download="download([$event])"
|
||||
@view="openDetail"
|
||||
/>
|
||||
<ShareDialog v-model="shareVisible" :document="shareDocument" @saved="shareSaved" />
|
||||
<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />
|
||||
<el-dialog
|
||||
v-model="folderVisible"
|
||||
:title="renameMode ? '重命名文件夹' : '新建文件夹'"
|
||||
@@ -489,14 +489,22 @@
|
||||
}
|
||||
}
|
||||
const shareVisible = ref(false)
|
||||
const shareDocument = ref<DoclibDocument | null>(null)
|
||||
function openShare(row: DoclibDocument) {
|
||||
if (!row?.canManage) return
|
||||
shareDocument.value = row
|
||||
const shareDocuments = ref<DoclibDocument[]>([])
|
||||
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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user