feat: 计划时间改为年月日时分 + 到期自动开始 + 耗时统计同步

1. 日期选择改为 datetime-local(年月日时分):
   - 计划开始时间/截止时间均精确到分钟
   - 展示时显示完整时间而非仅日期

2. 到期自动开始逻辑:
   - 当前时间 >= plan.startTime 且 status=pending → 自动视为开始
   - 自动触发 store 更新 status=in_progress + actualStartAt
   - 用户仍可在计划开始前手动点击"开始"按钮
   - 按钮改为"提前开始",已到期的不再显示按钮

3. 耗时计算统一用实际时间:
   - 耗时 = actualStartAt 到 completedAt(或当前时间)
   - 自动开始的 plan 使用 startTime 作为 effectiveStartAt
   - 概览总耗时/开始日期计算也同步纳入自动开始的 plan

4. checkbox 与自动开始联动:
   - 自动开始后 checkbox 可交互(不需要手动点开始)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-15 15:53:41 +08:00
parent 2d3f56b00c
commit 24fb392017
2 changed files with 36 additions and 22 deletions

View File

@@ -56,25 +56,33 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<div className="space-y-3">
{typePlans.map((plan) => {
const now = new Date().toISOString();
// 自动开始:如果到了计划开始日期且状态还是 pending视为已开始
const autoStarted = plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date();
const effectiveStatus = autoStarted ? 'in_progress' : plan.status;
const effectiveStartAt = plan.actualStartAt || (autoStarted ? plan.startTime : null);
// 耗时用实际时间戳计算
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
? calcPlanDuration(plan.actualStartAt, plan.completedAt)
: plan.status === 'in_progress' && plan.actualStartAt
? calcPlanDuration(plan.actualStartAt, now)
: effectiveStatus === 'in_progress' && effectiveStartAt
? calcPlanDuration(effectiveStartAt, now)
: { days: 0, hours: 0 };
const durText = dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
// 如果自动开始了,触发 store 更新(副作用)
if (autoStarted && !plan.actualStartAt) {
onUpdate(plan.id, { status: 'in_progress' });
}
return (
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[plan.status]}`}>
{STATUS_LABEL[plan.status]}
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
{STATUS_LABEL[effectiveStatus]}
</span>
</div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{plan.startTime.slice(0, 10)} {plan.endTime.slice(0, 10)}</span>
<span>{plan.startTime.slice(0, 16).replace('T', ' ')} {plan.endTime.slice(0, 16).replace('T', ' ')}</span>
{plan.actualStartAt && <span className="text-blue-600">{plan.actualStartAt.slice(0, 16).replace('T', ' ')}</span>}
{plan.completedAt && <span className="text-green-600">{plan.completedAt.slice(0, 16).replace('T', ' ')}</span>}
<span className="font-medium text-[var(--ink)]"> {durText}</span>
@@ -106,14 +114,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2">
<button
disabled={plan.status !== 'in_progress'}
disabled={plan.status !== 'in_progress' && !autoStarted}
onClick={() => {
if (plan.status !== 'in_progress') return;
if (plan.status !== 'in_progress' && !autoStarted) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
onUpdate(plan.id, { tasks: updatedTasks });
}}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
@@ -142,14 +150,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
return req ? (
<div key={rid} className="flex items-center gap-2">
<button
disabled={plan.status !== 'in_progress'}
disabled={plan.status !== 'in_progress' && !autoStarted}
onClick={() => {
if (plan.status !== 'in_progress') return;
if (plan.status !== 'in_progress' && !autoStarted) return;
const current = plan.completedRequirementIds || [];
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
onUpdate(plan.id, { completedRequirementIds: next });
}}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
@@ -169,8 +177,8 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
)}
</div>
<div className="flex items-center gap-1 ml-3">
{plan.status === 'pending' && (
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="h-7 px-2 flex items-center gap-1 rounded-md text-[11px] font-medium text-blue-600 hover:bg-blue-50 border border-blue-200" title="开始">
{plan.status === 'pending' && !autoStarted && (
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="h-7 px-2 flex items-center gap-1 rounded-md text-[11px] font-medium text-blue-600 hover:bg-blue-50 border border-blue-200" title="提前开始">
<Play className="h-3 w-3" />
</button>
)}
@@ -235,11 +243,11 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const now = new Date().toISOString().slice(0, 10);
const now = new Date().toISOString().slice(0, 16);
const [title, setTitle] = useState(initial?.title ?? '');
const [owner] = useState(initial?.owner ?? currentUserName);
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
const [newTaskTitle, setNewTaskTitle] = useState('');
@@ -287,12 +295,12 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="date" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="date" value={endTime} onChange={(e) => setEndTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
</div>
{versionDeadline && (