All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
- 新增 PATCH /api/projects/:id 开发者有权限可编辑项目 - 侧边栏项目列表改用项目API直接拉取,路由切换时自动刷新 - 项目筛选器和权限分配下拉框只显示项目名称,标签自动折叠 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
15 KiB
Vue
308 lines
15 KiB
Vue
<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>
|