- 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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user