新增: 灵感归档箱与任务归档动线

- ideasStore/taskStore: archiveIdea/archiveTask IPC 封装(本地移除/本地更新,对齐 delete 语义)
- project.ts 组合 store 显式透传 archiveIdea/archiveTask
- Ideas.vue: 筛选栏归档箱 Tab(独立数据源 ideaApi.listArchived,主查询不受扰);archiveCurrentIdea 处理器(归档确认+归档箱内取消归档自动清选中);IdeaDetail 传 in-archive-box prop
- IdeaDetail.vue: inArchiveBox prop + archive emit;操作区归档/取消归档按钮,归档箱模式隐藏删除(防误删归档记忆)
- i18n zh-CN/en ideas 补 filterArchived/archiveBoxEmpty/quickArchive/quickUnarchive/confirmArchive/toast 键(前批遗留)
验收: vue-tsc --noEmit EXIT 0
This commit is contained in:
lxy
2026-10-05 00:35:02 +08:00
parent c6ebf15407
commit 83e86cd6b7
13 changed files with 201 additions and 9 deletions
+10
View File
@@ -49,4 +49,14 @@ export const ideaApi = {
listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> { listEvaluations(ideaId: string): Promise<IdeaEvaluationRecord[]> {
return invoke('list_idea_evaluations', { ideaId }) return invoke('list_idea_evaluations', { ideaId })
}, },
/** 归档/取消归档灵感(归档功能 P1,V47)。返回是否命中(灵感不存在/已软删为 false)。 */
archive(id: string, archived: boolean): Promise<boolean> {
return invoke('archive_idea', { id, archived })
},
/** 列出归档灵感(archived = 1 且未删,updated_at DESC)。 */
listArchived(): Promise<IdeaRecord[]> {
return invoke('list_archived_ideas')
},
} }
+10
View File
@@ -59,4 +59,14 @@ export const taskApi = {
advance(id: string, targetStatus: string): Promise<TaskRecord> { advance(id: string, targetStatus: string): Promise<TaskRecord> {
return invoke('advance_task', { id, targetStatus }) return invoke('advance_task', { id, targetStatus })
}, },
/** 归档/取消归档任务(归档功能 P1,V46)。返回是否命中(任务不存在/已软删为 false)。 */
archive(id: string, archived: boolean): Promise<boolean> {
return invoke('archive_task', { id, archived })
},
/** 列出归档任务(archived = 1 且未删,updated_at DESC)。projectId 可选过滤。 */
listArchived(projectId?: string): Promise<TaskRecord[]> {
return invoke('list_archived_tasks', { projectId: projectId ?? null })
},
} }
+8
View File
@@ -37,6 +37,8 @@ export interface IdeaRecord {
scores: string | null scores: string | null
/** 关联灵感 id JSON 数组字符串(对齐后端 related_ids Option<String>),null/空串=无关联 */ /** 关联灵感 id JSON 数组字符串(对齐后端 related_ids Option<String>),null/空串=无关联 */
related_ids: string | null related_ids: string | null
/** 归档标记(归档功能 P1,V47 列)。归档灵感退出主视图,仅在归档箱可见。 */
archived: boolean
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -189,6 +191,12 @@ export interface TaskRecord {
* 老任务无结构化规格时为 undefined。 * 老任务无结构化规格时为 undefined。
*/ */
content_json?: string content_json?: string
/**
* 归档标记(归档功能 P1,V46 列)。与 status/queue 正交的纯元数据:
* archived=true 的任务退出主视图(后端活跃查询恒过滤),仅在归档视图可见。
* 老任务后端 DEFAULT 0 保证该字段始终存在。
*/
archived: boolean
created_at: string created_at: string
updated_at: string updated_at: string
} }
+9 -2
View File
@@ -286,10 +286,14 @@
</div> </div>
</div> </div>
<!-- 操作(P0-② 立项按钮已上移至标题行,此处仅留删除) --> <!-- 操作(P0-② 立项按钮已上移至标题行) -->
<div class="detail-section" style="margin-top:8px"> <div class="detail-section" style="margin-top:8px">
<div class="action-buttons"> <div class="action-buttons">
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')"> <!-- 归档功能 P1:主列表=归档动线;归档箱=取消归档动线(删除隐藏,防误删归档记忆) -->
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('archive', !inArchiveBox)">
{{ inArchiveBox ? $t('ideas.quickUnarchive') : $t('ideas.quickArchive') }}
</button>
<button v-if="!inArchiveBox" class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }} {{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
</button> </button>
</div> </div>
@@ -315,12 +319,15 @@ const props = defineProps<{
evalError: string evalError: string
promoting: boolean promoting: boolean
deleting: boolean deleting: boolean
/** 归档功能 P1:父视图是否处于归档箱模式(决定操作按钮语义:归档↔取消归档) */
inArchiveBox?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'evaluate'): void (e: 'evaluate'): void
(e: 'promote'): void (e: 'promote'): void
(e: 'delete'): void (e: 'delete'): void
(e: 'archive', archived: boolean): void
(e: 'status-change', value: IdeaStatus): void (e: 'status-change', value: IdeaStatus): void
// 返 Promise<boolean> 让子组件 await 知成功/失败。 // 返 Promise<boolean> 让子组件 await 知成功/失败。
// true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。 // true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。
+9
View File
@@ -28,6 +28,7 @@ export default {
createFailed: 'Failed to create idea', createFailed: 'Failed to create idea',
deleteFailed: 'Failed to delete idea', deleteFailed: 'Failed to delete idea',
relateFailed: 'Failed to update relations', relateFailed: 'Failed to update relations',
archiveFailed: 'Archive operation failed',
}, },
// Action feedback toasts (WK-16/18: success/failure light toast, aligns Knowledge.createdToast) // Action feedback toasts (WK-16/18: success/failure light toast, aligns Knowledge.createdToast)
@@ -147,6 +148,14 @@ export default {
// ⑥ Status change confirm (terminal promoted/rejected/archived or leaving approved) // ⑥ Status change confirm (terminal promoted/rejected/archived or leaving approved)
confirmStatus: 'Change idea "{title}" status from {from} to {to}?', confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
promoteFailed: 'Promotion failed', promoteFailed: 'Promotion failed',
// Archive P1: idea archive box
filterArchived: '📦 Archive',
archiveBoxEmpty: 'No archived ideas',
quickArchive: '📦 Archive',
quickUnarchive: '📤 Unarchive',
confirmArchive: 'Archive idea "{title}"? It will leave the main view; find it in the Archive box.',
archivedToast: 'Archived',
unarchivedToast: 'Unarchived',
evalFailed: 'Evaluation failed', evalFailed: 'Evaluation failed',
}, },
} }
+9
View File
@@ -53,6 +53,14 @@ export default {
quickStatus: 'Status', quickStatus: 'Status',
quickPriority: 'Priority', quickPriority: 'Priority',
quickDelete: '🗑 Delete', quickDelete: '🗑 Delete',
// Archive P1: archive moves out of main view, keep searchable; delete moves to trash
quickArchive: '📦 Archive',
quickUnarchive: '📤 Unarchive',
confirmArchive: 'Archive task "{title}"? It will leave the main view; find it in the Archive box.',
archivedToast: 'Archived',
unarchivedToast: 'Unarchived',
archiveBox: '📦 Archive',
archiveBoxEmpty: 'No archived tasks',
// Batch action bar (shared by Tasks / ProjectDetail / TaskDetail subtasks) // Batch action bar (shared by Tasks / ProjectDetail / TaskDetail subtasks)
batch: { batch: {
@@ -129,6 +137,7 @@ export default {
createFailed: 'Failed to create task', createFailed: 'Failed to create task',
deleteFailed: 'Failed to delete task', deleteFailed: 'Failed to delete task',
updateFailed: 'Failed to update task', updateFailed: 'Failed to update task',
archiveFailed: 'Archive operation failed',
selectProject: 'Please select a project', selectProject: 'Please select a project',
}, },
}, },
+9
View File
@@ -28,6 +28,7 @@ export default {
createFailed: '创建灵感失败', createFailed: '创建灵感失败',
deleteFailed: '删除灵感失败', deleteFailed: '删除灵感失败',
relateFailed: '更新关联关系失败', relateFailed: '更新关联关系失败',
archiveFailed: '归档操作失败',
}, },
// 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast) // 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast)
@@ -147,6 +148,14 @@ export default {
// ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口) // ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口)
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?', confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
promoteFailed: '立项失败', promoteFailed: '立项失败',
// 归档功能 P1(灵感归档箱)
filterArchived: '📦 归档箱',
archiveBoxEmpty: '暂无归档灵感',
quickArchive: '📦 归档到归档箱',
quickUnarchive: '📤 取消归档',
confirmArchive: '归档灵感「{title}」?归档后移出主视图,可在归档箱查看。',
archivedToast: '已归档',
unarchivedToast: '已取消归档',
evalFailed: '评估失败', evalFailed: '评估失败',
}, },
} }
+9
View File
@@ -53,6 +53,14 @@ export default {
quickStatus: '状态', quickStatus: '状态',
quickPriority: '优先级', quickPriority: '优先级',
quickDelete: '🗑 删除', quickDelete: '🗑 删除',
// 归档功能 P1(归档=移出主视图保留检索,区别于删除=进回收站)
quickArchive: '📦 归档',
quickUnarchive: '📤 取消归档',
confirmArchive: '归档任务「{title}」?归档后移出主视图,可在归档箱查看。',
archivedToast: '已归档',
unarchivedToast: '已取消归档',
archiveBox: '📦 归档箱',
archiveBoxEmpty: '暂无归档任务',
// 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务) // 批量操作栏(三处任务列表共用:Tasks / ProjectDetail / TaskDetail 子任务)
batch: { batch: {
@@ -129,6 +137,7 @@ export default {
createFailed: '创建任务失败', createFailed: '创建任务失败',
deleteFailed: '删除任务失败', deleteFailed: '删除任务失败',
updateFailed: '更新任务失败', updateFailed: '更新任务失败',
archiveFailed: '归档操作失败',
selectProject: '请选择项目', selectProject: '请选择项目',
}, },
}, },
+2
View File
@@ -179,6 +179,7 @@ function createStore() {
createTask: tasksStore.createTask, createTask: tasksStore.createTask,
updateTask: tasksStore.updateTask, updateTask: tasksStore.updateTask,
deleteTask: tasksStore.deleteTask, deleteTask: tasksStore.deleteTask,
archiveTask: tasksStore.archiveTask,
// P1-b A0:统计独立通道(Dashboard 卡片 + ActiveProjectsPanel 各项目任务数) // P1-b A0:统计独立通道(Dashboard 卡片 + ActiveProjectsPanel 各项目任务数)
loadStats, loadStats,
// idea actions // idea actions
@@ -186,6 +187,7 @@ function createStore() {
createIdea: ideasStore.createIdea, createIdea: ideasStore.createIdea,
updateIdea: ideasStore.updateIdea, updateIdea: ideasStore.updateIdea,
deleteIdea: ideasStore.deleteIdea, deleteIdea: ideasStore.deleteIdea,
archiveIdea: ideasStore.archiveIdea,
evaluateIdea: ideasStore.evaluateIdea, evaluateIdea: ideasStore.evaluateIdea,
promoteIdea: ideasStore.promoteIdea, promoteIdea: ideasStore.promoteIdea,
relateIdeas: ideasStore.relateIdeas, relateIdeas: ideasStore.relateIdeas,
+14
View File
@@ -70,6 +70,19 @@ export function createIdeasStore() {
}) })
} }
/**
* 归档/取消归档灵感(归档功能 P1)。归档=移出主视图(list_by_query 恒带 archived=0),
* 本地同步从列表移除(取消归档时调 loadIdeas 重拉)。返回后端命中布尔。
*/
async function archiveIdea(id: string, archived: boolean): Promise<boolean> {
const ok = await runWithCatch(state, t('ideas.err.archiveFailed'), async () => {
const hit = await ideaApi.archive(id, archived)
if (hit && archived) state.ideas = state.ideas.filter(x => x.id !== id)
return hit
})
return ok === true
}
return { return {
loadIdeas, loadIdeas,
createIdea, createIdea,
@@ -78,5 +91,6 @@ export function createIdeasStore() {
evaluateIdea, evaluateIdea,
promoteIdea, promoteIdea,
relateIdeas, relateIdeas,
archiveIdea,
} }
} }
+14
View File
@@ -69,10 +69,24 @@ export function createTasksStore() {
return ok === true return ok === true
} }
/**
* 归档/取消归档任务(归档功能 P1)。归档=移出主视图(后端活跃查询恒过滤),
* 本地同步从列表移除(取消归档时调 loadTasks 重拉)。返回后端命中布尔。
*/
async function archiveTask(id: string, archived: boolean): Promise<boolean> {
const ok = await runWithCatch(state, t('tasks.err.archiveFailed'), async () => {
const hit = await taskApi.archive(id, archived)
if (hit && archived) state.tasks = state.tasks.filter(x => x.id !== id)
return hit
})
return ok === true
}
return { return {
loadTasks, loadTasks,
createTask, createTask,
updateTask, updateTask,
deleteTask, deleteTask,
archiveTask,
} }
} }
+48 -4
View File
@@ -54,7 +54,7 @@
<div class="idea-list"> <div class="idea-list">
<!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) --> <!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div> <div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div> <div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ activeFilter === 'archived' ? $t('ideas.archiveBoxEmpty') : $t('ideas.listEmpty') }}</div>
<template v-else> <template v-else>
<div <div
v-for="idea in pagedIdeas" v-for="idea in pagedIdeas"
@@ -93,9 +93,11 @@
:eval-error="evalError" :eval-error="evalError"
:promoting="promoting" :promoting="promoting"
:deleting="deleting" :deleting="deleting"
:in-archive-box="activeFilter === 'archived'"
@evaluate="evaluateCurrentIdea" @evaluate="evaluateCurrentIdea"
@promote="promoteToProject" @promote="promoteToProject"
@delete="deleteCurrentIdea" @delete="deleteCurrentIdea"
@archive="archiveCurrentIdea"
@status-change="onStatusChange" @status-change="onStatusChange"
@update-desc="onUpdateDesc" @update-desc="onUpdateDesc"
@update-related="onUpdateRelated" @update-related="onUpdateRelated"
@@ -160,7 +162,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { useProjectStore } from '../stores/project' import { useProjectStore } from '../stores/project'
import type { IdeaStatus, IdeaQuery } from '../api/types' import type { IdeaRecord, IdeaStatus, IdeaQuery } from '../api/types'
import { ideaApi } from '../api'
import { formatDate } from '../utils/time' import { formatDate } from '../utils/time'
import { stripMd } from '../utils/markdown' import { stripMd } from '../utils/markdown'
import { scoreTier } from '../utils/ideaEval' import { scoreTier } from '../utils/ideaEval'
@@ -180,7 +183,7 @@ const store = useProjectStore()
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings) // 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm() const { confirmState, confirmDialog, answerConfirm } = useConfirm()
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted' type FilterKey = 'all' | 'hot' | 'pending' | 'promoted' | 'archived'
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef), // ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。 // key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
@@ -212,6 +215,8 @@ const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' }, { key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' }, { key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' }, { key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
// 归档功能 P1:归档箱(独立数据源 listArchived,不走主查询)
{ key: 'archived', labelKey: 'ideas.filterArchived', icon: '📦' },
] ]
// ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ── // ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ──
@@ -237,9 +242,21 @@ function buildIdeaQuery(): IdeaQuery {
return q return q
} }
// 归档功能 P1:归档箱独立数据源(切到归档箱 filter 时拉取;其余 filter 保持 null 不请求)
const archivedIdeas = ref<IdeaRecord[]>([])
// 按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选, // 按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选,
// 避免 promote/relate/监听触发无参 loadIdeas 全量冲掉当前筛选视图) // 避免 promote/relate/监听触发无参 loadIdeas 全量冲掉当前筛选视图)
function loadCurrentView() { // 归档箱模式:拉归档数据源,主查询不动。
async function loadCurrentView() {
if (activeFilter.value === 'archived') {
try {
archivedIdeas.value = await ideaApi.listArchived()
} catch (e: any) {
Message.error(e?.toString() ?? t('ideas.err.loadFailed'))
}
return
}
store.setActiveIdeaQuery(buildIdeaQuery()) store.setActiveIdeaQuery(buildIdeaQuery())
return store.loadIdeas(buildIdeaQuery()) return store.loadIdeas(buildIdeaQuery())
} }
@@ -272,6 +289,9 @@ watch(sortMode, () => { page.value = 1; void reloadIdeas(true) })
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) }) watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
const filteredIdeas = computed(() => { const filteredIdeas = computed(() => {
// 归档箱模式:整列表切归档数据源
if (activeFilter.value === 'archived') return archivedIdeas.value
let ideas = store.ideas let ideas = store.ideas
// hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter: // hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter:
@@ -380,6 +400,30 @@ async function deleteCurrentIdea() {
} }
} }
// 归档功能 P1:归档/取消归档当前灵感(归档箱内取消归档后自动切回 all 重载)。
async function archiveCurrentIdea(archived: boolean) {
if (!currentIdea.value || deleting.value) return
if (archived && !await confirmDialog(t('ideas.confirmArchive', { title: currentIdea.value.title }))) return
deleting.value = true
try {
const ok = await store.archiveIdea(currentIdea.value.id, archived)
if (!ok) { Message.error(t('ideas.err.archiveFailed')); return }
Message.success(archived ? t('ideas.archivedToast') : t('ideas.unarchivedToast'))
if (activeFilter.value === 'archived') {
// 归档箱内:本地移除 + 选中态清理
archivedIdeas.value = archivedIdeas.value.filter(i => i.id !== currentIdea.value?.id)
if (selectedId.value === currentIdea.value?.id) selectedId.value = null
} else {
// 主列表归档成功:store 已本地移除,清选中(归档项不再可见)
selectedId.value = null
}
} catch (e: any) {
Message.error(e?.toString() ?? t('ideas.err.archiveFailed'))
} finally {
deleting.value = false
}
}
async function promoteToProject() { async function promoteToProject() {
if (!currentIdea.value || promoting.value) return if (!currentIdea.value || promoting.value) return
promoting.value = true promoting.value = true
+50 -3
View File
@@ -60,6 +60,8 @@
<div class="filter-group view-toggle"> <div class="filter-group view-toggle">
<button class="seg-btn" :class="{ 'is-active': viewMode === 'list' }" @click="viewMode = 'list'">{{ $t('tasks.kanban.toggleList') }}</button> <button class="seg-btn" :class="{ 'is-active': viewMode === 'list' }" @click="viewMode = 'list'">{{ $t('tasks.kanban.toggleList') }}</button>
<button class="seg-btn" :class="{ 'is-active': viewMode === 'kanban' }" @click="viewMode = 'kanban'">{{ $t('tasks.kanban.toggleKanban') }}</button> <button class="seg-btn" :class="{ 'is-active': viewMode === 'kanban' }" @click="viewMode = 'kanban'">{{ $t('tasks.kanban.toggleKanban') }}</button>
<!-- 归档箱 P1:开关态独立于 list/kanban,开启后整页只渲染归档任务 -->
<button class="seg-btn" :class="{ 'is-active': archiveBoxMode }" @click="toggleArchiveBox">{{ $t('tasks.archiveBox') }}</button>
</div> </div>
<!-- 列表模式全选(看板无批量栏,隐藏) --> <!-- 列表模式全选(看板无批量栏,隐藏) -->
<label v-if="viewMode === 'list'" class="select-all" :title="$t('tasks.batch.selectHint')"> <label v-if="viewMode === 'list'" class="select-all" :title="$t('tasks.batch.selectHint')">
@@ -77,7 +79,8 @@
<div class="task-groups" v-if="viewMode === 'list'"> <div class="task-groups" v-if="viewMode === 'list'">
<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="filteredGroups.length === 0" class="empty-state"> <div v-else-if="filteredGroups.length === 0" class="empty-state">
<div class="empty-icon">📋</div> <div class="empty-icon">{{ archiveBoxMode ? '📦' : '📋' }}</div>
<p v-if="archiveBoxMode">{{ $t('tasks.archiveBoxEmpty') }}</p>
<div>{{ $t('tasks.group.empty') }}</div> <div>{{ $t('tasks.group.empty') }}</div>
<button class="btn btn-primary btn-sm" @click="openCreateModal()">{{ $t('tasks.create') }}</button> <button class="btn btn-primary btn-sm" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
</div> </div>
@@ -258,6 +261,7 @@
</button> </button>
</div> </div>
<div class="quick-menu-divider"></div> <div class="quick-menu-divider"></div>
<button class="quick-menu-item" @click="quickArchive(row.task, true)">{{ $t('tasks.quickArchive') }}</button>
<button class="quick-menu-item quick-menu-danger" @click="quickDelete(row.task)">{{ $t('tasks.quickDelete') }}</button> <button class="quick-menu-item quick-menu-danger" @click="quickDelete(row.task)">{{ $t('tasks.quickDelete') }}</button>
</div> </div>
</div> </div>
@@ -434,6 +438,10 @@ const activeProject = usePersistedRef('tasks.activeProject', 'all')
const activeStatuses = usePersistedRef<string[]>('tasks.activeStatuses', []) const activeStatuses = usePersistedRef<string[]>('tasks.activeStatuses', [])
// 列表/看板视图切换(持久化;看板模式隐藏状态多选与排序,列即状态) // 列表/看板视图切换(持久化;看板模式隐藏状态多选与排序,列即状态)
const viewMode = usePersistedRef<'list' | 'kanban'>('tasks.viewMode', 'list') const viewMode = usePersistedRef<'list' | 'kanban'>('tasks.viewMode', 'list')
// 归档功能 P1:归档箱开关(持久化)。开启后整页只渲染归档任务(独立数据源,不经 buildTaskQuery)。
const archiveBoxMode = usePersistedRef('tasks.archiveBoxMode', false)
// 归档箱数据源(仅归档箱模式拉取;列表态保持 null 不请求)
const archivedTasks = ref<TaskRecord[]>([])
// 状态多选切换:点击「全部」清空数组(空=未筛选=显示全部); // 状态多选切换:点击「全部」清空数组(空=未筛选=显示全部);
// 点击具体状态时移除「全部」残留(已有具体筛选则「全部」芯片不应保留激活)。 // 点击具体状态时移除「全部」残留(已有具体筛选则「全部」芯片不应保留激活)。
@@ -547,6 +555,43 @@ async function quickDelete(task: TaskRecord, childCount = 0) {
showToast(e?.toString() ?? t('tasks.err.deleteFailed'), 'error') showToast(e?.toString() ?? t('tasks.err.deleteFailed'), 'error')
} }
} }
// 归档功能 P1:快捷归档(archive=true 归档,false=归档箱内取消归档)。
// 归档后任务退出主视图(后端活跃查询恒过滤),本地从当前数据源移除;归档箱内取消则从归档箱移除并回主列表。
async function quickArchive(task: TaskRecord, archived: boolean) {
quickMenuId.value = null
if (archived && !await confirmDialog(t('tasks.confirmArchive', { title: task.title }))) return
try {
const ok = await store.archiveTask(task.id, archived)
if (!ok) { showToast(t('tasks.err.archiveFailed'), 'error'); return }
showToast(archived ? t('tasks.archivedToast') : t('tasks.unarchivedToast'))
// 归档箱模式:取消归档后从归档箱移除;列表模式无需动 store(store.archiveTask 已移除)
if (archiveBoxMode.value) archivedTasks.value = archivedTasks.value.filter(x => x.id !== task.id)
} catch (e: any) {
showToast(e?.toString() ?? t('tasks.err.archiveFailed'), 'error')
}
}
// 归档箱开关:开启时拉归档数据源;关闭时按当前筛选重拉主列表。
// 看板模式进入归档箱自动切回列表(归档箱走列表渲染,看板列语义=状态,与归档正交)。
async function toggleArchiveBox() {
archiveBoxMode.value = !archiveBoxMode.value
quickMenuId.value = null
if (archiveBoxMode.value) {
if (viewMode.value === 'kanban') viewMode.value = 'list'
loading.value = true
try {
const pid = activeProject.value === 'all' ? undefined : activeProject.value
archivedTasks.value = await taskApi.listArchived(pid)
} catch (e: any) {
showToast(e?.toString() ?? t('tasks.err.loadFailed'), 'error')
} finally {
loading.value = false
}
} else {
loading.value = true
try { await store.loadTasks(buildTaskQuery()) } finally { loading.value = false }
}
}
// 点击外部关闭快捷菜单 // 点击外部关闭快捷菜单
function closeQuickMenu() { quickMenuId.value = null } function closeQuickMenu() { quickMenuId.value = null }
function toggleGroup(name: string) { function toggleGroup(name: string) {
@@ -631,10 +676,12 @@ interface TaskGroup {
} }
// 树组装 — 顶层 = 无 parent_id 的任务,每个顶层挂 children;父进度前端计算(D6,全量已在前端)。 // 树组装 — 顶层 = 无 parent_id 的任务,每个顶层挂 children;父进度前端计算(D6,全量已在前端)。
// 归档功能 P1:归档箱模式数据源切 archivedTasks(独立拉取,不经 buildTaskQuery)。
const listSource = computed<TaskRecord[]>(() => (archiveBoxMode.value ? archivedTasks.value : store.tasks))
const taskRows = computed<TaskRow[]>(() => { const taskRows = computed<TaskRow[]>(() => {
// 子任务按 parent_id 分组(1 级嵌套) // 子任务按 parent_id 分组(1 级嵌套)
const childMap = new Map<string, TaskRecord[]>() const childMap = new Map<string, TaskRecord[]>()
for (const t of store.tasks) { for (const t of listSource.value) {
if (!t.parent_id) continue if (!t.parent_id) continue
const arr = childMap.get(t.parent_id) ?? [] const arr = childMap.get(t.parent_id) ?? []
arr.push(t) arr.push(t)
@@ -648,7 +695,7 @@ const taskRows = computed<TaskRow[]>(() => {
const bv = (b as any)[sortBy.value] ?? '' const bv = (b as any)[sortBy.value] ?? ''
return String(bv).localeCompare(String(av)) return String(bv).localeCompare(String(av))
} }
const tops = store.tasks.filter(t => !t.parent_id).sort(cmp) const tops = listSource.value.filter(t => !t.parent_id).sort(cmp)
const rows: TaskRow[] = [] const rows: TaskRow[] = []
for (const t of tops) { for (const t of tops) {
const children = (childMap.get(t.id) ?? []).sort(cmp) const children = (childMap.get(t.id) ?? []).sort(cmp)