diff --git a/backend/src/routes/okr.ts b/backend/src/routes/okr.ts index 7fce6f6..a085f00 100644 --- a/backend/src/routes/okr.ts +++ b/backend/src/routes/okr.ts @@ -104,12 +104,15 @@ okrRoutes.post('/okr/key-results/:id/pause', ); // POST /api/okr/key-results/:id/resume — 恢复 +const resumeSchema = z.object({ newEndDate: z.string().min(1) }); okrRoutes.post('/okr/key-results/:id/resume', requireRole('admin', 'manager', 'developer'), + zValidator('json', resumeSchema), async (c) => { const krId = c.req.param('id'); + const { newEndDate } = c.req.valid('json'); const user = c.get('user'); - const result = await okrService.resumeKR(krId, user.sub, user.displayName); + const result = await okrService.resumeKR(krId, newEndDate, user.sub, user.displayName); return c.json({ code: 0, data: result, message: 'success' }); } ); diff --git a/backend/src/routes/overview.ts b/backend/src/routes/overview.ts index 957f3b4..d2aa780 100644 --- a/backend/src/routes/overview.ts +++ b/backend/src/routes/overview.ts @@ -153,9 +153,10 @@ overviewRoutes.get('/overview', async (c) => { }); } - // 6. 本周待完成 KR(截止日期在本周范围内,且未完成的) - const weekStart = dayjs().startOf('week'); - const weekEnd = dayjs().endOf('week'); + // 6. 指定周的 KR(支持 weekOffset 参数,0=本周,1=下周,-1=上周) + const weekOffset = parseInt(c.req.query('weekOffset') || '0'); + const weekStart = dayjs().startOf('week').add(weekOffset, 'week'); + const weekEnd = dayjs().endOf('week').add(weekOffset, 'week'); const allKRsRaw = await db.select().from(keyResults); const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o])); @@ -296,7 +297,11 @@ overviewRoutes.get('/overview', async (c) => { weeklyCodeActivity, okrProgress, urgentKRs, - weeklyKRStats: { total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress }, + weeklyKRStats: { + total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress, + weekOffset, + weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'), + }, overdueKRs: overdueList, recentCommits: await getRecentCommits(), }, diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 8cd4072..993be96 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -282,6 +282,15 @@ projectRoutes.get('/projects/:id', async (c) => { milestones: milestoneData, taskMatrix, gitActivity, + recentCommitList: recentCommits + .sort((a, b) => dayjs(b.committedAt).valueOf() - dayjs(a.committedAt).valueOf()) + .slice(0, 15) + .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, + committedAt: c.committedAt instanceof Date ? c.committedAt.toISOString() : c.committedAt, + })), okr: { objectives: okrData, overallProgress: avgOKRProgress, diff --git a/backend/src/services/okr.ts b/backend/src/services/okr.ts index a7e8fe1..261f810 100644 --- a/backend/src/services/okr.ts +++ b/backend/src/services/okr.ts @@ -301,16 +301,18 @@ export async function pauseKR(krId: string, reason: string, operatorId: string, // ── 恢复 ── -export async function resumeKR(krId: string, operatorId: string, operatorName: string) { +export async function resumeKR(krId: string, newEndDate: string, operatorId: string, operatorName: string) { const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) }); if (!kr) throw new AppError(40404, 'Key result not found', 404); - await db.update(keyResults).set({ status: 'active', updatedAt: new Date() }) + const oldEndDate = kr.endDate || '未设置'; + await db.update(keyResults).set({ status: 'active', endDate: newEndDate, updatedAt: new Date() }) .where(eq(keyResults.id, krId)); - await addKRLog(krId, 'resumed', '恢复为进行中', operatorId, operatorName); + await addKRLog(krId, 'resumed', `恢复为进行中,截止日期 ${oldEndDate} → ${newEndDate}`, operatorId, operatorName); await recalcObjectiveProgress(kr.objectiveId); - return { id: krId, status: 'active' }; + await recalcObjectiveDates(kr.objectiveId); + return { id: krId, status: 'active', endDate: newEndDate }; } // ── 取消 ── diff --git a/frontend/src/api/okr.ts b/frontend/src/api/okr.ts index f69bacf..14e6ec9 100644 --- a/frontend/src/api/okr.ts +++ b/frontend/src/api/okr.ts @@ -32,8 +32,8 @@ export function pauseKRApi(krId: string, data: { reason: string }) { return request.post(`/api/okr/key-results/${krId}/pause`, data); } -export function resumeKRApi(krId: string) { - return request.post(`/api/okr/key-results/${krId}/resume`); +export function resumeKRApi(krId: string, data: { newEndDate: string }) { + return request.post(`/api/okr/key-results/${krId}/resume`, data); } export function cancelKRApi(krId: string, data: { reason: string }) { diff --git a/frontend/src/views/GitActivity.vue b/frontend/src/views/GitActivity.vue index bfd9e12..81fb76f 100644 --- a/frontend/src/views/GitActivity.vue +++ b/frontend/src/views/GitActivity.vue @@ -53,7 +53,7 @@ const weeklyOptions = computed(() => { const found = (w.byUser || []).find((u: any) => u.userId === userId); return found?.commits || 0; }), - itemStyle: { color: CHART_COLORS[i % CHART_COLORS.length], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] }, + itemStyle: { color: ['#3B5998', '#0D9668', '#D4920A', '#7C4DBA', '#DC2626', '#06B6D4', '#8B5CF6', '#EC4899'][i % 8], borderRadius: i === userSet.size - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0] }, })); return { diff --git a/frontend/src/views/OKR.vue b/frontend/src/views/OKR.vue index eceda9b..a641cda 100644 --- a/frontend/src/views/OKR.vue +++ b/frontend/src/views/OKR.vue @@ -1,318 +1,272 @@ - - - - 创建目标 - + + + + {{ stats.projectCount }} 个项目 + · + {{ stats.totalObj }} 个目标 + · + {{ stats.totalKR }} 个任务 + · + 已完成 {{ stats.completedKR }} + · + 进度 {{ stats.avgProgress }}% + + + 全部展开 + 全部折叠 + + - - - - - {{ obj.title }} - {{ obj.ownerName }} · {{ obj.projectName }} · {{ obj.period }} - - - {{ Math.round(obj.progress) }}% - - + + + + + + + + ▶ + {{ card.projectName }} + {{ card.objectives.length }} 个目标 + + + {{ card.totalProgress }}% - - + 关键结果 - 删除 + + - - - {{ kr.title }} - - - + + + + + + ▶ + {{ obj.title }} + {{ obj.keyResults?.length || 0 }} 项 + + {{ obj.ownerName }} · {{ obj.startDate || '' }} ~ {{ obj.endDate || '' }} + {{ Math.round(obj.progress) }}% + + + + + + + {{ kr.title }} + 已取消 + 已暂停 + 已延期 + + + + + + {{ kr.progress || kr.currentValue }}% + - - - handleUpdateKR(kr.id, v || 0)" - :min="0" - size="tiny" - style="width: 80px" - /> - - {{ kr.currentValue }} - / {{ kr.targetValue }} {{ kr.unit }} - - {{ kr.progress }}% - X - - - 暂无关键结果, - 点击添加 - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 权重用于计算目标总进度(加权平均)。权重越大,该关键结果对目标进度的影响越大。 - - diff --git a/frontend/src/views/Overview.vue b/frontend/src/views/Overview.vue index 9ff4a65..1acf378 100644 --- a/frontend/src/views/Overview.vue +++ b/frontend/src/views/Overview.vue @@ -13,11 +13,24 @@ const router = useRouter(); const loading = ref(true); const overviewData = ref(null); const projectOptions = ref>([]); +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 = {}; + const params: any = { weekOffset: weekOffset.value }; if (filters?.period) params.period = filters.period; if (filters?.projectIds) params.projectIds = filters.projectIds.join(','); @@ -132,13 +145,21 @@ function formatCommitTime(isoStr: string) { - - 本周关键结果 - - 共 {{ overviewData.weeklyKRStats.total }} 项 · - 已完成 {{ overviewData.weeklyKRStats.completed }} 项 · - 整体进度 {{ overviewData.weeklyKRStats.avgProgress }}% + + + + ‹ + {{ weekLabel(weekOffset) }}关键结果 + › + + + {{ overviewData.weeklyKRStats.weekLabel }} · + 共 {{ overviewData.weeklyKRStats.total }} 项 · + 已完成 {{ overviewData.weeklyKRStats.completed }} 项 · + 进度 {{ overviewData.weeklyKRStats.avgProgress }}% + + 回到本周 @@ -238,6 +259,11 @@ function formatCommitTime(isoStr: string) { .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; } diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue index 208e19b..c825d9e 100644 --- a/frontend/src/views/ProjectDetail.vue +++ b/frontend/src/views/ProjectDetail.vue @@ -185,6 +185,11 @@ const reasonAction = ref<'pause' | 'cancel'>('pause'); const reasonKRId = ref(''); const reasonText = ref(''); +// 恢复弹窗 +const showResumeModal = ref(false); +const resumeKRId = ref(''); +const resumeEndDate = ref(null); + // 日志弹窗 const showLogsModal = ref(false); const logsKRTitle = ref(''); @@ -201,7 +206,9 @@ function handleKRMenu(key: string, kr: any) { reasonText.value = ''; showReasonModal.value = true; } else if (key === 'resume') { - doResume(kr.id); + resumeKRId.value = kr.id; + resumeEndDate.value = null; + showResumeModal.value = true; } } @@ -236,10 +243,15 @@ async function doReasonAction() { } catch { message.error('操作失败'); } } -async function doResume(krId: string) { +async function doResume() { + if (!resumeEndDate.value) { + message.warning('请选择新的截止日期'); + return; + } try { - await resumeKRApi(krId); + await resumeKRApi(resumeKRId.value, { newEndDate: tsToDateStr(resumeEndDate.value) }); message.success('已恢复'); + showResumeModal.value = false; loadData(); } catch { message.error('恢复失败'); } } @@ -286,6 +298,18 @@ const { chartRef: gitMiniRef } = useECharts(gitMiniChartOptions); function clamp(v: number) { return Math.min(Math.max(v, 0), 100); } +function formatTime(isoStr: string) { + if (!isoStr) return ''; + const d = new Date(isoStr); + const now = new Date(); + const diffH = Math.floor((now.getTime() - d.getTime()) / 3600000); + const diffD = Math.floor((now.getTime() - d.getTime()) / 86400000); + if (diffH < 1) return '刚刚'; + if (diffH < 24) return diffH + '小时前'; + if (diffD < 7) return diffD + '天前'; + return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); +} + // 判断当前用户是否可以编辑该 Objective(创建者本人 或 admin) function canEditObj(obj: any): boolean { if (authStore.isAdmin) return true; @@ -297,16 +321,14 @@ function canEditObj(obj: any): boolean { - - {{ data.project?.identifier }} - {{ data.project?.name }} - - - - - 项目整体进度 - - - {{ data.okr.overallProgress }}% + + + + {{ data.project?.identifier }} - {{ data.project?.name }} + {{ data.okr.overallProgress }}% + + + @@ -314,12 +336,12 @@ function canEditObj(obj: any): boolean { - - - OKR 目标进度 - {{ data.okr?.objectives?.length || 0 }} 个目标 + + + OKR 目标进度 + {{ data.okr?.objectives?.length || 0 }} 个目标 - 创建目标 + + 创建目标 @@ -331,9 +353,9 @@ function canEditObj(obj: any): boolean { {{ obj.ownerName }} · {{ obj.startDate || '未设置' }} ~ {{ obj.endDate || '未设置' }} · {{ obj.period }} {{ Math.round(obj.progress) }}% - - +任务 - × + + +任务 + × @@ -412,12 +434,23 @@ function canEditObj(obj: any): boolean { - - - - 项目标识{{ data.project?.identifier }} - OKR 目标数{{ data.okr?.objectives?.length || 0 }} - 绑定仓库{{ data.gitActivity?.boundRepos?.length || 0 }} 个 + + + + + 最近提交 + {{ data.project?.identifier }} · 共 {{ data.gitActivity?.recentCommits || 0 }} 次 + + + + + + {{ c.sha }} + {{ c.message }} + {{ c.authorName }} · {{ formatTime(c.committedAt) }} + + + 暂无提交记录 @@ -462,6 +495,18 @@ function canEditObj(obj: any): boolean { + + + + + + + + + 任务暂停后恢复,请重新设置截止日期。 + + + @@ -530,8 +575,10 @@ function canEditObj(obj: any): boolean { .kr-pct-ro { font-size:13px;font-weight:600;white-space:nowrap; } .kr-empty { font-size:13px;color:var(--color-text-muted);padding:var(--space-2) 0; } -.info-grid { display:flex;flex-direction:column;gap:var(--space-3); } -.info-item { display:flex;justify-content:space-between; } -.info-label { font-size:13px;color:var(--color-text-secondary); } -.info-value { font-size:13px;font-weight:600; } +.commit-list { display:flex;flex-direction:column;gap:0;max-height:300px;overflow-y:auto; } +.commit-item { display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid rgba(0,0,0,0.04);font-size:12px; } +.commit-item:last-child { border-bottom:none; } +.commit-sha { color:var(--color-primary-hex);font-family:monospace;font-size:11px;flex-shrink:0; } +.commit-msg { flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--color-text-primary); } +.commit-info { color:var(--color-text-muted);white-space:nowrap;flex-shrink:0;font-size:11px; } diff --git a/frontend/src/views/ProjectList.vue b/frontend/src/views/ProjectList.vue index 016a3dc..66b0e76 100644 --- a/frontend/src/views/ProjectList.vue +++ b/frontend/src/views/ProjectList.vue @@ -127,6 +127,16 @@ async function handleDelete(id: string, name: string) { } } +// 从 URL 提取仓库名 +function extractRepoName(raw: string) { + let cleaned = raw.trim().replace(/\.git$/, ''); + if (cleaned.includes('://')) { + try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {} + } + if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned; + return cleaned; +} + // ── 表格列 ── const columns = [ { title: '标识', key: 'identifier', width: 100 }, @@ -134,26 +144,24 @@ const columns = [ { title: '绑定仓库', key: 'repos', - width: 220, + width: 160, render: (row: any) => { const repos = row.repos || []; - if (!repos.length) { - return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定'); - } + if (!repos.length) return h('span', { style: 'color:var(--color-text-muted);font-size:12px' }, '未绑定'); return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' }, - repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => r.repoName })) + repos.map((r: any) => h(NTag, { size: 'small', type: 'info', round: true }, { default: () => extractRepoName(r.repoName) })) ); }, }, { title: '操作', key: 'actions', - width: 240, + width: 200, render: (row: any) => { - return h('div', { style: 'display:flex;gap:8px' }, [ + return h('div', { style: 'display:flex;gap:6px' }, [ h(NButton, { size: 'tiny', type: 'info', onClick: () => router.push(`/projects/${row.id}`) }, { default: () => '查看' }), canCreate - ? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '管理仓库' }) + ? h(NButton, { size: 'tiny', type: 'default', onClick: () => openRepoModal(row) }, { default: () => '仓库' }) : null, canCreate ? h(NButton, { size: 'tiny', type: 'error', onClick: () => handleDelete(row.id, row.name) }, { default: () => '删除' })