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

- 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
+48 -4
View File
@@ -54,7 +54,7 @@
<div class="idea-list">
<!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
<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>
<div
v-for="idea in pagedIdeas"
@@ -93,9 +93,11 @@
:eval-error="evalError"
:promoting="promoting"
:deleting="deleting"
:in-archive-box="activeFilter === 'archived'"
@evaluate="evaluateCurrentIdea"
@promote="promoteToProject"
@delete="deleteCurrentIdea"
@archive="archiveCurrentIdea"
@status-change="onStatusChange"
@update-desc="onUpdateDesc"
@update-related="onUpdateRelated"
@@ -160,7 +162,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
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 { stripMd } from '../utils/markdown'
import { scoreTier } from '../utils/ideaEval'
@@ -180,7 +183,7 @@ const store = useProjectStore()
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted' | 'archived'
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
@@ -212,6 +215,8 @@ const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
// 归档功能 P1:归档箱(独立数据源 listArchived,不走主查询)
{ key: 'archived', labelKey: 'ideas.filterArchived', icon: '📦' },
]
// ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ──
@@ -237,9 +242,21 @@ function buildIdeaQuery(): IdeaQuery {
return q
}
// 归档功能 P1:归档箱独立数据源(切到归档箱 filter 时拉取;其余 filter 保持 null 不请求)
const archivedIdeas = ref<IdeaRecord[]>([])
// 按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选,
// 避免 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())
return store.loadIdeas(buildIdeaQuery())
}
@@ -272,6 +289,9 @@ watch(sortMode, () => { page.value = 1; void reloadIdeas(true) })
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
const filteredIdeas = computed(() => {
// 归档箱模式:整列表切归档数据源
if (activeFilter.value === 'archived') return archivedIdeas.value
let ideas = store.ideas
// 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() {
if (!currentIdea.value || promoting.value) return
promoting.value = true
+50 -3
View File
@@ -60,6 +60,8 @@
<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 === '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>
<!-- 列表模式全选(看板无批量栏,隐藏) -->
<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 v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
<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>
<button class="btn btn-primary btn-sm" @click="openCreateModal()">{{ $t('tasks.create') }}</button>
</div>
@@ -258,6 +261,7 @@
</button>
</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>
</div>
</div>
@@ -434,6 +438,10 @@ const activeProject = usePersistedRef('tasks.activeProject', 'all')
const activeStatuses = usePersistedRef<string[]>('tasks.activeStatuses', [])
// 列表/看板视图切换(持久化;看板模式隐藏状态多选与排序,列即状态)
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')
}
}
// 归档功能 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 toggleGroup(name: string) {
@@ -631,10 +676,12 @@ interface TaskGroup {
}
// 树组装 — 顶层 = 无 parent_id 的任务,每个顶层挂 children;父进度前端计算(D6,全量已在前端)。
// 归档功能 P1:归档箱模式数据源切 archivedTasks(独立拉取,不经 buildTaskQuery)。
const listSource = computed<TaskRecord[]>(() => (archiveBoxMode.value ? archivedTasks.value : store.tasks))
const taskRows = computed<TaskRow[]>(() => {
// 子任务按 parent_id 分组(1 级嵌套)
const childMap = new Map<string, TaskRecord[]>()
for (const t of store.tasks) {
for (const t of listSource.value) {
if (!t.parent_id) continue
const arr = childMap.get(t.parent_id) ?? []
arr.push(t)
@@ -648,7 +695,7 @@ const taskRows = computed<TaskRow[]>(() => {
const bv = (b as any)[sortBy.value] ?? ''
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[] = []
for (const t of tops) {
const children = (childMap.get(t.id) ?? []).sort(cmp)