feat: 团队总览改版 + OKR 创建简化 + 时区修复

- 总览页6面板:项目OKR进度/KR状态/本周关键结果/代码活动/历史逾期/最近提交
- 去掉PR合入时间和产品线进度(重复),新增历史逾期未完成和最近提交动态
- OKR创建简化:目标只需标题+负责人,时间自动从任务推算
- KR创建简化:只需任务内容+起止时间,去掉目标值/单位/权重
- 修复时区问题:日期选择器UTC偏移导致少1天
- 今天截止显示橙色标签,已逾期红色,进行中蓝色
- DataCard支持自定义header slot
- 目标时间范围自动取任务最早开始~最晚截止

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-04-10 10:45:08 +08:00
parent 44464dd334
commit 690766528a
5 changed files with 290 additions and 127 deletions

View File

@ -6,6 +6,19 @@ import dayjs from 'dayjs';
export const overviewRoutes = new Hono(); export const overviewRoutes = new Hono();
async function getRecentCommits() {
const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(15);
const allUsers = await db.select().from(users);
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
return recent.map(c => ({
sha: c.sha?.slice(0, 7) || '',
message: (c.message || '').split('\n')[0].slice(0, 60),
authorName: c.userId ? userMap.get(c.userId) || c.authorName : c.authorName,
repoName: c.repoName,
committedAt: c.committedAt instanceof Date ? c.committedAt.toISOString() : c.committedAt,
}));
}
overviewRoutes.get('/overview', async (c) => { overviewRoutes.get('/overview', async (c) => {
const period = c.req.query('period'); const period = c.req.query('period');
const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || []; const projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || [];
@ -140,33 +153,92 @@ overviewRoutes.get('/overview', async (c) => {
}); });
} }
// 6. PR Merge Time (last 12 weeks) // 6. 本周待完成 KR截止日期在本周范围内且未完成的
const mergedPRs = await db.select().from(gitPRs) const weekStart = dayjs().startOf('week');
.where(gte(gitPRs.mergedAt, twelveWeeksAgo)); const weekEnd = dayjs().endOf('week');
const prWeekMap: Record<string, { totalHours: number; count: number }> = {}; const allKRsRaw = await db.select().from(keyResults);
for (let i = 0; i < 12; i++) { const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
const ws = dayjs().subtract(11 - i, 'week').startOf('week').format('YYYY-MM-DD');
prWeekMap[ws] = { totalHours: 0, count: 0 }; const thisWeekKRs = allKRsRaw.filter(kr => {
if (!kr.endDate) return false;
const end = dayjs(kr.endDate);
// 严格筛选:截止日期在本周一到本周日之间
return (end.isAfter(weekStart) || end.isSame(weekStart, 'day')) &&
(end.isBefore(weekEnd) || end.isSame(weekEnd, 'day'));
}).sort((a, b) => {
// 未完成的排前面,已完成的排后面;同状态按截止日期排
const aDone = (a.currentValue || 0) >= (a.targetValue || 100) ? 1 : 0;
const bDone = (b.currentValue || 0) >= (b.targetValue || 100) ? 1 : 0;
if (aDone !== bDone) return aDone - bDone;
return dayjs(a.endDate).valueOf() - dayjs(b.endDate).valueOf();
});
const urgentKRs = [];
for (const kr of thisWeekKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
const endDate = kr.endDate || '';
const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day'));
const daysLeft = dayjs(endDate).startOf('day').diff(dayjs().startOf('day'), 'day');
const isCompleted = (kr.currentValue || 0) >= (kr.targetValue || 100);
urgentKRs.push({
id: kr.id,
title: kr.title,
progress: kr.currentValue || 0,
endDate,
isOverdue: isOverdue && !isCompleted,
isCompleted,
daysLeft,
objectiveTitle: obj?.title || '',
ownerName: owner?.displayName || '未指定',
projectName: proj?.name || '',
projectIdentifier: proj?.identifier || '',
});
} }
for (const pr of mergedPRs) { // 7. 历史逾期未完成(截止日期在本周之前且未完成的)
if (pr.mergedAt && pr.mergeTimeHours !== null && pr.state === 'merged') { const overdueKRs = allKRsRaw.filter(kr => {
const ws = dayjs(pr.mergedAt).startOf('week').format('YYYY-MM-DD'); if (!kr.endDate) return false;
if (prWeekMap[ws]) { if ((kr.currentValue || 0) >= (kr.targetValue || 100)) return false;
prWeekMap[ws].totalHours += pr.mergeTimeHours || 0; return dayjs(kr.endDate).isBefore(weekStart);
prWeekMap[ws].count++; }).sort((a, b) => dayjs(a.endDate!).valueOf() - dayjs(b.endDate!).valueOf());
}
} const overdueList = [];
for (const kr of overdueKRs.slice(0, 20)) {
const obj = allObjsMap.get(kr.objectiveId);
const owner = obj?.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const proj = obj?.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
const daysOverdue = dayjs().startOf('day').diff(dayjs(kr.endDate!).startOf('day'), 'day');
overdueList.push({
id: kr.id,
title: kr.title,
progress: kr.currentValue || 0,
endDate: kr.endDate,
daysOverdue,
ownerName: owner?.displayName || '未指定',
projectName: proj?.name || '',
projectIdentifier: proj?.identifier || '',
});
} }
const prMergeTime = { const weeklyTotal = urgentKRs.length;
weeks: Object.entries(prWeekMap).map(([weekStart, data]) => ({ const weeklyCompleted = urgentKRs.filter(kr => kr.isCompleted).length;
weekStart, const weeklyAvgProgress = weeklyTotal > 0
avgHours: data.count > 0 ? Math.round(data.totalHours / data.count * 10) / 10 : 0, ? Math.round(urgentKRs.reduce((s, kr) => s + kr.progress, 0) / weeklyTotal)
prCount: data.count, : 0;
})),
};
return c.json({ return c.json({
code: 0, code: 0,
@ -176,7 +248,10 @@ overviewRoutes.get('/overview', async (c) => {
projectProgress, projectProgress,
weeklyCodeActivity, weeklyCodeActivity,
okrProgress, okrProgress,
prMergeTime, urgentKRs,
weeklyKRStats: { total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress },
overdueKRs: overdueList,
recentCommits: await getRecentCommits(),
}, },
message: 'success', message: 'success',
}); });

