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

12 KiB
Raw Blame History

文档库多选批量设置分享 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 将首个测试改为多选可分享语义,并新增交集测试:

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):

./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts

Expected: FAIL(share 仍要求 length === 1,且无 shareUserIntersection)

  • Step 3: 最小实现

model.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: 跑测试确认通过
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts

Expected: PASS

  • Step 5: Commit
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 追加:

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: 跑测试确认失败
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts

Expected: FAIL(无 saveDoclibBatchShares)

  • Step 3: 最小实现

在 doclib.ts 的 saveDoclibShares 旁增加:

export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
  request.post<DoclibDocument[]>({
    url: `${base}/files/batch-shares`,
    data: { ids, userIds }
  })
  • Step 4: 跑测试确认通过
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts

Expected: PASS

  • Step 5: Commit
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 后增加:

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:

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 旁:

@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 下对改动文件做语法检查

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
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 为数组时由父组件刷新)。

示意:

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: 跑前端相关测试

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
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: 再跑前端测试套件
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」/ 空测试步骤。