merge: 合入文档库多选批量设置分享(#25)。

This commit is contained in:
wuzilong committed 2026-11-12 17:44:52 +08:00
commit 71927bedef
12 files changed
+634 -29

No files matched your search

+12 -1
View File
@@ -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))
+5
View File
@@ -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)
+20 -5
View File
@@ -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,请继续」批准落盘)。待用户审阅本文件后进入实现计划。
+5
View File
@@ -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: '生产部' }))
+16 -8
View File
@@ -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)
+22 -2
View File
@@ -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')
+18 -1
View File
@@ -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