This commit is contained in:
@@ -8,6 +8,7 @@ import Hours from './pages/admin/Hours';
|
||||
import Users from './pages/admin/Users';
|
||||
import Export from './pages/admin/Export';
|
||||
import FourHourExport from './pages/admin/FourHourExport';
|
||||
import Backups from './pages/admin/Backups';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -23,6 +24,7 @@ export default function App() {
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="export" element={<Export />} />
|
||||
<Route path="four-hour-export" element={<FourHourExport />} />
|
||||
<Route path="backups" element={<Backups />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/u" replace />} />
|
||||
</Routes>
|
||||
|
||||
+32
-3
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import type {
|
||||
WorkGroup, UserItem, UserDetail, ActivityItem, Participant,
|
||||
HoursActivityResult, ExportResult, FourHourPreviewResult, FourHourPlanItem,
|
||||
FourHourExportHistoryItem, BackupConfig, BackupRuntime, BackupItem,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
@@ -102,9 +103,37 @@ export const exportApi = {
|
||||
}) => api.post<ExportResult>('/export', opts).then(r => r.data),
|
||||
fourHourPreview: () =>
|
||||
api.post<FourHourPreviewResult>('/export/four-hour/preview').then(r => r.data),
|
||||
fourHourConfirm: (plan: FourHourPlanItem[]) =>
|
||||
api.post<{ ok: boolean; updated: number; skipped: number; total_processed: number }>(
|
||||
fourHourConfirm: (plan: FourHourPlanItem[], extra?: { eligible_count?: number; eligible_hours_total?: number; note?: string }) =>
|
||||
api.post<{ ok: boolean; updated: number; skipped: number; total_processed: number; export_id?: number; total_applied?: number }>(
|
||||
'/export/four-hour/confirm',
|
||||
{ plan }
|
||||
{ plan, ...(extra || {}) }
|
||||
).then(r => r.data),
|
||||
fourHourHistory: () =>
|
||||
api.get<{ ok: boolean; list: FourHourExportHistoryItem[] }>('/export/four-hour/history').then(r => r.data),
|
||||
fourHourVoid: (export_id: number) =>
|
||||
api.post<{ ok: boolean; export_id: number; rolled: number; skipped: number; rolled_hours: number }>(
|
||||
'/export/four-hour/void',
|
||||
{ export_id }
|
||||
).then(r => r.data),
|
||||
backupRuntime: () =>
|
||||
api.get<BackupRuntime>('/export/backups/runtime').then(r => r.data),
|
||||
backupList: () =>
|
||||
api.get<{ ok: boolean; list: BackupItem[] }>('/export/backups/list').then(r => r.data),
|
||||
backupCreate: () =>
|
||||
api.post<{ ok: boolean; item: BackupItem }>('/export/backups/create').then(r => r.data),
|
||||
backupConfig: () =>
|
||||
api.get<{ ok: boolean; config: BackupConfig }>('/export/backups/config').then(r => r.data),
|
||||
backupSaveConfig: (cfg: Partial<BackupConfig>) =>
|
||||
api.put<{ ok: boolean; config: BackupConfig; runtime?: BackupRuntime }>('/export/backups/config', cfg).then(r => r.data),
|
||||
backupDelete: (filename: string) =>
|
||||
api.post<{ ok: boolean; deleted: boolean; list: BackupItem[] }>('/export/backups/delete', { filename }).then(r => r.data),
|
||||
backupDownloadUrl: (filename: string) => {
|
||||
const token = localStorage.getItem('volunteer_admin_token') || '';
|
||||
return `/api/export/backups/download/${encodeURIComponent(filename)}?auth=${encodeURIComponent(token)}`;
|
||||
},
|
||||
backupRestore: (filename: string) =>
|
||||
api.post<{ ok: boolean; snapshot: BackupItem; will_restart: boolean }>(
|
||||
'/export/backups/restore',
|
||||
{ filename, confirm: 'I_AGREE_RESTORE_FROM_BACKUP_WILL_RESTART' }
|
||||
).then(r => r.data),
|
||||
};
|
||||
|
||||
@@ -143,3 +143,57 @@ export const IconCheck: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<path d="M4 12.5l4.5 4.5L20 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconUndo: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M9 14L4 9l5-5" />
|
||||
<path d="M4 9h10a5 5 0 0 1 0 10h-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconHistory: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v5h5" />
|
||||
<path d="M12 8v5l3 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBackup: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M5 4h11l3 3v13H5z" />
|
||||
<path d="M16 4v3h3" />
|
||||
<path d="M12 10v6" />
|
||||
<path d="M9 13h6" />
|
||||
<path d="M8 19v2M16 19v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconDownload: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M12 3v10M7 10l5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconRestore: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M15 14l5-5-5-5" />
|
||||
<path d="M20 9H9a5 5 0 0 0 0 10h2" />
|
||||
<path d="M9 19h11" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPlay: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M7 5l12 7-12 7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconSave: React.FC<IconProps> = ({ size, ...rest }) => (
|
||||
<svg {...base(size)} {...rest}>
|
||||
<path d="M5 4h10l4 4v12H5z" />
|
||||
<path d="M15 4v6H8v4h7zM8 14" />
|
||||
<path d="M8 13h8M8 17h8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import Input from '../../components/Input';
|
||||
import { exportApi, errMsg } from '../../api';
|
||||
import type { BackupConfig, BackupItem, BackupRuntime } from '../../types';
|
||||
import {
|
||||
IconBackup,
|
||||
IconCheck,
|
||||
IconDownload,
|
||||
IconRestore,
|
||||
IconTrash,
|
||||
IconPlay,
|
||||
IconSave,
|
||||
} from '../../components/Icons';
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${Number(v).toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: BackupConfig['mode']; label: string; hint: string }> = [
|
||||
{ value: 'manual', label: '仅手动(不自动)', hint: '关闭定时调度,仅通过页面上「立即备份」按钮触发。' },
|
||||
{ value: 'hourly', label: '每小时整点', hint: '每小时 00 分 01 秒触发。' },
|
||||
{ value: 'every6h', label: '每 6 小时', hint: '00:00 / 06:00 / 12:00 / 18:00 触发。' },
|
||||
{ value: 'every12h', label: '每 12 小时', hint: '00:00 / 12:00 触发。' },
|
||||
{ value: 'daily', label: '每日指定时间(推荐)', hint: '每天在下方指定的 UTC+8 时间触发。' },
|
||||
];
|
||||
|
||||
export default function Backups() {
|
||||
const [rt, setRt] = useState<BackupRuntime | null>(null);
|
||||
const [list, setList] = useState<BackupItem[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [cfgForm, setCfgForm] = useState<BackupConfig | null>(null);
|
||||
const [saveCfgBusy, setSaveCfgBusy] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const refreshTimer = useRef<number | null>(null);
|
||||
|
||||
const loadAll = (showNotice = false) => {
|
||||
setBusy(true);
|
||||
(async () => {
|
||||
try {
|
||||
const [rtR, listR] = await Promise.all([exportApi.backupRuntime(), exportApi.backupList()]);
|
||||
setRt(rtR);
|
||||
setCfgForm({ ...rtR.config });
|
||||
setList(Array.isArray(listR?.list) ? listR.list : []);
|
||||
if (showNotice) setNotice(new Date().toLocaleString('zh-CN') + ' 已刷新');
|
||||
setTimeout(() => setNotice(null), 2200);
|
||||
} catch (e) {
|
||||
setErrorMsg(errMsg(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
refreshTimer.current = window.setInterval(() => loadAll(false), 30 * 1000);
|
||||
return () => {
|
||||
if (refreshTimer.current) window.clearInterval(refreshTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const doCreate = () => {
|
||||
setCreating(true);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.backupCreate();
|
||||
if (r?.ok) {
|
||||
setNotice(`已创建手动备份:${r.item.filename}(${fmtSize(r.item.size_bytes)})`);
|
||||
loadAll(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMsg(errMsg(e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const doSaveCfg = () => {
|
||||
if (!cfgForm) return;
|
||||
setSaveCfgBusy(true);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.backupSaveConfig(cfgForm);
|
||||
if (r?.ok) {
|
||||
setCfgForm({ ...r.config });
|
||||
if (r.runtime) setRt({ ...r.runtime, config: r.config });
|
||||
setNotice('调度配置已保存,下次运行时生效');
|
||||
setTimeout(() => setNotice(null), 2400);
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMsg(errMsg(e));
|
||||
} finally {
|
||||
setSaveCfgBusy(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const doDelete = (it: BackupItem) => {
|
||||
const need = it.kind === 'restoresnap'
|
||||
? `删除还原前自动快照 ${it.filename}`
|
||||
: `删除备份 ${it.filename}`;
|
||||
const input = window.prompt(
|
||||
`即将${need}\n文件大小:${fmtSize(it.size_bytes)}\n创建时间:${it.created_at}\n\n删除不可恢复,请谨慎操作。\n\n请在输入框内输入:"我确认删除 ${it.filename}"`,
|
||||
''
|
||||
);
|
||||
if (input === null) return;
|
||||
if (input.trim() !== `我确认删除 ${it.filename}`) {
|
||||
alert('确认文本不匹配,已取消');
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.backupDelete(it.filename);
|
||||
setList(Array.isArray(r?.list) ? r.list : []);
|
||||
setNotice(it.kind === 'restoresnap' ? '已删除快照' : '已删除备份');
|
||||
setTimeout(() => setNotice(null), 1800);
|
||||
} catch (e) {
|
||||
setErrorMsg(errMsg(e));
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const doRestore = (it: BackupItem) => {
|
||||
if (it.kind !== 'backup') {
|
||||
alert('仅可从普通备份(restoresnap 为还原前的应急快照,不建议作为还原源)作为还原源。请在列表中选择 kind=backup 的文件。');
|
||||
return;
|
||||
}
|
||||
const m1 =
|
||||
`即将从备份还原数据库:\n\n` +
|
||||
` · 备份文件:${it.filename}\n` +
|
||||
` · 备份时间:${it.created_at}\n` +
|
||||
` · 文件大小:${fmtSize(it.size_bytes)}\n\n` +
|
||||
`还原后将覆盖当前所有数据(部员/活动/时长记录/四小时核销记录等),\n` +
|
||||
`系统会先对当前数据库自动拍一张「还原前快照(restoresnap)」作为应急回滚点,\n` +
|
||||
`然后覆盖数据库并自动重启后端进程(Docker/守护进程会拉起来)。\n\n` +
|
||||
`请在下方输入框输入:"我确认从备份 ${it.filename} 还原并重启服务"`;
|
||||
const i1 = window.prompt(m1, '');
|
||||
if (i1 === null) return;
|
||||
const expected = `我确认从备份 ${it.filename} 还原并重启服务`;
|
||||
if (i1.trim() !== expected) { alert('确认文本不匹配,已取消'); return; }
|
||||
const i2 = window.prompt(
|
||||
`最后一道确认:请再次输入 "YES, RESTORE NOW"(全大写,不含引号)以继续。`,
|
||||
''
|
||||
);
|
||||
if (i2 === null) return;
|
||||
if (i2.trim() !== 'YES, RESTORE NOW') { alert('确认文本不匹配,已取消'); return; }
|
||||
(async () => {
|
||||
try {
|
||||
setRestarting(true);
|
||||
const r = await exportApi.backupRestore(it.filename);
|
||||
if (r?.ok) {
|
||||
alert(
|
||||
`还原已受理,后端将在 1 秒内重启。\n\n` +
|
||||
`还原前自动快照:${r.snapshot.filename}\n` +
|
||||
`快照大小:${fmtSize(r.snapshot.size_bytes)}\n` +
|
||||
`快照时间:${r.snapshot.created_at}\n\n` +
|
||||
`若重启后出现异常,请使用该快照文件再次还原或联系管理员。\n\n` +
|
||||
`将在 5 秒后自动刷新当前页面。`
|
||||
);
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
setRestarting(false);
|
||||
setErrorMsg(errMsg(e));
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const cfg = cfgForm || rt?.config;
|
||||
const stats = useMemo(() => {
|
||||
const backups = list.filter(i => i.kind === 'backup');
|
||||
const snaps = list.filter(i => i.kind === 'restoresnap');
|
||||
const totalBytes = list.reduce((s, i) => s + Number(i.size_bytes || 0), 0);
|
||||
const newest = list[0] || null;
|
||||
return { backups, snaps, totalBytes, newest };
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="mb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 p-3 bg-primary-50 text-primary-600 rounded-lg shadow-sm">
|
||||
<IconBackup size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="section-title">数据备份 / 还原</h1>
|
||||
<p className="muted mt-1 leading-relaxed">
|
||||
管理 SQLite 数据库文件(volunteer.db)的备份、调度、下载与还原。
|
||||
还原属于高风险操作:必须双重文本确认;还原前会先自动拍一张「restoresnap」快照用于应急回滚。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{(notice || errorMsg) ? (
|
||||
<div className={`card p-3 md:p-4 text-sm leading-relaxed border ${errorMsg ? 'bg-rose-50 border-rose-200 text-rose-800' : 'bg-emerald-50 border-emerald-200 text-emerald-800'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 pt-0.5">{errorMsg ? '⚠' : '✓'}</div>
|
||||
<div className="flex-1 whitespace-pre-line">{errorMsg || notice}</div>
|
||||
<button className="text-xs text-gray-500 hover:text-gray-700 shrink-0" onClick={() => { setNotice(null); setErrorMsg(null); }}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{restarting ? (
|
||||
<div className="card p-5 md:p-8 text-center bg-amber-50 border border-amber-200 animate-pulse">
|
||||
<div className="text-lg md:text-xl font-semibold text-amber-800">后端正在重启以完成还原…请稍候,页面将自动刷新</div>
|
||||
<div className="muted mt-2">若 30 秒后仍无法访问,请手动刷新页面或检查容器/守护进程。</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid grid-cols-1 xl:grid-cols-3 gap-5">
|
||||
<div className="xl:col-span-2 card p-4 md:p-5">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-gray-800 flex items-center gap-2">
|
||||
<IconBackup size={20} className="text-primary-600" />
|
||||
备份调度配置
|
||||
</h3>
|
||||
<p className="muted text-sm mt-1">所有时间均按服务器 UTC+8(Asia/Shanghai)计算,与系统其他时区策略保持一致。</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
<Button variant="secondary" onClick={() => loadAll(true)} disabled={busy}>
|
||||
{busy ? '刷新中...' : <span className="inline-flex items-center gap-2">刷新状态</span>}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={doCreate} disabled={creating || restarting}>
|
||||
<span className="inline-flex items-center gap-2"><IconPlay size={17} />{creating ? '备份中...' : '立即备份'}</span>
|
||||
</Button>
|
||||
<Button onClick={doSaveCfg} disabled={!cfg || saveCfgBusy || restarting}>
|
||||
<span className="inline-flex items-center gap-2"><IconSave size={17} />{saveCfgBusy ? '保存中...' : '保存配置'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cfg ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold mb-2 text-gray-700">调度模式</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{MODE_OPTIONS.map(m => {
|
||||
const active = cfg.mode === m.value;
|
||||
return (
|
||||
<label
|
||||
key={m.value}
|
||||
className={`group relative block cursor-pointer rounded-lg border transition-all ${active ? 'border-primary-500 ring-1 ring-primary-500/20 bg-primary-50/60' : 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="sr-only"
|
||||
name="backup-mode"
|
||||
checked={active}
|
||||
onChange={() => setCfgForm((c) => c ? { ...c, mode: m.value } : c)}
|
||||
/>
|
||||
<div className="p-3.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`text-sm font-semibold ${active ? 'text-primary-700' : 'text-gray-800'}`}>{m.label}</div>
|
||||
<div className={`w-5 h-5 rounded-full border-2 grid place-items-center ${active ? 'border-primary-500 bg-primary-500' : 'border-gray-300 bg-white'}`}>
|
||||
{active ? <IconCheck size={12} className="text-white" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 leading-relaxed">{m.hint}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-700">
|
||||
每日触发时间(HH:mm,UTC+8)
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={cfg.daily_time || '02:00'}
|
||||
onChange={(e) => setCfgForm(c => c ? { ...c, daily_time: e.target.value } : c)}
|
||||
disabled={cfg.mode === 'manual'}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-500 leading-relaxed">
|
||||
仅「每日指定时间」模式生效;其余模式按自身规则自动运行。
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-700">自动保留份数(1 ~ 365)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step={1}
|
||||
value={String(cfg.keep ?? 30)}
|
||||
onChange={(e) => {
|
||||
const n = Math.min(365, Math.max(1, Math.floor(Number(e.target.value) || 1)));
|
||||
setCfgForm(c => c ? { ...c, keep: n } : c);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-500 leading-relaxed">
|
||||
超过保留份数的旧备份(含快照一起计入)会在每次备份成功后自动删除最老的文件。
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary-600"
|
||||
checked={!!cfg.enabled}
|
||||
onChange={(e) => setCfgForm(c => c ? { ...c, enabled: e.target.checked } : c)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">启用调度器(若关闭则仅允许手动备份)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-gray-100">
|
||||
<div className="p-3 rounded-lg bg-gray-50/70 border border-gray-100">
|
||||
<div className="text-xs text-gray-500">最近一次备份</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-gray-800">
|
||||
{rt?.last_run ? String(rt.last_run).slice(0, 19) : '暂无'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-gray-50/70 border border-gray-100">
|
||||
<div className="text-xs text-gray-500">下次计划运行</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-gray-800">
|
||||
{rt?.next_run ? String(rt.next_run).slice(0, 19) : (cfg?.mode === 'manual' || !cfg?.enabled ? '(调度已禁用)' : '—')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-gray-50/70 border border-gray-100">
|
||||
<div className="text-xs text-gray-500">数据库 / 备份目录</div>
|
||||
<div className="mt-0.5 text-xs font-mono text-gray-700 break-words">
|
||||
{rt?.db_path || '—'}
|
||||
<div className="mt-0.5 text-gray-500">{rt?.backups_dir || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 md:p-5">
|
||||
<h3 className="font-semibold text-lg text-gray-800 mb-4">存储空间概览</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-primary-50/60 border border-primary-100">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">备份文件</div>
|
||||
<div className="text-2xl font-bold text-primary-700 leading-tight">{stats.backups.length}</div>
|
||||
</div>
|
||||
<IconBackup size={28} className="text-primary-600/80 shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-50/60 border border-amber-100">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">还原前自动快照</div>
|
||||
<div className="text-2xl font-bold text-amber-700 leading-tight">{stats.snaps.length}</div>
|
||||
</div>
|
||||
<IconRestore size={28} className="text-amber-600/80 shrink-0" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">占用总空间</div>
|
||||
<div className="text-2xl font-bold text-gray-800 leading-tight">{fmtSize(stats.totalBytes)}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 shrink-0">
|
||||
保留上限 {cfg?.keep ?? 30} 份
|
||||
</div>
|
||||
</div>
|
||||
{stats.newest ? (
|
||||
<div className="p-3 rounded-lg border border-gray-200 bg-white">
|
||||
<div className="text-xs text-gray-500">最新文件</div>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-800 break-all font-mono">{stats.newest.filename}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-gray-500">
|
||||
<span>{stats.newest.created_at?.slice(0, 19)}</span>
|
||||
<span>{fmtSize(stats.newest.size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-4 md:p-5">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-gray-800">备份文件列表</h3>
|
||||
<p className="muted text-sm mt-0.5">共 {list.length} 个文件,每 30 秒自动刷新。下载链接通过 ?auth= 参数携带管理令牌。</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-right">
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded px-2 py-0.5 mr-2 bg-primary-50 border border-primary-100 text-primary-700">backup = 手动/定时备份</span>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="inline-flex items-center gap-1.5 rounded px-2 py-0.5 bg-amber-50 border border-amber-100 text-amber-700">restoresnap = 还原前自动快照</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<div className="py-14 text-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-lg bg-gray-50/50">
|
||||
暂无备份,请点击上方「立即备份」创建第一份
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg bg-white">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-200">
|
||||
{['类型', '文件名', '创建时间', '大小', '操作'].map(h => (
|
||||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((it, i) => (
|
||||
<tr key={it.filename} className={`border-b border-gray-200 ${i % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{it.kind === 'backup' ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-primary-700 bg-primary-50 border border-primary-100 rounded px-2 py-0.5">
|
||||
<IconBackup size={13} /> 备份
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-100 rounded px-2 py-0.5">
|
||||
<IconRestore size={13} /> 快照
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<code className="text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded px-2 py-1 inline-block break-all">{it.filename}</code>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-gray-600">{String(it.created_at || '').slice(0, 19)}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap font-medium text-gray-700">{fmtSize(it.size_bytes)}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={exportApi.backupDownloadUrl(it.filename)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
title="下载该备份文件"
|
||||
>
|
||||
<IconDownload size={14} /> 下载
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={it.kind === 'backup' ? 'secondary' : 'ghost'}
|
||||
onClick={() => doRestore(it)}
|
||||
disabled={restarting || it.kind !== 'backup'}
|
||||
className="!px-2.5 !py-1 !text-xs"
|
||||
title={it.kind === 'backup' ? '用这份备份覆盖当前数据库(高风险,会自动重启服务)' : '快照仅作为应急回滚点,请选择 backup 类型进行还原'}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5"><IconRestore size={14} /> 还原</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => doDelete(it)}
|
||||
disabled={restarting}
|
||||
className="!px-2.5 !py-1 !text-xs"
|
||||
title="删除此文件(不可恢复)"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5"><IconTrash size={14} /> 删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import { exportApi, errMsg } from '../../api';
|
||||
import { exportToExcel, exportTableToImage, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export';
|
||||
import type { FourHourPreviewResult } from '../../types';
|
||||
import type { FourHourExportHistoryItem, FourHourPreviewResult } from '../../types';
|
||||
import {
|
||||
IconFourHour,
|
||||
IconFileExcel,
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
IconCheck,
|
||||
IconUsers,
|
||||
IconClock,
|
||||
IconUndo,
|
||||
IconHistory,
|
||||
} from '../../components/Icons';
|
||||
|
||||
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
|
||||
@@ -69,8 +71,29 @@ export default function FourHourExport() {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [preview, setPreview] = useState<FourHourPreviewResult | null>(null);
|
||||
const [confirmedMsg, setConfirmedMsg] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<FourHourExportHistoryItem[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [voidingId, setVoidingId] = useState<number | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadHistory = () => {
|
||||
setHistoryLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.fourHourHistory();
|
||||
setHistory(Array.isArray(r?.list) ? r.list : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
const loadPreview = () => {
|
||||
setLoading(true);
|
||||
setConfirmedMsg(null);
|
||||
@@ -101,16 +124,22 @@ export default function FourHourExport() {
|
||||
setConfirming(true);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.fourHourConfirm(preview.confirm_plan);
|
||||
const r = await exportApi.fourHourConfirm(preview.confirm_plan, {
|
||||
eligible_count: preview.summary.eligible_count,
|
||||
eligible_hours_total: preview.summary.eligible_hours_total,
|
||||
});
|
||||
if (r?.ok) {
|
||||
const msg2 =
|
||||
`确认成功!\n\n` +
|
||||
`批次编号:#${r.export_id || '—'}\n` +
|
||||
`计划处理明细条数:${r.total_processed}\n` +
|
||||
`实际更新成功:${r.updated}\n` +
|
||||
`跳过(并发冲突/已结算):${r.skipped}\n\n` +
|
||||
`跳过(并发冲突/已结算):${r.skipped}\n` +
|
||||
`本次实际核销合计:${Number(r.total_applied || 0).toFixed(1)} h\n\n` +
|
||||
`请点击下方「重新生成预览」以查看核销后的最新数据。`;
|
||||
setConfirmedMsg(msg2);
|
||||
alert(msg2);
|
||||
loadHistory();
|
||||
} else {
|
||||
alert('确认失败:服务器未返回成功状态');
|
||||
}
|
||||
@@ -122,6 +151,44 @@ export default function FourHourExport() {
|
||||
})();
|
||||
};
|
||||
|
||||
const doVoid = (it: FourHourExportHistoryItem) => {
|
||||
const prefix =
|
||||
`即将撤回批次 #${it.id}(${String(it.created_at || '').slice(0, 19)})\n` +
|
||||
`该批次:${it.eligible_count} 人 / ${Number(it.eligible_hours_total || 0).toFixed(1)} h / ${it.items_updated} 条明细\n` +
|
||||
`操作人:${it.admin_username || '—'}\n\n` +
|
||||
`撤回后将:\n` +
|
||||
` 1. 把该批次核销掉的小时数逐条加回「未核销余额」;\n` +
|
||||
` 2. 标记该批次状态为 voided(不可再次撤回)。\n\n` +
|
||||
`请在输入框内输入 "撤回批次 #${it.id}"(不含引号)以继续。`;
|
||||
const input = window.prompt(prefix, '');
|
||||
if (input === null) return;
|
||||
if (input.trim() !== `撤回批次 #${it.id}`) {
|
||||
alert('确认文本不匹配,已取消');
|
||||
return;
|
||||
}
|
||||
setVoidingId(it.id);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.fourHourVoid(it.id);
|
||||
if (r?.ok) {
|
||||
alert(
|
||||
`撤回成功!\n\n` +
|
||||
`批次:#${r.export_id}\n` +
|
||||
`成功回退明细:${r.rolled}\n` +
|
||||
`跳过:${r.skipped}\n` +
|
||||
`累计回退小时数:${Number(r.rolled_hours || 0).toFixed(1)} h`
|
||||
);
|
||||
loadHistory();
|
||||
if (preview) loadPreview();
|
||||
}
|
||||
} catch (e) {
|
||||
alert(errMsg(e));
|
||||
} finally {
|
||||
setVoidingId(null);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const eligibleCount = preview?.summary?.eligible_count || 0;
|
||||
const eligibleHours = Number(preview?.summary?.eligible_hours_total || 0).toFixed(1);
|
||||
|
||||
@@ -290,6 +357,93 @@ export default function FourHourExport() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="card p-4 md:p-5">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap mb-4">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="shrink-0 p-2.5 bg-gray-50 text-primary-600 rounded-lg border border-gray-200 shadow-sm">
|
||||
<IconHistory size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-gray-800 leading-tight">核销批次历史</h3>
|
||||
<p className="muted text-sm mt-0.5">
|
||||
最近 {Math.min(200, history.length || 0)} 条 · 状态为「active」的批次可一键撤回(把小时数加回未核销余额)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={loadHistory} disabled={historyLoading}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IconHistory size={16} />
|
||||
{historyLoading ? '刷新中...' : '刷新历史'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-lg bg-gray-50/50">
|
||||
暂无核销批次记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg bg-white">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-200">
|
||||
{['批次', '时间', '状态', '操作人', '达标部员', '核销合计', '明细条数', '撤回时间', '操作'].map((h) => (
|
||||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((it, i) => {
|
||||
const isVoided = it.status === 'voided';
|
||||
return (
|
||||
<tr
|
||||
key={it.id}
|
||||
className={`border-b border-gray-200 ${i % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap font-mono text-xs text-gray-600">#{it.id}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{String(it.created_at || '').slice(0, 19)}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{isVoided ? (
|
||||
<span className="text-xs font-medium text-gray-600 bg-gray-100 border border-gray-200 rounded px-2 py-0.5">
|
||||
已撤回
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-2 py-0.5">
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{it.admin_username || '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-center font-medium">{it.eligible_count}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-center font-semibold text-primary-700">
|
||||
{Number(it.eligible_hours_total || 0).toFixed(1)} h
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-center">{it.items_updated}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
|
||||
{isVoided ? String(it.voided_at || '').slice(0, 19) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isVoided ? 'ghost' : 'danger'}
|
||||
disabled={!it.can_void || voidingId === it.id}
|
||||
onClick={() => doVoid(it)}
|
||||
className="!px-3"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<IconUndo size={15} />
|
||||
{voidingId === it.id ? '撤回中...' : '撤回'}
|
||||
</span>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{preview ? (
|
||||
<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">
|
||||
|
||||
@@ -5,7 +5,7 @@ import Modal from '../../components/Modal';
|
||||
import Input from '../../components/Input';
|
||||
import Brand from '../../components/Brand';
|
||||
import { authApi, errMsg } from '../../api';
|
||||
import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu, IconFourHour } from '../../components/Icons';
|
||||
import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu, IconFourHour, IconBackup } from '../../components/Icons';
|
||||
|
||||
type MenuItem = { path: string; label: string; icon: ReactNode };
|
||||
|
||||
@@ -15,6 +15,7 @@ const menu: MenuItem[] = [
|
||||
{ path: '/admin/users', label: '用户管理', icon: <IconUsers size={20} /> },
|
||||
{ path: '/admin/four-hour-export', label: '四小时导出', icon: <IconFourHour size={20} /> },
|
||||
{ path: '/admin/export', label: '数据导出', icon: <IconExport size={20} /> },
|
||||
{ path: '/admin/backups', label: '备份管理', icon: <IconBackup size={20} /> },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
|
||||
@@ -107,6 +107,42 @@ export interface FourHourPlanItem {
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface FourHourExportHistoryItem {
|
||||
id: number;
|
||||
status: 'active' | 'voided' | string;
|
||||
admin_username: string | null;
|
||||
eligible_count: number;
|
||||
eligible_hours_total: number;
|
||||
items_updated: number;
|
||||
note: string | null;
|
||||
created_at: string | null;
|
||||
voided_at: string | null;
|
||||
can_void: boolean;
|
||||
}
|
||||
|
||||
export interface BackupItem {
|
||||
filename: string;
|
||||
created_at: string;
|
||||
size_bytes: number;
|
||||
kind: 'backup' | 'restoresnap';
|
||||
}
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean;
|
||||
mode: 'manual' | 'hourly' | 'every6h' | 'every12h' | 'daily';
|
||||
daily_time: string;
|
||||
keep: number;
|
||||
}
|
||||
|
||||
export interface BackupRuntime {
|
||||
ok: boolean;
|
||||
config: BackupConfig;
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
db_path?: string;
|
||||
backups_dir?: string;
|
||||
}
|
||||
|
||||
export interface FourHourPreviewResult {
|
||||
ok: boolean;
|
||||
generated_at: string;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const TZ_OFFSET_HOURS = 8;
|
||||
const BACKUP_FILENAME_RE = /^volunteer-backup-(\d{8})-(\d{6})-([A-Za-z0-9]{8})\.db$/;
|
||||
const SNAP_FILENAME_RE = /^volunteer-restoresnap-(\d{8})-(\d{6})-([A-Za-z0-9]{8})\.db$/;
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean;
|
||||
mode: 'manual' | 'hourly' | 'every6h' | 'every12h' | 'daily';
|
||||
daily_time: string;
|
||||
keep: number;
|
||||
}
|
||||
|
||||
export interface BackupItem {
|
||||
filename: string;
|
||||
created_at: string;
|
||||
size_bytes: number;
|
||||
kind: 'backup' | 'restoresnap';
|
||||
}
|
||||
|
||||
export interface BackupRuntime {
|
||||
config: BackupConfig;
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
}
|
||||
|
||||
function utc8Now(): Date {
|
||||
const d = new Date();
|
||||
const utcMs = d.getTime() + d.getTimezoneOffset() * 60000;
|
||||
return new Date(utcMs + TZ_OFFSET_HOURS * 3600000);
|
||||
}
|
||||
function pad2(n: number) { return String(Math.max(0, Math.floor(n))).padStart(2, '0'); }
|
||||
function fmtStamp(d: Date) {
|
||||
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
||||
}
|
||||
function fmtIso(d: Date) {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function getDataDir(): string {
|
||||
const p = path.join(__dirname, '..', '..', 'data');
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
return p;
|
||||
}
|
||||
export function getBackupsDir(): string {
|
||||
const p = path.join(getDataDir(), 'backups');
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
return p;
|
||||
}
|
||||
export function getDbPath(): string {
|
||||
return path.join(getDataDir(), 'volunteer.db');
|
||||
}
|
||||
function getConfigPath(): string {
|
||||
return path.join(getDataDir(), 'backup-config.json');
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: BackupConfig = {
|
||||
enabled: true,
|
||||
mode: 'daily',
|
||||
daily_time: '02:00',
|
||||
keep: 30,
|
||||
};
|
||||
|
||||
export function loadConfig(): BackupConfig {
|
||||
const fp = getConfigPath();
|
||||
try {
|
||||
if (!fs.existsSync(fp)) {
|
||||
fs.writeFileSync(fp, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf8');
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
||||
const cfg: BackupConfig = { ...DEFAULT_CONFIG, ...(raw || {}) };
|
||||
if (!['manual', 'hourly', 'every6h', 'every12h', 'daily'].includes(cfg.mode)) cfg.mode = 'daily';
|
||||
if (!/^\d{2}:\d{2}$/.test(cfg.daily_time)) cfg.daily_time = '02:00';
|
||||
if (!Number.isFinite(cfg.keep) || cfg.keep < 1) cfg.keep = 30;
|
||||
cfg.keep = Math.min(365, Math.max(1, Math.floor(cfg.keep)));
|
||||
cfg.enabled = !!cfg.enabled;
|
||||
return cfg;
|
||||
} catch (e) {
|
||||
console.error('[backup] loadConfig error, use defaults', e);
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
export function saveConfig(cfg: Partial<BackupConfig>): BackupConfig {
|
||||
const current = loadConfig();
|
||||
const next: BackupConfig = { ...current, ...cfg };
|
||||
if (!['manual', 'hourly', 'every6h', 'every12h', 'daily'].includes(next.mode)) next.mode = 'daily';
|
||||
if (!/^\d{2}:\d{2}$/.test(next.daily_time)) next.daily_time = '02:00';
|
||||
if (!Number.isFinite(next.keep) || next.keep < 1) next.keep = 30;
|
||||
next.keep = Math.min(365, Math.max(1, Math.floor(next.keep)));
|
||||
next.enabled = !!next.enabled;
|
||||
fs.writeFileSync(getConfigPath(), JSON.stringify(next, null, 2), 'utf8');
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseBackupDate(name: string): Date | null {
|
||||
const m = name.match(BACKUP_FILENAME_RE) || name.match(SNAP_FILENAME_RE);
|
||||
if (!m) return null;
|
||||
const [, ymd, hms] = m;
|
||||
const yy = Number(ymd.slice(0, 4));
|
||||
const mo = Number(ymd.slice(4, 6)) - 1;
|
||||
const dd = Number(ymd.slice(6, 8));
|
||||
const hh = Number(hms.slice(0, 2));
|
||||
const mi = Number(hms.slice(2, 4));
|
||||
const ss = Number(hms.slice(4, 6));
|
||||
return new Date(yy, mo, dd, hh, mi, ss);
|
||||
}
|
||||
|
||||
export function listBackups(): BackupItem[] {
|
||||
const dir = getBackupsDir();
|
||||
const names = fs.readdirSync(dir).filter((n) => BACKUP_FILENAME_RE.test(n) || SNAP_FILENAME_RE.test(n));
|
||||
const items: BackupItem[] = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const stat = fs.statSync(path.join(dir, name));
|
||||
const d = parseBackupDate(name);
|
||||
items.push({
|
||||
filename: name,
|
||||
created_at: d ? fmtIso(d) : '',
|
||||
size_bytes: Number(stat.size || 0),
|
||||
kind: SNAP_FILENAME_RE.test(name) ? 'restoresnap' : 'backup',
|
||||
});
|
||||
} catch {
|
||||
// ignore unreadable
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)) || b.filename.localeCompare(a.filename));
|
||||
return items;
|
||||
}
|
||||
|
||||
function safeFilename(name: string): boolean {
|
||||
return (BACKUP_FILENAME_RE.test(name) || SNAP_FILENAME_RE.test(name)) && !name.includes('/') && !name.includes('\\') && !name.includes('..');
|
||||
}
|
||||
|
||||
function cleanupByKeep(cfg: BackupConfig): BackupItem[] {
|
||||
const list = listBackups();
|
||||
const keep = Math.max(0, Math.floor(cfg.keep));
|
||||
if (list.length <= keep) return list;
|
||||
// Keep newest, delete oldest (sorted desc already)
|
||||
const toDelete = list.slice(keep);
|
||||
for (const it of toDelete) {
|
||||
try { fs.unlinkSync(path.join(getBackupsDir(), it.filename)); } catch (e) { console.error('[backup] cleanup error', e); }
|
||||
}
|
||||
return listBackups();
|
||||
}
|
||||
|
||||
export function createBackup(opts: { trigger?: 'manual' | 'scheduled'; forceSnapshot?: boolean } = {}): BackupItem {
|
||||
const trigger = opts.trigger || 'manual';
|
||||
const src = getDbPath();
|
||||
if (!fs.existsSync(src)) {
|
||||
throw new Error('源数据库文件不存在,无法备份');
|
||||
}
|
||||
const dir = getBackupsDir();
|
||||
const stamp = fmtStamp(utc8Now());
|
||||
const rand = crypto.randomBytes(4).toString('hex');
|
||||
const prefix = opts.forceSnapshot ? 'volunteer-restoresnap' : 'volunteer-backup';
|
||||
const filename = `${prefix}-${stamp}-${rand}.db`;
|
||||
const dest = path.join(dir, filename);
|
||||
// copy with integrity: read -> verify sha -> write
|
||||
const buf = fs.readFileSync(src);
|
||||
fs.writeFileSync(dest, buf);
|
||||
const verify = fs.readFileSync(dest);
|
||||
if (verify.length !== buf.length || crypto.createHash('sha256').update(buf).digest('hex') !== crypto.createHash('sha256').update(verify).digest('hex')) {
|
||||
try { fs.unlinkSync(dest); } catch { /* noop */ }
|
||||
throw new Error('备份文件校验失败,已自动删除损坏副本');
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
if (trigger === 'scheduled') {
|
||||
const rPath = path.join(getDataDir(), 'backup-runtime.json');
|
||||
const rt = fs.existsSync(rPath) ? JSON.parse(fs.readFileSync(rPath, 'utf8') || '{}') : {};
|
||||
rt.last_run = fmtIso(utc8Now());
|
||||
fs.writeFileSync(rPath, JSON.stringify(rt, null, 2), 'utf8');
|
||||
}
|
||||
cleanupByKeep(cfg);
|
||||
const stat = fs.statSync(dest);
|
||||
const d = parseBackupDate(filename)!;
|
||||
return {
|
||||
filename,
|
||||
created_at: fmtIso(d),
|
||||
size_bytes: Number(stat.size || 0),
|
||||
kind: opts.forceSnapshot ? 'restoresnap' : 'backup',
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteBackup(filename: string): { ok: boolean; deleted: boolean } {
|
||||
if (!safeFilename(filename)) throw new Error('备份文件名非法');
|
||||
const p = path.join(getBackupsDir(), filename);
|
||||
if (!fs.existsSync(p)) return { ok: true, deleted: false };
|
||||
fs.unlinkSync(p);
|
||||
return { ok: true, deleted: true };
|
||||
}
|
||||
|
||||
export function restoreBackup(filename: string): { ok: true; snapshot: BackupItem } {
|
||||
if (!safeFilename(filename)) throw new Error('备份文件名非法');
|
||||
const p = path.join(getBackupsDir(), filename);
|
||||
if (!fs.existsSync(p)) throw new Error('备份文件不存在');
|
||||
const src = getDbPath();
|
||||
// 1) 先对当前数据库拍一张 restoresnap,防止误操作
|
||||
const snap = createBackup({ trigger: 'manual', forceSnapshot: true });
|
||||
// 2) 覆盖 database file
|
||||
const buf = fs.readFileSync(p);
|
||||
fs.writeFileSync(src, buf);
|
||||
return { ok: true, snapshot: snap };
|
||||
}
|
||||
|
||||
export function getNextRunMs(cfg: BackupConfig): number | null {
|
||||
if (!cfg.enabled || cfg.mode === 'manual') return null;
|
||||
const now = utc8Now();
|
||||
let next: Date;
|
||||
if (cfg.mode === 'hourly') {
|
||||
next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 1);
|
||||
next = new Date(next.getTime() + 3600 * 1000);
|
||||
} else if (cfg.mode === 'every6h') {
|
||||
const base = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 1);
|
||||
const offsetHours = [0, 6, 12, 18];
|
||||
next = new Date(base);
|
||||
let found = false;
|
||||
for (const h of offsetHours) {
|
||||
const cand = new Date(base.getTime() + h * 3600 * 1000);
|
||||
if (cand.getTime() > now.getTime()) { next = cand; found = true; break; }
|
||||
}
|
||||
if (!found) next = new Date(base.getTime() + 24 * 3600 * 1000);
|
||||
} else if (cfg.mode === 'every12h') {
|
||||
const base = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 1);
|
||||
const offsetHours = [0, 12];
|
||||
let found = false;
|
||||
next = new Date(base);
|
||||
for (const h of offsetHours) {
|
||||
const cand = new Date(base.getTime() + h * 3600 * 1000);
|
||||
if (cand.getTime() > now.getTime()) { next = cand; found = true; break; }
|
||||
}
|
||||
if (!found) next = new Date(base.getTime() + 24 * 3600 * 1000);
|
||||
} else {
|
||||
// daily
|
||||
const [hhStr, mmStr] = (cfg.daily_time || '02:00').split(':');
|
||||
const hh = Math.min(23, Math.max(0, Number(hhStr) || 0));
|
||||
const mm = Math.min(59, Math.max(0, Number(mmStr) || 0));
|
||||
next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, 1);
|
||||
if (next.getTime() <= now.getTime()) next = new Date(next.getTime() + 24 * 3600 * 1000);
|
||||
}
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
export function getRuntime(): BackupRuntime {
|
||||
const cfg = loadConfig();
|
||||
const rPath = path.join(getDataDir(), 'backup-runtime.json');
|
||||
let lastRun: string | null = null;
|
||||
try {
|
||||
if (fs.existsSync(rPath)) {
|
||||
const rt = JSON.parse(fs.readFileSync(rPath, 'utf8') || '{}');
|
||||
lastRun = rt.last_run || null;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
let nextRun: string | null = null;
|
||||
const ms = getNextRunMs(cfg);
|
||||
if (ms !== null) {
|
||||
const d = new Date(utc8Now().getTime() + ms);
|
||||
nextRun = fmtIso(d);
|
||||
}
|
||||
return { config: cfg, last_run: lastRun, next_run: nextRun };
|
||||
}
|
||||
|
||||
let tickHandle: any = null;
|
||||
|
||||
export function startScheduler(onFire?: (item: BackupItem) => void) {
|
||||
if (tickHandle) return;
|
||||
const loop = async () => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const wait = getNextRunMs(cfg);
|
||||
const rPath = path.join(getDataDir(), 'backup-runtime.json');
|
||||
const rt = fs.existsSync(rPath) ? JSON.parse(fs.readFileSync(rPath, 'utf8') || '{}') : {};
|
||||
const ms = getNextRunMs(cfg);
|
||||
rt.next_run = ms === null ? null : fmtIso(new Date(utc8Now().getTime() + ms));
|
||||
fs.writeFileSync(rPath, JSON.stringify(rt, null, 2), 'utf8');
|
||||
if (wait === null || wait <= 0) return;
|
||||
tickHandle = setTimeout(() => {
|
||||
try {
|
||||
const c = loadConfig();
|
||||
if (c.enabled && c.mode !== 'manual') {
|
||||
const item = createBackup({ trigger: 'scheduled' });
|
||||
if (onFire) onFire(item);
|
||||
console.log('[backup] scheduled backup created', item.filename, item.size_bytes, 'bytes');
|
||||
}
|
||||
} catch (e) { console.error('[backup] scheduled backup failed', e); }
|
||||
// schedule next iteration
|
||||
tickHandle = null;
|
||||
loop();
|
||||
}, Math.min(wait, 2 ** 31 - 1));
|
||||
} catch (e) {
|
||||
console.error('[backup] scheduler loop error', e);
|
||||
tickHandle = null;
|
||||
setTimeout(loop, 30 * 1000);
|
||||
}
|
||||
};
|
||||
loop();
|
||||
}
|
||||
@@ -56,3 +56,26 @@ CREATE TABLE IF NOT EXISTS admins (
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS four_hour_exports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
admin_username TEXT,
|
||||
eligible_count INTEGER NOT NULL DEFAULT 0,
|
||||
eligible_hours_total REAL NOT NULL DEFAULT 0,
|
||||
items_updated INTEGER NOT NULL DEFAULT 0,
|
||||
plan_json TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
||||
voided_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS four_hour_export_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
export_id INTEGER NOT NULL,
|
||||
hour_id INTEGER NOT NULL,
|
||||
delta REAL NOT NULL,
|
||||
FOREIGN KEY (export_id) REFERENCES four_hour_exports(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fhe_items_export ON four_hour_export_items(export_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fhe_items_hour ON four_hour_export_items(hour_id);
|
||||
|
||||
@@ -9,6 +9,7 @@ import usersRoutes from './routes/users';
|
||||
import activitiesRoutes from './routes/activities';
|
||||
import hoursRoutes from './routes/hours';
|
||||
import exportRoutes from './routes/export';
|
||||
import { startScheduler } from './backup';
|
||||
|
||||
process.env.TZ = 'Asia/Shanghai';
|
||||
|
||||
@@ -36,6 +37,13 @@ async function bootstrap() {
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Volunteer system server running on http://0.0.0.0:${PORT}`);
|
||||
try {
|
||||
startScheduler((item) => {
|
||||
console.log(`[backup] Scheduled backup written: ${item.filename} (${item.size_bytes} B)`);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[backup] scheduler start failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+232
-3
@@ -1,6 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../db';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
getBackupsDir,
|
||||
getDbPath,
|
||||
getRuntime,
|
||||
listBackups,
|
||||
loadConfig,
|
||||
restoreBackup,
|
||||
saveConfig,
|
||||
} from '../backup';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -352,6 +365,9 @@ router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
|
||||
res.status(400).json({ error: '导出计划为空,请先生成预览' });
|
||||
return;
|
||||
}
|
||||
const eligibleCount = Number((req.body as any)?.eligible_count || 0) || 0;
|
||||
const eligibleHours = Number((req.body as any)?.eligible_hours_total || 0) || 0;
|
||||
const adminUsername = String(req.adminUsername || '').trim() || null;
|
||||
const db = getDb();
|
||||
try {
|
||||
db.execCompat('BEGIN');
|
||||
@@ -361,17 +377,40 @@ router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
|
||||
`);
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let totalApplied = 0;
|
||||
const appliedItems: Array<{ hour_id: number; delta: number }> = [];
|
||||
for (const p of plan) {
|
||||
const hid = Number(p.hour_id);
|
||||
const d = Number(p.delta || 0);
|
||||
if (!hid || d <= 0.0001) { skipped++; continue; }
|
||||
const r = updStmt.run(d, hid, d);
|
||||
if (r && r.changes > 0) updated++;
|
||||
else skipped++;
|
||||
if (r && r.changes > 0) {
|
||||
updated++;
|
||||
totalApplied += d;
|
||||
appliedItems.push({ hour_id: hid, delta: Number(d.toFixed(1)) });
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
const planJson = JSON.stringify(plan);
|
||||
const noteRaw = (req.body as any)?.note;
|
||||
const note = noteRaw ? String(noteRaw).slice(0, 500) : null;
|
||||
const insEx = db.prepareCompat(`
|
||||
INSERT INTO four_hour_exports (status, admin_username, eligible_count, eligible_hours_total, items_updated, plan_json, note)
|
||||
VALUES ('active', ?, ?, ?, ?, ?, ?)
|
||||
`).run(adminUsername, eligibleCount, eligibleHours, updated, planJson, note);
|
||||
const exportId = Number(insEx?.lastInsertRowid || 0);
|
||||
if (exportId > 0 && appliedItems.length > 0) {
|
||||
const insItem = db.prepareCompat(`
|
||||
INSERT INTO four_hour_export_items (export_id, hour_id, delta) VALUES (?, ?, ?)
|
||||
`);
|
||||
for (const it of appliedItems) {
|
||||
insItem.run(exportId, it.hour_id, it.delta);
|
||||
}
|
||||
}
|
||||
db.save();
|
||||
db.execCompat('COMMIT');
|
||||
res.json({ ok: true, updated, skipped, total_processed: plan.length });
|
||||
res.json({ ok: true, updated, skipped, total_processed: plan.length, export_id: exportId, total_applied: Number(totalApplied.toFixed(1)) });
|
||||
} catch (e: any) {
|
||||
try { db.execCompat('ROLLBACK'); } catch {}
|
||||
console.error('[4h-export] confirm error', e);
|
||||
@@ -379,4 +418,194 @@ router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/four-hour/history', authMiddleware, (_req: AuthRequest, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepareCompat(`
|
||||
SELECT id, status, admin_username, eligible_count, eligible_hours_total,
|
||||
items_updated, note, created_at, voided_at
|
||||
FROM four_hour_exports
|
||||
ORDER BY id DESC
|
||||
LIMIT 200
|
||||
`).all() || [];
|
||||
const list = (rows as any[]).map(r => ({
|
||||
id: Number(r.id),
|
||||
status: String(r.status || 'active'),
|
||||
admin_username: r.admin_username || null,
|
||||
eligible_count: Number(r.eligible_count || 0),
|
||||
eligible_hours_total: Number(Number(r.eligible_hours_total || 0).toFixed(1)),
|
||||
items_updated: Number(r.items_updated || 0),
|
||||
note: r.note || null,
|
||||
created_at: r.created_at || null,
|
||||
voided_at: r.voided_at || null,
|
||||
can_void: String(r.status || 'active') === 'active',
|
||||
}));
|
||||
res.json({ ok: true, list });
|
||||
});
|
||||
|
||||
router.post('/four-hour/void', authMiddleware, (req: AuthRequest, res) => {
|
||||
const exportId = Number((req.body as any)?.export_id || 0);
|
||||
if (!exportId) {
|
||||
res.status(400).json({ error: '缺少 export_id' });
|
||||
return;
|
||||
}
|
||||
const adminUsername = String(req.adminUsername || '').trim() || null;
|
||||
const db = getDb();
|
||||
try {
|
||||
const head = db.prepareCompat(`SELECT * FROM four_hour_exports WHERE id = ?`).get(exportId);
|
||||
if (!head) { res.status(404).json({ error: '核销批次不存在' }); return; }
|
||||
if (String(head.status || '') !== 'active') {
|
||||
res.status(400).json({ error: `该批次状态为 ${String(head.status || '')},不可再次撤回` });
|
||||
return;
|
||||
}
|
||||
const items = db.prepareCompat(`
|
||||
SELECT id, hour_id, delta FROM four_hour_export_items WHERE export_id = ?
|
||||
`).all(exportId) || [];
|
||||
db.execCompat('BEGIN');
|
||||
const subStmt = db.prepareCompat(`
|
||||
UPDATE hours SET exported_hours = MAX(0, COALESCE(exported_hours, 0) - ?)
|
||||
WHERE id = ? AND COALESCE(exported_hours, 0) >= ? - 0.0001
|
||||
`);
|
||||
let rolled = 0;
|
||||
let skipped = 0;
|
||||
let rolledHours = 0;
|
||||
for (const it of items as any[]) {
|
||||
const hid = Number(it.hour_id);
|
||||
const d = Number(it.delta || 0);
|
||||
if (!hid || d <= 0.0001) { skipped++; continue; }
|
||||
const r = subStmt.run(d, hid, d);
|
||||
if (r && r.changes > 0) {
|
||||
rolled++;
|
||||
rolledHours += d;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
db.prepareCompat(`
|
||||
UPDATE four_hour_exports SET status = 'voided', voided_at = datetime('now','+8 hours'), note = COALESCE(note,'') || ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
`\n[已撤回 ${new Date().toISOString().slice(0,19).replace('T',' ')}${adminUsername ? ' by '+adminUsername : ''}]`,
|
||||
exportId
|
||||
);
|
||||
db.save();
|
||||
db.execCompat('COMMIT');
|
||||
res.json({ ok: true, export_id: exportId, rolled, skipped, rolled_hours: Number(rolledHours.toFixed(1)) });
|
||||
} catch (e: any) {
|
||||
try { db.execCompat('ROLLBACK'); } catch {}
|
||||
console.error('[4h-export] void error', e);
|
||||
res.status(500).json({ error: e?.message || '撤回失败,已回滚' });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Backup endpoints =====
|
||||
router.get('/backups/runtime', authMiddleware, (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, ...getRuntime(), db_path: getDbPath(), backups_dir: getBackupsDir() });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message || '获取备份运行时失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backups/list', authMiddleware, (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, list: listBackups() });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message || '列出备份失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/backups/create', authMiddleware, (_req: AuthRequest, res) => {
|
||||
try {
|
||||
const item = createBackup({ trigger: 'manual' });
|
||||
res.json({ ok: true, item });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message || '手动备份失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backups/config', authMiddleware, (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, config: loadConfig() });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message || '读取配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/backups/config', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const body: any = req.body || {};
|
||||
const next = saveConfig({
|
||||
enabled: typeof body.enabled === 'boolean' ? body.enabled : undefined,
|
||||
mode: typeof body.mode === 'string' ? body.mode as any : undefined,
|
||||
daily_time: typeof body.daily_time === 'string' ? body.daily_time : undefined,
|
||||
keep: typeof body.keep === 'number' ? body.keep : undefined,
|
||||
});
|
||||
res.json({ ok: true, config: next, runtime: getRuntime() });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message || '保存配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/backups/delete', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const filename = String((req.body as any)?.filename || '');
|
||||
if (!filename) { res.status(400).json({ error: '缺少 filename' }); return; }
|
||||
const r = deleteBackup(filename);
|
||||
res.json({ ...r, list: listBackups() });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ error: e?.message || '删除失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backups/download/:filename', (req, res, next) => {
|
||||
const authHeader = req.headers.authorization || '';
|
||||
const authParam = String(req.query.auth || '');
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authParam;
|
||||
if (!token) { res.status(401).json({ error: '未登录' }); return; }
|
||||
// manual jwt check inline (avoid double middleware)
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('../config');
|
||||
jwt.verify(token, JWT_SECRET);
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token 无效' });
|
||||
return;
|
||||
}
|
||||
const filename = String(req.params.filename || '');
|
||||
if (!/^volunteer-(backup|restoresnap)-\d{8}-\d{6}-[A-Za-z0-9]{8}\.db$/.test(filename)) {
|
||||
res.status(400).json({ error: '文件名非法' });
|
||||
return;
|
||||
}
|
||||
const p = path.join(getBackupsDir(), filename);
|
||||
if (!fs.existsSync(p)) { res.status(404).json({ error: '文件不存在' }); return; }
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
const s = fs.createReadStream(p);
|
||||
s.on('error', (e) => { res.status(500).json({ error: String(e?.message || '读取失败') }); });
|
||||
s.pipe(res);
|
||||
void next;
|
||||
});
|
||||
|
||||
router.post('/backups/restore', authMiddleware, (req: AuthRequest, res) => {
|
||||
const filename = String((req.body as any)?.filename || '');
|
||||
if (!filename) { res.status(400).json({ error: '缺少 filename' }); return; }
|
||||
const confirm = String((req.body as any)?.confirm || '');
|
||||
if (confirm !== 'I_AGREE_RESTORE_FROM_BACKUP_WILL_RESTART') {
|
||||
res.status(400).json({ error: '缺少二次确认标识' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = restoreBackup(filename);
|
||||
res.json({ ok: true, snapshot: r.snapshot, will_restart: true });
|
||||
// 写入重启标记,避免响应未发出即退出
|
||||
const marker = path.join(getBackupsDir(), '.restart_after_restore');
|
||||
try { fs.writeFileSync(marker, JSON.stringify({ at: new Date().toISOString(), by: req.adminUsername || null, from: filename }), 'utf8'); } catch { /* noop */ }
|
||||
setTimeout(() => {
|
||||
try { process.exit(0); } catch { /* noop */ }
|
||||
}, 800);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e?.message || '还原失败,未覆盖当前数据库' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user