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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user