fix: 时间显示统一使用本地时区(修复UTC显示问题)

之前 ISO 时间戳直接 slice(0, 16).replace('T', ' ') 显示 UTC 时间
(北京时间晚 8 小时),现统一用 formatDateTime 转本地时区。

新增 lib/format.ts:
- formatDateTime(iso) → YYYY-MM-DD HH:mm(本地时区)
- formatDate(iso) → YYYY-MM-DD(本地时区)
- formatDateTimeShort(iso) → MM-DD HH:mm(本地时区)

替换文件:
- versions/[id]/page.tsx (实际开始/截止)
- workspace/page.tsx (计划任务时间)
- BugDetailDrawer.tsx (基本信息+操作日志)
- TestCaseDetailDrawer.tsx (执行时间)
- DevTaskDetailDrawer.tsx (开始/提测时间)
- PlanDetailDrawer.tsx (计划/实际/完成时间)
- PlanTab.tsx (计划任务列表时间)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-16 14:15:20 +08:00
parent 6d2f6d8b9f
commit 4ac0dab4c5
8 changed files with 70 additions and 17 deletions

View File

@@ -27,6 +27,7 @@ import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task'; import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task';
import { formatDateTime } from '@/lib/format';
const PRIORITY_STYLE: Record<string, string> = { const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600', P0: 'bg-red-500/10 text-red-600',
@@ -397,7 +398,7 @@ export default function VersionDetailPage() {
}); });
vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); });
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStart = startDates.length > 0 ? startDates.sort()[0].slice(0, 16).replace('T', ' ') : (version.startDate ?? null); const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null);
// 实际截止日期 // 实际截止日期
const endDates: string[] = []; const endDates: string[] = [];
@@ -405,7 +406,7 @@ export default function VersionDetailPage() {
vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); }); vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); });
vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
const actualEnd = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 16).replace('T', ' ') : null; const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null;
// 逾期天数 // 逾期天数
const deadline = version.expectedReleaseDate; const deadline = version.expectedReleaseDate;

View File

@@ -19,6 +19,7 @@ import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawe
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer'; import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
import { BugCreateModal } from '@/components/bug/BugCreateModal'; import { BugCreateModal } from '@/components/bug/BugCreateModal';
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task'; import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task';
import { formatDateTime } from '@/lib/format';
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case'; import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
@@ -322,7 +323,7 @@ function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigat
<span className="text-[var(--line)]">/</span> <span className="text-[var(--line)]">/</span>
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button> <button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
{item.extra?.dueDate && <span className="ml-2"> {item.extra.dueDate}</span>} {item.extra?.dueDate && <span className="ml-2"> {item.extra.dueDate}</span>}
{item.extra?.startTime && <span className="ml-2">{item.extra.startTime.slice(0, 16).replace('T', ' ')} {item.extra.endTime?.slice(0, 16).replace('T', ' ')}</span>} {item.extra?.startTime && <span className="ml-2">{formatDateTime(item.extra.startTime)} {formatDateTime(item.extra.endTime)}</span>}
</div> </div>
</div> </div>
); );

View File

