修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:58 +08:00
parent 8bc5380ecb
commit fbd8fae44b
30 changed files with 829 additions and 138 deletions
+62 -21
View File
@@ -14,6 +14,12 @@
</div>
</header>
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 筛选栏(紧凑下拉) -->
<div class="filter-bar">
<div class="filter-group">
@@ -46,7 +52,6 @@
<!-- 任务分组列表 -->
<div class="task-groups">
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredGroups.length === 0" class="empty-state">
<div class="empty-icon">📋</div>
<div>{{ $t('tasks.group.empty') }}</div>
@@ -85,16 +90,16 @@
<span class="task-parent-icon">📑</span>
<span class="task-title">{{ row.task.title }}</span>
<span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span>
<!-- 子进度徽章(done+cancelled / total) -->
<span class="sub-progress-badge" :title="$t('tasks.tree.progress')">{{ row.progress!.done }}/{{ row.progress!.total }}</span>
<!-- 子进度徽章(done+cancelled / total);WK-11:搜索态隐藏(树形进度失真) -->
<span v-if="!isSearching" class="sub-progress-badge" :title="$t('tasks.tree.progress')">{{ row.progress!.done }}/{{ row.progress!.total }}</span>
</div>
<div class="task-meta">
<span class="branch-tag" v-if="row.task.branch_name">
<span class="branch-icon"></span>{{ row.task.branch_name }}
</span>
<span class="task-date">{{ formatRelative(row.task.updated_at) }}</span>
<!-- 迷你进度条(渐变填充,宽度=完成子任务百分比) -->
<div class="mini-progress" :title="$t('tasks.tree.progress')">
<!-- 迷你进度条(渐变填充,宽度=完成子任务百分比);WK-11:搜索态隐藏 -->
<div v-if="!isSearching" class="mini-progress" :title="$t('tasks.tree.progress')">
<div class="mini-progress-fill" :style="{ width: parentPct(row) + '%' }"></div>
</div>
</div>
@@ -105,7 +110,7 @@
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
<button v-for="s in quickStatusesFor(row.task)" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }}
</button>
</div>
@@ -155,7 +160,7 @@
<div v-if="quickMenuId === child.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(child.id, s.key)">
<button v-for="s in quickStatusesFor(child)" :key="s.key" class="quick-menu-item" @click="quickAdvance(child.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }}
</button>
</div>
@@ -196,7 +201,7 @@
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
<div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
<button v-for="s in quickStatusesFor(row.task)" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }}
</button>
</div>
@@ -274,6 +279,9 @@
</div>
</div>
<!-- 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) -->
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
<!-- 确认弹层(删除任务,替代原生 window.confirm) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
@@ -285,17 +293,20 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { formatRelative } from '@/utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass, TASK_STATUS_TRANSITIONS } from '../constants/project'
import { taskApi } from '@/api'
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef'
import { useToast } from '@/composables/useToast'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// WK-13:快捷操作失败 toast 反馈(对齐 Projects.vue useToast 模式)
const { toast, showToast } = useToast()
// 列表筛选/分页状态持久化到 localStorage(key 前缀 'tasks.'),
// 刷新页面后保留用户上次选择。collapsedGroups 单独走 df-tasks-collapsed(沿用既有实现)。
@@ -304,6 +315,8 @@ const activeStatus = usePersistedRef('tasks.activeStatus', 'all')
const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
const loading = ref(false)
// WK-11:搜索态(keyword 过滤可能截断父子关系,子进度徽章/迷你条语义失真 → 隐藏)
const isSearching = computed(() => searchKeyword.value.trim().length > 0)
// F-260805 D5:列表页一次性加载 + 前端组装树,移除真分页(page/pageSize/totalTasks 已删)。
// 分组折叠状态(localStorage 记忆)
@@ -339,7 +352,13 @@ const quickStatuses = computed(() => [
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
])
/** WK-8:快捷状态目标 = 状态机合法流转目标(TASK_STATUS_TRANSITIONS 过滤,对齐 TaskDetail ADVANCE_MAP) */
function quickStatusesFor(task: TaskRecord): { key: string; icon: string; label: string }[] {
const valid = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
return quickStatuses.value.filter(s => valid.has(s.key))
}
const quickPriorities = computed(() => [
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
@@ -351,14 +370,20 @@ async function quickAdvance(id: string, target: string) {
try {
await taskApi.advance(id, target)
await store.loadTasks(buildTaskQuery())
} catch (e) { console.error('快捷改状态失败:', e) }
} catch (e: any) {
// WK-13:快捷操作失败 toast 反馈(原 console.error 静默,用户无感知)
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
}
async function quickPriority(id: string, priority: number) {
quickMenuId.value = null
try {
await store.updateTask(id, 'priority', String(priority))
// WK-13:改用 taskApi 直调(不走 store.updateTask,避免失败置 store.error 触发整表错误态)
await taskApi.update(id, 'priority', String(priority))
await store.loadTasks(buildTaskQuery())
} catch (e) { console.error('快捷改优先级失败:', e) }
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
}
async function quickDelete(task: TaskRecord, childCount = 0) {
quickMenuId.value = null
@@ -368,7 +393,9 @@ async function quickDelete(task: TaskRecord, childCount = 0) {
: t('tasks.confirmDelete', { title: task.title })
if (!await confirmDialog(msg)) return
try {
await store.deleteTask(task.id)
// WK-13:deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
const ok = await store.deleteTask(task.id)
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
} catch (e) { console.error('删除失败:', e) }
}
// 点击外部关闭快捷菜单
@@ -392,6 +419,8 @@ try {
// 搜索防抖
let _searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchKeyword, () => {
// WK-10:登记当前关键字到 barrel(数据变更联动刷新复用同一筛选,不冲掉搜索结果)
store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined)
if (_searchTimer) clearTimeout(_searchTimer)
_searchTimer = setTimeout(() => {
loading.value = true
@@ -604,6 +633,7 @@ function onKeydown(e: KeyboardEvent) {
onMounted(async () => {
store.setActiveTaskProject(activeProject.value)
store.setActiveTaskStatus(activeStatus.value)
store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined)
loading.value = true
try {
await Promise.all([
@@ -692,7 +722,8 @@ onUnmounted(() => {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
overflow: hidden;
/* WK-9:原 overflow:hidden 会把快捷菜单(.quick-menu 绝对定位向下溢出)裁切。
改为组自身不裁切,圆角裁剪下放给 header(顶角)与末行(底角,见下),菜单可正常浮出。 */
}
.group-header {
@@ -704,6 +735,9 @@ onUnmounted(() => {
user-select: none;
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
/* WK-9:承接原 .task-group overflow:hidden 的顶角圆角裁剪(自身 overflow,组不再裁切快捷菜单) */
border-radius: var(--df-radius-lg) var(--df-radius-lg) 0 0;
overflow: hidden;
}
.group-header:hover { background: var(--df-accent-bg); }
.task-group.collapsed .group-header { border-bottom: none; }
@@ -733,6 +767,8 @@ onUnmounted(() => {
cursor: pointer;
}
.task-item:last-child { border-bottom: none; }
/* WK-9:末行承接组底角圆角裁剪(替代原 .task-group overflow:hidden) */
.task-item:last-child { border-radius: 0 0 var(--df-radius-lg) var(--df-radius-lg); }
.task-item:hover { background: var(--df-accent-bg); }
.task-main { flex: 1; min-width: 0; }
@@ -781,13 +817,7 @@ onUnmounted(() => {
color: var(--df-text-dim);
}
.status-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
flex-shrink: 0;
white-space: nowrap;
}
/* status-tag 尺寸/圆角 + status-* 7 态色已提取到全局 components.css(WK-6 对齐三视图),此处不再重复定义 */
/* 快捷操作 */
.task-actions {
@@ -909,6 +939,17 @@ onUnmounted(() => {
}
.quick-menu-danger { color: var(--df-danger); }
/* ===== 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) ===== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 200; max-width: 80vw;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; }
.toast-success { background: var(--df-success); color: #fff; }
/* 空态 */
/* empty-state 已提取到全局 global.css */
.empty-icon { font-size: 32px; opacity: 0.4; }