关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
530 lines
25 KiB
TypeScript
530 lines
25 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { FilterSelect } from '@/components/FilterSelect';
|
||
import { formatDateTime } from '@/lib/format';
|
||
import type { Requirement } from '@/lib/requirement';
|
||
import type { PlanTask, RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
|
||
import {
|
||
canSaveRequirementCoverageDraft,
|
||
canOpenRequirementCoverageRecord,
|
||
getResearchDirectionProgressSummary,
|
||
getResearchDirectionStatus,
|
||
getRequirementCoverage,
|
||
getRequirementCoverageStatus,
|
||
getRequirementCoverageSummary,
|
||
REQUIREMENT_COVERAGE_LABEL,
|
||
startResearchDirectionWork,
|
||
startRequirementCoverageWork,
|
||
updateResearchDirectionProgress,
|
||
updateRequirementCoverage,
|
||
} from '@/lib/version-plan';
|
||
|
||
type RequirementOption = Pick<Requirement, 'id' | 'code' | 'title'> & { isHistorical?: boolean };
|
||
|
||
interface CoverageProps {
|
||
plan: VersionPlan;
|
||
requirements: RequirementOption[];
|
||
canEdit: boolean;
|
||
currentUserName: string;
|
||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||
}
|
||
|
||
interface DirectionProps {
|
||
plan: VersionPlan;
|
||
canEdit: boolean;
|
||
currentUserName: string;
|
||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||
}
|
||
|
||
interface LinkedRequirementReferenceProps {
|
||
requirements: RequirementOption[];
|
||
}
|
||
|
||
interface LogTimelineProps {
|
||
logs?: Array<VersionPlanLog | VersionPlanLogView>;
|
||
className?: string;
|
||
fillHeight?: boolean;
|
||
}
|
||
|
||
const COVERAGE_BADGE_STYLE: Record<RequirementCoverageStatus, string> = {
|
||
not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500',
|
||
partial: 'border-amber-200 bg-amber-50 text-amber-700',
|
||
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||
};
|
||
|
||
function getLogTone(log: VersionPlanLog): { badge: string } {
|
||
if (log.aiStatus === 'error') {
|
||
return {
|
||
badge: 'border-red-200 bg-red-50 text-red-700',
|
||
};
|
||
}
|
||
if (log.type === 'ai_decompose') {
|
||
return {
|
||
badge: 'border-violet-200 bg-violet-50 text-violet-700',
|
||
};
|
||
}
|
||
if (log.coverageStatus === 'completed') {
|
||
return {
|
||
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||
};
|
||
}
|
||
if (log.coverageStatus === 'partial') {
|
||
return {
|
||
badge: 'border-amber-200 bg-amber-50 text-amber-700',
|
||
};
|
||
}
|
||
return {
|
||
badge: 'border-zinc-200 bg-zinc-50 text-zinc-600',
|
||
};
|
||
}
|
||
|
||
function getLogTypeLabel(log: VersionPlanLog): string {
|
||
if (log.type === 'ai_decompose') return 'AI 拆解';
|
||
if (log.type === 'research_direction_progress') return '调研方向';
|
||
if (log.type === 'requirement_progress') return '需求进度';
|
||
return '系统记录';
|
||
}
|
||
|
||
function getLogMonthKey(createdAt: string): string | null {
|
||
const date = new Date(createdAt);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||
}
|
||
|
||
function getLogMonthLabel(monthKey: string): string {
|
||
const [year, month] = monthKey.split('-');
|
||
return `${year}年${month}月`;
|
||
}
|
||
|
||
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
|
||
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
|
||
const [completedContent, setCompletedContent] = useState('');
|
||
const [remainingContent, setRemainingContent] = useState('');
|
||
const summary = getRequirementCoverageSummary(plan);
|
||
|
||
if (requirements.length === 0) return null;
|
||
|
||
const openEditor = (req: RequirementOption) => {
|
||
const coverage = getRequirementCoverage(plan, req.id);
|
||
setEditingRequirementId(req.id);
|
||
setCompletedContent(coverage?.completedContent ?? '');
|
||
setRemainingContent(coverage?.remainingContent ?? '');
|
||
};
|
||
|
||
const closeEditor = () => {
|
||
setEditingRequirementId(null);
|
||
setCompletedContent('');
|
||
setRemainingContent('');
|
||
};
|
||
|
||
const startCoverage = (req: RequirementOption) => {
|
||
if (!canEdit) return;
|
||
const patch = startRequirementCoverageWork(plan, {
|
||
requirementId: req.id,
|
||
updatedBy: currentUserName,
|
||
});
|
||
onUpdate(plan.id, patch);
|
||
};
|
||
|
||
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||
const coverage = getRequirementCoverage(plan, req.id);
|
||
const workStartedAt = coverage?.currentWorkStartedAt;
|
||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, workStartedAt)) return;
|
||
const patch = updateRequirementCoverage(plan, {
|
||
requirementId: req.id,
|
||
status,
|
||
completedContent: status === 'partial' ? completedContent : undefined,
|
||
remainingContent: status === 'partial' ? remainingContent : undefined,
|
||
updatedBy: currentUserName,
|
||
requirementCode: req.code,
|
||
requirementTitle: req.title,
|
||
});
|
||
onUpdate(plan.id, patch);
|
||
closeEditor();
|
||
};
|
||
|
||
return (
|
||
<div className="mt-3 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">引用需求</div>
|
||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
|
||
完全完成 {summary.completed} / {summary.total}
|
||
{summary.partial > 0 && <span className="ml-2 text-amber-700">部分完成 {summary.partial}</span>}
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{summary.percent}%</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||
{requirements.map((req) => {
|
||
const status = getRequirementCoverageStatus(plan, req.id);
|
||
const coverage = getRequirementCoverage(plan, req.id);
|
||
const isEditing = editingRequirementId === req.id;
|
||
const hasStartedWork = Boolean(coverage?.currentWorkStartedAt);
|
||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
|
||
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
|
||
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, coverage?.currentWorkStartedAt);
|
||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, coverage?.currentWorkStartedAt);
|
||
return (
|
||
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||
<div className="flex min-w-0 items-start gap-2">
|
||
<span className={`mt-0.5 inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${COVERAGE_BADGE_STYLE[status]}`}>
|
||
{REQUIREMENT_COVERAGE_LABEL[status]}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className="shrink-0 font-mono text-[11px] text-[var(--ink-muted)]">{req.code}</span>
|
||
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
|
||
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600">历史</span>}
|
||
</div>
|
||
{(coverage?.completedContent || coverage?.remainingContent || coverage?.currentWorkStartedAt) && (
|
||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
|
||
{coverage.completedContent && <div className="line-clamp-2">已完成:{coverage.completedContent}</div>}
|
||
{coverage.remainingContent && <div className="line-clamp-2 text-amber-700">剩余:{coverage.remainingContent}</div>}
|
||
{coverage.currentWorkStartedAt && <div className="text-blue-600">已开始:{formatDateTime(coverage.currentWorkStartedAt)}</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{canEdit && status !== 'completed' && (
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{canStartWork && (
|
||
<button
|
||
type="button"
|
||
onClick={() => startCoverage(req)}
|
||
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
|
||
>
|
||
开始任务
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(req))}
|
||
disabled={!canOpenRecord}
|
||
title={!hasStartedWork ? '请先开始任务' : undefined}
|
||
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{isEditing ? '收起' : '记录'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isEditing && (
|
||
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
|
||
<textarea
|
||
value={completedContent}
|
||
onChange={(e) => setCompletedContent(e.target.value)}
|
||
rows={2}
|
||
placeholder="本次已完成的内容"
|
||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||
/>
|
||
<textarea
|
||
value={remainingContent}
|
||
onChange={(e) => setRemainingContent(e.target.value)}
|
||
rows={2}
|
||
placeholder="剩余未完成的内容"
|
||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||
/>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => saveCoverage(req, 'completed')}
|
||
disabled={!canSaveCompleted}
|
||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||
>
|
||
完全完成
|
||
</button>
|
||
<div className="ml-auto flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={closeEditor}
|
||
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => saveCoverage(req, 'partial')}
|
||
disabled={!canSavePartial}
|
||
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||
>
|
||
保存记录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onUpdate }: DirectionProps) {
|
||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||
const [completedContent, setCompletedContent] = useState('');
|
||
const [remainingContent, setRemainingContent] = useState('');
|
||
const tasks = plan.tasks ?? [];
|
||
const summary = getResearchDirectionProgressSummary(plan);
|
||
|
||
if (tasks.length === 0) return null;
|
||
|
||
const openEditor = (task: PlanTask) => {
|
||
setEditingTaskId(task.id);
|
||
setCompletedContent(task.completedContent ?? '');
|
||
setRemainingContent(task.remainingContent ?? '');
|
||
};
|
||
|
||
const closeEditor = () => {
|
||
setEditingTaskId(null);
|
||
setCompletedContent('');
|
||
setRemainingContent('');
|
||
};
|
||
|
||
const startDirection = (task: PlanTask) => {
|
||
if (!canEdit) return;
|
||
const patch = startResearchDirectionWork(plan, {
|
||
taskId: task.id,
|
||
updatedBy: currentUserName,
|
||
});
|
||
onUpdate(plan.id, patch);
|
||
};
|
||
|
||
const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, task.currentWorkStartedAt)) return;
|
||
const patch = updateResearchDirectionProgress(plan, {
|
||
taskId: task.id,
|
||
status,
|
||
completedContent: status === 'partial' ? completedContent : undefined,
|
||
remainingContent: status === 'partial' ? remainingContent : undefined,
|
||
updatedBy: currentUserName,
|
||
});
|
||
onUpdate(plan.id, patch);
|
||
closeEditor();
|
||
};
|
||
|
||
return (
|
||
<div className="mt-3 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">调研方向</div>
|
||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
|
||
完全完成 {summary.completed} / {summary.total}
|
||
{summary.partial > 0 && <span className="ml-2 text-amber-700">部分完成 {summary.partial}</span>}
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{summary.percent}%</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||
{tasks.map((task) => {
|
||
const status = getResearchDirectionStatus(task);
|
||
const isEditing = editingTaskId === task.id;
|
||
const hasStartedWork = Boolean(task.currentWorkStartedAt);
|
||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
|
||
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
|
||
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, task.currentWorkStartedAt);
|
||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, task.currentWorkStartedAt);
|
||
return (
|
||
<div key={task.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||
<div className="flex min-w-0 items-start gap-2">
|
||
<span className={`mt-0.5 inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${COVERAGE_BADGE_STYLE[status]}`}>
|
||
{REQUIREMENT_COVERAGE_LABEL[status]}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div>
|
||
{(task.completedContent || task.remainingContent || task.currentWorkStartedAt) && (
|
||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
|
||
{task.completedContent && <div className="line-clamp-2">已完成:{task.completedContent}</div>}
|
||
{task.remainingContent && <div className="line-clamp-2 text-amber-700">剩余:{task.remainingContent}</div>}
|
||
{task.currentWorkStartedAt && <div className="text-blue-600">已开始:{formatDateTime(task.currentWorkStartedAt)}</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{canEdit && status !== 'completed' && (
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{canStartWork && (
|
||
<button
|
||
type="button"
|
||
onClick={() => startDirection(task)}
|
||
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
|
||
>
|
||
开始任务
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(task))}
|
||
disabled={!canOpenRecord}
|
||
title={!hasStartedWork ? '请先开始任务' : undefined}
|
||
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{isEditing ? '收起' : '记录'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isEditing && (
|
||
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
|
||
<textarea
|
||
value={completedContent}
|
||
onChange={(event) => setCompletedContent(event.target.value)}
|
||
rows={2}
|
||
placeholder="本次已完成的调研内容"
|
||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||
/>
|
||
<textarea
|
||
value={remainingContent}
|
||
onChange={(event) => setRemainingContent(event.target.value)}
|
||
rows={2}
|
||
placeholder="剩余未完成的调研内容"
|
||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||
/>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => saveDirection(task, 'completed')}
|
||
disabled={!canSaveCompleted}
|
||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||
>
|
||
完全完成
|
||
</button>
|
||
<div className="ml-auto flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={closeEditor}
|
||
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => saveDirection(task, 'partial')}
|
||
disabled={!canSavePartial}
|
||
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||
>
|
||
保存记录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PlanLinkedRequirementReferenceList({ requirements }: LinkedRequirementReferenceProps) {
|
||
if (requirements.length === 0) return null;
|
||
|
||
return (
|
||
<div className="mt-3 space-y-2">
|
||
<div>
|
||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">引用需求</div>
|
||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">用于说明调研方向覆盖哪些需求,不参与调研进度计算</div>
|
||
</div>
|
||
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2">
|
||
{requirements.map((req) => (
|
||
<div key={req.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||
<span className="shrink-0 font-mono text-[11px] text-[var(--ink-muted)]">{req.code}</span>
|
||
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
|
||
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600">历史</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
|
||
const [selectedMonth, setSelectedMonth] = useState('all');
|
||
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||
const monthOptions = Array.from(new Set(sortedLogs.map((log) => getLogMonthKey(log.createdAt)).filter(Boolean) as string[]))
|
||
.sort((a, b) => b.localeCompare(a));
|
||
const effectiveMonth = selectedMonth === 'all' || monthOptions.includes(selectedMonth) ? selectedMonth : 'all';
|
||
const visibleLogs = effectiveMonth === 'all'
|
||
? sortedLogs
|
||
: sortedLogs.filter((log) => getLogMonthKey(log.createdAt) === effectiveMonth);
|
||
const frameClass = className || 'border-l border-[var(--line)] pl-4';
|
||
const listClass = fillHeight
|
||
? 'mt-3 min-h-0 flex-1 overflow-y-auto pr-1'
|
||
: 'mt-3 max-h-80 overflow-y-auto pr-1';
|
||
|
||
return (
|
||
<aside className={frameClass}>
|
||
<div className="shrink-0 border-b border-[var(--line)] pb-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[13px] font-semibold text-[var(--ink)]">日志</div>
|
||
<div className="mt-0.5 text-[11px] text-[var(--ink-muted)]">计划行为与 AI 操作记录</div>
|
||
</div>
|
||
<FilterSelect
|
||
value={effectiveMonth}
|
||
onChange={setSelectedMonth}
|
||
options={monthOptions.map((month) => ({ value: month, label: getLogMonthLabel(month) }))}
|
||
allLabel="全部月份"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{visibleLogs.length === 0 ? (
|
||
<div className="mt-4 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
|
||
暂无日志
|
||
</div>
|
||
) : (
|
||
<div className={listClass}>
|
||
<div>
|
||
{visibleLogs.map((log) => {
|
||
const tone = getLogTone(log);
|
||
return (
|
||
<div key={log.id} className="border-b border-[var(--line)] py-3 first:pt-0 last:border-b-0 last:pb-0">
|
||
<div className="min-w-0">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-semibold ${tone.badge}`}>
|
||
{getLogTypeLabel(log)}
|
||
</span>
|
||
<span className="min-w-0 truncate text-[11px] text-[var(--ink-muted)]">{log.actor}</span>
|
||
</div>
|
||
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
|
||
</div>
|
||
<div className="mt-1.5 text-[12px] font-semibold leading-5 text-[var(--ink)]">{log.title}</div>
|
||
{'planTitle' in log && log.planTitle && (
|
||
<div className="mt-1 truncate text-[11px] text-[var(--ink-soft)]" title={log.planTitle}>
|
||
{log.planTitle}
|
||
</div>
|
||
)}
|
||
{log.detail && (
|
||
<div className="mt-2 border-l-2 border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-[11px] leading-5 text-[var(--ink-soft)]">
|
||
<div className="whitespace-pre-wrap">{log.detail}</div>
|
||
</div>
|
||
)}
|
||
{log.workStartedAt && (
|
||
<div className="mt-2 inline-flex rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700">
|
||
本次开始:{formatDateTime(log.workStartedAt)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|