Add per-card save and collapse on settings page

Each settings section and exchange card gets its own save button and fold toggle with state persisted in localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-23 19:37:16 +08:00
parent 0dedaa2b4d
commit ea5c6cddb4
3 changed files with 337 additions and 33 deletions
+160
View File
@@ -2642,8 +2642,168 @@ button.btn-sm {
.settings-display-panel,
.settings-macro-panel,
.settings-supervisor-panel {
margin-bottom: 0;
}
.settings-section {
margin-bottom: 16px;
}
.settings-section-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-soft);
}
.settings-section.is-collapsed .settings-section-head {
border-bottom-color: transparent;
}
.settings-section-head .settings-display-title {
flex: 1;
margin: 0;
min-width: 0;
}
.settings-section-head-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.settings-section-fold {
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border-soft);
border-radius: 6px;
background: color-mix(in srgb, var(--panel) 90%, var(--accent) 10%);
color: var(--accent);
cursor: pointer;
font-size: 0;
line-height: 1;
transition: transform 0.15s ease, border-color 0.15s ease;
position: relative;
}
.settings-section-fold::before {
content: "▾";
font-size: 0.85rem;
line-height: 28px;
display: block;
text-align: center;
}
.settings-section-fold:hover {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border-soft));
}
.settings-section.is-collapsed .settings-section-fold::before {
content: "▸";
}
.settings-section-save {
flex-shrink: 0;
font-size: 0.82rem;
padding: 6px 14px;
}
.settings-section-body {
padding: 14px 16px;
}
.settings-section.is-collapsed .settings-section-body {
display: none;
}
.settings-page-toolbar {
margin-top: 4px;
}
.settings-card-topbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--border-soft);
}
.settings-card-fold {
flex-shrink: 0;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid var(--border-soft);
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 0;
line-height: 1;
transition: color 0.15s ease, border-color 0.15s ease;
position: relative;
}
.settings-card-fold::before {
content: "▾";
font-size: 0.8rem;
line-height: 26px;
display: block;
text-align: center;
}
.settings-card-fold:hover {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft));
}
.settings-card.is-collapsed .settings-card-fold::before {
content: "▸";
}
.settings-card-title {
flex: 1;
min-width: 0;
font-size: 0.92rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-card-save {
flex-shrink: 0;
font-size: 0.78rem;
padding: 5px 12px;
}
.settings-card-body {
display: block;
}
.settings-card.is-collapsed .settings-card-body {
display: none;
}
@media (max-width: 720px) {
.settings-section-head {
flex-wrap: wrap;
}
.settings-section-head-actions {
width: 100%;
justify-content: flex-end;
}
.settings-card-topbar {
flex-wrap: wrap;
}
}
.settings-display-title {
+122 -7
View File
@@ -3648,6 +3648,105 @@
renderSettingsList(data);
};
});
bindSettingsCardFolds(list);
list.querySelectorAll(".settings-card-save").forEach((btn) => {
btn.addEventListener("click", () => {
void saveSettingsSection("exchange", { label: btn.dataset.label || "账户" });
});
});
}
const SETTINGS_FOLD_KEY = "hub_settings_section_fold";
function settingsFoldStorageKey(section, cardKey) {
return cardKey ? `${SETTINGS_FOLD_KEY}_${section}_${cardKey}` : `${SETTINGS_FOLD_KEY}_${section}`;
}
function getSettingsFoldState(section, cardKey) {
try {
return localStorage.getItem(settingsFoldStorageKey(section, cardKey)) === "1";
} catch (_) {
return false;
}
}
function setSettingsFoldState(section, collapsed, cardKey) {
try {
localStorage.setItem(settingsFoldStorageKey(section, cardKey), collapsed ? "1" : "0");
} catch (_) {}
}
function applySettingsSectionFold(el) {
const section = el.dataset.settingsSection;
if (!section) return;
const collapsed = getSettingsFoldState(section);
el.classList.toggle("is-collapsed", collapsed);
const btn = el.querySelector(":scope > .settings-section-head > .settings-section-fold");
if (btn) btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
}
function applySettingsCardFold(card) {
const key = card.dataset.key || card.dataset.idx || "";
const collapsed = getSettingsFoldState("exchange", String(key));
card.classList.toggle("is-collapsed", collapsed);
const btn = card.querySelector(".settings-card-fold");
if (btn) btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
}
function bindSettingsCardFolds(root) {
(root || document).querySelectorAll(".settings-card").forEach((card) => {
if (card.dataset.foldBound === "1") return;
card.dataset.foldBound = "1";
applySettingsCardFold(card);
const foldBtn = card.querySelector(".settings-card-fold");
if (!foldBtn) return;
foldBtn.addEventListener("click", () => {
const key = card.dataset.key || card.dataset.idx || "";
const collapsed = !card.classList.contains("is-collapsed");
card.classList.toggle("is-collapsed", collapsed);
foldBtn.setAttribute("aria-expanded", collapsed ? "false" : "true");
setSettingsFoldState("exchange", collapsed, String(key));
});
});
}
function initSettingsSectionFolds() {
document.querySelectorAll(".settings-section[data-settings-section]").forEach((el) => {
applySettingsSectionFold(el);
if (el.dataset.foldBound === "1") return;
el.dataset.foldBound = "1";
const foldBtn = el.querySelector(":scope > .settings-section-head > .settings-section-fold");
if (foldBtn) {
foldBtn.addEventListener("click", () => {
const section = el.dataset.settingsSection;
const collapsed = !el.classList.contains("is-collapsed");
el.classList.toggle("is-collapsed", collapsed);
foldBtn.setAttribute("aria-expanded", collapsed ? "false" : "true");
setSettingsFoldState(section, collapsed);
});
}
});
document.querySelectorAll(".settings-section-save").forEach((btn) => {
if (btn.dataset.saveBound === "1") return;
btn.dataset.saveBound = "1";
btn.addEventListener("click", () => {
const section = btn.dataset.settingsSection || "";
if (section === "macro") {
const form = document.getElementById("macro-event-form");
if (form) form.requestSubmit();
return;
}
const label =
section === "display"
? "显示与导航"
: section === "supervisor"
? "交易监管"
: section === "exchanges"
? "交易所账户"
: "设置";
void saveSettingsSection(section, { label });
});
});
}
function macroDatetimeLocalToApi(v) {
@@ -3798,6 +3897,7 @@
syncDisplayPrefsUI(data);
syncSupervisorSettingsUI(data);
renderSettingsList(data);
initSettingsSectionFolds();
});
}
@@ -3806,7 +3906,15 @@
const envOff = ex.env_disabled
? '<span class="badge">环境变量强制关</span>'
: "";
return `<div class="settings-card" data-idx="${idx}" data-key="${esc(ex.key || ex.id || "")}">
const cardKey = esc(ex.key || ex.id || String(idx));
const cardTitle = esc(ex.name || ex.key || `账户 ${idx + 1}`);
return `<div class="settings-card" data-idx="${idx}" data-key="${cardKey}">
<div class="settings-card-topbar">
<button type="button" class="settings-card-fold" aria-expanded="true" aria-label="折叠"></button>
<span class="settings-card-title">${cardTitle}</span>
<button type="button" class="primary settings-card-save" data-label="${cardTitle}">保存</button>
</div>
<div class="settings-card-body">
<div class="settings-card-head">
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
${envOff}
@@ -3825,6 +3933,7 @@
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
<button type="button" class="danger btn-del-ex" data-idx="${idx}">删除账户</button>
</div>
</div>
</div>`;
}
@@ -3888,7 +3997,8 @@
};
}
async function saveSettings() {
async function saveSettingsSection(section, opts) {
const options = opts || {};
const body = collectSettingsFromUI();
try {
const r = await apiFetch("/api/settings", {
@@ -3897,28 +4007,33 @@
body: JSON.stringify(body),
});
const j = await r.json();
if (j.ok) {
showToast("设置已保存(已写入 hub_settings.json");
if (!j.ok) {
showToast("保存失败", true);
return;
}
const label = options.label || "设置";
showToast(`${label}已保存`);
if (j.settings) {
settingsCache = j.settings;
syncDisplayPrefsUI(j.settings);
syncSupervisorSettingsUI(j.settings);
renderSettingsList(j.settings);
loadSettingsMetaLine();
} else {
await loadSettingsUI();
}
if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows);
if (!pageNavAllowed(currentPage())) {
history.replaceState({}, "", "/monitor");
setActiveNav();
}
} else showToast("保存失败", true);
} catch (e) {
showToast(String(e), true);
}
}
async function saveSettings() {
await saveSettingsSection("all", { label: "全部设置" });
}
document.getElementById("btn-logout").onclick = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
+36 -7
View File
@@ -843,8 +843,13 @@
</div>
</details>
<p id="settings-meta-line" class="settings-meta-line"></p>
<div class="settings-display-panel card">
<section class="settings-section card settings-display-panel" data-settings-section="display">
<div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
<h3 class="settings-display-title">显示与导航</h3>
<button type="button" class="primary settings-section-save" data-settings-section="display">保存</button>
</div>
<div class="settings-section-body">
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-account-pnl" checked />
监控区显示资金账户、交易账户与浮动盈亏
@@ -875,8 +880,14 @@
</label>
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入,直接访问 URL 会跳回监控区。</p>
</div>
<div class="settings-macro-panel card">
</section>
<section class="settings-section card settings-macro-panel" data-settings-section="macro">
<div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
<h3 class="settings-display-title">宏观关键数据(风控前置)</h3>
<button type="button" class="ghost settings-section-save" data-settings-section="macro">保存表单</button>
</div>
<div class="settings-section-body">
<p class="settings-display-hint">
手动录入 FOMC / CPI / 就业数据发布时间(北京时间)。监控区在发布前后各 1 小时提示风险:有仓注意仓位,无仓建议等待。仅提醒,不拦截下单。
</p>
@@ -904,8 +915,14 @@
</form>
<div id="macro-event-list" class="macro-event-list"></div>
</div>
<div class="settings-supervisor-panel card">
</section>
<section class="settings-section card settings-supervisor-panel" data-settings-section="supervisor">
<div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
<h3 class="settings-display-title">交易监管 · 企业微信</h3>
<button type="button" class="primary settings-section-save" data-settings-section="supervisor">保存</button>
</div>
<div class="settings-section-body">
<p class="settings-display-hint">
与四所实例策略通知独立;手动/中控开平仓与新开仓会推送至此 Webhook。链接可在下方单独修改。
</p>
@@ -948,13 +965,25 @@
</div>
</div>
</div>
<div class="toolbar">
<button type="button" id="btn-settings-save" class="primary">保存设置</button>
<button type="button" id="btn-settings-add">添加交易所</button>
<button type="button" id="btn-settings-reload">重新加载</button>
</section>
<section class="settings-section card" data-settings-section="exchanges">
<div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
<h3 class="settings-display-title">交易所账户</h3>
<div class="settings-section-head-actions">
<button type="button" id="btn-settings-add" class="ghost">添加交易所</button>
<button type="button" class="primary settings-section-save" data-settings-section="exchanges">全部保存</button>
</div>
</div>
<div class="settings-section-body">
<div id="settings-list" class="settings-grid-wrap"></div>
</div>
</section>
<div class="toolbar settings-page-toolbar">
<button type="button" id="btn-settings-save" class="primary">保存全部</button>
<button type="button" id="btn-settings-reload" class="ghost">重新加载</button>
</div>
</div>
</div>
<div id="tpsl-modal" class="modal hidden" aria-hidden="true">