修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账
This commit is contained in:
@@ -42,6 +42,9 @@ function onKey(e: KeyboardEvent) {
|
||||
// Enter 确认:危险操作(删除)仍走 confirm 分支,父级二次语义由 dangerLabel 区分;
|
||||
// 避免在 input/textarea 内回车误触发(SVG/按钮 mask 场景无输入控件,守卫保留)。
|
||||
const target = e.target as HTMLElement
|
||||
// LW-1(安全):焦点在按钮上按 Enter 直接 return —— 否则「焦点在取消按钮按 Enter」
|
||||
// 会先触发 cancel 按钮 click(emit false)再被此处 emit true 覆盖,点取消=执行删除。
|
||||
if (target && target.tagName === 'BUTTON') return
|
||||
if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,16 +58,16 @@ function getProjectTaskCount(projectId: string): number {
|
||||
return store.projectTaskCounts[projectId] ?? 0
|
||||
}
|
||||
|
||||
// 阶段 label key:projectStageInfo 返回的 stage 值(coding/testing/release/planning)
|
||||
// 阶段 label key:projectStageInfo 返回的 stage 值(coding/testing/release/planning/paused/cancelled)
|
||||
// 直接拼接 i18n key dashboard.stage.<stage>,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。
|
||||
// 面板名为"活跃项目",只显示 active 状态(DB 实际默认值,ProjectStatus union 历史遗留未含
|
||||
// 'active',此处断言绕过;DB 实际无 planning/in_progress 等值产生,详见功能决策记录)。
|
||||
// 面板名为"活跃项目",按真实生命周期过滤:排除已完结/已取消(后端状态机无 'active',原
|
||||
// filter status==='active' 恒空,见走查 DBP-1)。
|
||||
//
|
||||
// 问题3:按「最近活跃」排序(last_active_at 优先,回退 updated_at),反映业务活跃
|
||||
// (任务/灵感/状态推进事件)而非元信息修改时间。取前 6 条(completed 归档项目不混入)。
|
||||
const displayProjects = computed(() =>
|
||||
store.projects
|
||||
.filter(p => (p.status as string) === 'active')
|
||||
.filter(p => p.status !== 'completed' && p.status !== 'cancelled')
|
||||
.sort((a, b) =>
|
||||
(b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at)
|
||||
)
|
||||
@@ -114,6 +114,8 @@ const displayProjects = computed(() =>
|
||||
.dot-release { background: var(--df-success); }
|
||||
.dot-planning { background: var(--df-info); }
|
||||
.dot-cancelled { background: var(--df-text-dim); }
|
||||
/* WK-1:paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */
|
||||
.dot-paused { background: var(--df-text-dim); }
|
||||
|
||||
.project-name {
|
||||
font-size: 12px;
|
||||
@@ -135,6 +137,7 @@ const displayProjects = computed(() =>
|
||||
.chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); }
|
||||
.chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||
.chip-cancelled { background: rgba(255,255,255,0.06); color: var(--df-text-dim); }
|
||||
.chip-paused { background: rgba(255,255,255,0.06); color: var(--df-text-dim); }
|
||||
|
||||
.project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
||||
.project-bar {
|
||||
@@ -154,6 +157,7 @@ const displayProjects = computed(() =>
|
||||
.fill-release { background: var(--df-success); }
|
||||
.fill-planning { background: var(--df-info); }
|
||||
.fill-cancelled { background: var(--df-text-dim); }
|
||||
.fill-paused { background: var(--df-text-dim); }
|
||||
|
||||
.project-pct {
|
||||
font-family: var(--df-font-mono);
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
</button>
|
||||
<ul v-show="statusMenuOpen" class="status-menu">
|
||||
<li
|
||||
v-for="s in statusOptions"
|
||||
v-for="s in menuStatusOptions"
|
||||
:key="s.value"
|
||||
class="status-menu-item"
|
||||
:class="{ active: s.value === idea.status }"
|
||||
@@ -391,6 +391,9 @@ watch(() => props.idea.description, () => void nextTick(measureDesc))
|
||||
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
|
||||
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。
|
||||
const statusMenuOpen = ref(false)
|
||||
// WK-5:状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除,
|
||||
// 见 Ideas.vue onStatusChange 兜底);statusOptions 仍含 promoted 供 label 映射(已转立项的灵感 badge 正常显示)。
|
||||
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
|
||||
function closeStatusMenu() {
|
||||
statusMenuOpen.value = false
|
||||
}
|
||||
|
||||
@@ -106,10 +106,12 @@ const resizing = ref(false)
|
||||
// maximized=全宽(flex:1填充) | sidebar=固定宽
|
||||
const panelStyle = computed(() => {
|
||||
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
|
||||
// LW-11:内联 width 用 min(panelWidth, 100vw) —— 小屏 media query 的 width:100vw 会被内联样式
|
||||
// 更高优先级覆盖致失效;min() 让窄视口下宽度钳制到 100vw,media query 的 absolute 定位/层级仍生效
|
||||
return {
|
||||
width: panelWidth.value + 'px',
|
||||
minWidth: panelWidth.value + 'px',
|
||||
maxWidth: panelWidth.value + 'px',
|
||||
width: `min(${panelWidth.value}px, 100vw)`,
|
||||
minWidth: `min(${panelWidth.value}px, 100vw)`,
|
||||
maxWidth: `min(${panelWidth.value}px, 100vw)`,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -92,15 +92,16 @@ const appSettings = useAppSettingsStore()
|
||||
// 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
|
||||
// 优先级:用户手动设置 > 小窗口自动收缩(≤768px)
|
||||
const COLLAPSE_THRESHOLD = 768
|
||||
const userCollapsed = ref(false) // 用户手动设置的收缩状态
|
||||
// LW-10:useSetting 响应式读(loadAll 异步填充缓存后 watch 自动同步刷新)——
|
||||
// 原 onMounted 用 appSettings.get 同步读早于父级 loadAll 完成,重启后偏好丢失
|
||||
const userCollapsed = appSettings.useSetting('df-sidebar-collapsed', false)
|
||||
const isNarrowWindow = ref(false) // 小窗口自动收缩标记
|
||||
|
||||
const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value)
|
||||
|
||||
// 持久化用户手动收缩状态
|
||||
// 持久化用户手动收缩状态(useSetting 写 ref 自动 debounce 落库,无需显式 appSettings.set)
|
||||
function toggleCollapsed() {
|
||||
userCollapsed.value = !userCollapsed.value
|
||||
void appSettings.set('df-sidebar-collapsed', userCollapsed.value)
|
||||
}
|
||||
|
||||
// 监听窗口宽度:小窗口自动收缩(但不改变用户偏好)
|
||||
@@ -118,8 +119,7 @@ function handleCollapseKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 恢复用户偏好(默认展开)
|
||||
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
|
||||
// LW-10:收缩偏好由 useSetting 响应式恢复(loadAll 填充缓存后 watch 自动同步),移除 onMounted 同步读
|
||||
// 检查当前窗口宽度
|
||||
checkWindowWidth()
|
||||
window.addEventListener('resize', checkWindowWidth)
|
||||
|
||||
@@ -136,6 +136,8 @@ watch(() => props.view, () => nextTick(measureDesc))
|
||||
.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
|
||||
.stage-dim { background: rgba(158,158,158,0.15); color: #9e9e9e; }
|
||||
|
||||
.card-desc-wrap {
|
||||
position: relative;
|
||||
|
||||
@@ -23,10 +23,17 @@ export const PROJECT_STATUS_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** 项目状态 → 卡片 badge 样式 class */
|
||||
// WK-1:对齐 7 态真实生命周期(planning/in_progress/testing/releasing/completed/paused/cancelled)。
|
||||
// 类名对齐 ProjectCard.vue scoped 的 stage-* 色系(design/coding/testing/release),paused/cancelled 走新增
|
||||
// stage-dim(置灰)。'active' 键删除:后端无此状态,老数据兜底走 stage-design(与 projectStageInfo 兜底一致)。
|
||||
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
|
||||
planning: 'stage-design',
|
||||
active: 'stage-coding',
|
||||
in_progress: 'stage-coding',
|
||||
testing: 'stage-testing',
|
||||
releasing: 'stage-release',
|
||||
completed: 'stage-release',
|
||||
paused: 'stage-dim',
|
||||
cancelled: 'stage-dim',
|
||||
}
|
||||
|
||||
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
|
||||
@@ -39,10 +46,17 @@ export interface ProjectStageInfo {
|
||||
stage: string
|
||||
}
|
||||
|
||||
// WK-1:对齐 7 态。stage 值决定 Dashboard dot/chip/fill 颜色档(planning/coding/testing/release/paused/cancelled),
|
||||
// 需与 ActiveProjectsPanel.vue 的 dot-*/chip-*/fill-* 类一一对应(paused 为该组件新增 dim 类)。
|
||||
// 'active' 键已删,老数据兜底走 projectStageInfo 默认 planning。
|
||||
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
|
||||
planning: { stepIndex: 0, progress: 20, stage: 'planning' },
|
||||
active: { stepIndex: 2, progress: 55, stage: 'coding' },
|
||||
in_progress: { stepIndex: 2, progress: 55, stage: 'coding' },
|
||||
testing: { stepIndex: 3, progress: 80, stage: 'testing' },
|
||||
releasing: { stepIndex: 4, progress: 95, stage: 'release' },
|
||||
completed: { stepIndex: 4, progress: 100, stage: 'release' },
|
||||
paused: { stepIndex: 1, progress: 40, stage: 'paused' },
|
||||
cancelled: { stepIndex: 0, progress: 0, stage: 'cancelled' },
|
||||
}
|
||||
|
||||
export function projectStatusLabel(status: string): string {
|
||||
@@ -113,6 +127,19 @@ export function taskStatusClass(status: string): string {
|
||||
return TASK_STATUS_CLASS[mapLegacyStatus(status)] ?? 'status-todo'
|
||||
}
|
||||
|
||||
// WK-8:任务状态机合法流转目标(源状态 → 可达目标态,对齐 TaskDetail.vue ADVANCE_MAP 的 target 集)。
|
||||
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done/cancelled 为终态,无推进目标);
|
||||
// 新建/手动改状态仍走后端 TaskStatus::is_valid 兜底校验。
|
||||
export const TASK_STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||
todo: ['in_progress', 'cancelled'],
|
||||
in_progress: ['in_review', 'blocked', 'cancelled'],
|
||||
in_review: ['testing', 'in_progress', 'blocked', 'cancelled'],
|
||||
testing: ['done', 'in_review', 'blocked', 'cancelled'],
|
||||
blocked: ['in_progress', 'cancelled'],
|
||||
done: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
// ── 任务优先级 ──
|
||||
|
||||
export const PRIORITY_LABELS: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
|
||||
|
||||
@@ -41,8 +41,11 @@ export default {
|
||||
stage: {
|
||||
planning: 'Planning',
|
||||
coding: 'Coding',
|
||||
testing: 'Testing',
|
||||
release: 'Release',
|
||||
done: 'Done',
|
||||
paused: 'Paused',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
ideaStatus: {
|
||||
draft: 'Draft',
|
||||
|
||||
@@ -41,8 +41,11 @@ export default {
|
||||
stage: {
|
||||
planning: '规划中',
|
||||
coding: '编码中',
|
||||
testing: '测试中',
|
||||
release: '发布中',
|
||||
done: '已完成',
|
||||
paused: '已暂停',
|
||||
cancelled: '已取消',
|
||||
},
|
||||
ideaStatus: {
|
||||
draft: '草稿',
|
||||
|
||||
@@ -106,6 +106,8 @@ const routes = [
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
// LW-12:路由切换回到顶部(消除滚动残留:从长列表页切到短页仍停在原滚动位置)
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -149,21 +149,26 @@ export function useKnowledgeStore() {
|
||||
return record ?? null
|
||||
}
|
||||
|
||||
async function updateStatus(id: string, status: string) {
|
||||
await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => {
|
||||
async function updateStatus(id: string, status: string): Promise<boolean> {
|
||||
// WK-3:返成功布尔(失败时 runWithCatch 已置 state.error),调用方据此中止推进/报错
|
||||
const ok = await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => {
|
||||
await knowledgeApi.updateStatus(id, status)
|
||||
// 从 items 和 candidates 中同步移除
|
||||
state.items = state.items.filter(k => k.id !== id)
|
||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||
return true
|
||||
})
|
||||
return ok === true
|
||||
}
|
||||
|
||||
async function archive(id: string) {
|
||||
await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
|
||||
async function archive(id: string): Promise<boolean> {
|
||||
const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
|
||||
await knowledgeApi.archive(id)
|
||||
state.items = state.items.filter(k => k.id !== id)
|
||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||
return true
|
||||
})
|
||||
return ok === true
|
||||
}
|
||||
|
||||
// ── 配置 ──
|
||||
|
||||
+20
-2
@@ -6,7 +6,7 @@ import { createIdeasStore } from './project/ideas'
|
||||
import { createWorkflowStore } from './project/workflow'
|
||||
import { state, clearError } from './project/state'
|
||||
import { taskApi } from '@/api/task'
|
||||
import type { DfDataChangedPayload, TaskQuery } from '@/api/types'
|
||||
import type { DfDataChangedPayload, TaskQuery, IdeaQuery } from '@/api/types'
|
||||
|
||||
// ── barrel 组合器(零行为变更,纯重构) ──
|
||||
//
|
||||
@@ -41,21 +41,34 @@ function createStore() {
|
||||
// 避免刷新把已选 status 冲掉)。
|
||||
let _activeTaskProject: string | undefined = undefined
|
||||
let _activeTaskStatus: string | undefined = undefined
|
||||
// WK-10:Tasks 视图搜索关键字登记(数据变更联动刷新时一并下沉 keyword,避免冲掉搜索结果)
|
||||
let _activeTaskKeyword: string | undefined = undefined
|
||||
function setActiveTaskProject(projectKey: string | undefined) {
|
||||
_activeTaskProject = projectKey
|
||||
}
|
||||
function setActiveTaskStatus(statusKey: string | undefined) {
|
||||
_activeTaskStatus = statusKey
|
||||
}
|
||||
function setActiveTaskKeyword(keyword: string | undefined) {
|
||||
_activeTaskKeyword = keyword
|
||||
}
|
||||
// 构造当前 task 视图筛选对应的 query(供数据变更 listener 复用同一筛选拉取)
|
||||
function buildActiveTaskQuery(): TaskQuery | undefined {
|
||||
if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发
|
||||
const query: TaskQuery = {}
|
||||
if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject
|
||||
if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus
|
||||
if (_activeTaskKeyword) query.keyword = _activeTaskKeyword
|
||||
return query
|
||||
}
|
||||
|
||||
// WK-4:Ideas 视图当前查询登记(数据变更联动刷新按当前筛选重载,不再无参 loadIdeas 全量冲掉筛选)。
|
||||
// undefined = Ideas 视图未挂载(如 Dashboard)→ 退化为全量刷新,保持面板统计新鲜。
|
||||
let _activeIdeaQuery: IdeaQuery | undefined = undefined
|
||||
function setActiveIdeaQuery(q: IdeaQuery | undefined) {
|
||||
_activeIdeaQuery = q
|
||||
}
|
||||
|
||||
let _dataChangedUnlisten: (() => void) | null = null
|
||||
|
||||
async function startDataChangedListener() {
|
||||
@@ -76,7 +89,8 @@ function createStore() {
|
||||
// Dashboard 数字(与 Tasks 视图分页/筛选解耦,不受 q undefined 影响)
|
||||
void loadStats()
|
||||
} else if (entity === 'idea') {
|
||||
void ideasStore.loadIdeas()
|
||||
// WK-4:按 Ideas 视图当前 query 重载(未挂载时退化为全量刷新 Dashboard 面板)
|
||||
void ideasStore.loadIdeas(_activeIdeaQuery)
|
||||
}
|
||||
})
|
||||
return _dataChangedUnlisten
|
||||
@@ -179,6 +193,10 @@ function createStore() {
|
||||
setActiveTaskProject,
|
||||
// F-260621-02(P1 status 下沉):Tasks 视图登记当前 status 筛选,供 listener 复用同一筛选拉取
|
||||
setActiveTaskStatus,
|
||||
// WK-10:Tasks 视图登记当前搜索关键字,供 listener 复用同一筛选拉取
|
||||
setActiveTaskKeyword,
|
||||
// WK-4: Ideas 视图登记当前查询,供 listener 按筛选重载(不冲掉筛选视图)
|
||||
setActiveIdeaQuery,
|
||||
pendingApproval: computed(() => state.pendingApproval),
|
||||
// computed
|
||||
stats,
|
||||
|
||||
@@ -12,8 +12,11 @@ export function createIdeasStore() {
|
||||
* F-260621-02:status/keyword/order_by 下沉后端 WHERE,取代前端 computed filter。
|
||||
* - 传 `query` → 多条件查询(状态/关键词/排序/分页)。
|
||||
* - 不传 → 全量(created_at DESC,等价旧行为)。
|
||||
*
|
||||
* WK-7:成功时清 state.error(load 后不残留旧错误),失败由 runWithCatch 置 error。
|
||||
*/
|
||||
async function loadIdeas(query?: IdeaQuery) {
|
||||
state.error = null
|
||||
await runWithCatch(state, t('ideas.err.loadFailed'), async () => {
|
||||
state.ideas = await ideaApi.list(query)
|
||||
})
|
||||
@@ -36,11 +39,14 @@ export function createIdeasStore() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIdea(id: string) {
|
||||
await runWithCatch(state, t('ideas.err.deleteFailed'), async () => {
|
||||
async function deleteIdea(id: string): Promise<boolean> {
|
||||
// WK-7:返成功布尔(失败时 runWithCatch 已置 state.error),调用方据此保留选中态
|
||||
const ok = await runWithCatch(state, t('ideas.err.deleteFailed'), async () => {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
return true
|
||||
})
|
||||
return ok === true
|
||||
}
|
||||
|
||||
async function evaluateIdea(id: string) {
|
||||
@@ -51,15 +57,16 @@ export function createIdeasStore() {
|
||||
}
|
||||
|
||||
async function promoteIdea(id: string) {
|
||||
const res = await ideaApi.promote(id)
|
||||
await loadIdeas() // 后端已回写 status=promoted/promoted_to,刷新列表
|
||||
return res
|
||||
// WK-4:不再内部全量 loadIdeas(会把 store.ideas 冲成全集、冲掉当前筛选视图)。
|
||||
// 由视图(Ideas.vue promoteToProject)操作后按当前 buildIdeaQuery() 主动重载。
|
||||
return await ideaApi.promote(id)
|
||||
}
|
||||
|
||||
async function relateIdeas(subjectId: string, targetIds: string[]) {
|
||||
await runWithCatch(state, t('ideas.err.relateFailed'), async () => {
|
||||
await ideaApi.relateIdeas(subjectId, targetIds)
|
||||
await loadIdeas() // 后端已原子更新全部受影响灵感,刷新列表保持 store 一致
|
||||
// WK-4:后端原子更新受影响灵感,原全量 loadIdeas 会冲掉当前筛选视图;
|
||||
// 改由视图(Ideas.vue onUpdateRelated)按当前 query 重载。
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { taskApi } from '@/api'
|
||||
import type { TaskQuery, ProjectId } from '@/api/types'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { runWithCatch } from '@/composables/useStoreAction'
|
||||
import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction'
|
||||
import { state } from './state'
|
||||
|
||||
/** 任务 CRUD 子 store(共享全局 state) */
|
||||
export function createTasksStore() {
|
||||
// WK-10:并发 loadTasks 竞态守卫(快速切筛选/搜索防旧响应晚到覆盖新结果,
|
||||
// 对齐 knowledge store 的 _itemsSeq 模式)。旧请求的失败也不污染当前视图。
|
||||
let _tasksSeq = 0
|
||||
|
||||
/**
|
||||
* 加载任务列表。
|
||||
*
|
||||
@@ -17,8 +21,10 @@ export function createTasksStore() {
|
||||
* status/keyword 下沉后端 WHERE(P1/P2),取代 Tasks.vue 旧的前端内存 filter。
|
||||
*/
|
||||
async function loadTasks(queryOrProjectId?: TaskQuery | string) {
|
||||
await runWithCatch(state, t('tasks.err.loadFailed'), async () => {
|
||||
state.tasks = await taskApi.list(queryOrProjectId)
|
||||
const seq = ++_tasksSeq
|
||||
await runWithCatchGuarded(state, t('tasks.err.loadFailed'), () => seq === _tasksSeq, async () => {
|
||||
const result = await taskApi.list(queryOrProjectId)
|
||||
if (seq === _tasksSeq) state.tasks = result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,13 +57,16 @@ export function createTasksStore() {
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
await runWithCatch(state, t('tasks.err.deleteFailed'), async () => {
|
||||
async function deleteTask(id: string): Promise<boolean> {
|
||||
// WK-13:返成功布尔(失败时 runWithCatch 已置 state.error),快捷删除据此 toast 反馈
|
||||
const ok = await runWithCatch(state, t('tasks.err.deleteFailed'), async () => {
|
||||
// 后端已级联软删子任务(delete_task 返回 { ok, cascaded });
|
||||
// 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id)
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id)
|
||||
return true
|
||||
})
|
||||
return ok === true
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
@@ -50,6 +51,18 @@
|
||||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* — 任务状态徽章色(7 态):Tasks 列表 + TaskDetail/ProjectDetail 详情共用 —
|
||||
WK-6:原仅 TaskDetail/ProjectDetail scoped 定义,Tasks.vue 列表拿不到 → 裸文本。
|
||||
提取全局对齐三视图;配色以 TaskDetail.vue(7 态最全)为准,尺寸/圆角统一走上方 .status-tag。
|
||||
(TaskDetail 的 scoped 同名类特异度更高会覆盖,但值与全局一致,无视觉差异) */
|
||||
.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-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* — 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享 —
|
||||
原 IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致
|
||||
(ProjectDetail 注释自承认"复用 IdeaDetail score-bar-* 样式定义")。
|
||||
|
||||
+35
-8
@@ -8,6 +8,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="search-box">
|
||||
@@ -46,9 +52,8 @@
|
||||
<!-- 左侧:灵感列表 -->
|
||||
<section class="idea-list-panel">
|
||||
<div class="idea-list">
|
||||
<!-- 三态:加载/错误/空(对齐 Tasks.vue:43-45) -->
|
||||
<!-- 三态:加载/空(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
|
||||
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
|
||||
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
|
||||
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -244,6 +249,13 @@ function buildIdeaQuery(): IdeaQuery {
|
||||
return q
|
||||
}
|
||||
|
||||
// WK-4:按当前视图查询加载(登记 setActiveIdeaQuery 供 df-data-changed 联动刷新复用同一筛选,
|
||||
// 避免 promote/relate/监听触发无参 loadIdeas 全量冲掉当前筛选视图)
|
||||
function loadCurrentView() {
|
||||
store.setActiveIdeaQuery(buildIdeaQuery())
|
||||
return store.loadIdeas(buildIdeaQuery())
|
||||
}
|
||||
|
||||
// 按当前筛选/搜索/排序触发后端加载(防抖仅对 keyword 输入,筛选/排序即时)。
|
||||
// 让 store.loading 驱动「加载中」空态显示(对齐 Tasks.vue 三态)。
|
||||
// G5.3:keyword 防抖 timer 用 useTimerOwnership 每实例管理 + onUnmounted 自动清理,
|
||||
@@ -255,7 +267,7 @@ async function reloadIdeas(immediate = false) {
|
||||
clearOwn(keywordDebounce)
|
||||
keywordDebounce = null
|
||||
}
|
||||
const run = () => store.loadIdeas(buildIdeaQuery())
|
||||
const run = () => loadCurrentView()
|
||||
if (immediate) {
|
||||
await run()
|
||||
} else {
|
||||
@@ -319,6 +331,13 @@ watch([filteredIdeas, () => selectedId.value], () => {
|
||||
if (targetPage !== page.value) page.value = targetPage
|
||||
})
|
||||
|
||||
// WK-12:分页越界自愈(删除/筛选变化后 page 超界 → 钳制到最后一页,防空白页)
|
||||
watch([filteredIdeas, pageSize], () => {
|
||||
if (pageSize.value <= 0) return
|
||||
const maxPage = Math.max(1, Math.ceil(filteredIdeas.value.length / pageSize.value))
|
||||
if (page.value > maxPage) page.value = maxPage
|
||||
})
|
||||
|
||||
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
|
||||
|
||||
// 评分档位 class(高/中/低 三色标),阈值统一走 scoreTier(单一来源,消除 4 处重复)
|
||||
@@ -361,8 +380,9 @@ async function deleteCurrentIdea() {
|
||||
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await store.deleteIdea(currentIdea.value.id)
|
||||
selectedId.value = null
|
||||
// WK-7:删除失败不清理选中态(错误走 error-banner),仅成功时清空详情
|
||||
const ok = await store.deleteIdea(currentIdea.value.id)
|
||||
if (ok) selectedId.value = null
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
@@ -373,6 +393,8 @@ async function promoteToProject() {
|
||||
promoting.value = true
|
||||
try {
|
||||
const res = await store.promoteIdea(currentIdea.value.id)
|
||||
// WK-4:promote 后按当前视图重载(store 不再内部全量 loadIdeas 冲掉筛选)
|
||||
await loadCurrentView()
|
||||
router.push(`/projects/${res.project_id}`)
|
||||
} catch (e: any) {
|
||||
const msg = e?.toString() ?? t('ideas.promoteFailed')
|
||||
@@ -384,7 +406,7 @@ async function promoteToProject() {
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await store.loadIdeas(buildIdeaQuery())
|
||||
await loadCurrentView()
|
||||
}
|
||||
|
||||
const evaluating = ref(false)
|
||||
@@ -407,10 +429,13 @@ async function evaluateCurrentIdea() {
|
||||
|
||||
async function onStatusChange(newStatus: IdeaStatus) {
|
||||
if (!currentIdea.value) return
|
||||
// WK-5:promoted 走独立立项流程(promoteToProject),状态菜单/下拉已排除,此处兜底禁止直接置
|
||||
if (newStatus === 'promoted') return
|
||||
// ⑥ 状态切换确认:落到 promoted/rejected/archived 等终态(或离开 approved 失去立项入口)
|
||||
// 需用户确认,避免误点。中性流转(draft↔pending_review↔approved)直接落库。
|
||||
// (WK-5:上方已拦截 promoted,此处不再重复比对,TS 收窄后 'promoted' 无交集)
|
||||
const from = currentIdea.value.status
|
||||
const needsConfirm = newStatus === 'promoted' || newStatus === 'rejected' || newStatus === 'archived'
|
||||
const needsConfirm = newStatus === 'rejected' || newStatus === 'archived'
|
||||
|| (from === 'approved' && newStatus !== 'approved')
|
||||
if (needsConfirm) {
|
||||
const msg = t('ideas.confirmStatus', {
|
||||
@@ -433,6 +458,8 @@ async function onUpdateDesc(desc: string) {
|
||||
async function onUpdateRelated(ids: string[]) {
|
||||
if (!currentIdea.value) return
|
||||
await store.relateIdeas(currentIdea.value.id, ids)
|
||||
// WK-4:relate 后端原子更新受影响灵感,按当前视图重载(store 不再内部全量 loadIdeas 冲筛选)
|
||||
await loadCurrentView()
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-tags'(标签编辑保存)
|
||||
@@ -442,7 +469,7 @@ async function onUpdateTags(tags: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadIdeas(buildIdeaQuery())
|
||||
await loadCurrentView()
|
||||
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36)
|
||||
if (route.params.id) {
|
||||
const id = route.params.id as string
|
||||
|
||||
+26
-11
@@ -286,13 +286,20 @@ async function onDetailSave(payload: { id: string; data: { title: string; conten
|
||||
|
||||
// ===== 审核操作(收件箱) =====
|
||||
// P0-②:发布/拒绝/归档后自动选中下一条(而非清空详情),减少审核者来回点击
|
||||
// WK-2:index 须在 store 操作前快照(操作后 candidates 已 filter 移除 currentId,findIndex 恒 -1)
|
||||
// WK-3:操作失败(updateStatus/archive 返 false)toast 报错 + 中止推进(不 selectNext,不清详情)
|
||||
async function publishCurrent() {
|
||||
if (!detail.value) return
|
||||
const currentId = detail.value.knowledge.id
|
||||
const idx = store.candidates.findIndex(c => c.id === currentId)
|
||||
submitting.value = true
|
||||
try {
|
||||
await store.updateStatus(currentId, 'published')
|
||||
await selectNextCandidate(currentId)
|
||||
const ok = await store.updateStatus(currentId, 'published')
|
||||
if (!ok) {
|
||||
showToast(t('knowledge.err.updateStatusFailed'), 'error')
|
||||
return
|
||||
}
|
||||
await selectNextCandidate(idx)
|
||||
showToast(t('knowledge.publishedToast'), 'success')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -302,10 +309,15 @@ async function publishCurrent() {
|
||||
async function rejectCurrent() {
|
||||
if (!detail.value) return
|
||||
const currentId = detail.value.knowledge.id
|
||||
const idx = store.candidates.findIndex(c => c.id === currentId)
|
||||
submitting.value = true
|
||||
try {
|
||||
await store.archive(currentId)
|
||||
await selectNextCandidate(currentId)
|
||||
const ok = await store.archive(currentId)
|
||||
if (!ok) {
|
||||
showToast(t('knowledge.err.archiveFailed'), 'error')
|
||||
return
|
||||
}
|
||||
await selectNextCandidate(idx)
|
||||
showToast(t('knowledge.rejectedToast'), 'info')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -315,12 +327,17 @@ async function rejectCurrent() {
|
||||
async function archiveCurrent() {
|
||||
if (!detail.value) return
|
||||
const currentId = detail.value.knowledge.id
|
||||
const idx = store.candidates.findIndex(c => c.id === currentId)
|
||||
submitting.value = true
|
||||
try {
|
||||
await store.archive(currentId)
|
||||
const ok = await store.archive(currentId)
|
||||
if (!ok) {
|
||||
showToast(t('knowledge.err.archiveFailed'), 'error')
|
||||
return
|
||||
}
|
||||
// 知识库 tab 无「下一条」语义(library 不限 candidate),归档后清空详情
|
||||
if (topTab.value === 'inbox') {
|
||||
await selectNextCandidate(currentId)
|
||||
await selectNextCandidate(idx)
|
||||
} else {
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
@@ -338,11 +355,9 @@ function pickNextCandidate(remaining: KnowledgeRecord[], currentIndex: number):
|
||||
return remaining[currentIndex] ?? remaining[currentIndex - 1] ?? null
|
||||
}
|
||||
|
||||
async function selectNextCandidate(currentId: string) {
|
||||
const before = store.candidates
|
||||
const idx = before.findIndex(c => c.id === currentId)
|
||||
const remaining = store.candidates // store 操作后已 filter 移除 currentId
|
||||
const next = idx >= 0 ? pickNextCandidate(remaining, idx) : null
|
||||
async function selectNextCandidate(currentIndex: number) {
|
||||
const remaining = store.candidates // 操作后已 filter 移除 currentId
|
||||
const next = currentIndex >= 0 ? pickNextCandidate(remaining, currentIndex) : null
|
||||
if (next) {
|
||||
await selectKnowledge(next.id)
|
||||
} else {
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
@@ -219,6 +219,13 @@ const pagedProjects = computed(() => {
|
||||
return store.projects.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
// WK-12:分页越界自愈(删除/回收后 store.projects 数量变化,page 超界 → 钳制到最后一页,防空白页)
|
||||
watch([() => store.projects, pageSize], () => {
|
||||
if (pageSize.value <= 0) return
|
||||
const maxPage = Math.max(1, Math.ceil(store.projects.length / pageSize.value))
|
||||
if (page.value > maxPage) page.value = maxPage
|
||||
})
|
||||
|
||||
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
|
||||
+62
-21
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user