feat: add backup/restore/version, fix leave duplicate, add Settings page to nav

- Add /api/admin/backup/list, /create, /download, /restore endpoints
- Add /api/version endpoint (git commit info)
- Add Settings page to sidebar nav + App.jsx routes
- Fix lateness stats: add total_late_count/total_late_minutes keys
- Fix leave aggregation: no more duplicate text in status
- Settings page now shows backup/restore UI + version info
- Fix leave upload: overwrite existing records on re-upload
This commit is contained in:
IT Dog
2026-07-21 02:34:30 +08:00
parent 6f90d60b13
commit d5d9d644a0
5 changed files with 1543 additions and 18 deletions
+2
View File
@@ -11,6 +11,7 @@ import AccidentDetail from './pages/accident/Detail'
import UploadPage from './pages/upload/Upload'
import RosterPage from './pages/roster/Roster'
import HolidaysPage from './pages/holidays/Holidays'
import Settings from './pages/Settings'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
@@ -51,6 +52,7 @@ export default function App() {
<Route path="upload" element={<UploadPage />} />
<Route path="roster" element={<RosterPage />} />
<Route path="holidays" element={<HolidaysPage />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
)
+2 -1
View File
@@ -1,7 +1,7 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree } from 'lucide-react'
import { LogOut, Users, AlertTriangle, LayoutDashboard, Upload, Calendar, Menu, X, Palmtree, Settings as SettingsIcon } from 'lucide-react'
const NAV_ITEMS = [
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
]
export default function Layout() {
+191 -12
View File
@@ -1,6 +1,7 @@
import { useState } from "react"
import { Trash2, AlertTriangle, Shield } from "lucide-react"
import { useState, useEffect } from "react"
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } from "lucide-react"
import api from "../api"
import toast from "react-hot-toast"
const RESET_SECTIONS = [
{
@@ -27,6 +28,75 @@ export default function Settings() {
const [confirmText, setConfirmText] = useState({})
const [loading, setLoading] = useState({})
const [result, setResult] = useState({})
const [version, setVersion] = useState(null)
const [backups, setBackups] = useState([])
const [loadingBackups, setLoadingBackups] = useState(false)
const [creatingBackup, setCreatingBackup] = useState(false)
const [restoring, setRestoring] = useState(null)
const [restoreConfirm, setRestoreConfirm] = useState("")
useEffect(() => {
fetchVersion()
fetchBackups()
}, [])
const fetchVersion = async () => {
try {
const { data } = await api.get("/api/version")
setVersion(data)
} catch (e) {
// ignore
}
}
const fetchBackups = async () => {
setLoadingBackups(true)
try {
const { data } = await api.get("/api/admin/backup/list")
setBackups(data || [])
} catch (e) {
console.error("Failed to fetch backups:", e)
} finally {
setLoadingBackups(false)
}
}
const handleCreateBackup = async () => {
setCreatingBackup(true)
try {
const { data } = await api.post("/api/admin/backup/create")
toast.success(`Backup created: ${data.saved}`)
fetchBackups()
} catch (e) {
toast.error("Backup failed: " + (e.response?.data?.detail || e.message))
} finally {
setCreatingBackup(false)
}
}
const handleRestore = async (filename) => {
if (restoreConfirm !== filename) {
toast.error("Please type the filename to confirm restore")
return
}
setRestoring(filename)
try {
const { data } = await api.post("/api/admin/backup/restore", { filename })
toast.success(`Restored from ${filename}. Auto-backup: ${data.auto_backup}`)
setRestoreConfirm("")
fetchBackups()
} catch (e) {
toast.error("Restore failed: " + (e.response?.data?.detail || e.message))
} finally {
setRestoring(null)
}
}
const handleDownload = (filename) => {
const token = localStorage.getItem("aars_token")
if (!token) return
window.open(`/api/admin/backup/download/${filename}?token=${token}`, "_blank")
}
const handleReset = async (section) => {
const confirmVal = confirmText[section] || ""
@@ -59,16 +129,126 @@ export default function Settings() {
}
}
const formatSize = (bytes) => {
if (!bytes) return "—"
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
return (bytes / 1024 / 1024).toFixed(1) + " MB"
}
const formatDate = (iso) => {
if (!iso) return "—"
try {
const d = new Date(iso)
return d.toLocaleString("zh-HK", { timeZone: "Asia/Hong_Kong" })
} catch { return iso }
}
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-slate-700" />
<div>
<h1 className="text-2xl font-bold text-slate-900">系統管理</h1>
<p className="text-sm text-slate-500 mt-0.5">
危險操作請小心使用
</p>
<div className="max-w-4xl mx-auto space-y-6">
{/* Version Info */}
{version && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-5 py-4 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-semibold text-blue-900">
AARS v{version.version}
</div>
<div className="text-xs text-blue-700 mt-0.5">
Commit: <code className="bg-blue-100 px-1 rounded">{version.commit?.slice(0, 8)}</code>
{version.date && <> · {new Date(version.date).toLocaleString("zh-HK", {timeZone:"Asia/Hong_Kong"})}</>}
</div>
</div>
</div>
)}
{/* Backup / Restore */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-slate-600" />
<h2 className="text-lg font-semibold text-slate-800">💾 資料庫 Backup / Restore</h2>
</div>
<button
onClick={handleCreateBackup}
disabled={creatingBackup}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 rounded-md transition-colors"
>
<RefreshCw className={`w-4 h-4 ${creatingBackup ? "animate-spin" : ""}`} />
{creatingBackup ? "建立中..." : "建立 Backup"}
</button>
</div>
<p className="text-sm text-slate-500 mt-1">手動建立快照或從過往備份還原</p>
</div>
<div className="divide-y divide-slate-100">
{loadingBackups ? (
<div className="px-5 py-6 text-sm text-slate-400 text-center">載入中...</div>
) : backups.length === 0 ? (
<div className="px-5 py-6 text-sm text-slate-400 text-center">尚無備份記錄</div>
) : (
backups.map((b) => (
<div key={b.filename} className="px-5 py-3 flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-800 truncate">{b.filename}</div>
<div className="text-xs text-slate-400">
{formatDate(b.created)} · {formatSize(b.size)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleDownload(b.filename)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-md transition-colors"
title="Download backup"
>
<Download className="w-3.5 h-3.5" /> 下載
</button>
<button
onClick={() => setRestoreConfirm(b.filename === restoreConfirm ? "" : b.filename)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded-md transition-colors"
title="Restore from this backup"
>
<Upload className="w-3.5 h-3.5" /> 還原
</button>
</div>
</div>
))
)}
</div>
{/* Restore confirmation */}
{restoreConfirm && (
<div className="px-5 py-4 bg-amber-50 border-t border-amber-100">
<p className="text-sm text-amber-800 mb-2">
還原將覆蓋當前資料庫輸入 <code className="font-mono bg-amber-100 px-1 rounded">{restoreConfirm}</code> 確認
</p>
<div className="flex items-center gap-3">
<input
type="text"
className="flex-1 px-3 py-2 text-sm border border-amber-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder={"輸入檔案名稱確認"}
value={restoreConfirm}
onChange={(e) => setRestoreConfirm(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && restoreConfirm === restoreConfirm) handleRestore(restoreConfirm) }}
/>
<button
onClick={() => handleRestore(restoreConfirm)}
disabled={restoreConfirm !== restoreConfirm || restoring}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 rounded-md transition-colors"
>
{restoring ? "還原中..." : "確認還原"}
</button>
<button
onClick={() => setRestoreConfirm("")}
className="px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
>
取消
</button>
</div>
</div>
)}
</div>
{/* Danger Zone */}
@@ -99,7 +279,7 @@ export default function Settings() {
<div className="flex items-center gap-3">
<input
type="text"
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-slate-400"
className="w-36 px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-slate-400"
placeholder={`輸入 "${section.key}"`}
value={confirmText[section.key] || ""}
onChange={(e) => {
@@ -121,7 +301,6 @@ export default function Settings() {
</div>
</div>
{/* Result message */}
{res && (
<div
className={`mt-3 text-sm px-3 py-2 rounded-md ${