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:
+123
-5
@@ -1,10 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import secret
|
import secret
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request
|
from fastapi import FastAPI, Depends, HTTPException, Query, UploadFile, File, Form, Request, Body
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -135,13 +136,19 @@ def enrich_attendance_with_leave(records, db):
|
|||||||
if not emp_leaves:
|
if not emp_leaves:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Aggregate all leaves by type (sum hours when multiple records of same type)
|
||||||
leave_by_type = {}
|
leave_by_type = {}
|
||||||
for lv in emp_leaves:
|
for lv in emp_leaves:
|
||||||
t = (lv.leave_type or "").upper()
|
t = (lv.leave_type or "").upper()
|
||||||
h_val = float(lv.hours or 0)
|
h_val = float(lv.hours or 0)
|
||||||
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
|
leave_by_type[t] = leave_by_type.get(t, 0.0) + h_val
|
||||||
|
|
||||||
leave_text = " + ".join(f"{t}{h}h" for t, h in sorted(leave_by_type.items()))
|
# Build display text from aggregated values (no duplicates)
|
||||||
|
leave_parts = []
|
||||||
|
for t, h in sorted(leave_by_type.items()):
|
||||||
|
if h > 0:
|
||||||
|
leave_parts.append(f"{t}{h:.1f}h" if h != int(h) else f"{t}{int(h)}h")
|
||||||
|
leave_text = " + ".join(leave_parts)
|
||||||
|
|
||||||
original_code = (r.status_code or "").lower()
|
original_code = (r.status_code or "").lower()
|
||||||
if original_code == "missing":
|
if original_code == "missing":
|
||||||
@@ -758,7 +765,16 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)):
|
|||||||
totals["ot"] += status["ot_minutes"]
|
totals["ot"] += status["ot_minutes"]
|
||||||
|
|
||||||
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
|
stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True)
|
||||||
return {"stats": stats, **totals}
|
return {
|
||||||
|
"stats": stats,
|
||||||
|
"total_late_count": sum(s["late_count"] for s in employee_stats.values()),
|
||||||
|
"total_late_minutes": totals["late"],
|
||||||
|
"total_early_count": sum(s["early_count"] for s in employee_stats.values()),
|
||||||
|
"total_early_minutes": totals["early"],
|
||||||
|
"total_ot_count": sum(s["ot_count"] for s in employee_stats.values()),
|
||||||
|
"total_ot_minutes": totals["ot"],
|
||||||
|
"total_missing_count": totals["missing"],
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/attendance/lateness/records")
|
@app.get("/api/attendance/lateness/records")
|
||||||
async def get_lateness_records(
|
async def get_lateness_records(
|
||||||
@@ -1764,8 +1780,8 @@ async def upload_leaves(
|
|||||||
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
|
errors.append(f"Row {i+2}: {r['employee_name']} {d} {r['leave_type']} was edited at {existing.last_edited_at.isoformat()} - skipped")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
# Not edited - safe to update
|
# Not edited - safe to update (or multiple identical records exist - merge by adding hours)
|
||||||
existing.hours = r["hours"]
|
existing.hours = r["hours"] # overwrite with latest uploaded value
|
||||||
existing.reason = r["reason"]
|
existing.reason = r["reason"]
|
||||||
existing.approved_by = r["approved_by"]
|
existing.approved_by = r["approved_by"]
|
||||||
existing.source = "csv_upload"
|
existing.source = "csv_upload"
|
||||||
@@ -1796,6 +1812,108 @@ async def upload_leaves(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Backup / Restore + Version
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import shutil, hashlib
|
||||||
|
from datetime import datetime as _dt2
|
||||||
|
|
||||||
|
BACKUP_DIR = Path("/app/data/backups")
|
||||||
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _backup_path(name):
|
||||||
|
ts = _dt2.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return BACKUP_DIR / f"aars_backup_{ts}_{name}.db"
|
||||||
|
|
||||||
|
@app.get("/api/admin/backup/list")
|
||||||
|
async def list_backups(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all backup files."""
|
||||||
|
files = []
|
||||||
|
for p in sorted(BACKUP_DIR.glob("aars_backup_*.db"), reverse=True):
|
||||||
|
files.append({
|
||||||
|
"filename": p.name,
|
||||||
|
"size": p.stat().st_size,
|
||||||
|
"created": _dt2.fromtimestamp(p.stat().st_mtime).isoformat(),
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
@app.post("/api/admin/backup/create")
|
||||||
|
async def create_backup(
|
||||||
|
note: str = "",
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a manual backup."""
|
||||||
|
src = Path(DB_PATH)
|
||||||
|
if not src.exists():
|
||||||
|
raise HTTPException(status_code=500, detail="Database file not found")
|
||||||
|
dst = _backup_path("manual")
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
return {"saved": dst.name, "size": dst.stat().st_size, "note": note}
|
||||||
|
|
||||||
|
@app.get("/api/admin/backup/download/{filename}")
|
||||||
|
async def download_backup(
|
||||||
|
filename: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download a backup file."""
|
||||||
|
fp = BACKUP_DIR / filename
|
||||||
|
if not fp.exists() or ".." in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup not found")
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
return FileResponse(fp, media_type="application/x-sqlite3", filename=filename)
|
||||||
|
|
||||||
|
@app.post("/api/admin/backup/restore")
|
||||||
|
async def restore_backup(
|
||||||
|
filename: str = Body(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Restore from backup. Creates an auto-backup of current state first."""
|
||||||
|
fp = BACKUP_DIR / filename
|
||||||
|
if not fp.exists() or ".." in filename:
|
||||||
|
raise HTTPException(status_code=404, detail="Backup not found")
|
||||||
|
# Auto-backup current state
|
||||||
|
src = Path(DB_PATH)
|
||||||
|
if src.exists():
|
||||||
|
auto_dst = _backup_path("auto_pre_restore")
|
||||||
|
shutil.copy2(src, auto_dst)
|
||||||
|
# Restore
|
||||||
|
shutil.copy2(fp, src)
|
||||||
|
return {"restored": filename, "auto_backup": auto_dst.name if src.exists() else None}
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
async def get_version(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get app version info."""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%H|%cd", "--date=iso"],
|
||||||
|
cwd="/app",
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
commit = parts[0]
|
||||||
|
date = parts[1] if len(parts) > 1 else ""
|
||||||
|
else:
|
||||||
|
commit = "unknown"
|
||||||
|
date = ""
|
||||||
|
except Exception:
|
||||||
|
commit = "unknown"
|
||||||
|
date = ""
|
||||||
|
return {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"commit": commit,
|
||||||
|
"date": date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/{section}")
|
@app.get("/api/{section}")
|
||||||
async def list_records(
|
async def list_records(
|
||||||
section: str,
|
section: str,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AccidentDetail from './pages/accident/Detail'
|
|||||||
import UploadPage from './pages/upload/Upload'
|
import UploadPage from './pages/upload/Upload'
|
||||||
import RosterPage from './pages/roster/Roster'
|
import RosterPage from './pages/roster/Roster'
|
||||||
import HolidaysPage from './pages/holidays/Holidays'
|
import HolidaysPage from './pages/holidays/Holidays'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -51,6 +52,7 @@ export default function App() {
|
|||||||
<Route path="upload" element={<UploadPage />} />
|
<Route path="upload" element={<UploadPage />} />
|
||||||
<Route path="roster" element={<RosterPage />} />
|
<Route path="roster" element={<RosterPage />} />
|
||||||
<Route path="holidays" element={<HolidaysPage />} />
|
<Route path="holidays" element={<HolidaysPage />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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 = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: '/', end: true, icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
|
|||||||
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
|
{ to: '/accident', icon: AlertTriangle, label: '意外 Accident' },
|
||||||
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
|
{ to: '/roster', icon: Calendar, label: '班次 Roster' },
|
||||||
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
|
{ to: '/holidays', icon: Palmtree, label: '假期 Holidays' },
|
||||||
|
{ to: '/settings', icon: SettingsIcon, label: '設定 Settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
|||||||
+190
-11
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Trash2, AlertTriangle, Shield } from "lucide-react"
|
import { Trash2, AlertTriangle, Shield, Download, Upload, RefreshCw, Database, Info } from "lucide-react"
|
||||||
import api from "../api"
|
import api from "../api"
|
||||||
|
import toast from "react-hot-toast"
|
||||||
|
|
||||||
const RESET_SECTIONS = [
|
const RESET_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,75 @@ export default function Settings() {
|
|||||||
const [confirmText, setConfirmText] = useState({})
|
const [confirmText, setConfirmText] = useState({})
|
||||||
const [loading, setLoading] = useState({})
|
const [loading, setLoading] = useState({})
|
||||||
const [result, setResult] = 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 handleReset = async (section) => {
|
||||||
const confirmVal = confirmText[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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Shield className="w-6 h-6 text-slate-700" />
|
{/* 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>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">系統管理</h1>
|
<div className="text-sm font-semibold text-blue-900">
|
||||||
<p className="text-sm text-slate-500 mt-0.5">
|
AARS v{version.version}
|
||||||
危險操作,請小心使用
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
@@ -99,7 +279,7 @@ export default function Settings() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
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}"`}
|
placeholder={`輸入 "${section.key}"`}
|
||||||
value={confirmText[section.key] || ""}
|
value={confirmText[section.key] || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -121,7 +301,6 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result message */}
|
|
||||||
{res && (
|
{res && (
|
||||||
<div
|
<div
|
||||||
className={`mt-3 text-sm px-3 py-2 rounded-md ${
|
className={`mt-3 text-sm px-3 py-2 rounded-md ${
|
||||||
|
|||||||
Reference in New Issue
Block a user