修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复

This commit is contained in:
lxy
2026-08-12 00:29:06 +08:00
parent 9407f821b9
commit d31a27512c
36 changed files with 2614 additions and 264 deletions
+180
View File
@@ -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 }
}
@@ -0,0 +1,74 @@
/**
* 任务批量勾选状态 — Tasks / ProjectDetail / TaskDetail 子任务三处列表共用。
*
* 选中集为 `Set<string>`(整体替换触发响应式,对齐 Projects.vue 批量导入模式,不做原地 mutate)。
* scopeIds = 当前可见/可勾选 id 范围(随筛选、刷新变化);全选覆盖该范围全部行。
*
* 父/子语义:勾选父任务只勾父自身,不级联勾子(删除时后端自动级联软删子,推进/改字段只作用于父)。
*/
import { computed, ref, type ComputedRef, type Ref } from 'vue'
export interface TaskBatchSelection {
/** 选中 id 集(响应式 Set,写操作一律整体替换) */
selected: Ref<Set<string>>
count: ComputedRef<number>
/** 范围非空且全部勾选 */
allSelected: ComputedRef<boolean>
/** 有勾选但未全选(表头半选态) */
someSelected: ComputedRef<boolean>
isSelected: (id: string) => boolean
toggle: (id: string, checked: boolean) => void
toggleAll: (checked: boolean) => void
clear: () => void
/** 移除已不在 scopeIds 的 id(刷新/筛选后收敛幽灵选中),返回被清理数 */
prune: () => number
}
export function useTaskBatchSelection(opts: {
/** 当前可见可勾选 id 范围 */
scopeIds: ComputedRef<string[]>
}): TaskBatchSelection {
const selected = ref<Set<string>>(new Set())
const count = computed(() => selected.value.size)
const allSelected = computed(() => {
const scope = opts.scopeIds.value
return scope.length > 0 && scope.every(id => selected.value.has(id))
})
const someSelected = computed(() => count.value > 0 && !allSelected.value)
function isSelected(id: string): boolean {
return selected.value.has(id)
}
function toggle(id: string, checked: boolean) {
const next = new Set(selected.value)
if (checked) next.add(id)
else next.delete(id)
selected.value = next
}
function toggleAll(checked: boolean) {
selected.value = checked ? new Set(opts.scopeIds.value) : new Set()
}
function clear() {
selected.value = new Set()
}
function prune(): number {
const scope = new Set(opts.scopeIds.value)
let removed = 0
const next = new Set<string>()
for (const id of selected.value) {
if (scope.has(id)) next.add(id)
else removed++
}
if (removed > 0) selected.value = next
return removed
}
return { selected, count, allSelected, someSelected, isSelected, toggle, toggleAll, clear, prune }
}
+66
View File
@@ -0,0 +1,66 @@
import { ref } from 'vue'
import Sortable from 'sortablejs'
import { useI18n } from 'vue-i18n'
import { taskApi } from '@/api/task'
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
import { useToast } from '../useToast'
export function useTaskKanban(opts: {
onRefresh: () => Promise<void> | void
toastDurationMs?: number
}) {
const { t } = useI18n()
const { showToast } = useToast(opts.toastDurationMs)
const kanbanBusy = ref(false)
let s: Sortable[] = []
let _dragId: string | null = null
let _executing = false
function destroy() { s.forEach(x => x.destroy()); s = [] }
function setup(el: HTMLElement) {
destroy()
el.querySelectorAll('.kanban-col-body').forEach(body => {
s.push(new Sortable(body as HTMLElement, {
group: 'kanban',
draggable: '.task-card',
// 父卡禁拖:父状态=子聚合派生,拖动会被状态机强写,违反容器模型。
filter: '.kanban-empty, .task-card--parent',
forceFallback: true,
onMove: (e) => {
const from = (e.from as HTMLElement).dataset.status ?? ''
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status ?? ''
if (from === to) return true
return TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
},
onStart: (e) => {
_dragId = (e.item as HTMLElement).dataset.taskId ?? null
_executing = false
},
onEnd: async (e) => {
if (_executing) return
_executing = true
const from = (e.from as HTMLElement).dataset.status
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status
const id = _dragId
_dragId = null
if (!to || !from || to === from) { _executing = false; return }
if (!id) { _executing = false; return }
kanbanBusy.value = true
try {
await taskApi.advance(id, to)
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
} catch (err) {
showToast(err?.toString?.() ?? t('common.unknownError'), 'error')
} finally {
kanbanBusy.value = false
_executing = false
await opts.onRefresh()
}
},
}))
})
}
return { kanbanBusy, setupKanbanSortable: setup, destroyKanban: destroy }
}