重构: 拆Dashboard.vue仪表盘(strategy前端·Dashboard<500达标)
- 新建 components/dashboard/(StatCardRow 126 + ActiveProjectsPanel 174 + IdeasPanel 82): 统计卡+活跃项目+灵感池 - Dashboard.vue 563→238(<500达标): 头部+refresh+loadAll编排+共享样式 - store共享(useProjectStore单例, 子组件纯读, 无props/emit) strategy: 前端views, 子组件三拆; Dashboard<500达标(第四个继Ideas/Knowledge); scoped .df-panel父子各存(隔离必需)
This commit is contained in:
174
src/components/dashboard/ActiveProjectsPanel.vue
Normal file
174
src/components/dashboard/ActiveProjectsPanel.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<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" :style="{ animationDelay: `${300 + i * 50}ms` }">
|
||||||
|
<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 直读 store.projects/tasks,无需父传 props。
|
||||||
|
// getProjectStage 死键语义对齐 constants/project.ts 的 PROJECT_STAGE_INFO。
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
import { parseTs } from '@/utils/time'
|
||||||
|
|
||||||
|
const store = useProjectStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function getProjectStage(status: string): { stage: string; stageLabelKey: string; progress: number } {
|
||||||
|
// F-09 对齐:project status 死键(in_progress/paused/cancelled)已从后端映射移除,
|
||||||
|
// 此处 switch 同步收敛,与 constants/project.ts 的 PROJECT_STAGE_INFO 语义一致。
|
||||||
|
switch (status) {
|
||||||
|
case 'planning': return { stage: 'planning', stageLabelKey: 'planning', progress: 20 }
|
||||||
|
case 'active': return { stage: 'coding', stageLabelKey: 'coding', progress: 55 }
|
||||||
|
case 'completed': return { stage: 'release', stageLabelKey: 'done', progress: 100 }
|
||||||
|
default: return { stage: 'planning', stageLabelKey: 'planning', progress: 20 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectTaskCount(projectId: string): number {
|
||||||
|
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastActivity(iso: string): string {
|
||||||
|
const ms = parseTs(iso)
|
||||||
|
if (ms == null) return '—'
|
||||||
|
const diffMin = Math.floor((Date.now() - ms) / 60000)
|
||||||
|
if (diffMin < 1) return t('common.justNow')
|
||||||
|
if (diffMin < 60) return t('common.minutesAgo', { n: diffMin })
|
||||||
|
const diffH = Math.floor(diffMin / 60)
|
||||||
|
if (diffH < 24) return t('common.hoursAgo', { n: diffH })
|
||||||
|
const diffD = Math.floor(diffH / 24)
|
||||||
|
return t('common.dayAgo', { n: diffD })
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayProjects = computed(() =>
|
||||||
|
store.projects.map(p => {
|
||||||
|
const stageInfo = getProjectStage(p.status)
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
...stageInfo,
|
||||||
|
activeTasks: getProjectTaskCount(p.id),
|
||||||
|
lastActivity: formatLastActivity(p.updated_at),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 仅迁入项目行自身样式;df-panel/df-link 等通用面板类留 Dashboard.vue(多面板共享) */
|
||||||
|
.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: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); }
|
||||||
|
|
||||||
|
.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: rgba(123,111,240,0.12); 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); }
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.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>
|
||||||
82
src/components/dashboard/IdeasPanel.vue
Normal file
82
src/components/dashboard/IdeasPanel.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<section class="df-panel" style="animation-delay: 280ms">
|
||||||
|
<div class="df-panel-head">
|
||||||
|
<h2 class="df-panel-title">{{ $t('dashboard.ideaPool') }}</h2>
|
||||||
|
<router-link to="/ideas" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="idea-rows">
|
||||||
|
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row">
|
||||||
|
<div class="idea-score-ring" :class="'ring-' + idea.tier">
|
||||||
|
<span class="idea-score-num">{{ idea.score }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="idea-body">
|
||||||
|
<span class="idea-name">{{ idea.title }}</span>
|
||||||
|
<span class="idea-status">{{ ideaStatusLabel(idea.statusLabel) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayIdeas.length === 0" class="empty-hint">{{ $t('dashboard.empty.noIdeas') }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 灵感池面板 — 从 Dashboard.vue 抽出(灵感列表 + 评分环 + tier 着色 + 空态)。
|
||||||
|
// 共享 project store(全局单例),displayIdeas computed 直读 store.ideas(取前 4 条),无需父传 props。
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
|
||||||
|
const store = useProjectStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const displayIdeas = computed(() =>
|
||||||
|
store.ideas.slice(0, 4).map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
score: i.score ?? 0,
|
||||||
|
tier: (i.score ?? 0) >= 80 ? 'high' : (i.score ?? 0) >= 60 ? 'mid' : 'low',
|
||||||
|
statusLabel: i.status,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function ideaStatusLabel(status: string): string {
|
||||||
|
return t('dashboard.ideaStatus.' + status)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 仅迁入灵感行自身样式;df-panel/df-link 等通用面板类留 Dashboard.vue(多面板共享) */
|
||||||
|
.idea-rows { display: flex; flex-direction: column; }
|
||||||
|
.idea-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 0;
|
||||||
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
.idea-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.idea-score-ring {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ring-high { background: rgba(61,219,160,0.10); border: 0.5px solid rgba(61,219,160,0.15); }
|
||||||
|
.ring-mid { background: rgba(240,199,94,0.08); border: 0.5px solid rgba(240,199,94,0.12); }
|
||||||
|
.ring-low { background: rgba(240,101,101,0.08); border: 0.5px solid rgba(240,101,101,0.12); }
|
||||||
|
|
||||||
|
.idea-score-num {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ring-high .idea-score-num { color: var(--df-success); }
|
||||||
|
.ring-mid .idea-score-num { color: var(--df-warning); }
|
||||||
|
.ring-low .idea-score-num { color: var(--df-danger); }
|
||||||
|
|
||||||
|
.idea-name { font-size: 12px; font-weight: 500; color: var(--df-text); display: block; }
|
||||||
|
.idea-status { font-size: 10px; color: var(--df-text-dim); margin-top: 1px; display: block; }
|
||||||
|
</style>
|
||||||
126
src/components/dashboard/StatCardRow.vue
Normal file
126
src/components/dashboard/StatCardRow.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div
|
||||||
|
v-for="(stat, i) in stats" :key="stat.key"
|
||||||
|
class="stat-card"
|
||||||
|
:style="{ animationDelay: `${i * 60}ms` }"
|
||||||
|
>
|
||||||
|
<div class="stat-card-bg" :class="'stat-bg--' + stat.key"></div>
|
||||||
|
<div class="stat-card-inner">
|
||||||
|
<div class="stat-top">
|
||||||
|
<div class="stat-icon-wrap" :style="{ background: stat.iconBg }">
|
||||||
|
<span class="stat-icon">{{ stat.icon }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="stat-trend" :class="stat.trend > 0 ? 'up' : stat.trend < 0 ? 'down' : 'flat'">
|
||||||
|
<template v-if="stat.trend > 0">↑</template><template v-else-if="stat.trend < 0">↓</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
{{ stat.trend > 0 ? '+' : '' }}{{ stat.trend }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">{{ stat.value }}</div>
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 统计卡片行 — 从 Dashboard.vue 抽出(4 张统计卡: ideas/projects/tasks/drafts)。
|
||||||
|
// 共享 project store(全局单例),stats computed 直读 store.stats,无需父传 props。
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
|
||||||
|
const store = useProjectStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
|
||||||
|
iconBg: 'rgba(123,111,240,0.12)' },
|
||||||
|
{ key: 'projects', icon: '\u{1F4C2}', label: t('dashboard.stats.projects'), value: store.stats.projects, trend: 0,
|
||||||
|
iconBg: 'rgba(94,175,240,0.10)' },
|
||||||
|
{ key: 'tasks', icon: '⚡', label: t('dashboard.stats.activeTasks'), value: store.stats.activeTasks, trend: 0,
|
||||||
|
iconBg: 'rgba(61,219,160,0.10)' },
|
||||||
|
{ key: 'drafts', icon: '\u{1F4DD}', label: t('dashboard.stats.drafts'), value: store.stats.drafts, trend: 0,
|
||||||
|
iconBg: 'rgba(240,199,94,0.10)' },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 仅迁入统计卡行自身样式;页面级布局(dash-grid/responsive 全局栅格)留 Dashboard.vue */
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--df-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
animation: fadeInUp 0.5s var(--df-ease) both;
|
||||||
|
}
|
||||||
|
.stat-card-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.stat-bg--ideas { background: var(--df-accent-bg); }
|
||||||
|
.stat-bg--projects { background: var(--df-info-bg); }
|
||||||
|
.stat-bg--tasks { background: var(--df-success-bg); }
|
||||||
|
.stat-bg--drafts { background: var(--df-warning-bg); }
|
||||||
|
.stat-card-inner {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
}
|
||||||
|
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.stat-icon-wrap {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
}
|
||||||
|
.stat-icon { font-size: 14px; }
|
||||||
|
|
||||||
|
.stat-trend {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
}
|
||||||
|
.stat-trend.up { color: var(--df-success); background: rgba(61,219,160,0.10); }
|
||||||
|
.stat-trend.down { color: var(--df-danger); background: rgba(240,101,101,0.10); }
|
||||||
|
.stat-trend.flat { color: var(--df-text-dim); background: rgba(255,255,255,0.04); }
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.8px;
|
||||||
|
color: var(--df-text);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中等窗口:缩窄统计卡 */
|
||||||
|
@media (min-width: 901px) and (max-width: 1200px) {
|
||||||
|
.stat-row { grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||||
|
.stat-card-inner { padding: 10px 12px 8px; }
|
||||||
|
.stat-value { font-size: 22px; }
|
||||||
|
.stat-icon-wrap { width: 24px; height: 24px; }
|
||||||
|
.stat-icon { font-size: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超宽窗口:展开统计卡 */
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
.stat-row { gap: 14px; }
|
||||||
|
.stat-card-inner { padding: 14px 18px 12px; }
|
||||||
|
.stat-value { font-size: 28px; }
|
||||||
|
.stat-icon-wrap { width: 30px; height: 30px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,97 +24,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ Stat Cards ═══ -->
|
<!-- ═══ Stat Cards ═══ -->
|
||||||
<div class="stat-row">
|
<StatCardRow />
|
||||||
<div
|
|
||||||
v-for="(stat, i) in stats" :key="stat.key"
|
|
||||||
class="stat-card"
|
|
||||||
:style="{ animationDelay: `${i * 60}ms` }"
|
|
||||||
>
|
|
||||||
<div class="stat-card-bg" :class="'stat-bg--' + stat.key"></div>
|
|
||||||
<div class="stat-card-inner">
|
|
||||||
<div class="stat-top">
|
|
||||||
<div class="stat-icon-wrap" :style="{ background: stat.iconBg }">
|
|
||||||
<span class="stat-icon">{{ stat.icon }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="stat-trend" :class="stat.trend > 0 ? 'up' : stat.trend < 0 ? 'down' : 'flat'">
|
|
||||||
<template v-if="stat.trend > 0">↑</template><template v-else-if="stat.trend < 0">↓</template>
|
|
||||||
<template v-else>—</template>
|
|
||||||
{{ stat.trend > 0 ? '+' : '' }}{{ stat.trend }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-value">{{ stat.value }}</div>
|
|
||||||
<div class="stat-label">{{ stat.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══ Main Grid ═══ -->
|
<!-- ═══ Main Grid ═══ -->
|
||||||
<div class="dash-grid">
|
<div class="dash-grid">
|
||||||
<!-- Left: Active Projects -->
|
<!-- Left: Active Projects -->
|
||||||
<section class="df-panel" style="animation-delay: 240ms">
|
<ActiveProjectsPanel />
|
||||||
<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" :style="{ animationDelay: `${300 + i * 50}ms` }">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="dash-right">
|
<div class="dash-right">
|
||||||
<!-- Ideas -->
|
<!-- Ideas -->
|
||||||
<section class="df-panel" style="animation-delay: 280ms">
|
<IdeasPanel />
|
||||||
<div class="df-panel-head">
|
|
||||||
<h2 class="df-panel-title">{{ $t('dashboard.ideaPool') }}</h2>
|
|
||||||
<router-link to="/ideas" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
|
||||||
</div>
|
|
||||||
<div class="idea-rows">
|
|
||||||
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row">
|
|
||||||
<div class="idea-score-ring" :class="'ring-' + idea.tier">
|
|
||||||
<span class="idea-score-num">{{ idea.score }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="idea-body">
|
|
||||||
<span class="idea-name">{{ idea.title }}</span>
|
|
||||||
<span class="idea-status">{{ ideaStatusLabel(idea.statusLabel) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="displayIdeas.length === 0" class="empty-hint">{{ $t('dashboard.empty.noIdeas') }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll)。
|
||||||
|
// 统计卡/项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
|
||||||
|
import { onMounted, ref } 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'
|
||||||
import { parseTs } from '@/utils/time'
|
import StatCardRow from '@/components/dashboard/StatCardRow.vue'
|
||||||
|
import ActiveProjectsPanel from '@/components/dashboard/ActiveProjectsPanel.vue'
|
||||||
|
import IdeasPanel from '@/components/dashboard/IdeasPanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
@@ -124,71 +59,6 @@ const { t } = useI18n()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
|
||||||
const stats = computed(() => [
|
|
||||||
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
|
|
||||||
iconBg: 'rgba(123,111,240,0.12)' },
|
|
||||||
{ key: 'projects', icon: '\u{1F4C2}', label: t('dashboard.stats.projects'), value: store.stats.projects, trend: 0,
|
|
||||||
iconBg: 'rgba(94,175,240,0.10)' },
|
|
||||||
{ key: 'tasks', icon: '⚡', label: t('dashboard.stats.activeTasks'), value: store.stats.activeTasks, trend: 0,
|
|
||||||
iconBg: 'rgba(61,219,160,0.10)' },
|
|
||||||
{ key: 'drafts', icon: '\u{1F4DD}', label: t('dashboard.stats.drafts'), value: store.stats.drafts, trend: 0,
|
|
||||||
iconBg: 'rgba(240,199,94,0.10)' },
|
|
||||||
])
|
|
||||||
|
|
||||||
function getProjectStage(status: string): { stage: string; stageLabelKey: string; progress: number } {
|
|
||||||
// F-09 对齐:project status 死键(in_progress/paused/cancelled)已从后端映射移除,
|
|
||||||
// 此处 switch 同步收敛,与 constants/project.ts 的 PROJECT_STAGE_INFO 语义一致。
|
|
||||||
switch (status) {
|
|
||||||
case 'planning': return { stage: 'planning', stageLabelKey: 'planning', progress: 20 }
|
|
||||||
case 'active': return { stage: 'coding', stageLabelKey: 'coding', progress: 55 }
|
|
||||||
case 'completed': return { stage: 'release', stageLabelKey: 'done', progress: 100 }
|
|
||||||
default: return { stage: 'planning', stageLabelKey: 'planning', progress: 20 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectTaskCount(projectId: string): number {
|
|
||||||
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLastActivity(iso: string): string {
|
|
||||||
const ms = parseTs(iso)
|
|
||||||
if (ms == null) return '—'
|
|
||||||
const diffMin = Math.floor((Date.now() - ms) / 60000)
|
|
||||||
if (diffMin < 1) return t('common.justNow')
|
|
||||||
if (diffMin < 60) return t('common.minutesAgo', { n: diffMin })
|
|
||||||
const diffH = Math.floor(diffMin / 60)
|
|
||||||
if (diffH < 24) return t('common.hoursAgo', { n: diffH })
|
|
||||||
const diffD = Math.floor(diffH / 24)
|
|
||||||
return t('common.dayAgo', { n: diffD })
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayProjects = computed(() =>
|
|
||||||
store.projects.map(p => {
|
|
||||||
const stageInfo = getProjectStage(p.status)
|
|
||||||
return {
|
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
...stageInfo,
|
|
||||||
activeTasks: getProjectTaskCount(p.id),
|
|
||||||
lastActivity: formatLastActivity(p.updated_at),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const displayIdeas = computed(() =>
|
|
||||||
store.ideas.slice(0, 4).map(i => ({
|
|
||||||
id: i.id,
|
|
||||||
title: i.title,
|
|
||||||
score: i.score ?? 0,
|
|
||||||
tier: (i.score ?? 0) >= 80 ? 'high' : (i.score ?? 0) >= 60 ? 'mid' : 'low',
|
|
||||||
statusLabel: i.status,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
function ideaStatusLabel(status: string): string {
|
|
||||||
return t('dashboard.ideaStatus.' + status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一加载入口(projects/tasks/ideas 并行),管理 loading + errorMsg
|
// 统一加载入口(projects/tasks/ideas 并行),管理 loading + errorMsg
|
||||||
// refresh 与 onMounted 共用,避免两处 catch 逻辑重复
|
// refresh 与 onMounted 共用,避免两处 catch 逻辑重复
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
@@ -305,66 +175,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.error-dismiss:hover { opacity: 0.7; }
|
.error-dismiss:hover { opacity: 0.7; }
|
||||||
|
|
||||||
/* ═══ Stat Cards ═══ */
|
|
||||||
.stat-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.stat-card {
|
|
||||||
position: relative;
|
|
||||||
border-radius: var(--df-radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
animation: fadeInUp 0.5s var(--df-ease) both;
|
|
||||||
}
|
|
||||||
.stat-card-bg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.stat-bg--ideas { background: var(--df-accent-bg); }
|
|
||||||
.stat-bg--projects { background: var(--df-info-bg); }
|
|
||||||
.stat-bg--tasks { background: var(--df-success-bg); }
|
|
||||||
.stat-bg--drafts { background: var(--df-warning-bg); }
|
|
||||||
.stat-card-inner {
|
|
||||||
position: relative;
|
|
||||||
padding: 12px 14px 10px;
|
|
||||||
}
|
|
||||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
||||||
.stat-icon-wrap {
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
}
|
|
||||||
.stat-icon { font-size: 14px; }
|
|
||||||
|
|
||||||
.stat-trend {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
}
|
|
||||||
.stat-trend.up { color: var(--df-success); background: rgba(61,219,160,0.10); }
|
|
||||||
.stat-trend.down { color: var(--df-danger); background: rgba(240,101,101,0.10); }
|
|
||||||
.stat-trend.flat { color: var(--df-text-dim); background: rgba(255,255,255,0.04); }
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: -0.8px;
|
|
||||||
color: var(--df-text);
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.stat-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
font-weight: 450;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══ Grid Layout ═══ */
|
/* ═══ Grid Layout ═══ */
|
||||||
.dash-grid {
|
.dash-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -373,7 +183,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.dash-right { display: flex; flex-direction: column; gap: 12px; }
|
.dash-right { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
/* ═══ Panel ═══ */
|
/* ═══ Panel(子面板 df-panel 共享基类,scoped 下子组件需各自声明同名类样式;
|
||||||
|
此处保留供未来页内直挂面板,子组件已自带同名 .df-panel 样式) ═══ */
|
||||||
.df-panel {
|
.df-panel {
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
@@ -403,127 +214,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.df-link:hover { opacity: 1; text-decoration: underline; }
|
.df-link:hover { opacity: 1; text-decoration: underline; }
|
||||||
|
|
||||||
/* ═══ Project Rows ═══ */
|
|
||||||
.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: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); }
|
|
||||||
|
|
||||||
.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: rgba(123,111,240,0.12); 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); }
|
|
||||||
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══ Idea Rows ═══ */
|
|
||||||
.idea-rows { display: flex; flex-direction: column; }
|
|
||||||
.idea-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 7px 0;
|
|
||||||
border-bottom: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.idea-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.idea-score-ring {
|
|
||||||
width: 32px; height: 32px;
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ring-high { background: rgba(61,219,160,0.10); border: 0.5px solid rgba(61,219,160,0.15); }
|
|
||||||
.ring-mid { background: rgba(240,199,94,0.08); border: 0.5px solid rgba(240,199,94,0.12); }
|
|
||||||
.ring-low { background: rgba(240,101,101,0.08); border: 0.5px solid rgba(240,101,101,0.12); }
|
|
||||||
|
|
||||||
.idea-score-num {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.ring-high .idea-score-num { color: var(--df-success); }
|
|
||||||
.ring-mid .idea-score-num { color: var(--df-warning); }
|
|
||||||
.ring-low .idea-score-num { color: var(--df-danger); }
|
|
||||||
|
|
||||||
.idea-name { font-size: 12px; font-weight: 500; color: var(--df-text); display: block; }
|
|
||||||
.idea-status { font-size: 10px; color: var(--df-text-dim); margin-top: 1px; display: block; }
|
|
||||||
|
|
||||||
/* ═══ Responsive ═══ */
|
/* ═══ Responsive ═══ */
|
||||||
|
|
||||||
/* 窄窗口:单栏 */
|
/* 窄窗口:单栏 */
|
||||||
@@ -534,16 +224,6 @@ onMounted(() => {
|
|||||||
.dash-right > .df-panel { flex: 1 1 280px; min-width: 0; }
|
.dash-right > .df-panel { flex: 1 1 280px; min-width: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 中等窗口:两栏但右栏缩窄 */
|
|
||||||
@media (min-width: 901px) and (max-width: 1200px) {
|
|
||||||
.dash-grid { grid-template-columns: 1fr 280px; }
|
|
||||||
.stat-row { grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
|
||||||
.stat-card-inner { padding: 10px 12px 8px; }
|
|
||||||
.stat-value { font-size: 22px; }
|
|
||||||
.stat-icon-wrap { width: 24px; height: 24px; }
|
|
||||||
.stat-icon { font-size: 12px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 宽窗口:右栏展开 */
|
/* 宽窗口:右栏展开 */
|
||||||
@media (min-width: 1201px) {
|
@media (min-width: 1201px) {
|
||||||
.dash-grid { grid-template-columns: 1fr 340px; }
|
.dash-grid { grid-template-columns: 1fr 340px; }
|
||||||
@@ -553,10 +233,6 @@ onMounted(() => {
|
|||||||
@media (min-width: 1600px) {
|
@media (min-width: 1600px) {
|
||||||
.dashboard { padding: 20px 28px 24px; }
|
.dashboard { padding: 20px 28px 24px; }
|
||||||
.dash-grid { grid-template-columns: 1fr 400px; gap: 16px; }
|
.dash-grid { grid-template-columns: 1fr 400px; gap: 16px; }
|
||||||
.stat-row { gap: 14px; }
|
|
||||||
.stat-card-inner { padding: 14px 18px 12px; }
|
|
||||||
.stat-value { font-size: 28px; }
|
|
||||||
.stat-icon-wrap { width: 30px; height: 30px; }
|
|
||||||
.df-panel { padding: 16px 18px; }
|
.df-panel { padding: 16px 18px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user