修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 任务批量动作执行 — 四类动作(推进/改状态、取消·暂缓·恢复、删除、改优先级/指派)。
|
||||
*
|
||||
* 设计:后端零改动,前端串行循环复用现有单条 taskApi(advance 自带 CAS + can_transition +
|
||||
* 父聚合 + review_rounds;delete 级联软删;update 白名单含 priority/assignee、禁 status)。
|
||||
* 合法性预判走 `TASK_STATUS_TRANSITIONS` 单一真相源(与 TaskDetail ADVANCE_MAP 同源),
|
||||
* 非法项记 skipped(不算失败),后端二次校验兜底(防御选中后外部改动)。
|
||||
*
|
||||
* 每个动作内置 busy 置位 + 结束后统一 onRefresh + toast 汇总;调用方拿返回的
|
||||
* BatchActionResult 可自行展示详情,toast 已自动处理。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { taskApi } from '@/api/task'
|
||||
import type { TaskRecord } from '@/api/types'
|
||||
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
export interface BatchFailedItem {
|
||||
id: string
|
||||
title: string
|
||||
error: string
|
||||
}
|
||||
export interface BatchSkippedItem {
|
||||
id: string
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
export interface BatchActionResult {
|
||||
ok: number
|
||||
failed: BatchFailedItem[]
|
||||
/** 状态机非法被跳过(非失败,不打扰用户) */
|
||||
skipped: BatchSkippedItem[]
|
||||
/** deleteMany 级联软删的子任务数合计 */
|
||||
cascaded?: number
|
||||
}
|
||||
|
||||
/** 恢复类动作的目标态解析:deferred/cancelled→todo、blocked→in_progress、其余无恢复路径 */
|
||||
const RESUME_TARGET: Record<string, string> = {
|
||||
deferred: 'todo',
|
||||
cancelled: 'todo',
|
||||
blocked: 'in_progress',
|
||||
}
|
||||
|
||||
/** 推进类动作的结果文案 key(tasks 命名空间,非删除场景) */
|
||||
const LABEL_BY_KIND: Record<'advance' | 'update', string> = {
|
||||
advance: 'tasks.batch.advanceResult',
|
||||
update: 'tasks.batch.updateResult',
|
||||
}
|
||||
|
||||
export function useTaskBatchActions(opts: {
|
||||
/** 由 id 解析当前任务(标题/状态/父);解析不到视为已不存在记 failed */
|
||||
getTask: (id: string) => TaskRecord | undefined
|
||||
/** 操作完成后统一刷新(三视图各自:loadTasks / loadProjectTasks / loadChildren) */
|
||||
onRefresh?: () => Promise<void> | void
|
||||
toastDurationMs?: number
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { toast, showToast } = useToast(opts.toastDurationMs)
|
||||
const busy = ref(false)
|
||||
|
||||
function finalize(r: BatchActionResult, labelKey: string, durationMs?: number) {
|
||||
busy.value = false
|
||||
const { ok, failed, skipped } = r
|
||||
if (failed.length === 0 && skipped.length === 0) {
|
||||
const msg = r.cascaded != null ? t('tasks.batch.deleteResult', { n: ok, c: r.cascaded }) : t(labelKey, { n: ok })
|
||||
showToast(msg, 'success', durationMs)
|
||||
} else if (failed.length === 0) {
|
||||
showToast(`${t(labelKey, { n: ok })},${t('tasks.batch.skipped', { n: skipped.length })}`, 'warning', durationMs)
|
||||
} else {
|
||||
showToast(`${t(labelKey, { n: ok })},${t('tasks.batch.failed', { n: failed.length })}`, 'error', durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** 通用串行执行器:逐条 op,抛错=failed;skip 返回 reason 则跳过不进 API */
|
||||
async function run(
|
||||
ids: string[],
|
||||
op: (id: string, task: TaskRecord) => Promise<unknown>,
|
||||
skip: (task: TaskRecord) => string | null,
|
||||
labelKey: string,
|
||||
): Promise<BatchActionResult> {
|
||||
busy.value = true
|
||||
const result: BatchActionResult = { ok: 0, failed: [], skipped: [] }
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const task = opts.getTask(id)
|
||||
if (!task) {
|
||||
result.failed.push({ id, title: id, error: t('tasks.err.loadFailed') })
|
||||
continue
|
||||
}
|
||||
const reason = skip(task)
|
||||
if (reason) {
|
||||
result.skipped.push({ id, title: task.title, reason })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await op(id, task)
|
||||
result.ok++
|
||||
} catch (e: any) {
|
||||
result.failed.push({ id, title: task.title, error: e?.toString?.() ?? String(e) })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { if (opts.onRefresh) await opts.onRefresh() } catch {}
|
||||
finalize(result, labelKey)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 批量推进到统一目标态(取消/暂缓/恢复均经此) */
|
||||
function advanceMany(ids: string[], target: string): Promise<BatchActionResult> {
|
||||
return run(
|
||||
ids,
|
||||
id => taskApi.advance(id, target),
|
||||
task => (TASK_STATUS_TRANSITIONS[task.status]?.includes(target) ? null : t('tasks.batch.noCommonTarget')),
|
||||
LABEL_BY_KIND.advance,
|
||||
)
|
||||
}
|
||||
|
||||
function cancelMany(ids: string[]): Promise<BatchActionResult> {
|
||||
return advanceMany(ids, 'cancelled')
|
||||
}
|
||||
|
||||
function deferMany(ids: string[]): Promise<BatchActionResult> {
|
||||
return advanceMany(ids, 'deferred')
|
||||
}
|
||||
|
||||
/** 恢复:目标态随当前态解析(deferred/cancelled→todo、blocked→in_progress) */
|
||||
function resumeMany(ids: string[]): Promise<BatchActionResult> {
|
||||
return run(
|
||||
ids,
|
||||
(id, task) => taskApi.advance(id, RESUME_TARGET[task.status]),
|
||||
task => (RESUME_TARGET[task.status] ? null : t('tasks.batch.noCommonTarget')),
|
||||
LABEL_BY_KIND.advance,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除(级联软删)。父+子同选去重:子随父由后端级联软删,不重复删父的子;
|
||||
* 未选父时子任务独立删(仅删自身)。直调 taskApi.delete 而非 store 动作,
|
||||
* 避免单条失败污染 store.error 触发整表 error banner(对齐 Tasks.vue quickDelete 直调模式)。
|
||||
*/
|
||||
async function deleteMany(ids: string[]): Promise<BatchActionResult> {
|
||||
busy.value = true
|
||||
const selected = new Set(ids)
|
||||
const result: BatchActionResult = { ok: 0, failed: [], skipped: [], cascaded: 0 }
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const task = opts.getTask(id)
|
||||
if (!task) {
|
||||
result.failed.push({ id, title: id, error: t('tasks.err.loadFailed') })
|
||||
continue
|
||||
}
|
||||
if (task.parent_id && selected.has(task.parent_id)) continue // 子随父级联,跳过避免重复删
|
||||
try {
|
||||
const res = await taskApi.delete(id)
|
||||
if (res?.ok) {
|
||||
result.ok++
|
||||
result.cascaded = (result.cascaded ?? 0) + (res.cascaded ?? 0)
|
||||
} else {
|
||||
result.failed.push({ id, title: task.title, error: t('tasks.err.deleteFailed') })
|
||||
}
|
||||
} catch (e: any) {
|
||||
result.failed.push({ id, title: task.title, error: e?.toString?.() ?? String(e) })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (opts.onRefresh) await opts.onRefresh()
|
||||
finalize(result, 'tasks.batch.deleteResult')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 批量更新字段(priority / assignee;status 禁走此,一律 advance) */
|
||||
function updateMany(ids: string[], field: 'priority' | 'assignee', value: string): Promise<BatchActionResult> {
|
||||
return run(ids, id => taskApi.update(id, field, value), () => null, LABEL_BY_KIND.update)
|
||||
}
|
||||
|
||||
return { busy, toast, showToast, advanceMany, cancelMany, deferMany, resumeMany, deleteMany, updateMany }
|
||||
}
|
||||
Reference in New Issue
Block a user