View File

@ -120,9 +120,37 @@ export async function createKeyResult(objectiveId: string, data: {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
// 自动更新目标的时间范围(取所有任务的最早开始 ~ 最晚截止)
await recalcObjectiveDates(objectiveId);
return { id }; return { id };
} }
/**
* KR Objective startDate / endDate / period
*/
async function recalcObjectiveDates(objectiveId: string) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, objectiveId));
const starts = krs.map(kr => kr.startDate).filter(Boolean) as string[];
const ends = krs.map(kr => kr.endDate).filter(Boolean) as string[];
if (starts.length === 0 && ends.length === 0) return;
const earliest = starts.length > 0 ? starts.sort()[0] : null;
const latest = ends.length > 0 ? ends.sort().reverse()[0] : null;
const period = latest ? dateToPeriod(latest) : undefined;
const updateData: Record<string, any> = { updatedAt: new Date() };
if (earliest) updateData.startDate = earliest;
if (latest) updateData.endDate = latest;
if (period) updateData.period = period;
await db.update(objectives).set(updateData).where(eq(objectives.id, objectiveId));
}
export async function updateKeyResultProgress(krId: string, currentValue: number) { export async function updateKeyResultProgress(krId: string, currentValue: number) {
const kr = await db.query.keyResults.findFirst({ const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, krId), where: eq(keyResults.id, krId),
@ -194,4 +222,7 @@ export async function deleteKeyResult(id: string) {
.set({ progress: objectiveProgress, updatedAt: new Date() }) .set({ progress: objectiveProgress, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId)); .where(eq(objectives.id, kr.objectiveId));
} }
// 重算目标时间范围
await recalcObjectiveDates(kr.objectiveId);
} }

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
title: string; title?: string;
subtitle?: string; subtitle?: string;
loading?: boolean; loading?: boolean;
}>(); }>();
@ -8,11 +8,13 @@ defineProps<{
<template> <template>
<div class="data-card"> <div class="data-card">
<div class="card-header"> <div class="card-header" v-if="$slots.header || title">
<slot name="header">
<div> <div>
<h3 class="card-title">{{ title }}</h3> <h3 class="card-title">{{ title }}</h3>
<p class="card-subtitle" v-if="subtitle">{{ subtitle }}</p> <p class="card-subtitle" v-if="subtitle">{{ subtitle }}</p>
</div> </div>
</slot>
<slot name="header-extra" /> <slot name="header-extra" />
</div> </div>
<div class="card-body"> <div class="card-body">

View File

@ -89,26 +89,23 @@ const krPieOptions = computed(() => {
}; };
}); });
// Panel 6: PR
const prChartOptions = computed(() => {
const data = overviewData.value?.prMergeTime?.weeks || [];
return {
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.map((w: any) => w.weekStart), axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: '小时' },
series: [
{ type: 'line', data: data.map((w: any) => w.avgHours), smooth: true, itemStyle: { color: CHART_COLORS[0] }, areaStyle: { color: 'rgba(59,89,152,0.1)' } },
{ type: 'line', data: data.map(() => 48), lineStyle: { type: 'dashed', color: '#DC2626' }, symbol: 'none' },
],
grid: { top: 30, bottom: 30, left: 50, right: 20 },
};
});
const { chartRef: projectOKRRef } = useECharts(projectOKRChartOptions); const { chartRef: projectOKRRef } = useECharts(projectOKRChartOptions);
const { chartRef: krPieRef } = useECharts(krPieOptions); const { chartRef: krPieRef } = useECharts(krPieOptions);
const { chartRef: prRef } = useECharts(prChartOptions);
const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.weeks || []); 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> </script>
<template> <template>
@ -132,19 +129,38 @@ const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.w
<div ref="krPieRef" style="height: 260px" /> <div ref="krPieRef" style="height: 260px" />
</DataCard> </DataCard>
<!-- Panel 3: 产品线进度 --> <!-- Panel 3: 本周关键结果 -->
<DataCard title="产品线进度" subtitle="OKR 整体完成度"> <DataCard>
<div class="progress-list"> <template #header>
<div v-for="p in overviewData.projectProgress" :key="p.projectId" class="progress-item"> <div>
<router-link :to="`/projects/${p.projectId}`" class="progress-link"> <div style="font-weight:600;font-size:15px">本周关键结果</div>
<span class="progress-name">{{ p.identifier }} {{ p.name }}</span> <div style="font-size:12px;color:var(--color-text-secondary);margin-top:2px" v-if="overviewData.weeklyKRStats">
<div class="progress-bar-bg"> {{ overviewData.weeklyKRStats.total }} ·
<div class="progress-bar-fill" :style="{ width: p.currentCycleProgress + '%', background: p.currentCycleProgress >= 70 ? '#0D9668' : 'var(--color-primary-hex)' }" /> 已完成 <span style="color:#0D9668;font-weight:600">{{ overviewData.weeklyKRStats.completed }}</span> ·
整体进度 <span style="font-weight:600">{{ overviewData.weeklyKRStats.avgProgress }}%</span>
</div> </div>
<span class="progress-pct tabular-nums">{{ p.currentCycleProgress }}%</span>
</router-link>
</div> </div>
<div v-if="!overviewData.projectProgress?.length" style="padding:var(--space-4);text-align:center;color:var(--color-text-muted);font-size:13px">暂无项目</div> </template>
<div class="urgent-kr-list">
<div v-for="kr in overviewData.urgentKRs" :key="kr.id" class="urgent-kr-item" :class="{ 'kr-overdue': kr.isOverdue && !kr.isCompleted, 'kr-done': kr.isCompleted }">
<div class="urgent-kr-row">
<div class="urgent-kr-left">
<span class="urgent-kr-title" :class="{ 'title-done': kr.isCompleted }">{{ 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">
<div class="urgent-kr-bar-fill" :style="{ width: kr.progress + '%', background: kr.isCompleted ? '#0D9668' : kr.isOverdue ? '#DC2626' : 'var(--color-primary-hex)' }" />
</div>
<span class="urgent-kr-badge" :class="kr.isCompleted ? 'badge-done' : kr.isOverdue ? 'badge-overdue' : kr.daysLeft === 0 ? 'badge-today' : 'badge-normal'">
{{ 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> </div>
</DataCard> </DataCard>
@ -153,26 +169,49 @@ const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.w
<WeeklyCodeActivity :weeks="weeklyCodeWeeks" /> <WeeklyCodeActivity :weeks="weeklyCodeWeeks" />
</DataCard> </DataCard>
<!-- Panel 5: OKR 完成进度 --> <!-- Panel 5: 历史逾期未完成 -->
<DataCard title="OKR 目标详情" subtitle="各目标完成进度"> <DataCard>
<div class="okr-list"> <template #header>
<div v-for="obj in overviewData.okrProgress" :key="obj.id" class="okr-item" @click="router.push('/okr')" style="cursor:pointer"> <div>
<div class="okr-info"> <div style="font-weight:600;font-size:15px">历史逾期未完成</div>
<span class="okr-title">{{ obj.title }}</span> <div style="font-size:12px;color:var(--color-text-secondary);margin-top:2px">
<span class="okr-meta">{{ obj.ownerName }} · {{ obj.startDate || '' }} ~ {{ obj.endDate || '' }}</span> <span style="color:#DC2626;font-weight:600">{{ overviewData.overdueKRs?.length || 0 }}</span> 项待处理
</div> </div>
<div class="progress-bar-bg" style="flex:1">
<div class="progress-bar-fill" :style="{ width: obj.progress + '%', background: obj.progress >= 70 ? '#0D9668' : 'var(--color-primary-hex)' }" />
</div> </div>
<span class="progress-pct tabular-nums">{{ Math.round(obj.progress) }}%</span> </template>
<div class="overdue-list">
<div v-for="kr in overviewData.overdueKRs" :key="kr.id" class="overdue-item">
<div class="overdue-row">
<div class="overdue-left">
<span class="overdue-title">{{ kr.title }}</span>
<span class="overdue-meta">{{ kr.projectIdentifier }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }}</span>
</div>
<div class="overdue-right">
<div class="overdue-bar">
<div class="overdue-bar-fill" :style="{ width: kr.progress + '%' }" />
</div>
<span class="overdue-days">逾期{{ kr.daysOverdue }}</span>
</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>
<div v-if="!overviewData.okrProgress?.length" style="padding:var(--space-4);text-align:center;color:var(--color-text-muted);font-size:13px">暂无 OKR 目标</div>
</div> </div>
</DataCard> </DataCard>
<!-- Panel 6: PR 平均合入时间 --> <!-- Panel 6: 最近提交动态 -->
<DataCard title="PR 平均合入时间" subtitle="每周平均耗时 + 48h 预警线"> <DataCard title="最近提交动态" subtitle="最新 15 条 commit">
<div ref="prRef" style="height: 260px" /> <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> </DataCard>
</div> </div>
@ -185,18 +224,46 @@ const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.w
.panels-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); } .panels-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); }
@media (max-width: 768px) { .panels-grid { grid-template-columns: 1fr; } } @media (max-width: 768px) { .panels-grid { grid-template-columns: 1fr; } }
.progress-list, .okr-list { display: flex; flex-direction: column; gap: var(--space-2); max-height: 260px; overflow-y: auto; }
.progress-item { display: flex; align-items: center; }
.progress-link { display: flex; align-items: center; gap: var(--space-3); width: 100%; text-decoration: none; color: inherit; padding: var(--space-2) 0; border-radius: 6px; }
.progress-link:hover { background: rgba(0,0,0,0.02); }
.progress-name { width: 140px; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
.progress-bar-bg { flex: 1; height: 8px; background: #F0F0F0; border-radius: 4px; overflow: hidden; } .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; } .progress-bar-fill { height: 100%; background: var(--color-primary-hex); border-radius: 4px; transition: width 0.4s; }
.progress-pct { width: 44px; text-align: right; font-size: 13px; font-weight: 600; flex-shrink: 0; }
.okr-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-radius: 6px; } .urgent-kr-list { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow-y: auto; }
.okr-item:hover { background: rgba(0,0,0,0.02); } .urgent-kr-item { padding: 8px 12px; border-radius: 8px; background: #FAFAFA; transition: background 0.15s; }
.okr-info { width: 180px; flex-shrink: 0; } .urgent-kr-item:hover { background: #F3F4F6; }
.okr-title { font-size: 13px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .urgent-kr-item.kr-overdue { background: rgba(220,38,38,0.04); }
.okr-meta { font-size: 11px; color: var(--color-text-muted); } .urgent-kr-item.kr-done { opacity: 0.6; }
.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; }
.overdue-list { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow-y: auto; }
.overdue-item { padding: 8px 12px; border-radius: 8px; background: rgba(220,38,38,0.03); border-left: 3px solid #DC2626; }
.overdue-row { display: flex; align-items: center; gap: 12px; }
.overdue-left { flex: 1; min-width: 0; }
.overdue-title { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.overdue-meta { font-size: 11px; color: var(--color-text-muted); margin-top: 1px; display: block; }
.overdue-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; width: 130px; }
.overdue-bar { flex: 1; height: 4px; background: #E5E7EB; border-radius: 2px; overflow: hidden; }
.overdue-bar-fill { height: 100%; border-radius: 2px; background: #DC2626; }
.overdue-days { font-size: 11px; color: #DC2626; font-weight: 600; white-space: nowrap; }
.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> </style>

View File

@ -2,12 +2,14 @@
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { NSpin, NProgress, NTag, NButton, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, NDatePicker, NSwitch, useMessage } from 'naive-ui'; import { NSpin, NProgress, NTag, NButton, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, NDatePicker, NSwitch, useMessage } from 'naive-ui';
// NDatePicker still needed for KR creation dialog
import { getProjectDetailApi } from '@/api/projects'; import { getProjectDetailApi } from '@/api/projects';
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi } from '@/api/okr'; import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi } from '@/api/okr';
import request from '@/api/request'; import request from '@/api/request';
import DataCard from '@/components/shared/DataCard.vue'; import DataCard from '@/components/shared/DataCard.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts'; import { useECharts, CHART_COLORS } from '@/composables/useECharts';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import dayjs from 'dayjs';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -50,33 +52,38 @@ const currentUserId = computed(() => {
catch { return ''; } catch { return ''; }
}); });
// timestamp YYYY-MM-DD // timestamp YYYY-MM-DD使
function tsToDateStr(ts: number | null): string { function tsToDateStr(ts: number | null): string {
if (!ts) return ''; if (!ts) return '';
return new Date(ts).toISOString().slice(0, 10); const d = new Date(ts);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
} }
// //
const showCreateObj = ref(false); const showCreateObj = ref(false);
const newObj = ref({ title: '', ownerId: '', dateRange: null as [number, number] | null }); const newObj = ref({ title: '', ownerId: '' });
function openCreateObj() { function openCreateObj() {
newObj.value = { title: '', ownerId: currentUserId.value, dateRange: null }; newObj.value = { title: '', ownerId: currentUserId.value };
showCreateObj.value = true; showCreateObj.value = true;
} }
async function handleCreateObjective() { async function handleCreateObjective() {
if (!newObj.value.title || !newObj.value.dateRange) { if (!newObj.value.title) {
message.warning('请填写目标标题和时间范围'); message.warning('请填写目标标题');
return; return;
} }
try { try {
//
await createObjectiveApi({ await createObjectiveApi({
title: newObj.value.title, title: newObj.value.title,
ownerId: newObj.value.ownerId || currentUserId.value, ownerId: newObj.value.ownerId || currentUserId.value,
projectId: projectId.value, projectId: projectId.value,
startDate: tsToDateStr(newObj.value.dateRange[0]), startDate: dayjs().format('YYYY-MM-DD'),
endDate: tsToDateStr(newObj.value.dateRange[1]), endDate: dayjs().add(30, 'day').format('YYYY-MM-DD'),
}); });
message.success('目标创建成功'); message.success('目标创建成功');
showCreateObj.value = false; showCreateObj.value = false;
@ -90,28 +97,28 @@ async function handleCreateObjective() {
const showCreateKR = ref(false); const showCreateKR = ref(false);
const currentObjId = ref(''); const currentObjId = ref('');
const currentObjTitle = ref(''); const currentObjTitle = ref('');
const newKR = ref({ title: '', targetValue: 100, unit: '%', weight: 1, dateRange: null as [number, number] | null }); const newKR = ref({ title: '', dateRange: null as [number, number] | null });
function openAddKR(objId: string, objTitle: string) { function openAddKR(objId: string, objTitle: string) {
currentObjId.value = objId; currentObjId.value = objId;
currentObjTitle.value = objTitle; currentObjTitle.value = objTitle;
newKR.value = { title: '', targetValue: 100, unit: '%', weight: 1, dateRange: null }; newKR.value = { title: '', dateRange: null };
showCreateKR.value = true; showCreateKR.value = true;
} }
async function handleCreateKR() { async function handleCreateKR() {
if (!newKR.value.title || !newKR.value.targetValue) { if (!newKR.value.title || !newKR.value.dateRange) {
message.warning('请填写标题和目标值'); message.warning('请填写任务内容和起止时间');
return; return;
} }
try { try {
await createKeyResultApi(currentObjId.value, { await createKeyResultApi(currentObjId.value, {
title: newKR.value.title, title: newKR.value.title,
targetValue: newKR.value.targetValue, targetValue: 100,
unit: newKR.value.unit, unit: '%',
weight: newKR.value.weight, weight: 1,
startDate: newKR.value.dateRange ? tsToDateStr(newKR.value.dateRange[0]) : undefined, startDate: tsToDateStr(newKR.value.dateRange[0]),
endDate: newKR.value.dateRange ? tsToDateStr(newKR.value.dateRange[1]) : undefined, endDate: tsToDateStr(newKR.value.dateRange[1]),
}); });
message.success('关键结果已添加'); message.success('关键结果已添加');
showCreateKR.value = false; showCreateKR.value = false;
@ -219,7 +226,7 @@ function canEditObj(obj: any): boolean {
</div> </div>
<NTag size="small" :type="obj.progress >= 70 ? 'success' : 'default'" round>{{ Math.round(obj.progress) }}%</NTag> <NTag size="small" :type="obj.progress >= 70 ? 'success' : 'default'" round>{{ Math.round(obj.progress) }}%</NTag>
<div v-if="canEditObj(obj)" style="display:flex;gap:2px;margin-left:8px"> <div v-if="canEditObj(obj)" style="display:flex;gap:2px;margin-left:8px">
<NButton size="tiny" type="info" quaternary @click="openAddKR(obj.id, obj.title)">+KR</NButton> <NButton size="tiny" type="info" quaternary @click="openAddKR(obj.id, obj.title)">+任务</NButton>
<NButton size="tiny" type="error" quaternary @click="handleDeleteObj(obj.id)">×</NButton> <NButton size="tiny" type="error" quaternary @click="handleDeleteObj(obj.id)">×</NButton>
</div> </div>
</div> </div>
@ -270,7 +277,7 @@ function canEditObj(obj: any): boolean {
</div> </div>
</div> </div>
<div v-if="!obj.keyResults?.length" class="kr-empty"> <div v-if="!obj.keyResults?.length" class="kr-empty">
暂无关键结果 暂无任务
<a v-if="canEditObj(obj)" href="#" @click.prevent="openAddKR(obj.id, obj.title)" style="color:var(--color-primary-hex)">点击添加</a> <a v-if="canEditObj(obj)" href="#" @click.prevent="openAddKR(obj.id, obj.title)" style="color:var(--color-primary-hex)">点击添加</a>
</div> </div>
</div> </div>
@ -310,49 +317,30 @@ function canEditObj(obj: any): boolean {
</NSpin> </NSpin>
<!-- 创建目标弹窗 --> <!-- 创建目标弹窗 -->
<NModal v-model:show="showCreateObj" title="创建 OKR 目标" preset="dialog" positive-text="创建" @positive-click="handleCreateObjective"> <NModal v-model:show="showCreateObj" title="创建目标" preset="dialog" positive-text="创建" @positive-click="handleCreateObjective">
<NForm> <NForm>
<NFormItem label="目标标题" required> <NFormItem label="目标标题" required>
<NInput v-model:value="newObj.title" placeholder="例如:完成核心功能开发" /> <NInput v-model:value="newObj.title" placeholder="例如:完成核心功能开发" />
</NFormItem> </NFormItem>
<NFormItem label="时间范围" required>
<NDatePicker v-model:value="newObj.dateRange" type="daterange" style="width:100%" clearable
:default-value="[Date.now(), Date.now() + 90 * 86400000]"
start-placeholder="开始日期" end-placeholder="截止日期" />
</NFormItem>
<NFormItem label="负责人"> <NFormItem label="负责人">
<NSelect v-model:value="newObj.ownerId" :options="userOptions" placeholder="选择负责人" filterable /> <NSelect v-model:value="newObj.ownerId" :options="userOptions" placeholder="选择负责人" filterable />
</NFormItem> </NFormItem>
</NForm> </NForm>
<div style="font-size:12px;color:var(--color-text-tertiary);margin-top:4px"> <div style="font-size:12px;color:var(--color-text-tertiary);margin-top:4px">
系统会根据截止日期自动归类到对应季度如截止 6 Q2 目标的时间范围会根据下面添加的任务自动计算
</div> </div>
</NModal> </NModal>
<!-- 添加关键结果弹窗 --> <!-- 添加关键结果弹窗 -->
<NModal v-model:show="showCreateKR" :title="`添加关键结果 → ${currentObjTitle}`" preset="dialog" positive-text="添加" @positive-click="handleCreateKR"> <NModal v-model:show="showCreateKR" :title="`添加任务 → ${currentObjTitle}`" preset="dialog" positive-text="添加" @positive-click="handleCreateKR">
<NForm> <NForm>
<NFormItem label="关键结果" required> <NFormItem label="任务内容" required>
<NInput v-model:value="newKR.title" placeholder="例如:Sprint 交付率达到 85%" /> <NInput v-model:value="newKR.title" placeholder="例如:完成设备绑定功能开发" />
</NFormItem> </NFormItem>
<NFormItem label="时间范围"> <NFormItem label="起止时间" required>
<NDatePicker v-model:value="newKR.dateRange" type="daterange" style="width:100%" clearable <NDatePicker v-model:value="newKR.dateRange" type="daterange" style="width:100%" clearable
start-placeholder="开始日期" end-placeholder="截止日期" /> start-placeholder="开始日期" end-placeholder="截止日期" />
</NFormItem> </NFormItem>
<NFormItem label="目标值" required>
<NInputNumber v-model:value="newKR.targetValue" :min="0" style="width:100%" />
</NFormItem>
<NFormItem label="单位">
<NSelect v-model:value="newKR.unit" :options="[
{ value: '%', label: '%' },
{ value: '个', label: '个' },
{ value: '天', label: '天' },
{ value: '小时', label: '小时' },
]" />
</NFormItem>
<NFormItem label="权重">
<NInputNumber v-model:value="newKR.weight" :min="0.1" :max="10" :step="0.5" style="width:100%" />
</NFormItem>
</NForm> </NForm>
</NModal> </NModal>
</div> </div>