新增: 任务行 hover 快捷操作菜单(改状态/优先级/删除)
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单 - 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认) - 点击外部自动关闭菜单 - 补 i18n confirmDelete 翻译
This commit is contained in:
@@ -38,6 +38,7 @@ export default {
|
||||
unknownProject: 'Unknown Project',
|
||||
empty: 'No tasks yet',
|
||||
},
|
||||
confirmDelete: 'Delete task "{title}"? This action cannot be undone.',
|
||||
|
||||
// New task modal
|
||||
modal: {
|
||||
|
||||
@@ -38,6 +38,7 @@ export default {
|
||||
unknownProject: '未知项目',
|
||||
empty: '暂无任务',
|
||||
},
|
||||
confirmDelete: '确定删除任务「{title}」吗?此操作不可撤销。',
|
||||
|
||||
// 新建任务模态
|
||||
modal: {
|
||||
|
||||
@@ -84,7 +84,27 @@
|
||||
<span class="task-date">{{ formatRelative(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
|
||||
<div class="task-actions">
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
|
||||
<button class="task-quick-btn" title="快捷操作" @click.stop="toggleQuickMenu(task.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === task.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">状态</div>
|
||||
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(task.id, s.key)">
|
||||
<span>{{ s.icon }}</span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-menu-divider"></div>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">优先级</div>
|
||||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(task.id, p.value)">
|
||||
<span :class="p.cls">●</span>{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-menu-divider"></div>
|
||||
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(task)">🗑 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -145,6 +165,7 @@ 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 { taskApi } from '@/api'
|
||||
import type { TaskRecord, TaskQuery } from '@/api/types'
|
||||
import Paginator from '../components/Paginator.vue'
|
||||
|
||||
@@ -164,6 +185,49 @@ const pageSize = ref(20)
|
||||
|
||||
// 分组折叠状态(localStorage 记忆)
|
||||
const collapsedGroups = reactive(new Set<string>())
|
||||
|
||||
// 快捷菜单
|
||||
const quickMenuId = ref<string | null>(null)
|
||||
function toggleQuickMenu(id: string) {
|
||||
quickMenuId.value = quickMenuId.value === id ? null : id
|
||||
}
|
||||
const quickStatuses = computed(() => [
|
||||
{ key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') },
|
||||
{ key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') },
|
||||
{ key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') },
|
||||
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
|
||||
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
|
||||
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
|
||||
])
|
||||
const quickPriorities = computed(() => [
|
||||
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
|
||||
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
|
||||
{ value: 2, label: t('tasks.modal.priorityMedium'), cls: 'priority-medium' },
|
||||
{ value: 3, label: t('tasks.modal.priorityLow'), cls: 'priority-low' },
|
||||
])
|
||||
async function quickAdvance(id: string, target: string) {
|
||||
quickMenuId.value = null
|
||||
try {
|
||||
await taskApi.advance(id, target)
|
||||
await store.loadTasks(buildTaskQuery())
|
||||
} catch (e) { console.error('快捷改状态失败:', e) }
|
||||
}
|
||||
async function quickPriority(id: string, priority: number) {
|
||||
quickMenuId.value = null
|
||||
try {
|
||||
await store.updateTask(id, 'priority', String(priority))
|
||||
await store.loadTasks(buildTaskQuery())
|
||||
} catch (e) { console.error('快捷改优先级失败:', e) }
|
||||
}
|
||||
async function quickDelete(task: TaskRecord) {
|
||||
quickMenuId.value = null
|
||||
if (!confirm(t('tasks.confirmDelete', { title: task.title }))) return
|
||||
try {
|
||||
await store.deleteTask(task.id)
|
||||
} catch (e) { console.error('删除失败:', e) }
|
||||
}
|
||||
// 点击外部关闭快捷菜单
|
||||
function closeQuickMenu() { quickMenuId.value = null }
|
||||
function toggleGroup(name: string) {
|
||||
if (collapsedGroups.has(name)) {
|
||||
collapsedGroups.delete(name)
|
||||
@@ -355,11 +419,13 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
document.addEventListener('click', closeQuickMenu)
|
||||
})
|
||||
|
||||
import { onUnmounted } from 'vue'
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
document.removeEventListener('click', closeQuickMenu)
|
||||
if (_searchTimer) clearTimeout(_searchTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -555,6 +621,70 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 快捷操作 */
|
||||
.task-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.task-quick-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.task-item:hover .task-quick-btn { opacity: 0.6; }
|
||||
.task-quick-btn:hover { opacity: 1; }
|
||||
|
||||
.quick-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||
z-index: 20;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.quick-menu-section { padding: 4px 0; }
|
||||
.quick-menu-label {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
padding: 2px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.quick-menu-divider {
|
||||
height: 0.5px;
|
||||
background: var(--df-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.quick-menu-danger { color: var(--df-danger); }
|
||||
.priority-critical { color: var(--df-danger); }
|
||||
.priority-high { color: #ff9800; }
|
||||
.priority-medium { color: var(--df-info); }
|
||||
.priority-low { color: var(--df-text-dim); }
|
||||
|
||||
/* 空态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user