feat: KR 状态管理(延期/暂停/恢复/取消)+ 操作日志 + 异常事项面板

- KR 新增 status 字段(active/paused/cancelled)+ kr_logs 操作日志表
- 每个 KR 支持延期(选新日期+原因)、暂停、恢复、取消操作
- 延期过的 KR 显示蓝色「已延期」标签
- 暂停/取消的 KR 不计入目标进度
- 操作日志弹窗:时间线展示所有变更记录
- 团队总览「异常事项」面板:展示逾期/暂停/取消的 KR 及原因
- 本周关键结果面板正确显示取消/暂停状态

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zyc 2026-04-10 14:15:55 +08:00
parent 246410e12c
commit f88e2d9ab0
8 changed files with 551 additions and 124 deletions

View File

@ -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(),

View File

@ -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'),

View File

@ -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({

View File

@ -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 => ({
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;

View File

@ -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 => ({
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));
}

View File

@ -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`);
}

View File

@ -142,18 +142,18 @@ function formatCommitTime(isoStr: string) {
</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 v-for="kr in overviewData.urgentKRs" :key="kr.id" class="urgent-kr-item" :class="'kr-st-' + kr.displayStatus">
<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-title" :class="{ 'title-done': kr.isCompleted, 'title-cancelled': kr.isCancelled }">{{ 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 class="urgent-kr-bar" v-if="!kr.isCancelled">
<div class="urgent-kr-bar-fill" :style="{ width: kr.progress + '%', background: kr.isCompleted ? '#0D9668' : kr.isOverdue ? '#DC2626' : kr.isPaused ? '#9CA3AF' : '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 class="urgent-kr-badge" :class="'badge-' + kr.displayStatus">
{{ kr.isCancelled ? '已取消' : kr.isPaused ? '已暂停' : kr.isCompleted ? '已完成' : kr.isOverdue ? '已逾期' : kr.daysLeft === 0 ? '今天截止' : kr.daysLeft + '天后' }}
</span>
</div>
</div>
@ -169,33 +169,32 @@ function formatCommitTime(isoStr: string) {
<WeeklyCodeActivity :weeks="weeklyCodeWeeks" />
</DataCard>
<!-- Panel 5: 历史逾期未完成 -->
<!-- Panel 5: 异常事项 -->
<DataCard>
<template #header>
<div>
<div style="font-weight:600;font-size:15px">历史逾期未完成</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> 待处理
<span style="color:#DC2626;font-weight:600">{{ overviewData.overdueKRs?.length || 0 }}</span> 需关注
</div>
</div>
</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 class="abnormal-list">
<div v-for="kr in overviewData.overdueKRs" :key="kr.id" class="abnormal-item" :class="'abnormal-' + kr.status">
<div class="abnormal-row">
<div class="abnormal-left">
<div class="abnormal-title-row">
<span class="abnormal-title" :class="{ 'title-cancelled': kr.status === 'cancelled' }">{{ kr.title }}</span>
<span class="abnormal-badge" :class="'badge-' + kr.status">{{ kr.statusLabel }}</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 class="abnormal-meta">{{ kr.projectIdentifier }} · {{ kr.ownerName }} · 截止 {{ kr.endDate }}</div>
<div v-if="kr.reason" class="abnormal-reason">{{ kr.reason }}</div>
</div>
<div class="abnormal-pct tabular-nums">{{ kr.progress }}%</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>
</DataCard>
@ -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; }

View File

@ -1,10 +1,9 @@
<script setup lang="ts">
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 { NSpin, NProgress, NTag, NButton, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, NDatePicker, NSwitch, NDropdown, NTimeline, NTimelineItem, useMessage } from 'naive-ui';
import { getProjectDetailApi } from '@/api/projects';
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi } from '@/api/okr';
import { createObjectiveApi, createKeyResultApi, updateKeyResultApi, deleteObjectiveApi, deleteKeyResultApi, postponeKRApi, pauseKRApi, resumeKRApi, cancelKRApi, getKRLogsApi } from '@/api/okr';
import request from '@/api/request';
import DataCard from '@/components/shared/DataCard.vue';
import { useECharts, CHART_COLORS } from '@/composables/useECharts';
@ -158,6 +157,113 @@ async function handleDeleteKR(id: string) {
catch { message.error('删除失败'); }
}
// KR
function getKRMenuOptions(kr: any) {
if (kr.status === 'paused') {
return [
{ label: '恢复', key: 'resume' },
{ label: '取消', key: 'cancel' },
];
}
if (kr.status === 'cancelled') return [];
// active
return [
{ label: '延期', key: 'postpone' },
{ label: '暂停', key: 'pause' },
{ label: '取消', key: 'cancel' },
];
}
//
const showPostponeModal = ref(false);
const postponeKRId = ref('');
const postponeData = ref({ newEndDate: null as number | null, reason: '' });
// /
const showReasonModal = ref(false);
const reasonAction = ref<'pause' | 'cancel'>('pause');
const reasonKRId = ref('');
const reasonText = ref('');
//
const showLogsModal = ref(false);
const logsKRTitle = ref('');
const logsData = ref<any[]>([]);
function handleKRMenu(key: string, kr: any) {
if (key === 'postpone') {
postponeKRId.value = kr.id;
postponeData.value = { newEndDate: null, reason: '' };
showPostponeModal.value = true;
} else if (key === 'pause' || key === 'cancel') {
reasonAction.value = key;
reasonKRId.value = kr.id;
reasonText.value = '';
showReasonModal.value = true;
} else if (key === 'resume') {
doResume(kr.id);
}
}
async function doPostpone() {
if (!postponeData.value.newEndDate || !postponeData.value.reason) {
message.warning('请填写新截止日期和延期原因');
return;
}
try {
await postponeKRApi(postponeKRId.value, {
newEndDate: tsToDateStr(postponeData.value.newEndDate),
reason: postponeData.value.reason,
});
message.success('已延期');
showPostponeModal.value = false;
loadData();
} catch { message.error('延期失败'); }
}
async function doReasonAction() {
if (!reasonText.value) { message.warning('请填写原因'); return; }
try {
if (reasonAction.value === 'pause') {
await pauseKRApi(reasonKRId.value, { reason: reasonText.value });
message.success('已暂停');
} else {
await cancelKRApi(reasonKRId.value, { reason: reasonText.value });
message.success('已取消');
}
showReasonModal.value = false;
loadData();
} catch { message.error('操作失败'); }
}
async function doResume(krId: string) {
try {
await resumeKRApi(krId);
message.success('已恢复');
loadData();
} catch { message.error('恢复失败'); }
}
async function openLogs(kr: any) {
logsKRTitle.value = kr.title;
try {
const res = await getKRLogsApi(kr.id);
logsData.value = res.data.data || [];
} catch { logsData.value = []; }
showLogsModal.value = true;
}
function actionLabel(action: string) {
const map: Record<string, string> = { created: '创建', postponed: '延期', paused: '暂停', resumed: '恢复', cancelled: '取消', completed: '完成', progress_update: '更新进度' };
return map[action] || action;
}
function formatLogTime(t: any) {
if (!t) return '';
const d = new Date(typeof t === 'number' ? t * 1000 : t);
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
// Git
const gitMiniChartOptions = computed(() => {
const trend = data.value?.gitActivity?.weeklyTrend || [];
@ -232,48 +338,51 @@ function canEditObj(obj: any): boolean {
</div>
<div class="kr-list">
<div v-for="kr in obj.keyResults" :key="kr.id" class="kr-row">
<!-- 第一行标题 + 截止日期 -->
<div class="kr-header">
<span class="kr-name">{{ kr.title }}</span>
<div v-for="kr in obj.keyResults" :key="kr.id" class="kr-row" :class="{ 'kr-paused': kr.status === 'paused', 'kr-cancelled': kr.status === 'cancelled' }">
<!-- 行1标题行 -->
<div class="kr-line1">
<div class="kr-title-area">
<span class="kr-name" :class="{ 'kr-name-cancelled': kr.status === 'cancelled' }">{{ kr.title }}</span>
<NTag v-if="kr.status === 'paused'" size="small" type="warning" round>已暂停</NTag>
<NTag v-else-if="kr.status === 'cancelled'" size="small" type="error" round>已取消</NTag>
<NTag v-else-if="kr.wasPostponed" size="small" type="info" round>已延期</NTag>
</div>
<div class="kr-meta-area">
<span v-if="kr.endDate" class="kr-date">截止 {{ kr.endDate }}</span>
<span v-if="kr.wasPostponed || kr.status === 'paused' || kr.status === 'cancelled'" class="kr-log-link" @click="openLogs(kr)">日志</span>
<NDropdown v-if="canEditObj(obj) && kr.status !== 'cancelled'" :options="getKRMenuOptions(kr)" @select="(key: string) => handleKRMenu(key, kr)" trigger="click">
<NButton size="tiny" text quaternary>···</NButton>
</NDropdown>
</div>
<!-- 第二行进度条 + 输入框 + 完成按钮 -->
<div class="kr-control">
</div>
<!-- 行2进度条 + 操作 -->
<div class="kr-line2">
<div class="kr-bar-bg" style="flex:1">
<div class="kr-bar-fill" :style="{ width: kr.currentValue + '%', background: kr.currentValue >= 100 ? 'var(--color-success)' : 'var(--color-primary-hex)' }" />
<div class="kr-bar-fill" :style="{
width: (kr.status === 'cancelled' ? 0 : kr.currentValue) + '%',
background: kr.status === 'paused' ? '#9CA3AF' : kr.currentValue >= 100 ? 'var(--color-success)' : 'var(--color-primary-hex)'
}" />
</div>
<div class="kr-actions" v-if="canEditObj(obj)">
<input
class="kr-input"
type="number"
min="0" max="100"
:value="kr.currentValue"
@change="(e: Event) => {
let v = parseInt((e.target as HTMLInputElement).value) || 0;
if (v < 0) v = 0;
if (v > 100) v = 100;
(e.target as HTMLInputElement).value = String(v);
handleUpdateKR(kr.id, v);
}"
<!-- active 可编辑 -->
<template v-if="kr.status === 'active' && canEditObj(obj)">
<input class="kr-input" type="number" min="0" max="100" :value="kr.currentValue"
@change="(e: Event) => { let v = parseInt((e.target as HTMLInputElement).value) || 0; v = Math.min(Math.max(v,0),100); (e.target as HTMLInputElement).value = String(v); handleUpdateKR(kr.id, v); }"
/>
<span class="kr-pct-label tabular-nums">%</span>
<NSwitch
:value="kr.currentValue >= 100"
@update:value="() => handleToggleComplete(kr)"
size="small"
>
<span class="kr-pct-label">%</span>
<NSwitch :value="kr.currentValue >= 100" @update:value="() => handleToggleComplete(kr)" size="small">
<template #checked>已完成</template>
<template #unchecked>进行中</template>
</NSwitch>
</div>
<div class="kr-actions" v-else>
<span class="kr-target tabular-nums">{{ kr.currentValue }}%</span>
<NTag size="small" :type="kr.currentValue >= 100 ? 'success' : 'default'" round>
{{ kr.currentValue >= 100 ? '已完成' : '进行中' }}
</NTag>
</div>
<NButton v-if="authStore.isAdmin" size="tiny" text type="error" @click="handleDeleteKR(kr.id)" style="margin-left:4px">×</NButton>
</template>
<!-- active 只读 -->
<template v-else-if="kr.status === 'active'">
<span class="kr-pct-ro tabular-nums">{{ kr.currentValue }}%</span>
<NTag size="small" :type="kr.currentValue >= 100 ? 'success' : 'default'" round>{{ kr.currentValue >= 100 ? '已完成' : '进行中' }}</NTag>
</template>
<!-- paused / cancelled 只读 -->
<template v-else>
<span class="kr-pct-ro tabular-nums" style="color:#9CA3AF">{{ kr.currentValue }}%</span>
</template>
</div>
</div>
<div v-if="!obj.keyResults?.length" class="kr-empty">
@ -332,6 +441,42 @@ function canEditObj(obj: any): boolean {
</NModal>
<!-- 添加关键结果弹窗 -->
<!-- 延期弹窗 -->
<NModal v-model:show="showPostponeModal" title="延期任务" preset="dialog" positive-text="确认延期" @positive-click="doPostpone">
<NForm>
<NFormItem label="新截止日期" required>
<NDatePicker v-model:value="postponeData.newEndDate" type="date" style="width:100%" clearable />
</NFormItem>
<NFormItem label="延期原因" required>
<NInput v-model:value="postponeData.reason" type="textarea" :rows="2" placeholder="说明延期原因" />
</NFormItem>
</NForm>
</NModal>
<!-- 暂停/取消原因弹窗 -->
<NModal v-model:show="showReasonModal" :title="reasonAction === 'pause' ? '暂停任务' : '取消任务'" preset="dialog" :positive-text="reasonAction === 'pause' ? '确认暂停' : '确认取消'" @positive-click="doReasonAction">
<NForm>
<NFormItem :label="reasonAction === 'pause' ? '暂停原因' : '取消原因'" required>
<NInput v-model:value="reasonText" type="textarea" :rows="2" :placeholder="reasonAction === 'pause' ? '说明暂停原因' : '说明取消原因'" />
</NFormItem>
</NForm>
</NModal>
<!-- 操作日志弹窗 -->
<NModal v-model:show="showLogsModal" :title="`操作日志 — ${logsKRTitle}`" preset="card" style="max-width:500px">
<NTimeline v-if="logsData.length">
<NTimelineItem v-for="log in logsData" :key="log.id" :type="log.action === 'cancelled' ? 'error' : log.action === 'paused' ? 'warning' : log.action === 'postponed' ? 'info' : 'success'">
<template #header>
<span style="font-weight:600">{{ actionLabel(log.action) }}</span>
<span style="font-size:12px;color:var(--color-text-muted);margin-left:8px">{{ log.operatorName }} · {{ formatLogTime(log.createdAt) }}</span>
</template>
<span v-if="log.detail" style="font-size:13px;color:var(--color-text-secondary)">{{ log.detail }}</span>
</NTimelineItem>
</NTimeline>
<div v-else style="padding:var(--space-4);text-align:center;color:var(--color-text-muted)">暂无操作记录</div>
</NModal>
<!-- 添加任务弹窗 -->
<NModal v-model:show="showCreateKR" :title="`添加任务 → ${currentObjTitle}`" preset="dialog" positive-text="添加" @positive-click="handleCreateKR">
<NForm>
<NFormItem label="任务内容" required>
@ -358,24 +503,31 @@ function canEditObj(obj: any): boolean {
.obj-title { font-weight:600;font-size:14px; }
.obj-meta { font-size:12px;color:var(--color-text-secondary); }
.kr-list { display:flex;flex-direction:column;gap:var(--space-3);padding-left:var(--space-3);border-left:2px solid var(--color-border); }
.kr-row { display:flex;flex-direction:column;gap:6px;padding:var(--space-2) 0;border-bottom:1px solid var(--color-border); }
.kr-list { display:flex;flex-direction:column;gap:0;padding-left:var(--space-3);border-left:2px solid var(--color-border); }
.kr-row { padding:10px 8px;border-bottom:1px solid var(--color-border);border-radius:0; }
.kr-row:last-child { border-bottom:none; }
.kr-header { display:flex;justify-content:space-between;align-items:center; }
.kr-name { font-size:13px;color:var(--color-text-primary);font-weight:500; }
.kr-paused { opacity:0.6;background:rgba(212,146,10,0.02); }
.kr-cancelled { opacity:0.45;background:rgba(156,163,175,0.04); }
/* 行1标题 + 标签 + 截止日期 + 菜单 */
.kr-line1 { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px; }
.kr-title-area { display:flex;align-items:center;gap:6px;flex:1;min-width:0; }
.kr-name { font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.kr-name-cancelled { text-decoration:line-through;color:var(--color-text-muted); }
.kr-meta-area { display:flex;align-items:center;gap:8px;flex-shrink:0; }
.kr-date { font-size:11px;color:var(--color-text-muted);white-space:nowrap; }
.kr-control { display:flex;align-items:center;gap:var(--space-2); }
.kr-log-link { font-size:11px;color:var(--color-text-muted);cursor:pointer;text-decoration:underline; }
.kr-log-link:hover { color:var(--color-primary-hex); }
/* 行2进度条 + 输入框/标签 */
.kr-line2 { display:flex;align-items:center;gap:8px; }
.kr-bar-bg { height:6px;background:#F0F0F0;border-radius:3px;overflow:hidden; }
.kr-bar-fill { height:100%;border-radius:3px;transition:width 0.3s; }
.kr-actions { display:flex;align-items:center;gap:var(--space-2);flex-shrink:0; }
.kr-input {
width:56px;height:28px;border:1px solid var(--color-border);border-radius:4px;
text-align:center;font-size:13px;font-weight:600;outline:none;
background:var(--color-bg-card);color:var(--color-text-primary);
}
.kr-input { width:52px;height:26px;border:1px solid var(--color-border);border-radius:4px;text-align:center;font-size:13px;font-weight:600;outline:none;background:var(--color-bg-card);color:var(--color-text-primary); }
.kr-input:focus { border-color:var(--color-primary-hex); }
.kr-input::-webkit-inner-spin-button { -webkit-appearance:none; }
.kr-target { font-size:11px;color:var(--color-text-secondary);white-space:nowrap; }
.kr-pct-label { font-size:12px;color:var(--color-text-secondary); }
.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); }