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:
parent
246410e12c
commit
f88e2d9ab0
@ -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(),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<div class="overdue-right">
|
||||
<div class="overdue-bar">
|
||||
<div class="overdue-bar-fill" :style="{ width: kr.progress + '%' }" />
|
||||
<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>
|
||||
<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; }
|
||||
|
||||
@ -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>
|
||||
<span v-if="kr.endDate" class="kr-date">截止 {{ kr.endDate }}</span>
|
||||
</div>
|
||||
<!-- 第二行:进度条 + 输入框 + 完成按钮 -->
|
||||
<div class="kr-control">
|
||||
<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 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-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);
|
||||
}"
|
||||
<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>
|
||||
<!-- 行2:进度条 + 操作 -->
|
||||
<div class="kr-line2">
|
||||
<div class="kr-bar-bg" style="flex:1">
|
||||
<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>
|
||||
<!-- 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); }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user