feat(task): 批量改状态与看板数据接口

- POST /items/batch-status:逐条成败,不做全回滚;拖 10 条里 1 条冲突不该让另 9 条白干
- GET /items/board:四列一次取全,收件箱不进看板
- 修:/items/board 被 /{item_id} 抢先匹配导致 422
- 新增向量库配置项(M3 用,独立实例,不动 fnsvr-01)
- 3 个新测试,全量 46 passed
This commit is contained in:
dadao
2026-10-28 04:05:35 +08:00
parent e25a1a4e31
commit c4868f04cf
6 changed files with 329 additions and 1 deletions
+133
View File
@@ -361,6 +361,60 @@ async def list_items(
)
@router.get("/board", summary="看板:按状态分列的最近任务")
async def board(
ctx: MemberDep,
session: SessionDep,
per_column: Annotated[int, Query(ge=1, le=100)] = 30,
) -> dict:
"""一屏拖动需要的数据,一次拿全。
每列按"截止时间早的在前、没有截止时间的靠后"排,其次按更新时间。
只取 `per_column` 条:看板是"手上正在做的",不是全量列表。
"""
columns: dict[str, list[dict[str, Any]]] = {}
statuses = ("todo", "doing", "blocked", "done")
rows = (
(
await session.execute(
select(Item)
.where(
Item.workspace_id == ctx.ws_id,
Item.status.in_(statuses),
Item.deleted_at.is_(None),
)
.order_by(
Item.due_at.asc().nulls_last(),
Item.updated_at.desc(),
Item.id.desc(),
)
.limit(per_column * len(statuses))
)
)
.scalars()
.all()
)
by_id = {row.id: row for row in rows}
tag_map = await _tags_of(session, ctx.ws_id, list(by_id))
for status in statuses:
columns[status] = [
_item_out(row, tag_map.get(row.id)) for row in rows if row.status == status
][:per_column]
return ok(
{
"columns": columns,
"order": list(statuses),
"labels": {
"todo": "待办",
"doing": "进行中",
"blocked": "被阻塞",
"done": "已完成",
},
}
)
@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
@@ -423,6 +477,85 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
return ok(_item_out(item, tags.get(item.id)), message="已创建")
class BatchStatusItemIn(BaseModel):
id: uuid.UUID
version: int
class BatchStatusIn(BaseModel):
status: str
items: list[BatchStatusItemIn] = Field(min_length=1, max_length=50)
@router.post("/batch-status", summary="批量改状态(逐条返回成败)")
async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep) -> dict:
"""看板多选拖拽用。
不做"全成功或全回滚":拖 10 条里有 1 条被同事改过,其余 9 条应当照样生效,
失败的那条单独告诉前端原因。全回滚会让用户白干一次,而且更难解释。
"""
if body.status not in ALL_STATUSES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
now = datetime.now(timezone.utc)
updated: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for entry in body.items:
try:
item = await _load_item(session, ctx.ws_id, entry.id)
except AppError as exc:
failed.append({"id": str(entry.id), "code": int(exc.code), "message": exc.message})
continue
if item.status == body.status:
updated.append({"id": str(item.id), "version": item.version, "unchanged": True})
continue
try:
ensure_transition(item.status, body.status)
except AppError as exc:
failed.append(
{
"id": str(entry.id),
"code": int(exc.code),
"message": exc.message,
"allowed": (exc.details or {}).get("allowed"),
}
)
continue
previous = item.status
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
apply_status(fields, body.status, now=now)
try:
item = await _apply_update(
session,
item,
version=entry.version,
values={"status": fields["status"], "completed_at": fields["completed_at"]},
actor_id=ctx.user.id,
action="status_changed",
payload={"from": previous, "to": body.status, "batch": True},
)
except AppError as exc:
failed.append(
{
"id": str(entry.id),
"code": int(exc.code),
"message": exc.message,
"current_version": (exc.details or {}).get("current_version"),
}
)
continue
updated.append({"id": str(item.id), "version": item.version, "unchanged": False})
return ok(
{"updated": updated, "failed": failed},
message=f"已更新 {len(updated)} 条,{len(failed)} 条未生效",
)
@router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
async def update_item(
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep