diff --git a/Dockerfile b/Dockerfile index f38f18f..79f851f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,10 @@ FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production 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* ./ RUN npm install --omit=dev --no-audit --no-fund diff --git a/client/src/pages/admin/Activities.tsx b/client/src/pages/admin/Activities.tsx index 6d66d61..cf044a5 100644 --- a/client/src/pages/admin/Activities.tsx +++ b/client/src/pages/admin/Activities.tsx @@ -6,18 +6,13 @@ import Input from '../../components/Input'; import Select from '../../components/Select'; import Textarea from '../../components/Textarea'; 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 { IconCalendar, IconPlus, IconTrash, IconEdit } from '../../components/Icons'; type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] }; 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() { const [list, setList] = useState([]); const [groups, setGroups] = useState([]); @@ -43,9 +38,9 @@ export default function Activities() { useEffect(() => { load(); }, []); const openNew = () => { - const now = new Date(); + const now = utc8Now(); 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); setError(''); setModalOpen(true); diff --git a/client/src/pages/admin/Export.tsx b/client/src/pages/admin/Export.tsx index c207779..19b767e 100644 --- a/client/src/pages/admin/Export.tsx +++ b/client/src/pages/admin/Export.tsx @@ -1,7 +1,7 @@ import { ReactNode, useRef, useState } from 'react'; import Button from '../../components/Button'; 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 { IconExport, @@ -226,12 +226,12 @@ export default function Export() { }); sheets.push({ name: '活动时长总表(矩阵)', data }); } - exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.xlsx`); + exportToExcel(sheets, `义工数据-全部导出-${utc8DateStamp()}.xlsx`); }; const doImage = async () => { if (!previewRef.current) return; - await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.png`); + await exportTableToImage(previewRef.current, `义工数据-全部导出-${utc8DateStamp()}.png`); }; return ( @@ -315,7 +315,7 @@ export default function Export() {
宣传部义工时长记录系统 · 数据导出报告
-
义工数据汇总导出 | 生成时间:{new Date().toLocaleString('zh-CN')}
+
义工数据汇总导出 | 生成时间:{utc8DateTimeStamp()}
{preview.users?.length ? ( diff --git a/client/src/pages/admin/Hours.tsx b/client/src/pages/admin/Hours.tsx index 84b5142..168a5f9 100644 --- a/client/src/pages/admin/Hours.tsx +++ b/client/src/pages/admin/Hours.tsx @@ -3,7 +3,7 @@ import Button from '../../components/Button'; import Select from '../../components/Select'; import Input from '../../components/Input'; 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 { IconClock, IconFileExcel, IconFileImage } from '../../components/Icons'; @@ -96,13 +96,13 @@ export default function Hours() { '备注': p.remark || '—', })); 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 () => { if (!tableRef.current) return; const title = activityInfo?.title || '活动'; - await exportTableToImage(tableRef.current, `义工时长-${title}-${new Date().toISOString().slice(0,10)}.png`); + await exportTableToImage(tableRef.current, `义工时长-${title}-${utc8DateStamp()}.png`); }; return ( diff --git a/client/src/utils/export.ts b/client/src/utils/export.ts index 8818a9b..4764d90 100644 --- a/client/src/utils/export.ts +++ b/client/src/utils/export.ts @@ -1,6 +1,30 @@ import * as XLSX from 'xlsx'; 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) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -42,3 +66,11 @@ export function formatDateRange(s: string, e: string) { const b = e?.replace('T', ' ')?.slice(0, 16); 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); +} + diff --git a/docker-compose.yml b/docker-compose.yml index 9b92127..0c9bca0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,9 @@ services: - "3000:3000" volumes: - ./server/data:/app/server/data + - /etc/localtime:/etc/localtime:ro environment: - NODE_ENV=production - PORT=3000 + - TZ=Asia/Shanghai - JWT_SECRET=${JWT_SECRET:-volunteer-manage-secret-key-2026} diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index dc6c559..433a71f 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS activities ( content TEXT, start_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 ( @@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS hours ( user_id INTEGER NOT NULL, hours REAL NOT NULL DEFAULT 0, 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 (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(activity_id, user_id) @@ -54,5 +54,5 @@ CREATE TABLE IF NOT EXISTS admins ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')) ); diff --git a/server/src/index.ts b/server/src/index.ts index e18fea7..37c89ca 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,8 @@ import activitiesRoutes from './routes/activities'; import hoursRoutes from './routes/hours'; import exportRoutes from './routes/export'; +process.env.TZ = 'Asia/Shanghai'; + async function bootstrap() { await initDb(); const app = express();