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:
parent
44464dd334
commit
690766528a
@ -6,6 +6,19 @@ import dayjs from 'dayjs';
|
||||
|
||||
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) => {
|
||||
const period = c.req.query('period');
|
||||
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)
|
||||
const mergedPRs = await db.select().from(gitPRs)
|
||||
.where(gte(gitPRs.mergedAt, twelveWeeksAgo));
|
||||
// 6. 本周待完成 KR(截止日期在本周范围内,且未完成的)
|
||||
const weekStart = dayjs().startOf('week');
|
||||
const weekEnd = dayjs().endOf('week');
|
||||
|
||||
const prWeekMap: Record<string, { totalHours: number; count: number }> = {};
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const ws = dayjs().subtract(11 - i, 'week').startOf('week').format('YYYY-MM-DD');
|
||||
prWeekMap[ws] = { totalHours: 0, count: 0 };
|
||||
const allKRsRaw = await db.select().from(keyResults);
|
||||
const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o]));
|
||||
|
||||
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) {
|
||||
if (pr.mergedAt && pr.mergeTimeHours !== null && pr.state === 'merged') {
|
||||
const ws = dayjs(pr.mergedAt).startOf('week').format('YYYY-MM-DD');
|
||||
if (prWeekMap[ws]) {
|
||||
prWeekMap[ws].totalHours += pr.mergeTimeHours || 0;
|
||||
prWeekMap[ws].count++;
|
||||
}
|
||||
}
|
||||
// 7. 历史逾期未完成(截止日期在本周之前且未完成的)
|
||||
const overdueKRs = allKRsRaw.filter(kr => {
|
||||
if (!kr.endDate) return false;
|
||||
if ((kr.currentValue || 0) >= (kr.targetValue || 100)) return false;
|
||||
return dayjs(kr.endDate).isBefore(weekStart);
|
||||
}).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 = {
|
||||
weeks: Object.entries(prWeekMap).map(([weekStart, data]) => ({
|
||||
weekStart,
|
||||
avgHours: data.count > 0 ? Math.round(data.totalHours / data.count * 10) / 10 : 0,
|
||||
prCount: data.count,
|
||||
})),
|
||||
};
|
||||
const weeklyTotal = urgentKRs.length;
|
||||
const weeklyCompleted = urgentKRs.filter(kr => kr.isCompleted).length;
|
||||
const weeklyAvgProgress = weeklyTotal > 0
|
||||
? Math.round(urgentKRs.reduce((s, kr) => s + kr.progress, 0) / weeklyTotal)
|
||||
: 0;
|
||||
|
||||
return c.json({
|
||||
code: 0,
|
||||
@ -176,7 +248,10 @@ overviewRoutes.get('/overview', async (c) => {
|
||||
projectProgress,
|
||||
weeklyCodeActivity,
|
||||
okrProgress,
|
||||
prMergeTime,
|
||||
urgentKRs,
|
||||
weeklyKRStats: { total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress },
|
||||
overdueKRs: overdueList,
|
||||
recentCommits: await getRecentCommits(),
|
||||
},
|
||||
message: 'success',
|
||||
});
|
||||
|
||||
@ -120,9 +120,37 @@ export async function createKeyResult(objectiveId: string, data: {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// 自动更新目标的时间范围(取所有任务的最早开始 ~ 最晚截止)
|
||||
await recalcObjectiveDates(objectiveId);
|
||||
|
||||
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) {
|
||||
const kr = await db.query.keyResults.findFirst({
|
||||
where: eq(keyResults.id, krId),
|
||||
@ -194,4 +222,7 @@ export async function deleteKeyResult(id: string) {
|
||||
.set({ progress: objectiveProgress, updatedAt: new Date() })
|
||||
.where(eq(objectives.id, kr.objectiveId));
|
||||
}
|
||||
|
||||
// 重算目标时间范围
|
||||
await recalcObjectiveDates(kr.objectiveId);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
@ -8,11 +8,13 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="data-card">
|
||||
<div class="card-header">
|
||||
<div class="card-header" v-if="$slots.header || title">
|
||||
<slot name="header">
|
||||
<div>
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
<p class="card-subtitle" v-if="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="header-extra" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@ -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: krPieRef } = useECharts(krPieOptions);
|
||||
const { chartRef: prRef } = useECharts(prChartOptions);
|
||||
|
||||
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>
|
||||
@ -132,19 +129,38 @@ const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.w
|
||||
<div ref="krPieRef" style="height: 260px" />
|
||||
</DataCard>
|
||||
|
||||
<!-- Panel 3: 产品线进度 -->
|
||||
<DataCard title="产品线进度" subtitle="OKR 整体完成度">
|
||||
<div class="progress-list">
|
||||
<div v-for="p in overviewData.projectProgress" :key="p.projectId" class="progress-item">
|
||||
<router-link :to="`/projects/${p.projectId}`" class="progress-link">
|
||||
<span class="progress-name">{{ p.identifier }} {{ p.name }}</span>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" :style="{ width: p.currentCycleProgress + '%', background: p.currentCycleProgress >= 70 ? '#0D9668' : 'var(--color-primary-hex)' }" />
|
||||
<!-- Panel 3: 本周关键结果 -->
|
||||
<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" v-if="overviewData.weeklyKRStats">
|
||||
共 {{ overviewData.weeklyKRStats.total }} 项 ·
|
||||
已完成 <span style="color:#0D9668;font-weight:600">{{ overviewData.weeklyKRStats.completed }}</span> 项 ·
|
||||
整体进度 <span style="font-weight:600">{{ overviewData.weeklyKRStats.avgProgress }}%</span>
|
||||
</div>
|
||||
<span class="progress-pct tabular-nums">{{ p.currentCycleProgress }}%</span>
|
||||
</router-link>
|
||||
</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>
|
||||
</DataCard>
|
||||
|
||||
@ -153,26 +169,49 @@ const weeklyCodeWeeks = computed(() => overviewData.value?.weeklyCodeActivity?.w
|
||||
<WeeklyCodeActivity :weeks="weeklyCodeWeeks" />
|
||||
</DataCard>
|
||||
|
||||
<!-- Panel 5: OKR 完成进度 -->
|
||||
<DataCard title="OKR 目标详情" subtitle="各目标完成进度">
|
||||
<div class="okr-list">
|
||||
<div v-for="obj in overviewData.okrProgress" :key="obj.id" class="okr-item" @click="router.push('/okr')" style="cursor:pointer">
|
||||
<div class="okr-info">
|
||||
<span class="okr-title">{{ obj.title }}</span>
|
||||
<span class="okr-meta">{{ obj.ownerName }} · {{ obj.startDate || '' }} ~ {{ obj.endDate || '' }}</span>
|
||||
<!-- 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 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>
|
||||
<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 v-if="!overviewData.okrProgress?.length" style="padding:var(--space-4);text-align:center;color:var(--color-text-muted);font-size:13px">暂无 OKR 目标</div>
|
||||
</div>
|
||||
</DataCard>
|
||||
|
||||
<!-- Panel 6: PR 平均合入时间 -->
|
||||
<DataCard title="PR 平均合入时间" subtitle="每周平均耗时 + 48h 预警线">
|
||||
<div ref="prRef" style="height: 260px" />
|
||||
<!-- 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>
|
||||
|
||||
@ -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); }
|
||||
@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-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; }
|
||||
.okr-item:hover { background: rgba(0,0,0,0.02); }
|
||||
.okr-info { width: 180px; flex-shrink: 0; }
|
||||
.okr-title { font-size: 13px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.okr-meta { font-size: 11px; color: var(--color-text-muted); }
|
||||
.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); }
|
||||
.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>
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
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 { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi } from '@/api/okr';
|
||||
import request from '@/api/request';
|
||||
import DataCard from '@/components/shared/DataCard.vue';
|
||||
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -50,33 +52,38 @@ const currentUserId = computed(() => {
|
||||
catch { return ''; }
|
||||
});
|
||||
|
||||
// ── 日期辅助:timestamp → YYYY-MM-DD ──
|
||||
// ── 日期辅助:timestamp → YYYY-MM-DD(使用本地时区)──
|
||||
function tsToDateStr(ts: number | null): string {
|
||||
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 newObj = ref({ title: '', ownerId: '', dateRange: null as [number, number] | null });
|
||||
const newObj = ref({ title: '', ownerId: '' });
|
||||
|
||||
function openCreateObj() {
|
||||
newObj.value = { title: '', ownerId: currentUserId.value, dateRange: null };
|
||||
newObj.value = { title: '', ownerId: currentUserId.value };
|
||||
showCreateObj.value = true;
|
||||
}
|
||||
|
||||
async function handleCreateObjective() {
|
||||
if (!newObj.value.title || !newObj.value.dateRange) {
|
||||
message.warning('请填写目标标题和时间范围');
|
||||
if (!newObj.value.title) {
|
||||
message.warning('请填写目标标题');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 时间先传占位值,后续添加任务后自动更新
|
||||
await createObjectiveApi({
|
||||
title: newObj.value.title,
|
||||
ownerId: newObj.value.ownerId || currentUserId.value,
|
||||
projectId: projectId.value,
|
||||
startDate: tsToDateStr(newObj.value.dateRange[0]),
|
||||
endDate: tsToDateStr(newObj.value.dateRange[1]),
|
||||
startDate: dayjs().format('YYYY-MM-DD'),
|
||||
endDate: dayjs().add(30, 'day').format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('目标创建成功');
|
||||
showCreateObj.value = false;
|
||||
@ -90,28 +97,28 @@ async function handleCreateObjective() {
|
||||
const showCreateKR = ref(false);
|
||||
const currentObjId = 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) {
|
||||
currentObjId.value = objId;
|
||||
currentObjTitle.value = objTitle;
|
||||
newKR.value = { title: '', targetValue: 100, unit: '%', weight: 1, dateRange: null };
|
||||
newKR.value = { title: '', dateRange: null };
|
||||
showCreateKR.value = true;
|
||||
}
|
||||
|
||||
async function handleCreateKR() {
|
||||
if (!newKR.value.title || !newKR.value.targetValue) {
|
||||
message.warning('请填写标题和目标值');
|
||||
if (!newKR.value.title || !newKR.value.dateRange) {
|
||||
message.warning('请填写任务内容和起止时间');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createKeyResultApi(currentObjId.value, {
|
||||
title: newKR.value.title,
|
||||
targetValue: newKR.value.targetValue,
|
||||
unit: newKR.value.unit,
|
||||
weight: newKR.value.weight,
|
||||
startDate: newKR.value.dateRange ? tsToDateStr(newKR.value.dateRange[0]) : undefined,
|
||||
endDate: newKR.value.dateRange ? tsToDateStr(newKR.value.dateRange[1]) : undefined,
|
||||
targetValue: 100,
|
||||
unit: '%',
|
||||
weight: 1,
|
||||
startDate: tsToDateStr(newKR.value.dateRange[0]),
|
||||
endDate: tsToDateStr(newKR.value.dateRange[1]),
|
||||
});
|
||||
message.success('关键结果已添加');
|
||||
showCreateKR.value = false;
|
||||
@ -219,7 +226,7 @@ function canEditObj(obj: any): boolean {
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -270,7 +277,7 @@ function canEditObj(obj: any): boolean {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -310,49 +317,30 @@ function canEditObj(obj: any): boolean {
|
||||
</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>
|
||||
<NFormItem label="目标标题" required>
|
||||
<NInput v-model:value="newObj.title" placeholder="例如:完成核心功能开发" />
|
||||
</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="负责人">
|
||||
<NSelect v-model:value="newObj.ownerId" :options="userOptions" placeholder="选择负责人" filterable />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<div style="font-size:12px;color:var(--color-text-tertiary);margin-top:4px">
|
||||
系统会根据截止日期自动归类到对应季度(如截止 6 月 → Q2)。
|
||||
目标的时间范围会根据下面添加的任务自动计算。
|
||||
</div>
|
||||
</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>
|
||||
<NFormItem label="关键结果" required>
|
||||
<NInput v-model:value="newKR.title" placeholder="例如:Sprint 交付率达到 85%" />
|
||||
<NFormItem label="任务内容" required>
|
||||
<NInput v-model:value="newKR.title" placeholder="例如:完成设备绑定功能开发" />
|
||||
</NFormItem>
|
||||
<NFormItem label="时间范围">
|
||||
<NFormItem label="起止时间" required>
|
||||
<NDatePicker v-model:value="newKR.dateRange" type="daterange" style="width:100%" clearable
|
||||
start-placeholder="开始日期" end-placeholder="截止日期" />
|
||||
</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>
|
||||
</NModal>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user