feat: 统一全链路时区为 UTC+8 (Asia/Shanghai)
Deploy to Server / deploy (push) Successful in 1m36s

- schema.sql created_at DEFAULT 改为 datetime(now,+8 hours)
- Dockerfile runtime 安装 tzdata ENV TZ=Asia/Shanghai 并软链 /etc/localtime
- docker-compose.yml 挂载宿主机 localtime 并传 TZ=Asia/Shanghai
- server index.ts 入口设置 process.env.TZ
- client utils/export 新增 utc8Now/utc8Input/utc8DateStamp/utc8DateTimeStamp
- Activities 默认活动开始/结束时间改用 UTC+8
- 导出文件名日期戳和报告生成时间统一 utc8DateStamp/utc8DateTimeStamp
This commit is contained in:
Trae Assistant
2026-10-05 09:09:01 +08:00
parent 7f9bf15b7d
commit 30021d7b46
8 changed files with 53 additions and 18 deletions
+4
View File
@@ -16,6 +16,10 @@ FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
COPY server/package.json server/package-lock.json* ./ COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund RUN npm install --omit=dev --no-audit --no-fund
+3 -8
View File
@@ -6,18 +6,13 @@ import Input from '../../components/Input';
import Select from '../../components/Select'; import Select from '../../components/Select';
import Textarea from '../../components/Textarea'; import Textarea from '../../components/Textarea';
import { activitiesApi, groupsApi, errMsg } from '../../api'; import { activitiesApi, groupsApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export'; import { formatDateRange, utc8Now, utc8Input } from '../../utils/export';
import type { ActivityItem, WorkGroup } from '../../types'; import type { ActivityItem, WorkGroup } from '../../types';
import { IconCalendar, IconPlus, IconTrash, IconEdit } from '../../components/Icons'; import { IconCalendar, IconPlus, IconTrash, IconEdit } from '../../components/Icons';
type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] }; type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] };
const emptyForm: Form = { title: '', content: '', start_time: '', end_time: '', groups: [] }; const emptyForm: Form = { title: '', content: '', start_time: '', end_time: '', groups: [] };
function toLocalInput(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function Activities() { export default function Activities() {
const [list, setList] = useState<ActivityItem[]>([]); const [list, setList] = useState<ActivityItem[]>([]);
const [groups, setGroups] = useState<WorkGroup[]>([]); const [groups, setGroups] = useState<WorkGroup[]>([]);
@@ -43,9 +38,9 @@ export default function Activities() {
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const openNew = () => { const openNew = () => {
const now = new Date(); const now = utc8Now();
const end = new Date(now.getTime() + 2 * 3600 * 1000); const end = new Date(now.getTime() + 2 * 3600 * 1000);
setForm({ ...emptyForm, start_time: toLocalInput(now), end_time: toLocalInput(end) }); setForm({ ...emptyForm, start_time: utc8Input(now), end_time: utc8Input(end) });
setEditId(null); setEditId(null);
setError(''); setError('');
setModalOpen(true); setModalOpen(true);
+4 -4
View File
@@ -1,7 +1,7 @@
import { ReactNode, useRef, useState } from 'react'; import { ReactNode, useRef, useState } from 'react';
import Button from '../../components/Button'; import Button from '../../components/Button';
import { exportApi, errMsg } from '../../api'; import { exportApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export'; import { exportToExcel, exportTableToImage, formatDateRange, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export';
import type { ExportResult } from '../../types'; import type { ExportResult } from '../../types';
import { import {
IconExport, IconExport,
@@ -226,12 +226,12 @@ export default function Export() {
}); });
sheets.push({ name: '活动时长总表(矩阵)', data }); sheets.push({ name: '活动时长总表(矩阵)', data });
} }
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.xlsx`); exportToExcel(sheets, `义工数据-全部导出-${utc8DateStamp()}.xlsx`);
}; };
const doImage = async () => { const doImage = async () => {
if (!previewRef.current) return; if (!previewRef.current) return;
await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.png`); await exportTableToImage(previewRef.current, `义工数据-全部导出-${utc8DateStamp()}.png`);
}; };
return ( return (
@@ -315,7 +315,7 @@ export default function Export() {
<div ref={previewRef} className="card bg-white p-3 md:p-6 space-y-8"> <div ref={previewRef} className="card bg-white p-3 md:p-6 space-y-8">
<div className="border-b border-gray-200 pb-4 text-center"> <div className="border-b border-gray-200 pb-4 text-center">
<div className="text-xl md:text-2xl font-semibold tracking-tight text-gray-800">宣传部义工时长记录系统 · 数据导出报告</div> <div className="text-xl md:text-2xl font-semibold tracking-tight text-gray-800">宣传部义工时长记录系统 · 数据导出报告</div>
<div className="muted mt-1.5">义工数据汇总导出 | 生成时间:{new Date().toLocaleString('zh-CN')}</div> <div className="muted mt-1.5">义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}</div>
</div> </div>
{preview.users?.length ? ( {preview.users?.length ? (
+3 -3
View File
@@ -3,7 +3,7 @@ import Button from '../../components/Button';
import Select from '../../components/Select'; import Select from '../../components/Select';
import Input from '../../components/Input'; import Input from '../../components/Input';
import { activitiesApi, hoursApi, errMsg } from '../../api'; import { activitiesApi, hoursApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export'; import { exportToExcel, exportTableToImage, formatDateRange, utc8DateStamp } from '../../utils/export';
import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types'; import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types';
import { IconClock, IconFileExcel, IconFileImage } from '../../components/Icons'; import { IconClock, IconFileExcel, IconFileImage } from '../../components/Icons';
@@ -96,13 +96,13 @@ export default function Hours() {
'备注': p.remark || '—', '备注': p.remark || '—',
})); }));
const meta = [{ '活动名称': title, '活动时间': formatDateRange(activityInfo?.start_time || '', activityInfo?.end_time || ''), '登记人数': summary.people, '总时长(小时)': summary.hours.toFixed(2) }]; const meta = [{ '活动名称': title, '活动时间': formatDateRange(activityInfo?.start_time || '', activityInfo?.end_time || ''), '登记人数': summary.people, '总时长(小时)': summary.hours.toFixed(2) }];
exportToExcel([{ name: '汇总', data: meta }, { name: '时长明细', data }], `义工时长-${title}-${new Date().toISOString().slice(0,10)}.xlsx`); exportToExcel([{ name: '汇总', data: meta }, { name: '时长明细', data }], `义工时长-${title}-${utc8DateStamp()}.xlsx`);
}; };
const doImageExport = async () => { const doImageExport = async () => {
if (!tableRef.current) return; if (!tableRef.current) return;
const title = activityInfo?.title || '活动'; const title = activityInfo?.title || '活动';
await exportTableToImage(tableRef.current, `义工时长-${title}-${new Date().toISOString().slice(0,10)}.png`); await exportTableToImage(tableRef.current, `义工时长-${title}-${utc8DateStamp()}.png`);
}; };
return ( return (
+32
View File
@@ -1,6 +1,30 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
const TZ_OFFSET_HOURS = 8;
function pad2(n: number): string {
return String(Math.floor(Math.max(0, n))).padStart(2, '0');
}
export function utc8Now(): Date {
const d = new Date();
const utcMs = d.getTime() + d.getTimezoneOffset() * 60000;
return new Date(utcMs + TZ_OFFSET_HOURS * 3600000);
}
export function utc8Input(d: Date = utc8Now()): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
export function utc8DateStamp(d: Date = utc8Now()): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function utc8DateTimeStamp(d: Date = utc8Now()): string {
return `${utc8DateStamp(d)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
function downloadBlob(blob: Blob, filename: string) { function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -42,3 +66,11 @@ export function formatDateRange(s: string, e: string) {
const b = e?.replace('T', ' ')?.slice(0, 16); const b = e?.replace('T', ' ')?.slice(0, 16);
return `${a || ''} ~ ${b || ''}`; return `${a || ''} ~ ${b || ''}`;
} }
export function formatCnDateTime(s?: string | null, withSeconds = false): string {
if (!s) return '';
const str = String(s).replace('T', ' ');
const max = withSeconds ? 19 : 16;
return str.slice(0, max);
}
+2
View File
@@ -8,7 +8,9 @@ services:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./server/data:/app/server/data - ./server/data:/app/server/data
- /etc/localtime:/etc/localtime:ro
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- TZ=Asia/Shanghai
- JWT_SECRET=${JWT_SECRET:-volunteer-manage-secret-key-2026} - JWT_SECRET=${JWT_SECRET:-volunteer-manage-secret-key-2026}
+3 -3
View File
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS activities (
content TEXT, content TEXT,
start_time TEXT NOT NULL, start_time TEXT NOT NULL,
end_time TEXT NOT NULL, end_time TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours'))
); );
CREATE TABLE IF NOT EXISTS activity_groups ( CREATE TABLE IF NOT EXISTS activity_groups (
@@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS hours (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
hours REAL NOT NULL DEFAULT 0, hours REAL NOT NULL DEFAULT 0,
remark TEXT, remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(activity_id, user_id) UNIQUE(activity_id, user_id)
@@ -54,5 +54,5 @@ CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours'))
); );
+2
View File
@@ -10,6 +10,8 @@ import activitiesRoutes from './routes/activities';
import hoursRoutes from './routes/hours'; import hoursRoutes from './routes/hours';
import exportRoutes from './routes/export'; import exportRoutes from './routes/export';
process.env.TZ = 'Asia/Shanghai';
async function bootstrap() { async function bootstrap() {
await initDb(); await initDb();
const app = express(); const app = express();