feat: add settings tabs, auto backup, and show latest 5 backups
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vendored
+111
-42
@@ -41,10 +41,15 @@
|
||||
.hidden { display: none; }
|
||||
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; }
|
||||
.center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; }
|
||||
.settings-card { width: 100%; max-width: 420px; }
|
||||
.settings-stack { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.75rem; }
|
||||
.settings-card { width: 100%; max-width: 460px; }
|
||||
.settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; }
|
||||
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
|
||||
.settings-tabs { display: flex; border-bottom: 1px solid #243041; margin: -0.25rem -0.15rem 1.1rem; }
|
||||
.settings-tabs button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.65rem 0.95rem; cursor: pointer; font-size: 0.92rem; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.settings-tabs button.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.auto-box { background: #0c1117; border: 1px solid #243041; border-radius: 8px; padding: 0.85rem 0.95rem; margin-bottom: 1rem; }
|
||||
.check-row { display: flex; align-items: center; gap: 0.55rem; margin-bottom: 0.75rem; color: var(--text); font-size: 0.92rem; }
|
||||
.check-row input { width: auto; accent-color: var(--accent); }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); }
|
||||
.btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; }
|
||||
@@ -131,43 +136,60 @@
|
||||
</section>
|
||||
<section id="page-settings" class="hidden">
|
||||
<div class="center-page">
|
||||
<div class="settings-stack">
|
||||
<form id="settingsForm" class="tile settings-card">
|
||||
<div class="settings-title">账号设置</div>
|
||||
<p id="settingsAuthHint" class="settings-hint" style="margin-bottom:1rem"></p>
|
||||
<label class="field"><span class="label">用户名</span>
|
||||
<input id="setUsername" class="field-input" autocomplete="username" />
|
||||
</label>
|
||||
<label class="field"><span class="label">当前密码</span>
|
||||
<input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">新密码(留空则不修改)</span>
|
||||
<input id="setNewPass" type="password" class="field-input" autocomplete="new-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">确认新密码</span>
|
||||
<input id="setNewPass2" type="password" class="field-input" autocomplete="new-password" />
|
||||
</label>
|
||||
<p id="settingsErr" class="form-err"></p>
|
||||
<p id="settingsOk" class="form-ok"></p>
|
||||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
|
||||
</form>
|
||||
<div class="tile settings-card">
|
||||
<div class="settings-title">数据备份与恢复</div>
|
||||
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span></p>
|
||||
<p id="backupDirErr" class="form-err hidden"></p>
|
||||
<label class="field"><span class="label">当前密码(备份/恢复需验证)</span>
|
||||
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">选择备份</span>
|
||||
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select>
|
||||
</label>
|
||||
<p id="backupErr" class="form-err"></p>
|
||||
<p id="backupOk" class="form-ok"></p>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-primary" id="backupBtn">立即备份</button>
|
||||
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
|
||||
<div class="settings-tabs">
|
||||
<button type="button" id="tabAccount" class="active" data-tab="account">账户密码</button>
|
||||
<button type="button" id="tabBackup" data-tab="backup">数据备份与恢复</button>
|
||||
</div>
|
||||
<div id="panelAccount">
|
||||
<form id="settingsForm">
|
||||
<div class="settings-title">账户密码</div>
|
||||
<p id="settingsAuthHint" class="settings-hint"></p>
|
||||
<label class="field"><span class="label">用户名</span>
|
||||
<input id="setUsername" class="field-input" autocomplete="username" />
|
||||
</label>
|
||||
<label class="field"><span class="label">当前密码</span>
|
||||
<input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">新密码(留空则不修改)</span>
|
||||
<input id="setNewPass" type="password" class="field-input" autocomplete="new-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">确认新密码</span>
|
||||
<input id="setNewPass2" type="password" class="field-input" autocomplete="new-password" />
|
||||
</label>
|
||||
<p id="settingsErr" class="form-err"></p>
|
||||
<p id="settingsOk" class="form-ok"></p>
|
||||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="panelBackup" class="hidden">
|
||||
<div class="settings-title">数据备份与恢复</div>
|
||||
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span><br/>列表仅显示最近 5 条。</p>
|
||||
<p id="backupDirErr" class="form-err hidden"></p>
|
||||
<div class="auto-box">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" id="autoEnabled" checked />
|
||||
<span>启用自动备份</span>
|
||||
</label>
|
||||
<label class="field"><span class="label">备份间隔(小时)</span>
|
||||
<input id="autoInterval" type="number" min="1" max="168" value="24" class="field-input" />
|
||||
</label>
|
||||
<p class="settings-hint" style="margin-bottom:0" id="autoMeta">最近备份:—</p>
|
||||
</div>
|
||||
<label class="field"><span class="label">当前密码(操作需验证)</span>
|
||||
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">选择备份(最近 5 条)</span>
|
||||
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select>
|
||||
</label>
|
||||
<p id="backupErr" class="form-err"></p>
|
||||
<p id="backupOk" class="form-ok"></p>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-primary" id="autoSaveBtn">保存自动备份</button>
|
||||
<button type="button" class="btn-primary" id="backupBtn">立即备份</button>
|
||||
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -279,6 +301,16 @@
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
function showSettingsTab(tab) {
|
||||
const isAccount = tab === "account";
|
||||
document.getElementById("panelAccount").classList.toggle("hidden", !isAccount);
|
||||
document.getElementById("panelBackup").classList.toggle("hidden", isAccount);
|
||||
document.getElementById("tabAccount").classList.toggle("active", isAccount);
|
||||
document.getElementById("tabBackup").classList.toggle("active", !isAccount);
|
||||
}
|
||||
document.getElementById("tabAccount").addEventListener("click", () => showSettingsTab("account"));
|
||||
document.getElementById("tabBackup").addEventListener("click", () => showSettingsTab("backup"));
|
||||
|
||||
async function loadBackupInfo() {
|
||||
document.getElementById("backupErr").textContent = "";
|
||||
document.getElementById("backupOk").textContent = "";
|
||||
@@ -294,12 +326,25 @@
|
||||
} else {
|
||||
dirErr.classList.add("hidden");
|
||||
}
|
||||
document.getElementById("autoEnabled").checked = !!info.auto_enabled;
|
||||
document.getElementById("autoInterval").value = info.interval_hours || 24;
|
||||
let meta = "最近备份:" + (info.latest_backup_ms ? fmtBackupTime(info.latest_backup_ms) : "—");
|
||||
if (info.auto_enabled) meta += " · 下次约:" + (info.next_due_ms ? fmtBackupTime(info.next_due_ms) : "即将");
|
||||
document.getElementById("autoMeta").textContent = meta;
|
||||
renderBackupSelect(info.backups || []);
|
||||
const disabled = !state.authRequired || !info.writable;
|
||||
["backupPass","backupSelect","backupBtn","restoreBtn"].forEach(id => {
|
||||
document.getElementById(id).disabled = disabled;
|
||||
});
|
||||
const noAuth = !state.authRequired;
|
||||
const noWrite = !info.writable;
|
||||
document.getElementById("backupPass").disabled = noAuth;
|
||||
document.getElementById("backupSelect").disabled = noAuth;
|
||||
document.getElementById("autoEnabled").disabled = noAuth;
|
||||
document.getElementById("autoInterval").disabled = noAuth || !info.auto_enabled;
|
||||
document.getElementById("autoSaveBtn").disabled = noAuth;
|
||||
document.getElementById("backupBtn").disabled = noAuth || noWrite;
|
||||
document.getElementById("restoreBtn").disabled = noAuth || noWrite;
|
||||
}
|
||||
document.getElementById("autoEnabled").addEventListener("change", () => {
|
||||
document.getElementById("autoInterval").disabled = !document.getElementById("autoEnabled").checked || !state.authRequired;
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
document.getElementById("settingsErr").textContent = "";
|
||||
@@ -361,6 +406,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("autoSaveBtn").addEventListener("click", async () => {
|
||||
const pwd = document.getElementById("backupPass").value;
|
||||
document.getElementById("backupErr").textContent = "";
|
||||
document.getElementById("backupOk").textContent = "";
|
||||
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
|
||||
try {
|
||||
const r = await apiFetch("/api/settings/backup/auto", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
current_password: pwd,
|
||||
enabled: document.getElementById("autoEnabled").checked,
|
||||
interval_hours: Number(document.getElementById("autoInterval").value) || 24,
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || "save failed");
|
||||
document.getElementById("backupOk").textContent = d.message || "自动备份设置已保存";
|
||||
await loadBackupInfo();
|
||||
} catch (err) {
|
||||
document.getElementById("backupErr").textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("backupBtn").addEventListener("click", async () => {
|
||||
const pwd = document.getElementById("backupPass").value;
|
||||
document.getElementById("backupErr").textContent = "";
|
||||
|
||||
@@ -97,6 +97,12 @@ export type BackupInfo = {
|
||||
db_path: string;
|
||||
writable: boolean;
|
||||
backups: BackupItem[];
|
||||
auto_enabled: boolean;
|
||||
interval_hours: number;
|
||||
keep_count: number;
|
||||
display_limit: number;
|
||||
latest_backup_ms: number | null;
|
||||
next_due_ms: number | null;
|
||||
};
|
||||
|
||||
export async function fetchBackupInfo(): Promise<BackupInfo> {
|
||||
@@ -105,6 +111,21 @@ export async function fetchBackupInfo(): Promise<BackupInfo> {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function updateAutoBackup(body: {
|
||||
current_password: string;
|
||||
enabled: boolean;
|
||||
interval_hours: number;
|
||||
}): Promise<BackupInfo & { ok: boolean; message: string }> {
|
||||
const r = await apiFetch("/api/settings/backup/auto", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.detail || "save auto backup failed");
|
||||
return d;
|
||||
}
|
||||
|
||||
export async function createBackup(current_password: string): Promise<{ ok: boolean; message: string; name: string; path: string }> {
|
||||
const r = await apiFetch("/api/settings/backup", {
|
||||
method: "POST",
|
||||
|
||||
+227
-133
@@ -7,17 +7,21 @@ import {
|
||||
logout,
|
||||
restoreBackup,
|
||||
updateAccountSettings,
|
||||
updateAutoBackup,
|
||||
} from "../api/client";
|
||||
import AppNav from "../components/AppNav";
|
||||
import LoginGate from "../components/LoginGate";
|
||||
|
||||
type TabKey = "account" | "backup";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function fmtTime(ms: number): string {
|
||||
function fmtTime(ms: number | null | undefined): string {
|
||||
if (ms == null) return "—";
|
||||
try {
|
||||
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
|
||||
} catch {
|
||||
@@ -26,6 +30,7 @@ function fmtTime(ms: number): string {
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [tab, setTab] = useState<TabKey>("account");
|
||||
const [needLogin, setNeedLogin] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [username, setUsername] = useState("admin");
|
||||
@@ -41,15 +46,26 @@ export default function SettingsPage() {
|
||||
const [backupInfo, setBackupInfo] = useState<BackupInfo | null>(null);
|
||||
const [backupPassword, setBackupPassword] = useState("");
|
||||
const [selectedBackup, setSelectedBackup] = useState("");
|
||||
const [autoEnabled, setAutoEnabled] = useState(true);
|
||||
const [intervalHours, setIntervalHours] = useState(24);
|
||||
const [backupMsg, setBackupMsg] = useState<string | null>(null);
|
||||
const [backupErr, setBackupErr] = useState<string | null>(null);
|
||||
const [backupBusy, setBackupBusy] = useState(false);
|
||||
|
||||
const applyBackupInfo = useCallback((info: BackupInfo) => {
|
||||
setBackupInfo(info);
|
||||
setAutoEnabled(!!info.auto_enabled);
|
||||
setIntervalHours(Number(info.interval_hours) || 24);
|
||||
setSelectedBackup((prev) => {
|
||||
if (prev && info.backups.some((b) => b.name === prev)) return prev;
|
||||
return info.backups[0]?.name || "";
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadBackupInfo = useCallback(async () => {
|
||||
const info = await fetchBackupInfo();
|
||||
setBackupInfo(info);
|
||||
setSelectedBackup((prev) => prev || info.backups[0]?.name || "");
|
||||
}, []);
|
||||
applyBackupInfo(info);
|
||||
}, [applyBackupInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchAccountSettings(), fetchBackupInfo()])
|
||||
@@ -57,8 +73,7 @@ export default function SettingsPage() {
|
||||
setUsername(account.username);
|
||||
setNewUsername(account.username);
|
||||
setAuthRequired(account.auth_required);
|
||||
setBackupInfo(backup);
|
||||
setSelectedBackup(backup.backups[0]?.name || "");
|
||||
applyBackupInfo(backup);
|
||||
setNeedLogin(false);
|
||||
setReady(true);
|
||||
})
|
||||
@@ -68,7 +83,7 @@ export default function SettingsPage() {
|
||||
else setErr(m);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
}, [applyBackupInfo]);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -104,6 +119,26 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveAuto = async () => {
|
||||
setBackupBusy(true);
|
||||
setBackupErr(null);
|
||||
setBackupMsg(null);
|
||||
try {
|
||||
const res = await updateAutoBackup({
|
||||
current_password: backupPassword,
|
||||
enabled: autoEnabled,
|
||||
interval_hours: intervalHours,
|
||||
});
|
||||
setBackupMsg(res.message || "自动备份设置已保存");
|
||||
applyBackupInfo(res);
|
||||
await loadBackupInfo();
|
||||
} catch (ex) {
|
||||
setBackupErr(String(ex));
|
||||
} finally {
|
||||
setBackupBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackup = async () => {
|
||||
setBackupBusy(true);
|
||||
setBackupErr(null);
|
||||
@@ -154,137 +189,196 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">系统设置 · 账号与数据</div>
|
||||
<div className="sub">系统设置</div>
|
||||
<AppNav />
|
||||
|
||||
<div className="center-page">
|
||||
<div className="settings-stack">
|
||||
<form className="tile settings-card" onSubmit={submit}>
|
||||
<div className="settings-title">账号设置</div>
|
||||
{!authRequired && (
|
||||
<p className="settings-hint">当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。</p>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span className="label">用户名</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">当前密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">新密码(留空则不修改)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">确认新密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{err && <p className="form-err">{err}</p>}
|
||||
{msg && <p className="form-ok">{msg}</p>}
|
||||
|
||||
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
|
||||
{busy ? "保存中…" : "保存账号"}
|
||||
<div className="tile settings-card">
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "account" ? "active" : ""}
|
||||
onClick={() => setTab("account")}
|
||||
>
|
||||
账户密码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "backup" ? "active" : ""}
|
||||
onClick={() => setTab("backup")}
|
||||
>
|
||||
数据备份与恢复
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="tile settings-card">
|
||||
<div className="settings-title">数据备份与恢复</div>
|
||||
<p className="settings-hint">
|
||||
备份目录:<code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code>
|
||||
</p>
|
||||
{!backupInfo?.writable && (
|
||||
<p className="form-err">备份目录不可写,请检查服务器 /root/market_intel_backups 权限与 Docker 挂载。</p>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span className="label">当前密码(备份/恢复需验证)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={backupPassword}
|
||||
onChange={(e) => setBackupPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{backupInfo && backupInfo.backups.length > 0 ? (
|
||||
<label className="field">
|
||||
<span className="label">选择备份</span>
|
||||
<select
|
||||
className="field-input"
|
||||
value={selectedBackup}
|
||||
onChange={(e) => setSelectedBackup(e.target.value)}
|
||||
disabled={!authRequired}
|
||||
>
|
||||
{backupInfo.backups.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} · {fmtBytes(b.size_bytes)} · {fmtTime(b.modified_ms)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<p className="settings-hint">尚无备份文件</p>
|
||||
)}
|
||||
|
||||
{backupErr && <p className="form-err">{backupErr}</p>}
|
||||
{backupMsg && <p className="form-ok">{backupMsg}</p>}
|
||||
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={backupBusy || !authRequired || !backupPassword || !backupInfo?.writable}
|
||||
onClick={runBackup}
|
||||
>
|
||||
{backupBusy ? "处理中…" : "立即备份"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
disabled={
|
||||
backupBusy || !authRequired || !backupPassword || !selectedBackup || !backupInfo?.writable
|
||||
}
|
||||
onClick={runRestore}
|
||||
>
|
||||
恢复选中备份
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "account" && (
|
||||
<form onSubmit={submit}>
|
||||
<div className="settings-title">账户密码</div>
|
||||
{!authRequired && (
|
||||
<p className="settings-hint">当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。</p>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span className="label">用户名</span>
|
||||
<input
|
||||
className="field-input"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">当前密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">新密码(留空则不修改)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">确认新密码</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{err && <p className="form-err">{err}</p>}
|
||||
{msg && <p className="form-ok">{msg}</p>}
|
||||
|
||||
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
|
||||
{busy ? "保存中…" : "保存账号"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{tab === "backup" && (
|
||||
<div>
|
||||
<div className="settings-title">数据备份与恢复</div>
|
||||
<p className="settings-hint">
|
||||
备份目录:<code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code>
|
||||
<br />
|
||||
列表仅显示最近 {backupInfo?.display_limit ?? 5} 条;磁盘最多保留 {backupInfo?.keep_count ?? 30} 份。
|
||||
</p>
|
||||
{!backupInfo?.writable && (
|
||||
<p className="form-err">备份目录不可写,请检查服务器 /root/market_intel_backups 权限与 Docker 挂载。</p>
|
||||
)}
|
||||
|
||||
<div className="auto-box">
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoEnabled}
|
||||
onChange={(e) => setAutoEnabled(e.target.checked)}
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
<span>启用自动备份</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">备份间隔(小时)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(Number(e.target.value) || 24)}
|
||||
disabled={!authRequired || !autoEnabled}
|
||||
/>
|
||||
</label>
|
||||
<p className="settings-hint" style={{ marginBottom: 0 }}>
|
||||
最近备份:{fmtTime(backupInfo?.latest_backup_ms)}
|
||||
{autoEnabled ? ` · 下次约:${fmtTime(backupInfo?.next_due_ms)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span className="label">当前密码(操作需验证)</span>
|
||||
<input
|
||||
className="field-input"
|
||||
type="password"
|
||||
value={backupPassword}
|
||||
onChange={(e) => setBackupPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!authRequired}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{backupInfo && backupInfo.backups.length > 0 ? (
|
||||
<label className="field">
|
||||
<span className="label">选择备份(最近 {backupInfo.display_limit} 条)</span>
|
||||
<select
|
||||
className="field-input"
|
||||
value={selectedBackup}
|
||||
onChange={(e) => setSelectedBackup(e.target.value)}
|
||||
disabled={!authRequired}
|
||||
>
|
||||
{backupInfo.backups.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} · {fmtBytes(b.size_bytes)} · {fmtTime(b.modified_ms)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<p className="settings-hint">尚无备份文件</p>
|
||||
)}
|
||||
|
||||
{backupErr && <p className="form-err">{backupErr}</p>}
|
||||
{backupMsg && <p className="form-ok">{backupMsg}</p>}
|
||||
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={backupBusy || !authRequired || !backupPassword}
|
||||
onClick={saveAuto}
|
||||
>
|
||||
保存自动备份
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={backupBusy || !authRequired || !backupPassword || !backupInfo?.writable}
|
||||
onClick={runBackup}
|
||||
>
|
||||
{backupBusy ? "处理中…" : "立即备份"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
disabled={
|
||||
backupBusy || !authRequired || !backupPassword || !selectedBackup || !backupInfo?.writable
|
||||
}
|
||||
onClick={runRestore}
|
||||
>
|
||||
恢复选中备份
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+40
-3
@@ -31,11 +31,48 @@ a { color: var(--accent); text-decoration: none; }
|
||||
min-height: 50vh;
|
||||
padding: 1.5rem 0 3rem;
|
||||
}
|
||||
.settings-card { width: 100%; max-width: 420px; }
|
||||
.settings-stack { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.25rem; }
|
||||
.settings-card { width: 100%; max-width: 460px; }
|
||||
.settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; }
|
||||
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
|
||||
.settings-hint code { color: var(--text); font-size: 0.82rem; }
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: -0.25rem -0.15rem 1.1rem;
|
||||
border-bottom: 1px solid #243041;
|
||||
}
|
||||
.settings-tabs button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0.65rem 0.95rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.settings-tabs button.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.auto-box {
|
||||
background: #0c1117;
|
||||
border: 1px solid #243041;
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.check-row input { width: auto; accent-color: var(--accent); }
|
||||
.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||
.btn-danger {
|
||||
padding: 0.55rem 1.2rem;
|
||||
|
||||
Reference in New Issue
Block a user