Files
DevFlow/src/composables/task/useTaskBatchActions.ts
T

181 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 任务批量动作执行 — 四类动作(推进/改状态、取消·暂缓·恢复、删除、改优先级/指派)。
*
* 设计:后端零改动,前端串行循环复用现有单条 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 }
}