From f88e2d9ab0b84ac42c8469b870b8595a30439327 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Fri, 10 Apr 2026 14:15:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20KR=20=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88=E5=BB=B6=E6=9C=9F/=E6=9A=82=E5=81=9C/?= =?UTF-8?q?=E6=81=A2=E5=A4=8D/=E5=8F=96=E6=B6=88=EF=BC=89+=20=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E5=BF=97=20+=20=E5=BC=82=E5=B8=B8=E4=BA=8B?= =?UTF-8?q?=E9=A1=B9=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KR 新增 status 字段(active/paused/cancelled)+ kr_logs 操作日志表 - 每个 KR 支持延期(选新日期+原因)、暂停、恢复、取消操作 - 延期过的 KR 显示蓝色「已延期」标签 - 暂停/取消的 KR 不计入目标进度 - 操作日志弹窗:时间线展示所有变更记录 - 团队总览「异常事项」面板:展示逾期/暂停/取消的 KR 及原因 - 本周关键结果面板正确显示取消/暂停状态 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/db/schema.ts | 14 ++ backend/src/routes/okr.ts | 63 +++++++ backend/src/routes/overview.ts | 75 ++++++-- backend/src/routes/projects.ts | 37 ++-- backend/src/services/okr.ts | 134 ++++++++++++-- frontend/src/api/okr.ts | 20 +++ frontend/src/views/Overview.vue | 76 ++++---- frontend/src/views/ProjectDetail.vue | 256 +++++++++++++++++++++------ 8 files changed, 551 insertions(+), 124 deletions(-) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 910951a..71904bd 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -159,6 +159,7 @@ export const keyResults = sqliteTable('key_results', { currentValue: real('current_value').default(0), unit: text('unit'), weight: real('weight').default(1), + status: text('status').default('active'), // active / paused / cancelled startDate: text('start_date'), endDate: text('end_date'), linkedPlaneCycleId: text('linked_plane_cycle_id'), @@ -169,6 +170,19 @@ export const keyResults = sqliteTable('key_results', { objectiveIdx: index('idx_kr_objective').on(table.objectiveId), })); +// ── KR Operation Logs ── +export const krLogs = sqliteTable('kr_logs', { + id: text('id').primaryKey(), + krId: text('kr_id').references(() => keyResults.id).notNull(), + action: text('action').notNull(), // created / postponed / paused / resumed / cancelled / completed / progress_update + detail: text('detail'), + operatorId: text('operator_id').references(() => users.id), + operatorName: text('operator_name'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +}, (table) => ({ + krIdx: index('idx_kr_logs_kr').on(table.krId), +})); + // ── Author Mappings ── export const authorMappings = sqliteTable('author_mappings', { id: text('id').primaryKey(), diff --git a/backend/src/routes/okr.ts b/backend/src/routes/okr.ts index e4daa0d..7fce6f6 100644 --- a/backend/src/routes/okr.ts +++ b/backend/src/routes/okr.ts @@ -72,6 +72,69 @@ okrRoutes.patch('/okr/key-results/:id', } ); +// POST /api/okr/key-results/:id/postpone — 延期 +const postponeSchema = z.object({ + newEndDate: z.string().min(1), + reason: z.string().min(1), +}); +okrRoutes.post('/okr/key-results/:id/postpone', + requireRole('admin', 'manager', 'developer'), + zValidator('json', postponeSchema), + async (c) => { + const krId = c.req.param('id'); + const { newEndDate, reason } = c.req.valid('json'); + const user = c.get('user'); + const result = await okrService.postponeKR(krId, newEndDate, reason, user.sub, user.displayName); + return c.json({ code: 0, data: result, message: 'success' }); + } +); + +// POST /api/okr/key-results/:id/pause — 暂停 +const pauseSchema = z.object({ reason: z.string().min(1) }); +okrRoutes.post('/okr/key-results/:id/pause', + requireRole('admin', 'manager', 'developer'), + zValidator('json', pauseSchema), + async (c) => { + const krId = c.req.param('id'); + const { reason } = c.req.valid('json'); + const user = c.get('user'); + const result = await okrService.pauseKR(krId, reason, user.sub, user.displayName); + return c.json({ code: 0, data: result, message: 'success' }); + } +); + +// POST /api/okr/key-results/:id/resume — 恢复 +okrRoutes.post('/okr/key-results/:id/resume', + requireRole('admin', 'manager', 'developer'), + async (c) => { + const krId = c.req.param('id'); + const user = c.get('user'); + const result = await okrService.resumeKR(krId, user.sub, user.displayName); + return c.json({ code: 0, data: result, message: 'success' }); + } +); + +// POST /api/okr/key-results/:id/cancel — 取消 +const cancelSchema = z.object({ reason: z.string().min(1) }); +okrRoutes.post('/okr/key-results/:id/cancel', + requireRole('admin', 'manager', 'developer'), + zValidator('json', cancelSchema), + async (c) => { + const krId = c.req.param('id'); + const { reason } = c.req.valid('json'); + const user = c.get('user'); + const result = await okrService.cancelKR(krId, reason, user.sub, user.displayName); + return c.json({ code: 0, data: result, message: 'success' }); + } +); + +// GET /api/okr/key-results/:id/logs — 操作日志 +okrRoutes.get('/okr/key-results/:id/logs', async (c) => { + const krId = c.req.param('id'); + const logs = await okrService.getKRLogs(krId); + return c.json({ code: 0, data: logs, message: 'success' }); +}); + // DELETE /api/okr/objectives/:id okrRoutes.delete('/okr/objectives/:id', requireRole('admin'), diff --git a/backend/src/routes/overview.ts b/backend/src/routes/overview.ts index 009ab0e..957f3b4 100644 --- a/backend/src/routes/overview.ts +++ b/backend/src/routes/overview.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { db } from '../db/index'; -import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos } from '../db/schema'; +import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos, krLogs } from '../db/schema'; import { eq, desc, gte } from 'drizzle-orm'; import dayjs from 'dayjs'; @@ -189,14 +189,26 @@ overviewRoutes.get('/overview', async (c) => { const daysLeft = dayjs(endDate).startOf('day').diff(dayjs().startOf('day'), 'day'); const isCompleted = (kr.currentValue || 0) >= (kr.targetValue || 100); + const krStatus = kr.status || 'active'; + + // 确定显示状态 + let displayStatus: string; + if (krStatus === 'cancelled') displayStatus = 'cancelled'; + else if (krStatus === 'paused') displayStatus = 'paused'; + else if (isCompleted) displayStatus = 'completed'; + else if (isOverdue) displayStatus = 'overdue'; + else displayStatus = 'active'; urgentKRs.push({ id: kr.id, title: kr.title, progress: kr.currentValue || 0, endDate, - isOverdue: isOverdue && !isCompleted, - isCompleted, + isOverdue: displayStatus === 'overdue', + isCompleted: displayStatus === 'completed', + isPaused: displayStatus === 'paused', + isCancelled: displayStatus === 'cancelled', + displayStatus, daysLeft, objectiveTitle: obj?.title || '', ownerName: owner?.displayName || '未指定', @@ -205,15 +217,25 @@ overviewRoutes.get('/overview', async (c) => { }); } - // 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()); + // 7. 异常事项(逾期未完成 + 暂停 + 取消) + const abnormalKRs = allKRsRaw.filter(kr => { + // 暂停的 + if (kr.status === 'paused') return true; + // 取消的 + if (kr.status === 'cancelled') return true; + // 逾期未完成的(active 但截止日期已过且未 100%) + if (kr.status === 'active' && kr.endDate && (kr.currentValue || 0) < (kr.targetValue || 100)) { + return dayjs(kr.endDate).isBefore(dayjs().startOf('day')); + } + return false; + }).sort((a, b) => { + // 逾期排最前,暂停其次,取消最后 + const order = (kr: any) => kr.status === 'active' ? 0 : kr.status === 'paused' ? 1 : 2; + return order(a) - order(b); + }); const overdueList = []; - for (const kr of overdueKRs.slice(0, 20)) { + for (const kr of abnormalKRs.slice(0, 20)) { const obj = allObjsMap.get(kr.objectiveId); const owner = obj?.ownerId ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) @@ -221,23 +243,48 @@ overviewRoutes.get('/overview', async (c) => { 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'); + + // 获取最后一条操作日志作为原因 + const lastLog = await db.select().from(krLogs) + .where(eq(krLogs.krId, kr.id)) + .orderBy(desc(krLogs.createdAt)) + .limit(1); + const reason = lastLog[0]?.detail || ''; + + let itemStatus: string; + let statusLabel: string; + if (kr.status === 'paused') { + itemStatus = 'paused'; + statusLabel = '已暂停'; + } else if (kr.status === 'cancelled') { + itemStatus = 'cancelled'; + statusLabel = '已取消'; + } else { + itemStatus = 'overdue'; + statusLabel = '逾期' + 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, + status: itemStatus, + statusLabel, + reason, ownerName: owner?.displayName || '未指定', projectName: proj?.name || '', projectIdentifier: proj?.identifier || '', }); } + const activeKRs = urgentKRs.filter(kr => !kr.isCancelled && !kr.isPaused); 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) + const weeklyCancelled = urgentKRs.filter(kr => kr.isCancelled).length; + const weeklyPaused = urgentKRs.filter(kr => kr.isPaused).length; + const weeklyAvgProgress = activeKRs.length > 0 + ? Math.round(activeKRs.reduce((s, kr) => s + kr.progress, 0) / activeKRs.length) : 0; return c.json({ diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 3f42232..8cd4072 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -3,7 +3,7 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { v4 as uuid } from 'uuid'; import { db } from '../db/index'; -import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos } from '../db/schema'; +import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos, krLogs } from '../db/schema'; import { eq, and, desc, gte } from 'drizzle-orm'; import { requireRole } from '../middleware/role'; import { AppError } from '../middleware/error-handler'; @@ -190,18 +190,29 @@ projectRoutes.get('/projects/:id', async (c) => { startDate: obj.startDate || null, endDate: obj.endDate || null, progress: obj.progress || 0, - keyResults: krs.map(kr => ({ - id: kr.id, - title: kr.title, - targetValue: kr.targetValue, - currentValue: kr.currentValue || 0, - unit: kr.unit || '', - weight: kr.weight || 1, - startDate: kr.startDate || null, - endDate: kr.endDate || null, - progress: kr.targetValue > 0 - ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) - : 0, + keyResults: await Promise.all(krs.map(async kr => { + const logs = await db.select().from(krLogs) + .where(eq(krLogs.krId, kr.id)) + .orderBy(desc(krLogs.createdAt)) + .limit(5); + const wasPostponed = logs.some(l => l.action === 'postponed'); + const lastPostponeReason = logs.find(l => l.action === 'postponed')?.detail || null; + return { + id: kr.id, + title: kr.title, + targetValue: kr.targetValue, + currentValue: kr.currentValue || 0, + unit: kr.unit || '', + weight: kr.weight || 1, + status: kr.status || 'active', + wasPostponed, + lastPostponeReason, + startDate: kr.startDate || null, + endDate: kr.endDate || null, + progress: kr.targetValue > 0 + ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) + : 0, + }; })), }); totalOKRProgress += obj.progress || 0; diff --git a/backend/src/services/okr.ts b/backend/src/services/okr.ts index f3bcf0c..a7e8fe1 100644 --- a/backend/src/services/okr.ts +++ b/backend/src/services/okr.ts @@ -1,7 +1,8 @@ import { eq } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { db } from '../db/index'; -import { objectives, keyResults, users, projects } from '../db/schema'; +import { objectives, keyResults, users, projects, krLogs } from '../db/schema'; +import { desc } from 'drizzle-orm'; import { AppError } from '../middleware/error-handler'; export async function getOKRByPeriod(period?: string) { @@ -31,18 +32,31 @@ export async function getOKRByPeriod(period?: string) { startDate: obj.startDate || null, endDate: obj.endDate || null, progress: obj.progress || 0, - keyResults: krs.map(kr => ({ - id: kr.id, - title: kr.title, - targetValue: kr.targetValue, - currentValue: kr.currentValue || 0, - unit: kr.unit || '', - weight: kr.weight || 1, - startDate: kr.startDate || null, - endDate: kr.endDate || null, - progress: kr.targetValue > 0 - ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) - : 0, + keyResults: await Promise.all(krs.map(async kr => { + // 查是否有延期记录 + const postponeLog = await db.select().from(krLogs) + .where(eq(krLogs.krId, kr.id)) + .orderBy(desc(krLogs.createdAt)) + .limit(5); + const wasPostponed = postponeLog.some(l => l.action === 'postponed'); + const lastPostponeReason = postponeLog.find(l => l.action === 'postponed')?.detail || null; + + return { + id: kr.id, + title: kr.title, + targetValue: kr.targetValue, + currentValue: kr.currentValue || 0, + unit: kr.unit || '', + weight: kr.weight || 1, + status: kr.status || 'active', + wasPostponed, + lastPostponeReason, + startDate: kr.startDate || null, + endDate: kr.endDate || null, + progress: kr.targetValue > 0 + ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) + : 0, + }; })), }); } @@ -226,3 +240,97 @@ export async function deleteKeyResult(id: string) { // 重算目标时间范围 await recalcObjectiveDates(kr.objectiveId); } + +// ── 操作日志辅助 ── + +async function addKRLog(krId: string, action: string, detail: string | null, operatorId: string, operatorName: string) { + await db.insert(krLogs).values({ + id: uuid(), + krId, + action, + detail, + operatorId, + operatorName, + createdAt: new Date(), + }); +} + +async function recalcObjectiveProgress(objectiveId: string) { + const allKRs = await db.select().from(keyResults) + .where(eq(keyResults.objectiveId, objectiveId)); + // 只算 active 和已完成的 KR,暂停和取消的不计入 + const activeKRs = allKRs.filter(kr => kr.status !== 'paused' && kr.status !== 'cancelled'); + const totalWeight = activeKRs.reduce((sum, k) => sum + (k.weight || 1), 0); + const weightedProgress = activeKRs.reduce((sum, k) => { + const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0; + return sum + progress * (k.weight || 1); + }, 0); + const objProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0; + await db.update(objectives).set({ progress: objProgress, updatedAt: new Date() }) + .where(eq(objectives.id, objectiveId)); +} + +// ── 延期 ── + +export async function postponeKR(krId: string, newEndDate: string, reason: 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); + + const oldEndDate = kr.endDate || '未设置'; + await db.update(keyResults).set({ endDate: newEndDate, updatedAt: new Date() }) + .where(eq(keyResults.id, krId)); + + await addKRLog(krId, 'postponed', `截止日期 ${oldEndDate} → ${newEndDate},原因:${reason}`, operatorId, operatorName); + await recalcObjectiveDates(kr.objectiveId); + return { id: krId, endDate: newEndDate }; +} + +// ── 暂停 ── + +export async function pauseKR(krId: string, reason: 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: 'paused', updatedAt: new Date() }) + .where(eq(keyResults.id, krId)); + + await addKRLog(krId, 'paused', `原因:${reason}`, operatorId, operatorName); + await recalcObjectiveProgress(kr.objectiveId); + return { id: krId, status: 'paused' }; +} + +// ── 恢复 ── + +export async function resumeKR(krId: 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() }) + .where(eq(keyResults.id, krId)); + + await addKRLog(krId, 'resumed', '恢复为进行中', operatorId, operatorName); + await recalcObjectiveProgress(kr.objectiveId); + return { id: krId, status: 'active' }; +} + +// ── 取消 ── + +export async function cancelKR(krId: string, reason: 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: 'cancelled', updatedAt: new Date() }) + .where(eq(keyResults.id, krId)); + + await addKRLog(krId, 'cancelled', `原因:${reason}`, operatorId, operatorName); + await recalcObjectiveProgress(kr.objectiveId); + return { id: krId, status: 'cancelled' }; +} + +// ── 获取操作日志 ── + +export async function getKRLogs(krId: string) { + return await db.select().from(krLogs) + .where(eq(krLogs.krId, krId)) + .orderBy(desc(krLogs.createdAt)); +} diff --git a/frontend/src/api/okr.ts b/frontend/src/api/okr.ts index e5dcec9..f69bacf 100644 --- a/frontend/src/api/okr.ts +++ b/frontend/src/api/okr.ts @@ -23,3 +23,23 @@ export function deleteObjectiveApi(id: string) { export function deleteKeyResultApi(id: string) { return request.delete(`/api/okr/key-results/${id}`); } + +export function postponeKRApi(krId: string, data: { newEndDate: string; reason: string }) { + return request.post(`/api/okr/key-results/${krId}/postpone`, data); +} + +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 cancelKRApi(krId: string, data: { reason: string }) { + return request.post(`/api/okr/key-results/${krId}/cancel`, data); +} + +export function getKRLogsApi(krId: string) { + return request.get(`/api/okr/key-results/${krId}/logs`); +} diff --git a/frontend/src/views/Overview.vue b/frontend/src/views/Overview.vue index 81a43c0..9ff4a65 100644 --- a/frontend/src/views/Overview.vue +++ b/frontend/src/views/Overview.vue @@ -142,18 +142,18 @@ function formatCommitTime(isoStr: string) {
-
+
- {{ kr.title }} + {{ kr.title }} {{ kr.projectIdentifier }} · {{ kr.ownerName }} · {{ kr.endDate }}
-
-
+
+
- - {{ kr.isCompleted ? '已完成' : kr.isOverdue ? '已逾期' : kr.daysLeft === 0 ? '今天截止' : kr.daysLeft + '天后' }} + + {{ kr.isCancelled ? '已取消' : kr.isPaused ? '已暂停' : kr.isCompleted ? '已完成' : kr.isOverdue ? '已逾期' : kr.daysLeft === 0 ? '今天截止' : kr.daysLeft + '天后' }}
@@ -169,33 +169,32 @@ function formatCommitTime(isoStr: string) { - + -
-
-
-
- {{ kr.title }} - {{ kr.projectIdentifier }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }} -
-
-
-
+
+
+
+
+
+ {{ kr.title }} + {{ kr.statusLabel }}
- 逾期{{ kr.daysOverdue }}天 +
{{ kr.projectIdentifier }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }}
+
{{ kr.reason }}
+
{{ kr.progress }}%
- 无逾期事项 + 无异常事项
@@ -231,7 +230,13 @@ function formatCommitTime(isoStr: string) { .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; } +.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); } .urgent-kr-row { display: flex; align-items: center; gap: 12px; } .urgent-kr-left { flex: 1; min-width: 0; } @@ -248,16 +253,23 @@ function formatCommitTime(isoStr: string) { .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; } +.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; } diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue index 9a9cf88..208e19b 100644 --- a/frontend/src/views/ProjectDetail.vue +++ b/frontend/src/views/ProjectDetail.vue @@ -1,10 +1,9 @@