devperf/frontend/src/views/Overview.vue
zyc 512d3baca2
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
feat: 开发者可编辑项目、侧边栏项目列表优化、筛选器UI改进
- 新增 PATCH /api/projects/:id 开发者有权限可编辑项目
- 侧边栏项目列表改用项目API直接拉取,路由切换时自动刷新
- 项目筛选器和权限分配下拉框只显示项目名称,标签自动折叠

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:02:42 +08:00

308 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { NSpin } from 'naive-ui';
import { getOverviewApi, getProjectListApi } from '@/api/overview';
import DataCard from '@/components/shared/DataCard.vue';
import FilterBar from '@/components/shared/FilterBar.vue';
import EmptyState from '@/components/shared/EmptyState.vue';
import WeeklyCodeActivity from '@/components/charts/WeeklyCodeActivity.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
const router = useRouter();
const loading = ref(true);
const overviewData = ref<any>(null);
const projectOptions = ref<Array<{ value: string; label: string }>>([]);
const weekOffset = ref(0);
function weekLabel(offset: number) {
if (offset === 0) return '本周';
if (offset === 1) return '下周';
if (offset === -1) return '上周';
return offset > 0 ? `${offset}周后` : `${-offset}周前`;
}
async function changeWeek(delta: number) {
weekOffset.value += delta;
await loadData();
}
async function loadData(filters?: { period?: string; projectIds?: string[] }) {
loading.value = true;
try {
const params: any = { weekOffset: weekOffset.value };
if (filters?.period) params.period = filters.period;
if (filters?.projectIds) params.projectIds = filters.projectIds.join(',');
const [overviewRes, projectRes] = await Promise.all([
getOverviewApi(params),
getProjectListApi(),
]);
overviewData.value = overviewRes.data.data;
projectOptions.value = projectRes.data.data.map((p: any) => ({
value: p.id,
label: p.name,
}));
} catch (err) {
console.error('Failed to load overview:', err);
} finally {
loading.value = false;
}
}
onMounted(() => loadData());
// Panel 1: 各项目 OKR 进度(柱状图)
const projectOKRChartOptions = computed(() => {
const data = overviewData.value?.projectOKRProgress || [];
if (!data.length) return { title: { text: '暂无项目', left: 'center', top: 'center', textStyle: { color: '#9CA3AF', fontSize: 12 } } };
return {
tooltip: { trigger: 'axis', formatter: (params: any) => {
const p = params[0];
return `<strong>${p.name}</strong><br/>OKR 进度: ${p.value}%`;
}},
xAxis: { type: 'category', data: data.map((p: any) => p.identifier || p.name), axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
series: [{
type: 'bar',
data: data.map((p: any) => ({
value: p.progress,
itemStyle: { color: p.progress >= 70 ? '#0D9668' : p.progress >= 40 ? CHART_COLORS[0] : '#DC2626', borderRadius: [4, 4, 0, 0] },
})),
barWidth: '50%',
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 12, fontWeight: 600 },
}],
grid: { top: 30, bottom: 30, left: 50, right: 20 },
};
});
// Panel 2: KR 完成状态分布(环形图)
const krPieOptions = computed(() => {
const d = overviewData.value?.krDistribution || {};
const total = (d.completed || 0) + (d.inProgress || 0) + (d.notStarted || 0);
return {
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: [
{ value: d.completed || 0, name: '已完成', itemStyle: { color: '#0D9668' } },
{ value: d.inProgress || 0, name: '进行中', itemStyle: { color: CHART_COLORS[0] } },
{ value: d.notStarted || 0, name: '未开始', itemStyle: { color: '#9CA3AF' } },
],
label: { show: true, formatter: '{b}: {c}' },
emphasis: { label: { show: true, fontWeight: 'bold' } },
}],
graphic: total > 0 ? [{
type: 'text',
left: 'center', top: 'center',
style: { text: `${total}\n关键结果`, textAlign: 'center', fontSize: 14, fill: '#666' },
}] : [],
};
});
const { chartRef: projectOKRRef } = useECharts(projectOKRChartOptions);
const { chartRef: krPieRef } = useECharts(krPieOptions);
const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.weeks || []);
function formatCommitTime(isoStr: string) {
if (!isoStr) return '';
const d = new Date(isoStr);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffH = Math.floor(diffMs / 3600000);
const diffD = Math.floor(diffMs / 86400000);
if (diffH < 1) return '刚刚';
if (diffH < 24) return diffH + '小时前';
if (diffD < 7) return diffD + '天前';
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
</script>
<template>
<div class="overview-page">
<FilterBar
:projects="projectOptions"
:show-project-filter="true"
:show-period-filter="true"
@filter-change="loadData"
/>
<NSpin :show="loading">
<div class="panels-grid" v-if="overviewData">
<!-- Panel 1: 项目 OKR 进度 -->
<DataCard title="项目 OKR 进度" subtitle="各项目目标完成情况">
<div ref="projectOKRRef" style="height: 260px" />
</DataCard>
<!-- Panel 2: KR 完成状态分布 -->
<DataCard title="关键结果状态分布" :subtitle="`共 ${overviewData.krDistribution?.total || 0} 个关键结果`">
<div ref="krPieRef" style="height: 260px" />
</DataCard>
<!-- Panel 3: 本周关键结果 -->
<DataCard>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<div>
<div style="display:flex;align-items:center;gap:8px">
<span class="week-nav" @click="changeWeek(-1)"></span>
<span style="font-weight:600;font-size:15px">{{ weekLabel(weekOffset) }}关键结果</span>
<span class="week-nav" @click="changeWeek(1)"></span>
</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:2px" v-if="overviewData.weeklyKRStats">
{{ overviewData.weeklyKRStats.weekLabel }} ·
{{ overviewData.weeklyKRStats.total }} ·
已完成 <span style="color:#0D9668;font-weight:600">{{ overviewData.weeklyKRStats.completed }}</span> ·
进度 <span style="font-weight:600">{{ overviewData.weeklyKRStats.avgProgress }}%</span>
</div>
</div>
<span v-if="weekOffset !== 0" class="week-reset" @click="weekOffset = 0; loadData()">回到本周</span>
</div>
</template>
<div class="urgent-kr-list">
<div v-for="kr in overviewData.urgentKRs" :key="kr.id" class="urgent-kr-item" :class="'kr-st-' + kr.displayStatus">
<div class="urgent-kr-row">
<div class="urgent-kr-left">
<span class="urgent-kr-title" :class="{ 'title-done': kr.isCompleted, 'title-cancelled': kr.isCancelled }">{{ kr.title }}</span>
<span class="urgent-kr-meta">{{ kr.projectIdentifier }} · {{ kr.ownerName }} · {{ kr.endDate }}</span>
</div>
<div class="urgent-kr-right">
<div class="urgent-kr-bar" v-if="!kr.isCancelled">
<div class="urgent-kr-bar-fill" :style="{ width: kr.progress + '%', background: kr.isCompleted ? '#0D9668' : kr.isOverdue ? '#DC2626' : kr.isPaused ? '#9CA3AF' : 'var(--color-primary-hex)' }" />
</div>
<span class="urgent-kr-badge" :class="'badge-' + kr.displayStatus">
{{ kr.isCancelled ? '已取消' : kr.isPaused ? '已暂停' : kr.isCompleted ? '已完成' : kr.isOverdue ? '已逾期' : kr.daysLeft === 0 ? '今天截止' : kr.daysLeft + '天后' }}
</span>
</div>
</div>
</div>
<div v-if="!overviewData.urgentKRs?.length" style="padding:var(--space-5);text-align:center;color:var(--color-text-muted);font-size:13px">
本周无到期的关键结果
</div>
</div>
</DataCard>
<!-- Panel 4: 每周代码活动 -->
<DataCard title="每周代码活动" subtitle="最近 12 周提交 + PR">
<WeeklyCodeActivity :weeks="weeklyCodeWeeks" />
</DataCard>
<!-- Panel 5: 异常事项 -->
<DataCard>
<template #header>
<div>
<div style="font-weight:600;font-size:15px">异常事项</div>
<div style="font-size:12px;color:var(--color-text-secondary);margin-top:2px">
<span style="color:#DC2626;font-weight:600">{{ overviewData.overdueKRs?.length || 0 }}</span> 项需关注
</div>
</div>
</template>
<div class="abnormal-list">
<div v-for="kr in overviewData.overdueKRs" :key="kr.id" class="abnormal-item" :class="'abnormal-' + kr.status">
<div class="abnormal-row">
<div class="abnormal-left">
<div class="abnormal-title-row">
<span class="abnormal-title" :class="{ 'title-cancelled': kr.status === 'cancelled' }">{{ kr.title }}</span>
<span class="abnormal-badge" :class="'badge-' + kr.status">{{ kr.statusLabel }}</span>
</div>
<div class="abnormal-meta">{{ kr.projectIdentifier }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }}</div>
<div v-if="kr.reason" class="abnormal-reason">{{ kr.reason }}</div>
</div>
<div class="abnormal-pct tabular-nums">{{ kr.progress }}%</div>
</div>
</div>
<div v-if="!overviewData.overdueKRs?.length" style="padding:var(--space-5);text-align:center;color:var(--color-text-muted);font-size:13px">
无异常事项
</div>
</div>
</DataCard>
<!-- Panel 6: 最近提交动态 -->
<DataCard title="最近提交动态" subtitle="最新 15 条 commit">
<div class="commit-list">
<div v-for="c in overviewData.recentCommits" :key="c.sha" class="commit-item">
<div class="commit-main">
<span class="commit-msg">{{ c.message }}</span>
<span class="commit-meta">{{ c.authorName }} · {{ c.repoName }} · {{ c.sha }}</span>
</div>
<span class="commit-time">{{ formatCommitTime(c.committedAt) }}</span>
</div>
<div v-if="!overviewData.recentCommits?.length" style="padding:var(--space-5);text-align:center;color:var(--color-text-muted);font-size:13px">暂无提交记录</div>
</div>
</DataCard>
</div>
<EmptyState v-else-if="!loading" title="暂无数据" description="请先创建项目并绑定 Git 仓库。" />
</NSpin>
</div>
</template>
<style scoped>
.panels-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); }
@media (max-width: 768px) { .panels-grid { grid-template-columns: 1fr; } }
.progress-bar-bg { flex: 1; height: 8px; background: #F0F0F0; border-radius: 4px; overflow: hidden; }
.progress-bar-fill { height: 100%; background: var(--color-primary-hex); border-radius: 4px; transition: width 0.4s; }
.urgent-kr-list { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow-y: auto; }
.urgent-kr-item { padding: 8px 12px; border-radius: 8px; background: #FAFAFA; transition: background 0.15s; }
.urgent-kr-item:hover { background: #F3F4F6; }
.urgent-kr-item.kr-overdue { background: rgba(220,38,38,0.04); }
.kr-st-completed { opacity: 0.6; }
.kr-st-cancelled { opacity: 0.45; }
.kr-st-paused { opacity: 0.6; }
.title-cancelled { text-decoration: line-through; color: var(--color-text-muted); }
.badge-cancelled { background: rgba(156,163,175,0.1); color: #6B7280; }
.badge-paused { background: rgba(212,146,10,0.08); color: #B47D08; }
.badge-active { background: rgba(59,89,152,0.08); color: var(--color-primary-hex); }
.week-nav { font-size: 18px; font-weight: 700; color: var(--color-text-muted); cursor: pointer; padding: 0 4px; user-select: none; line-height: 1; }
.week-nav:hover { color: var(--color-primary-hex); }
.week-reset { font-size: 12px; color: var(--color-primary-hex); cursor: pointer; }
.week-reset:hover { text-decoration: underline; }
.urgent-kr-row { display: flex; align-items: center; gap: 12px; }
.urgent-kr-left { flex: 1; min-width: 0; }
.urgent-kr-title { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title-done { text-decoration: line-through; color: var(--color-text-muted); }
.urgent-kr-meta { font-size: 11px; color: var(--color-text-muted); margin-top: 1px; }
.urgent-kr-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; width: 120px; }
.urgent-kr-bar { flex: 1; height: 4px; background: #E5E7EB; border-radius: 2px; overflow: hidden; }
.urgent-kr-bar-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
.urgent-kr-badge { font-size: 11px; padding: 1px 6px; border-radius: 8px; white-space: nowrap; font-weight: 500; }
.badge-normal { background: rgba(59,89,152,0.08); color: var(--color-primary-hex); }
.badge-overdue { background: rgba(220,38,38,0.08); color: #DC2626; }
.badge-done { background: rgba(13,150,104,0.08); color: #0D9668; }
.badge-today { background: rgba(212,146,10,0.1); color: #B47D08; }
.abnormal-list { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow-y: auto; }
.abnormal-item { padding: 8px 12px; border-radius: 8px; border-left: 3px solid; }
.abnormal-overdue { border-left-color: #DC2626; background: rgba(220,38,38,0.03); }
.abnormal-paused { border-left-color: #D4920A; background: rgba(212,146,10,0.03); }
.abnormal-cancelled { border-left-color: #9CA3AF; background: rgba(156,163,175,0.05); opacity: 0.7; }
.abnormal-row { display: flex; align-items: flex-start; gap: 12px; }
.abnormal-left { flex: 1; min-width: 0; }
.abnormal-title-row { display: flex; align-items: center; gap: 8px; }
.abnormal-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title-cancelled { text-decoration: line-through; color: var(--color-text-muted); }
.abnormal-badge { font-size: 11px; padding: 1px 6px; border-radius: 8px; white-space: nowrap; font-weight: 500; flex-shrink: 0; }
.badge-overdue { background: rgba(220,38,38,0.08); color: #DC2626; }
.badge-paused { background: rgba(212,146,10,0.08); color: #B47D08; }
.badge-cancelled { background: rgba(156,163,175,0.1); color: #6B7280; }
.abnormal-meta { font-size: 11px; color: var(--color-text-muted); margin-top: 2px; }
.abnormal-reason { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; padding: 4px 8px; background: rgba(0,0,0,0.02); border-radius: 4px; }
.abnormal-pct { font-size: 13px; font-weight: 600; color: var(--color-text-secondary); flex-shrink: 0; }
.commit-list { display: flex; flex-direction: column; gap: 2px; max-height: 280px; overflow-y: auto; }
.commit-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 6px; }
.commit-item:hover { background: #F9FAFB; }
.commit-main { flex: 1; min-width: 0; }
.commit-msg { font-size: 13px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.commit-meta { font-size: 11px; color: var(--color-text-muted); display: block; margin-top: 1px; }
.commit-time { font-size: 11px; color: var(--color-text-muted); white-space: nowrap; flex-shrink: 0; }
</style>