188 lines
7.1 KiB
Vue
188 lines
7.1 KiB
Vue
<template>
|
|
<section class="df-panel" style="animation-delay: 240ms">
|
|
<div class="df-panel-head">
|
|
<h2 class="df-panel-title">{{ $t('dashboard.activeProjects') }}</h2>
|
|
<router-link to="/projects" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
|
</div>
|
|
<div class="project-list">
|
|
<div v-for="(p, i) in displayProjects" :key="p.id" class="project-row clickable" :style="{ animationDelay: `${300 + i * 50}ms` }" @click="goDetail(p.id)" :title="t('dashboard.goProjectDetail')">
|
|
<div class="project-row-top">
|
|
<div class="project-identity">
|
|
<span class="project-dot" :class="'dot-' + p.stage"></span>
|
|
<span class="project-name">{{ p.name }}</span>
|
|
</div>
|
|
<span class="project-stage-chip" :class="'chip-' + p.stage">{{ $t('dashboard.stage.' + p.stageLabelKey) }}</span>
|
|
</div>
|
|
<div class="project-bar-wrap">
|
|
<div class="project-bar">
|
|
<div class="project-bar-fill" :class="'fill-' + p.stage" :style="{ width: p.progress + '%' }"></div>
|
|
</div>
|
|
<span class="project-pct">{{ p.progress }}%</span>
|
|
</div>
|
|
<div class="project-meta-row">
|
|
<span class="project-meta-item">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 01-9 9"/></svg>
|
|
{{ $t('dashboard.taskUnit', { n: p.activeTasks }) }}
|
|
</span>
|
|
<span class="project-meta-time">{{ p.lastActivity }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="displayProjects.length === 0" class="empty-hint">{{ t('dashboard.empty.noProjects') }} <router-link to="/projects" class="df-link">{{ t('dashboard.empty.noProjectsLink') }}</router-link></div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// 活跃项目面板 — 从 Dashboard.vue 抽出(项目列表 + 进度条 + 阶段 chip + 空态)。
|
|
// 共享 project store(全局单例),displayProjects computed 过滤 active 状态按更新时间倒序取前 6 条。
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRouter } from 'vue-router'
|
|
import { useProjectStore } from '@/stores/project'
|
|
import { projectStageInfo } from '@/constants/project'
|
|
import { formatRelative } from '@/utils/time'
|
|
|
|
const store = useProjectStore()
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
|
|
// 点击项目行跳转详情(治本:列表项可进详情,与其他列表页交互一致)
|
|
function goDetail(projectId: string) {
|
|
router.push(`/projects/${projectId}`)
|
|
}
|
|
|
|
// P1-b A0:统计走后端 count_tasks 独立通道,不再 filter(store.tasks)。
|
|
// store.tasks 是 Tasks 视图当前筛选+分页数据,filter 会让面板数字随 Tasks 翻页/筛选错乱。
|
|
// store.projectTaskCounts[projectId] = 后端该项目 in_progress 任务数(loadStats 填充)。
|
|
function getProjectTaskCount(projectId: string): number {
|
|
return store.projectTaskCounts[projectId] ?? 0
|
|
}
|
|
|
|
// 阶段 label key:projectStageInfo 返回的 stage 值(coding/testing/release/planning/paused/cancelled)
|
|
// 直接拼接 i18n key dashboard.stage.<stage>,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。
|
|
// 面板名为"活跃项目",按真实生命周期过滤:排除已完结/已取消(后端状态机无 'active',原
|
|
// filter status==='active' 恒空,见走查 DBP-1)。
|
|
//
|
|
// 问题3:按「最近活跃」排序(last_active_at 优先,回退 updated_at),反映业务活跃
|
|
// (任务/灵感/状态推进事件)而非元信息修改时间。取前 6 条(completed 归档项目不混入)。
|
|
const displayProjects = computed(() =>
|
|
store.projects
|
|
.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)
|
|
)
|
|
.slice(0, 6)
|
|
.map(p => {
|
|
const info = projectStageInfo(p.status)
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
stage: info.stage,
|
|
stageLabelKey: info.stage,
|
|
progress: info.progress,
|
|
activeTasks: getProjectTaskCount(p.id),
|
|
lastActivity: formatRelative(p.last_active_at ?? p.updated_at),
|
|
}
|
|
})
|
|
)
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* df-panel/df-link 通用面板类已提取到 styles/components.css 全局 */
|
|
.project-list { display: flex; flex-direction: column; }
|
|
.project-row {
|
|
padding: 10px 0;
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
animation: fadeInUp 0.4s var(--df-ease) both;
|
|
}
|
|
.project-row.clickable {
|
|
cursor: pointer;
|
|
transition: background 0.15s var(--df-ease);
|
|
}
|
|
.project-row.clickable:hover {
|
|
background: var(--df-bg);
|
|
}
|
|
.project-row:last-child { border-bottom: none; }
|
|
|
|
.project-row-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
.project-identity { display: flex; align-items: center; gap: 8px; }
|
|
.project-dot {
|
|
width: 6px; height: 6px; border-radius: 50%;
|
|
}
|
|
.dot-coding { background: var(--df-accent); }
|
|
.dot-testing { background: var(--df-warning); }
|
|
.dot-release { background: var(--df-success); }
|
|
.dot-planning { background: var(--df-info); }
|
|
.dot-cancelled { background: var(--df-text-dim); }
|
|
/* paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */
|
|
.dot-paused { background: var(--df-text-dim); }
|
|
|
|
.project-name {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
font-family: var(--df-font-mono);
|
|
color: var(--df-text);
|
|
}
|
|
|
|
.project-stage-chip {
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
padding: 1px 6px;
|
|
border-radius: var(--df-radius-sm);
|
|
letter-spacing: 0.3px;
|
|
text-transform: uppercase;
|
|
}
|
|
.chip-coding { background: var(--df-accent-soft); color: var(--df-accent); }
|
|
.chip-testing { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
|
.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 {
|
|
flex: 1;
|
|
height: 3px;
|
|
background: rgba(255,255,255,0.06);
|
|
border-radius: var(--df-radius-sm);
|
|
overflow: hidden;
|
|
}
|
|
.project-bar-fill {
|
|
height: 100%;
|
|
border-radius: var(--df-radius-sm);
|
|
transition: width 0.6s var(--df-ease);
|
|
}
|
|
.fill-coding { background: var(--df-accent); }
|
|
.fill-testing { background: var(--df-warning); }
|
|
.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);
|
|
font-size: 11px;
|
|
color: var(--df-text-dim);
|
|
min-width: 32px;
|
|
text-align: right;
|
|
}
|
|
|
|
.project-meta-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.project-meta-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
color: var(--df-text-dim);
|
|
}
|
|
.project-meta-time {
|
|
font-size: 11px;
|
|
color: var(--df-text-dim);
|
|
font-family: var(--df-font-mono);
|
|
}
|
|
</style>
|