devperf/frontend/src/views/MemberDetail.vue
zyc 44464dd334 feat: DevPerf Dashboard 研发人效看板 v1.0
- 后端:Bun + Hono + Drizzle ORM + SQLite
- 前端:Vue 3 + Naive UI + ECharts
- 项目管理:创建项目 + 绑定 Git 仓库
- OKR 系统:目标/关键结果 CRUD + 进度追踪
- Git 同步:Gitea API 自动同步 commit/PR + 作者关联
- 数据看板:项目 OKR 进度 + KR 状态分布 + 代码活动
- 权限体系:admin/manager/developer/viewer 四级
- Docker 部署:docker-compose + nginx

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

200 lines
6.9 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { NSpin, NDataTable, NTag } from 'naive-ui';
import { getMemberDetailApi } from '@/api/members';
import DataCard from '@/components/shared/DataCard.vue';
import ContributionHeatmap from '@/components/charts/ContributionHeatmap.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import type { HeatmapDay } from '@/types';
const route = useRoute();
const loading = ref(true);
const data = ref<any>(null);
onMounted(async () => {
try {
const res = await getMemberDetailApi(route.params.id as string);
data.value = res.data.data;
} catch (err) {
console.error('Failed to load member:', err);
} finally {
loading.value = false;
}
});
/**
* B-14 fix: Map member API heatmap data to ContributionHeatmap's expected HeatmapDay format.
* Member Detail API returns contributionHeatmap.days which already has the correct fields.
*/
const heatmapDays = computed<HeatmapDay[]>(() => {
return data.value?.contributionHeatmap?.days || [];
});
// B-19 fix: Delivery trend line - ensure all sprint data points are shown
const trendOptions = computed(() => {
const cycles = data.value?.deliveryTrend?.cycles || [];
return {
tooltip: {
trigger: 'axis',
formatter(params: any) {
const item = params[0];
const cycle = cycles[item.dataIndex];
if (!cycle) return '';
return `<strong>${cycle.name}</strong><br/>` +
`分配: ${cycle.assignedPoints} 点<br/>` +
`完成: ${cycle.completedPoints} 点<br/>` +
`交付率: ${cycle.rate}%`;
},
},
xAxis: {
type: 'category',
data: cycles.map((c: any) => c.name),
axisLabel: { fontSize: 11, interval: 0, rotate: cycles.length > 6 ? 30 : 0 },
},
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
series: [{
type: 'line',
data: cycles.map((c: any) => c.rate),
smooth: true,
itemStyle: { color: CHART_COLORS[0] },
areaStyle: { color: 'rgba(59,89,152,0.1)' },
symbol: 'circle',
symbolSize: 8,
label: {
show: cycles.length <= 8,
formatter: '{c}%',
fontSize: 11,
},
}],
};
});
// KPI Radar
const radarOptions = computed(() => {
const kpi = data.value?.kpiScorecard;
if (!kpi) return {};
return {
radar: {
indicator: [
{ name: '交付率', max: 100 },
{ name: '交付速度', max: 100 },
{ name: '代码质量', max: 100 },
{ name: 'PR 效率', max: 100 },
{ name: '评审参与', max: 100 },
],
},
series: [{
type: 'radar',
data: [{
value: [
kpi.sprintDeliveryRate,
Math.max(0, 100 - kpi.avgDeliveryDays * 5),
Math.max(0, 100 - kpi.bugDensity * 100),
Math.max(0, 100 - kpi.prMergeTimeAvg),
kpi.reviewParticipation,
],
itemStyle: { color: CHART_COLORS[0] },
areaStyle: { color: 'rgba(59,89,152,0.2)' },
}],
}],
};
});
const { chartRef: trendRef } = useECharts(trendOptions);
const { chartRef: radarRef } = useECharts(radarOptions);
const taskColumns = [
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
{ title: '状态', key: 'status', width: 100, render: (row: any) => row.status },
{ title: '优先级', key: 'priority', width: 90 },
{ title: '点数', key: 'storyPoints', width: 70, align: 'center' as const },
{ title: '截止日期', key: 'dueDate', width: 110 },
];
/**
* B-20 fix: Add pagination to Current Tasks table.
* Default page size of 10 with option to expand.
*/
const taskPagination = {
pageSize: 10,
};
</script>
<template>
<div class="member-detail-page">
<NSpin :show="loading">
<template v-if="data">
<h2 style="margin-bottom: var(--space-4)">{{ data.member?.displayName }}</h2>
<p style="color: var(--color-text-secondary); margin-bottom: var(--space-5)">{{ data.member?.email }} - {{ data.member?.role }}</p>
<div class="grid-2">
<DataCard title="Sprint 交付率趋势" subtitle="最近 6 个 Sprint">
<div ref="trendRef" style="height: 240px" />
</DataCard>
<DataCard title="KPI 评分卡">
<div ref="radarRef" style="height: 240px" />
</DataCard>
</div>
<!-- B-14 fix: replaced placeholder with real ContributionHeatmap component -->
<DataCard title="贡献热力图" subtitle="最近 6 个月" style="margin-top: var(--space-5)">
<div style="height: 160px">
<ContributionHeatmap :days="heatmapDays" />
</div>
</DataCard>
<!-- B-20 fix: added pagination prop to NDataTable -->
<DataCard title="当前任务" style="margin-top: var(--space-5)">
<NDataTable
:columns="taskColumns"
:data="data.currentTasks || []"
:bordered="false"
:pagination="taskPagination"
size="small"
/>
</DataCard>
<!-- KPI Details -->
<div class="kpi-cards" style="margin-top: var(--space-5)">
<div class="kpi-card">
<span class="kpi-label">交付率</span>
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.sprintDeliveryRate || 0 }}%</span>
</div>
<div class="kpi-card">
<span class="kpi-label">平均交付天数</span>
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.avgDeliveryDays || 0 }}d</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Bug 密度</span>
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.bugDensity || 0 }}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">PR 合入时间</span>
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.prMergeTimeAvg || 0 }}h</span>
</div>
<div class="kpi-card">
<span class="kpi-label">连续活跃</span>
<span class="kpi-value tabular-nums">{{ data.kpiScorecard?.activityStreak || 0 }}d</span>
</div>
</div>
</template>
</NSpin>
</div>
</template>
<style scoped>
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); }
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
.kpi-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--space-3); }
.kpi-card {
background: var(--color-bg-card); border: 1px solid var(--color-border);
border-radius: var(--radius-card); padding: var(--space-4); text-align: center;
}
.kpi-label { display: block; font-size: 12px; color: var(--color-text-secondary); margin-bottom: var(--space-2); }
.kpi-value { font-size: 24px; font-weight: 700; color: var(--color-primary-hex); }
@media (max-width: 768px) { .kpi-cards { grid-template-columns: repeat(2, 1fr); } }
</style>