优化: 任务管理界面 UX 重构(紧凑布局/搜索/排序/分页/快捷键)

- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
- 新增排序选择器:更新/创建时间/优先级/状态
- 分组可折叠:点击组头切换,记忆状态到 localStorage
- 分页默认开启:pageSize=20(原 0=全量)
- 紧凑布局:任务行密度优化,显示总条数
- 空状态引导:图标 + 新建按钮
- 模态框样式收敛:去内联 style,统一 modal-field/modal-input 类
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
This commit is contained in:
2026-06-30 21:58:11 +08:00
parent 773ebb6ef6
commit 2780f6affa
3 changed files with 311 additions and 172 deletions

View File

@@ -4,6 +4,7 @@ export default {
title: '🔀 Task Queue', title: '🔀 Task Queue',
refresh: '🔄 Refresh', refresh: '🔄 Refresh',
create: '+ New Task', create: '+ New Task',
searchPlaceholder: 'Search title/description...',
// Filter bar // Filter bar
filter: { filter: {
@@ -11,6 +12,14 @@ export default {
status: 'Status: ', status: 'Status: ',
all: 'All', all: 'All',
}, },
// Sort
sort: {
label: 'Sort: ',
updated: 'Updated',
created: 'Created',
priority: 'Priority',
status: 'Status',
},
// Status filters (with icons) — D-260616-01 aligned to backend 7 states // Status filters (with icons) — D-260616-01 aligned to backend 7 states
statusFilter: { statusFilter: {
all: 'All', all: 'All',

View File

@@ -4,6 +4,7 @@ export default {
title: '🔀 任务队列', title: '🔀 任务队列',
refresh: '🔄 刷新', refresh: '🔄 刷新',
create: '+ 新建任务', create: '+ 新建任务',
searchPlaceholder: '搜索任务标题/描述...',
// 筛选栏 // 筛选栏
filter: { filter: {
@@ -11,6 +12,14 @@ export default {
status: '状态:', status: '状态:',
all: '全部', all: '全部',
}, },
// 排序
sort: {
label: '排序:',
updated: '更新时间',
created: '创建时间',
priority: '优先级',
status: '状态',
},
// 状态筛选(含图标) — D-260616-01 对齐后端 7 态 // 状态筛选(含图标) — D-260616-01 对齐后端 7 态
statusFilter: { statusFilter: {
all: '全部', all: '全部',

View File

@@ -1,61 +1,77 @@
<template> <template>
<div class="tasks"> <div class="tasks">
<!-- 顶部工具栏:标题 + 搜索 + 新建 -->
<header class="page-header"> <header class="page-header">
<h1>{{ $t('tasks.title') }}</h1> <h1>{{ $t('tasks.title') }}</h1>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-ghost" @click="refresh">{{ $t('tasks.refresh') }}</button> <input
v-model="searchKeyword"
class="search-input"
:placeholder="$t('tasks.searchPlaceholder')"
@keyup.esc="searchKeyword = ''"
/>
<button class="btn btn-primary" @click="openCreateModal">{{ $t('tasks.create') }}</button> <button class="btn btn-primary" @click="openCreateModal">{{ $t('tasks.create') }}</button>
</div> </div>
</header> </header>
<!-- 筛选栏 --> <!-- 筛选栏(紧凑下拉) -->
<div class="filter-bar"> <div class="filter-bar">
<div class="filter-group"> <div class="filter-group">
<span class="filter-label">{{ $t('tasks.filter.project') }}</span> <span class="filter-label">{{ $t('tasks.filter.project') }}</span>
<button <select v-model="activeProject" class="filter-select">
v-for="p in projectFilters" <option value="all">{{ $t('tasks.filter.all') }}</option>
:key="p.key" <option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
class="filter-btn" </select>
:class="{ active: activeProject === p.key }"
@click="activeProject = p.key"
>
{{ p.label }}
</button>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<span class="filter-label">{{ $t('tasks.filter.status') }}</span> <span class="filter-label">{{ $t('tasks.filter.status') }}</span>
<button <select v-model="activeStatus" class="filter-select">
v-for="s in statusFilters" <option v-for="s in statusFilters" :key="s.key" :value="s.key">
:key="s.key" {{ s.icon }} {{ s.label }}
class="filter-btn" </option>
:class="{ active: activeStatus === s.key }" </select>
@click="activeStatus = s.key"
>
<span class="filter-btn-icon">{{ s.icon }}</span>
<span class="filter-btn-label">{{ s.label }}</span>
</button>
</div> </div>
<div class="filter-group">
<span class="filter-label">{{ $t('tasks.sort.label') }}</span>
<select v-model="sortBy" class="filter-select">
<option value="updated_at">{{ $t('tasks.sort.updated') }}</option>
<option value="created_at">{{ $t('tasks.sort.created') }}</option>
<option value="priority">{{ $t('tasks.sort.priority') }}</option>
<option value="status">{{ $t('tasks.sort.status') }}</option>
</select>
</div>
<span class="filter-count">{{ $t('common.pagination.total', { n: store.tasks.length }) }}</span>
</div> </div>
<!-- 按项目分组 --> <!-- 任务分组列表 -->
<div class="task-groups"> <div class="task-groups">
<!-- Y-2: loading/error/empty 兜底(参考 Projects.vue 空态写法) -->
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div> <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="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredGroups.length === 0" class="empty-state">{{ $t('tasks.group.empty') }}</div> <div v-else-if="filteredGroups.length === 0" class="empty-state">
<div class="empty-icon">📋</div>
<div>{{ $t('tasks.group.empty') }}</div>
<button class="btn btn-primary btn-sm" @click="openCreateModal">{{ $t('tasks.create') }}</button>
</div>
<template v-else> <template v-else>
<section <section
v-for="group in filteredGroups" v-for="group in filteredGroups"
:key="group.projectName" :key="group.projectName"
class="task-group" class="task-group"
:class="{ collapsed: collapsedGroups.has(group.projectName) }"
> >
<div class="group-header"> <div class="group-header" @click="toggleGroup(group.projectName)">
<span class="group-chevron">{{ collapsedGroups.has(group.projectName) ? '▸' : '▾' }}</span>
<span class="group-icon">{{ group.icon }}</span> <span class="group-icon">{{ group.icon }}</span>
<h2 class="group-name">{{ group.projectName }}</h2> <h2 class="group-name">{{ group.projectName }}</h2>
<span class="group-count">{{ $t('tasks.group.taskCount', { n: group.tasks.length }) }}</span> <span class="group-count">{{ group.tasks.length }}</span>
</div> </div>
<div class="task-list"> <div v-show="!collapsedGroups.has(group.projectName)" class="task-list">
<div class="task-item" v-for="task in group.tasks" :key="task.id" @click="router.push(`/tasks/${task.id}`)"> <div
class="task-item"
v-for="task in group.tasks"
:key="task.id"
@click="router.push(`/tasks/${task.id}`)"
>
<div class="task-main"> <div class="task-main">
<div class="task-title-row"> <div class="task-title-row">
<span class="task-title">{{ task.title }}</span> <span class="task-title">{{ task.title }}</span>
@@ -63,11 +79,9 @@
</div> </div>
<div class="task-meta"> <div class="task-meta">
<span class="branch-tag" v-if="task.branch_name"> <span class="branch-tag" v-if="task.branch_name">
<span class="branch-icon"></span> <span class="branch-icon"></span>{{ task.branch_name }}
{{ task.branch_name }}
</span> </span>
<span class="task-date">{{ formatRelative(task.created_at) }}</span> <span class="task-date">{{ formatRelative(task.updated_at) }}</span>
<span class="task-time">{{ formatRelative(task.updated_at) }}</span>
</div> </div>
</div> </div>
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span> <span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
@@ -77,37 +91,47 @@
</template> </template>
</div> </div>
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 --> <!-- 分页器 -->
<Paginator <Paginator
v-model:page="page" v-model:page="page"
v-model:pageSize="pageSize" v-model:pageSize="pageSize"
:total="store.tasks.length" :total="store.tasks.length"
/> />
<!-- 新建任务模态框 --> <!-- 新建任务模态框(统一样式,去内联 style) -->
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false"> <div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
<div class="modal-box"> <div class="modal-box">
<h3>{{ $t('tasks.modal.title') }}</h3> <h3>{{ $t('tasks.modal.title') }}</h3>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.project') }}</label> <div class="modal-field">
<select v-model="newTaskProjectId" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px"> <label>{{ $t('tasks.modal.project') }}</label>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option> <select v-model="newTaskProjectId" class="modal-input">
</select> <option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.titleField') }}</label> </select>
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" /> </div>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.desc') }}</label> <div class="modal-field">
<input v-model="newTaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px;box-sizing:border-box" /> <label>{{ $t('tasks.modal.titleField') }}</label>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.branch') }}</label> <input v-model="newTaskTitle" class="modal-input" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
<input v-model="newTaskBranch" :placeholder="$t('tasks.modal.branchPlaceholder')" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px;box-sizing:border-box" /> </div>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.priority') }}</label> <div class="modal-field">
<select v-model="newTaskPriority" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px"> <label>{{ $t('tasks.modal.desc') }}</label>
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option> <input v-model="newTaskDesc" class="modal-input" :placeholder="$t('tasks.modal.descPlaceholder')" />
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option> </div>
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option> <div class="modal-field">
<option :value="3">{{ $t('tasks.modal.priorityLow') }}</option> <label>{{ $t('tasks.modal.branch') }}</label>
</select> <input v-model="newTaskBranch" class="modal-input" :placeholder="$t('tasks.modal.branchPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('tasks.modal.priority') }}</label>
<select v-model="newTaskPriority" class="modal-input">
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option>
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option>
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option>
<option :value="3">{{ $t('tasks.modal.priorityLow') }}</option>
</select>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button> <button class="btn btn-primary" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -115,7 +139,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
@@ -130,12 +154,42 @@ const { t } = useI18n()
const activeProject = ref('all') const activeProject = ref('all')
const activeStatus = ref('all') const activeStatus = ref('all')
// Y-2: 本视图加载态(loadTasks 不翻转全局 state.loading,故用局部 ref 精确反映任务加载) const sortBy = ref('updated_at')
const searchKeyword = ref('')
const loading = ref(false) const loading = ref(false)
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片 // 分页(默认开启 20 条/页)
const page = ref(1) const page = ref(1)
const pageSize = ref(0) const pageSize = ref(20)
// 分组折叠状态(localStorage 记忆)
const collapsedGroups = reactive(new Set<string>())
function toggleGroup(name: string) {
if (collapsedGroups.has(name)) {
collapsedGroups.delete(name)
} else {
collapsedGroups.add(name)
}
localStorage.setItem('df-tasks-collapsed', JSON.stringify([...collapsedGroups]))
}
// 恢复折叠记忆
try {
const saved = localStorage.getItem('df-tasks-collapsed')
if (saved) {
for (const name of JSON.parse(saved)) collapsedGroups.add(name)
}
} catch { /* 忽略脏数据 */ }
// 搜索防抖
let _searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchKeyword, () => {
if (_searchTimer) clearTimeout(_searchTimer)
_searchTimer = setTimeout(() => {
page.value = 1
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
}, 300)
})
// ── 新建任务模态框 ── // ── 新建任务模态框 ──
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -143,9 +197,7 @@ const newTaskProjectId = ref('')
const newTaskTitle = ref('') const newTaskTitle = ref('')
const newTaskDesc = ref('') const newTaskDesc = ref('')
const newTaskBranch = ref('') const newTaskBranch = ref('')
const newTaskPriority = ref(2) // 默认 medium const newTaskPriority = ref(2)
// 异步操作禁用态(IPC 期间防双击重复提交):confirmCreate 创建任务期间禁用确认按钮
const submitting = ref(false) const submitting = ref(false)
interface TaskGroup { interface TaskGroup {
@@ -154,12 +206,6 @@ interface TaskGroup {
tasks: TaskRecord[] tasks: TaskRecord[]
} }
const projectFilters = computed(() => [
{ key: 'all', label: t('tasks.filter.all') },
...store.projects.map(p => ({ key: p.id, label: p.name })),
])
// D-260616-01:筛选器对齐后端 7 态(删 review_ready/merged/abandoned,加 in_review/testing/done/blocked/cancelled)
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [ const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' }, { key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' }, { key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
@@ -181,20 +227,23 @@ const projectIcons: Record<string, string> = {
'flux': '⚡', 'flux': '⚡',
} }
// F-260621-02(P1 status 下沉):status 过滤从前端内存 filter 改为构造 query 调后端,
// store.tasks 已是筛选后的结果(后端 WHERE)。filteredGroups 仅做按项目分组(project
// 维度也有后端 query 下沉,这里 groupMap 仅做展示分组,不再二次 filter)。
//
// 注:project 维度同样下沉后端(activeProject watch 构造 query),store.tasks 已按
// project_id 过滤。groupMap 保留分组逻辑(展示按项目聚合),无需再 filter。
const filteredGroups = computed(() => { const filteredGroups = computed(() => {
// 分页(F-260621-02):pageSize=0 → 全量切片(等价改造前);否则取当前页窗口。 // 排序
// 先对扁平 store.tasks 切片,再分组(保持按项目聚合语义,分组在页窗口内)。 const sorted = [...store.tasks]
const all = store.tasks sorted.sort((a: any, b: any) => {
const filtered = pageSize.value > 0 if (sortBy.value === 'priority') return (a.priority ?? 2) - (b.priority ?? 2)
? all.slice((page.value - 1) * pageSize.value, page.value * pageSize.value) if (sortBy.value === 'status') return String(a.status).localeCompare(String(b.status))
: all const av = a[sortBy.value] ?? ''
const bv = b[sortBy.value] ?? ''
return String(bv).localeCompare(String(av))
})
// 分页切片
const filtered = pageSize.value > 0
? sorted.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)
: sorted
// 按项目分组
const groupMap = new Map<string, TaskRecord[]>() const groupMap = new Map<string, TaskRecord[]>()
for (const task of filtered) { for (const task of filtered) {
if (!groupMap.has(task.project_id)) { if (!groupMap.has(task.project_id)) {
@@ -215,18 +264,15 @@ const filteredGroups = computed(() => {
return result return result
}) })
// statusLabel / priorityLabel / priorityClass 由 ../constants/project 提供(与 ProjectDetail 文案统一)
// F-260621-02(P1 status 下沉):按当前 activeProject + activeStatus 构造后端 query。
// - 全 all → undefined(全量,等价旧行为)
// - 任一具体值 → query 下沉后端 WHERE(project_id / status)
function buildTaskQuery(): TaskQuery | undefined { function buildTaskQuery(): TaskQuery | undefined {
const projAll = activeProject.value === 'all' const projAll = activeProject.value === 'all'
const statusAll = activeStatus.value === 'all' const statusAll = activeStatus.value === 'all'
if (projAll && statusAll) return undefined const kw = searchKeyword.value.trim()
const query: TaskQuery = {} if (projAll && statusAll && !kw) return undefined
const query: TaskQuery = { order_by: sortBy.value }
if (!projAll) query.project_id = activeProject.value if (!projAll) query.project_id = activeProject.value
if (!statusAll) query.status = activeStatus.value if (!statusAll) query.status = activeStatus.value
if (kw) query.keyword = kw
return query return query
} }
@@ -238,6 +284,7 @@ async function refresh() {
loading.value = false loading.value = false
} }
} }
void refresh // 保留:将来可能由下拉刷新/手动刷新按钮触发
function openCreateModal() { function openCreateModal() {
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : '' newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
@@ -259,16 +306,14 @@ async function confirmCreate() {
branch_name: newTaskBranch.value.trim() || undefined, branch_name: newTaskBranch.value.trim() || undefined,
priority: newTaskPriority.value, priority: newTaskPriority.value,
}) })
if (!r) return // 失败已 toast,保持弹窗不关 if (!r) return
showCreateModal.value = false showCreateModal.value = false
} finally { } finally {
submitting.value = false submitting.value = false
} }
} }
// B-260615-29: 切换项目筛选时按筛选重载(避免仅前端 filter,跨项目视图不同步) // 筛选切换重载
// CR-260615-26(R-1): 同步登记当前筛选到 store,供 AR-11 listener 按筛选拉取
// F-260621-02(P1 status 下沉):重载 query 同时含 project_id + status,前端不再 filter status
watch(activeProject, () => { watch(activeProject, () => {
store.setActiveTaskProject(activeProject.value) store.setActiveTaskProject(activeProject.value)
page.value = 1 page.value = 1
@@ -276,17 +321,31 @@ watch(activeProject, () => {
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
}) })
// F-260621-02(P1 status 下沉):切换 status 筛选时构造 query 重载(后端 WHERE,非前端 filter), watch(activeStatus, () => {
// 并登记到 store 供 AR-11 listener 复用同一筛选(保 B-29 契约) store.setActiveTaskStatus(activeStatus.value)
watch(activeStatus, (key) => {
store.setActiveTaskStatus(key)
page.value = 1 page.value = 1
loading.value = true loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
}) })
watch(sortBy, () => {
page.value = 1
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
// Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键)
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault()
openCreateModal()
} else if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
document.querySelector<HTMLInputElement>('.search-input')?.focus()
}
}
onMounted(async () => { onMounted(async () => {
// R-1: 登记初始筛选(all=全量),让数据变更 listener 知道视图已挂载、按全量拉取
store.setActiveTaskProject(activeProject.value) store.setActiveTaskProject(activeProject.value)
store.setActiveTaskStatus(activeStatus.value) store.setActiveTaskStatus(activeStatus.value)
loading.value = true loading.value = true
@@ -295,6 +354,13 @@ onMounted(async () => {
} finally { } finally {
loading.value = false loading.value = false
} }
window.addEventListener('keydown', onKeydown)
})
import { onUnmounted } from 'vue'
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
if (_searchTimer) clearTimeout(_searchTimer)
}) })
</script> </script>
@@ -306,11 +372,27 @@ onMounted(async () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--df-gap-page); margin-bottom: var(--df-gap-page);
gap: 12px;
} }
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); } .page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); white-space: nowrap; }
.header-actions { display: flex; gap: 10px; } .header-actions { display: flex; gap: 10px; align-items: center; }
/* ===== 按钮 ===== */ /* 搜索框 */
.search-input {
width: 220px;
padding: 6px 12px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--df-accent); }
.search-input::placeholder { color: var(--df-text-dim); }
/* 按钮 */
.btn { .btn {
padding: 8px 16px; padding: 8px 16px;
border: none; border: none;
@@ -323,14 +405,16 @@ onMounted(async () => {
.btn-primary:hover { background: var(--df-accent-hover); } .btn-primary:hover { background: var(--df-accent-hover); }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); } .btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); } .btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
.btn-sm { padding: 4px 12px; font-size: 12px; }
/* ===== 筛选栏 ===== */ /* 筛选栏(紧凑) */
.filter-bar { .filter-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; align-items: center;
gap: 12px;
margin-bottom: var(--df-gap-page); margin-bottom: var(--df-gap-page);
padding: var(--df-pad-panel); padding: 10px 14px;
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg); border-radius: var(--df-radius-lg);
@@ -338,7 +422,6 @@ onMounted(async () => {
.filter-group { .filter-group {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; /* 窄屏按钮可换行,避免 nowrap 溢出 */
gap: 6px; gap: 6px;
} }
.filter-label { .filter-label {
@@ -346,37 +429,25 @@ onMounted(async () => {
color: var(--df-text-dim); color: var(--df-text-dim);
white-space: nowrap; white-space: nowrap;
} }
.filter-btn { .filter-select {
display: inline-flex; padding: 4px 10px;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
background: transparent; background: var(--df-bg);
color: var(--df-text-secondary); color: var(--df-text);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; outline: none;
transition: border-color 0.15s;
} }
.filter-btn-icon { line-height: 1; } /* 图标基线对齐,避免行高跳动 */ .filter-select:hover { border-color: var(--df-accent); }
.filter-btn-label { white-space: nowrap; } /* 文字不内断行,换行只发生在按钮间 */ .filter-count {
.filter-btn:hover { background: rgba(108, 99, 255, 0.06); color: var(--df-text); } margin-left: auto;
.filter-btn.active { font-size: 12px;
background: var(--df-accent); color: var(--df-text-dim);
color: #fff;
border-color: var(--df-accent);
}
/* 窄屏(手机宽):按钮内图标与文字改纵向排布,避免横向挤压致图标/文字在空格处错位断行 */
@media (max-width: 480px) {
.filter-btn {
flex-direction: column;
gap: 2px;
padding: 4px 8px;
}
} }
/* ===== 任务分组 ===== */ /* 任务分组 */
.task-groups { .task-groups {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -387,22 +458,33 @@ onMounted(async () => {
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg); border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel); overflow: hidden;
} }
.group-header { .group-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: var(--df-gap-head); padding: 10px 14px;
padding-bottom: 10px; cursor: pointer;
user-select: none;
border-bottom: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
}
.group-header:hover { background: rgba(108, 99, 255, 0.04); }
.task-group.collapsed .group-header { border-bottom: none; }
.group-chevron { font-size: 10px; color: var(--df-text-dim); width: 12px; text-align: center; }
.group-icon { font-size: 16px; }
.group-name { font-size: 14px; font-weight: 500; color: var(--df-text); flex: 1; }
.group-count {
font-size: 11px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.06);
padding: 1px 8px;
border-radius: 8px;
} }
.group-icon { font-size: 18px; }
.group-name { font-size: 15px; font-weight: 500; color: var(--df-text); }
.group-count { font-size: 12px; color: var(--df-text-dim); margin-left: 8px; }
/* ===== 任务条目 ===== */ /* 任务条目(紧凑) */
.task-list { .task-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -411,11 +493,12 @@ onMounted(async () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 14px; padding: 8px 14px;
border-radius: var(--df-radius); border-bottom: 0.5px solid rgba(255,255,255,0.03);
transition: background 0.1s; transition: background 0.1s;
cursor: pointer; cursor: pointer;
} }
.task-item:last-child { border-bottom: none; }
.task-item:hover { background: rgba(108, 99, 255, 0.04); } .task-item:hover { background: rgba(108, 99, 255, 0.04); }
.task-main { flex: 1; min-width: 0; } .task-main { flex: 1; min-width: 0; }
@@ -424,14 +507,22 @@ onMounted(async () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 4px;
} }
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .task-title {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.priority-badge { .priority-badge {
font-size: 10px; font-weight: 500; font-size: 10px;
font-weight: 500;
padding: 1px 6px; padding: 1px 6px;
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
flex-shrink: 0;
} }
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); } .priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
.priority-high { background: rgba(255,152,0,0.2); color: #ff9800; } .priority-high { background: rgba(255,152,0,0.2); color: #ff9800; }
@@ -441,55 +532,85 @@ onMounted(async () => {
.task-meta { .task-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
margin-top: 2px;
} }
.branch-tag { .branch-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
font-size: 12px;
color: var(--df-text-secondary);
background: rgba(108, 99, 255, 0.06);
padding: 2px 8px;
border-radius: var(--df-radius-xs);
font-family: monospace;
}
.branch-icon { font-size: 12px; color: var(--df-accent); }
.task-date { font-size: 11px; color: var(--df-text-dim); }
.task-time {
font-size: 11px; font-size: 11px;
color: var(--df-text-dim); color: var(--df-text-dim);
opacity: 0.7; }
.task-date {
font-size: 11px;
color: var(--df-text-dim);
} }
/* ===== 状态标签 ===== */
.status-tag { .status-tag {
font-size: 11px; font-weight: 500; font-size: 11px;
padding: 4px 10px; padding: 2px 8px;
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-xs);
flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
} }
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* D-260616-01:7 态新增 — testing(橙警告,近 review 区分)/blocked(红 danger,与 cancelled 区分用实心边框) */
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
/* ===== 空状态(Y-2: loading/error/empty 兜底,样式参考 Projects.vue) ===== */ /* 空态 */
.empty-state { .empty-state {
text-align: center; padding: 60px 20px; display: flex;
font-size: 14px; color: var(--df-text-dim); flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 0;
color: var(--df-text-dim);
font-size: 13px;
} }
.empty-icon { font-size: 32px; opacity: 0.4; }
/* ===== 模态框 ===== */ /* 模态框 */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal-overlay {
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; } position: fixed;
.modal-box h3 { color: var(--df-text); margin-bottom: 16px; } inset: 0;
.modal-box input, .modal-box select { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; } background: rgba(0,0,0,0.5);
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; } display: flex;
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; } align-items: center;
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; } justify-content: center;
z-index: 100;
}
.modal-box {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 20px;
min-width: 400px;
max-width: 480px;
}
.modal-box h3 { font-size: 16px; color: var(--df-text); margin-bottom: 16px; }
.modal-field {
margin-bottom: 12px;
}
.modal-field label {
display: block;
font-size: 12px;
color: var(--df-text-secondary);
margin-bottom: 4px;
}
.modal-input {
width: 100%;
box-sizing: border-box;
padding: 8px 12px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 13px;
outline: none;
}
.modal-input:focus { border-color: var(--df-accent); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style> </style>