@@ -9,6 +9,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import { formatDateTime } from '@/lib/format';
import type { BugStatus } from '@/lib/bug'; import type { BugStatus } from '@/lib/bug';
const LOG_ACTION_LABEL: Record<string, string> = { const LOG_ACTION_LABEL: Record<string, string> = {
@@ -160,9 +161,9 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]"> <div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.reportedBy}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.reportedBy}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{bug.assigneeId}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{bug.assigneeId}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.createdAt.slice(0, 16).replace('T', ' ')}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(bug.createdAt)}</span></div>
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.resolvedAt.slice(0, 16).replace('T', ' ')}</span></div>} {bug.resolvedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(bug.resolvedAt)}</span></div>}
{bug.closedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-emerald-600">{bug.closedAt.slice(0, 16).replace('T', ' ')}</span></div>} {bug.closedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-emerald-600">{formatDateTime(bug.closedAt)}</span></div>}
</div> </div>
</div> </div>
@@ -199,7 +200,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<div className="space-y-2.5"> <div className="space-y-2.5">
{[...bug.logs].reverse().map((log) => ( {[...bug.logs].reverse().map((log) => (
<div key={log.id} className="flex gap-2.5 text-[11px]"> <div key={log.id} className="flex gap-2.5 text-[11px]">
<span className="text-[var(--ink-muted)] tabular-nums shrink-0 w-[110px]">{log.createdAt.slice(0, 16).replace('T', ' ')}</span> <span className="text-[var(--ink-muted)] tabular-nums shrink-0 w-[110px]">{formatDateTime(log.createdAt)}</span>
<div className="flex-1"> <div className="flex-1">
<span className="font-medium text-[var(--ink)]">{log.operator}</span> <span className="font-medium text-[var(--ink)]">{log.operator}</span>
<span className="text-[var(--ink-soft)]"> {LOG_ACTION_LABEL[log.action] || log.action}</span> <span className="text-[var(--ink-soft)]"> {LOG_ACTION_LABEL[log.action] || log.action}</span>

View File

@@ -9,6 +9,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours, calcActualHoursByDates } from '@/lib/dev-task'; import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours, calcActualHoursByDates } from '@/lib/dev-task';
import { formatDateTime } from '@/lib/format';
import type { DevTaskStatus } from '@/lib/dev-task'; import type { DevTaskStatus } from '@/lib/dev-task';
interface Props { interface Props {
@@ -188,7 +189,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Play className="h-3 w-3 text-[var(--ink-muted)]" /> <Play className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{task.startDate.slice(0, 16).replace('T', ' ')}</span> <span className="text-[var(--ink)]">{formatDateTime(task.startDate)}</span>
</div> </div>
)} )}
{task.dueDate && ( {task.dueDate && (
@@ -202,7 +203,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" /> <Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink-muted)]"></span>
<span className="text-emerald-600 font-medium">{task.completedAt.slice(0, 16).replace('T', ' ')}</span> <span className="text-emerald-600 font-medium">{formatDateTime(task.completedAt)}</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case'; import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
import { calcActualHoursByDates } from '@/lib/dev-task'; import { calcActualHoursByDates } from '@/lib/dev-task';
import { formatDateTime } from '@/lib/format';
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import type { TestCaseStatus } from '@/lib/test-case'; import type { TestCaseStatus } from '@/lib/test-case';
@@ -153,8 +154,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdBy}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div> <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
{tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.startedAt.slice(0, 16).replace('T', ' ')}</span></div>} {tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
{tc.completedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.completedAt.slice(0, 16).replace('T', ' ')}</span></div>} {tc.completedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.completedAt)}</span></div>}
{tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{calcActualHoursByDates(tc.startedAt, tc.completedAt)}h</span></div>} {tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{calcActualHoursByDates(tc.startedAt, tc.completedAt)}h</span></div>}
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import type { PlanTask, VersionPlan } from '@/lib/version-plan'; import type { PlanTask, VersionPlan } from '@/lib/version-plan';
interface Props { interface Props {
@@ -109,18 +110,18 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</div> </div>
<div> <div>
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.startTime.slice(0, 16).replace('T', ' ')} {plan.endTime.slice(0, 16).replace('T', ' ')}</div> <div className="font-medium text-[var(--ink)] mt-0.5">{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</div>
</div> </div>
{plan.actualStartAt && ( {plan.actualStartAt && (
<div> <div>
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-blue-600 mt-0.5">{plan.actualStartAt.slice(0, 16).replace('T', ' ')}</div> <div className="font-medium text-blue-600 mt-0.5">{formatDateTime(plan.actualStartAt)}</div>
</div> </div>
)} )}
{plan.completedAt && ( {plan.completedAt && (
<div> <div>
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-emerald-600 mt-0.5">{plan.completedAt.slice(0, 16).replace('T', ' ')}</div> <div className="font-medium text-emerald-600 mt-0.5">{formatDateTime(plan.completedAt)}</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react'; import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
import type { VersionPlan, PlanTask } from '@/lib/version-plan'; import type { VersionPlan, PlanTask } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
interface Props { interface Props {
plans: VersionPlan[]; plans: VersionPlan[];
@@ -85,9 +86,9 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]"> <div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{plan.startTime.slice(0, 16).replace('T', ' ')} {plan.endTime.slice(0, 16).replace('T', ' ')}</span> <span>{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</span>
{plan.actualStartAt && <span className="text-blue-600">{plan.actualStartAt.slice(0, 16).replace('T', ' ')}</span>} {plan.actualStartAt && <span className="text-blue-600">{formatDateTime(plan.actualStartAt)}</span>}
{plan.completedAt && <span className="text-green-600">{plan.completedAt.slice(0, 16).replace('T', ' ')}</span>} {plan.completedAt && <span className="text-green-600">{formatDateTime(plan.completedAt)}</span>}
<span className="font-medium text-[var(--ink)]"> {durText}</span> <span className="font-medium text-[var(--ink)]"> {durText}</span>
</div> </div>
{plan.overdueReason && ( {plan.overdueReason && (

46
apps/web/lib/format.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* 时间格式化工具——统一处理 ISO 时间戳到本地时区显示
* 避免在 UI 直接 slice ISO 字符串导致显示 UTC 时间
*/
/**
* 格式化为 YYYY-MM-DD HH:mm本地时区
*/
export function formatDateTime(iso?: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
if (isNaN(d.getTime())) return '-';
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/**
* 格式化为 YYYY-MM-DD本地时区
*/
export function formatDate(iso?: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
if (isNaN(d.getTime())) return '-';
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
/**
* 格式化为 MM-DD HH:mm紧凑版本地时区
*/
export function formatDateTimeShort(iso?: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
if (isNaN(d.getTime())) return '-';
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}-${dd} ${hh}:${mi}`;
}