Refine brand management modal UX

This commit is contained in:
2026-04-13 18:52:56 +08:00
parent ce80e50aec
commit 305747d4ba
+360 -46
View File
@@ -14,23 +14,34 @@
--brand: #0f6fff;
--ok: #0a7f3f;
--warn: #b16a00;
--nav-height: 52px;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding-top: var(--nav-height);
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(circle at 0 0, #eef4ff 0, var(--bg) 40%), var(--bg);
color: var(--text);
}
body.modal-open {
overflow: hidden;
}
.top-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(180deg, #1f2a3a, #1a2431);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 30px rgba(14, 25, 42, 0.16);
}
.top-nav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
height: 52px;
height: var(--nav-height);
display: flex;
align-items: center;
gap: 8px;
@@ -66,6 +77,9 @@
margin: 24px auto;
padding: 0 16px 32px;
}
.page-card {
padding: 16px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
@@ -84,6 +98,20 @@
font-size: 13px;
line-height: 1.5;
}
.page-sub {
margin-bottom: 0;
max-width: 720px;
}
.compact-note-list {
margin: 0;
padding-left: 18px;
color: var(--sub);
font-size: 13px;
line-height: 1.65;
}
.compact-note-list li + li {
margin-top: 4px;
}
label {
display: block;
margin: 10px 0 6px;
@@ -128,18 +156,39 @@
align-items: center;
margin-bottom: 10px;
}
.pill-btn {
border: 1px solid #b9cae8;
border-radius: 999px;
padding: 4px 10px;
background: #f5f9ff;
color: #254575;
cursor: pointer;
font-size: 12px;
font-weight: 600;
.brand-toolbar {
display: grid;
gap: 12px;
}
.pill-btn:hover {
background: #ebf3ff;
.brand-toolbar-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.brand-toolbar-btn {
width: 100%;
min-height: 46px;
border: 1px solid #c9d7ee;
border-radius: 12px;
padding: 10px 14px;
background: #f3f7ff;
color: #24416d;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.brand-toolbar-btn:hover {
background: #eaf1ff;
border-color: #abc0e7;
transform: translateY(-1px);
}
.brand-toolbar-btn:active {
transform: translateY(0);
}
.brand-toolbar-btn.stat-btn {
background: #f7faff;
}
.brand-toolbar-btn.action-btn {
background: #eef3fc;
}
.table-wrap {
overflow: auto;
@@ -240,7 +289,7 @@
display: grid;
gap: 8px;
position: sticky;
top: 12px;
top: calc(var(--nav-height) + 20px);
}
.tab-btn {
width: 100%;
@@ -257,6 +306,58 @@
.manage-panel.hidden {
display: none;
}
.panel-stack {
display: grid;
gap: 12px;
}
.section-card {
padding: 16px;
}
.section-card .title:last-child,
.section-card .sub:last-child {
margin-bottom: 0;
}
.collapse-card {
padding: 0;
overflow: hidden;
}
.collapse-card summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
font-weight: 700;
color: #1f355a;
cursor: pointer;
}
.collapse-card summary::-webkit-details-marker {
display: none;
}
.collapse-card summary::after {
content: "展开";
flex: 0 0 auto;
border: 1px solid #c8d6ee;
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
color: #47648f;
background: #f6f9ff;
}
.collapse-card[open] summary::after {
content: "收起";
}
.collapse-card[open] summary {
border-bottom: 1px solid var(--line);
background: #fbfcff;
}
.collapse-body {
padding: 14px 16px 16px;
display: grid;
gap: 14px;
}
.sync-log {
min-height: 240px;
white-space: pre-wrap;
@@ -269,6 +370,9 @@
padding: 10px;
margin: 0;
}
.sync-log.compact {
min-height: 180px;
}
.sync-schedule-card {
margin: 14px 0;
padding: 12px;
@@ -304,36 +408,112 @@
inset: 0;
background: rgba(10, 20, 38, 0.45);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
padding: 16px;
z-index: 999;
padding: calc(var(--nav-height) + 24px) 16px 20px;
overflow-y: auto;
z-index: 10000;
}
.modal-backdrop.hidden {
display: none !important;
}
.modal-card {
width: min(920px, 100%);
max-height: 90vh;
overflow: auto;
max-height: calc(100vh - var(--nav-height) - 44px);
min-height: min(680px, calc(100vh - var(--nav-height) - 44px));
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
}
.modal-head {
display: grid;
gap: 8px;
padding-bottom: 2px;
border-bottom: 1px solid #eef2f8;
}
.modal-card .title,
.modal-card .sub {
margin-bottom: 0;
}
.modal-content {
flex: 1 1 auto;
min-height: 320px;
overflow: auto;
display: flex;
}
.modal-list {
width: 100%;
height: 100%;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: #fcfdff;
padding: 12px;
}
.modal-list-grid {
display: grid;
gap: 10px;
}
.modal-list-item {
border: 1px solid #dbe4f2;
border-radius: 10px;
background: #fff;
padding: 12px 14px;
display: grid;
gap: 8px;
}
.modal-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.modal-list-title {
font-size: 16px;
font-weight: 700;
color: #203554;
}
.modal-list-meta {
font-size: 12px;
color: #62728a;
}
.modal-list-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.modal-list-empty {
height: 100%;
display: grid;
place-items: center;
color: var(--sub);
font-size: 14px;
}
.modal-card textarea {
width: 100%;
min-height: 360px;
min-height: 420px;
height: auto;
resize: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
overflow: auto;
background: #fcfdff;
}
.modal-card pre {
min-height: 240px;
min-height: 100%;
height: 100%;
width: 100%;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
@@ -343,6 +523,15 @@
border-radius: 10px;
padding: 10px;
margin: 0;
overflow: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #eef2f8;
flex: 0 0 auto;
}
@media (max-width: 1020px) {
.manage-layout {
@@ -353,6 +542,24 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.brand-toolbar-row {
grid-template-columns: 1fr;
}
.brand-toolbar-btn,
.brand-toolbar-btn.stat-btn,
.brand-toolbar-btn.action-btn {
width: 100%;
min-width: 0;
}
.modal-backdrop {
padding: calc(var(--nav-height) + 12px) 12px 12px;
}
.modal-card {
max-height: calc(100vh - var(--nav-height) - 24px);
min-height: calc(100vh - var(--nav-height) - 24px);
}
}
</style>
</head>
<body>
@@ -366,8 +573,9 @@
</nav>
<div class="wrap">
<section class="card">
<section class="card page-card">
<h1 class="title">数据管理</h1>
<p class="sub page-sub">把常用操作留在前面,把低频说明收起来。</p>
<div class="manage-layout">
<aside class="manage-tabs">
@@ -379,19 +587,21 @@
<div class="manage-content">
<section id="brandTabPanel" class="manage-panel">
<div class="result-head">
<button id="brandCountBtn" type="button" class="pill-btn">品牌数: -</button>
<button id="manufacturerCountBtn" type="button" class="pill-btn">厂商数: -</button>
<div class="panel-stack">
<article class="card section-card">
<div class="brand-toolbar">
<div class="brand-toolbar-row">
<button id="brandCountBtn" type="button" class="brand-toolbar-btn stat-btn">品牌数: -</button>
<button id="manufacturerCountBtn" type="button" class="brand-toolbar-btn stat-btn">厂商数: -</button>
<button id="editBrandListBtn" type="button" class="brand-toolbar-btn action-btn">编辑品牌列表</button>
<button id="editBrandRelationsBtn" type="button" class="brand-toolbar-btn action-btn">编辑品牌-厂商关系</button>
<button id="editBrandAliasesBtn" type="button" class="brand-toolbar-btn action-btn">编辑品牌同义词</button>
</div>
<div class="btns">
<button id="editBrandListBtn" type="button">编辑品牌列表</button>
<button id="editBrandRelationsBtn" type="button">编辑品牌-厂商关系</button>
<button id="editBrandAliasesBtn" type="button">编辑品牌同义词</button>
</div>
<div id="brandStats" class="sub">索引未加载。</div>
</article>
<article class="card section-card">
<div class="table-wrap">
<table>
<thead>
@@ -406,29 +616,49 @@
</tbody>
</table>
</div>
</article>
</div>
</section>
<section id="sourceTabPanel" class="manage-panel hidden">
<h3 class="title">数据来源管理(权重排序)</h3>
<p class="sub">拖拽调整优先级。越靠前权重越高。初始化规则:`_cn.md` 在前,非 `cn` 在后。</p>
<div class="panel-stack">
<article class="card section-card">
<h3 class="title">数据来源排序</h3>
<p class="sub">拖拽调整优先级,越靠前权重越高。</p>
<div class="btns">
<button id="saveSourceOrderBtn" type="button" class="primary">保存来源排序</button>
<button id="resetSourceOrderBtn" type="button">重置来源排序</button>
</div>
<div id="sourceOrderStats" class="sub">来源列表未加载。</div>
</article>
<article class="card section-card">
<div class="source-order-wrap">
<ul id="sourceOrderList" class="source-order-list">
<li class="sub">暂无来源数据</li>
</ul>
</div>
</article>
</div>
</section>
<section id="syncTabPanel" class="manage-panel hidden">
<div class="panel-stack">
<article class="card section-card">
<h3 class="title">原始数据同步</h3>
<p class="sub">从上游 `KHwang9883/MobileModels` 拉取原始 markdown 数据,并重建 `dist/device_index.json`。如已开启 MySQL 自动装载,也会同步刷新 MySQL。请先启动完整服务。</p>
<ul class="compact-note-list">
<li>从上游拉取原始 markdown,并重建 <code>dist/device_index.json</code></li>
<li>如果已开启 MySQL 自动装载,同步时也会刷新 MySQL。</li>
<li>开始前请确认完整服务已经启动。</li>
</ul>
</article>
<details class="card collapse-card">
<summary>同步配置</summary>
<div class="collapse-body">
<div class="sync-schedule-card">
<h4 class="title">MySQL 自动装载</h4>
<p class="sub">控制同步任务和容器后续启动时是否自动导入 schema 与 seed。保持关闭更安全;开启后,启动容器和“开始同步原始数据”都可能刷新 MySQL 数据。</p>
<p class="sub">控制同步任务和容器后续启动时是否自动导入 schema 与 seed。</p>
<div class="sync-schedule-grid">
<label class="check-row">
<input id="mysqlAutoLoadEnabled" type="checkbox" />
@@ -440,16 +670,18 @@
</div>
<div id="mysqlSettingsStatus" class="sub">正在读取 MySQL 自动装载设置。</div>
</div>
<div class="sync-schedule-card">
<h4 class="title">外部 MySQL 初始化</h4>
<p class="sub">面向关闭自动装载的外部 MySQL。点击后会执行 schema 与 seed 导入,自动创建数据库,并重建 `mobilemodels` 相关表与视图。请确认连接参数与账号权限无误后再执行。</p>
<p class="sub">面向关闭自动装载的外部 MySQL,需要时再执行。</p>
<div class="btns">
<button id="initMysqlBtn" type="button">初始化外部 MySQL</button>
</div>
</div>
<div class="sync-schedule-card">
<h4 class="title">每日自动同步</h4>
<p class="sub">在项目容器内按固定时间自动拉取上游原始数据,并重建索引与 MySQL Seed。时间按容器时区执行,设置会持久化到运行期数据目录。</p>
<p class="sub">按固定时间自动拉取上游并重建索引与 MySQL Seed。</p>
<div class="sync-schedule-grid">
<label class="check-row">
<input id="scheduleEnabled" type="checkbox" />
@@ -469,22 +701,43 @@
</div>
<div id="scheduleStatus" class="sub">正在读取自动同步设置。</div>
</div>
</div>
</details>
<article class="card section-card">
<div class="btns">
<button id="syncUpstreamBtn" type="button" class="primary">开始同步原始数据</button>
<button id="refreshSyncStatusBtn" type="button">刷新同步状态</button>
</div>
<div id="syncStatus" class="sub">正在检测同步能力。</div>
<pre id="syncLog" class="sync-log mono">暂无同步记录</pre>
</article>
<details class="card collapse-card">
<summary>任务日志</summary>
<div class="collapse-body">
<pre id="syncLog" class="sync-log compact mono">暂无同步记录</pre>
</div>
</details>
</div>
</section>
<section id="indexTabPanel" class="manage-panel hidden">
<div class="panel-stack">
<article class="card section-card">
<h3 class="title">索引数据</h3>
<p class="sub">这里集中显示 `dist/device_index.json` 的加载状态与基础统计,并提供手动重新加载入口</p>
<p class="sub">查看当前索引加载状态,并在需要时手动刷新</p>
<div class="btns">
<button id="reloadIndexBtn" type="button" class="primary">重新加载索引</button>
</div>
<div id="indexStatus" class="sub">索引尚未加载。</div>
<pre id="indexSummary" class="sync-log mono">暂无索引信息</pre>
</article>
<details class="card collapse-card">
<summary>索引详情</summary>
<div class="collapse-body">
<pre id="indexSummary" class="sync-log compact mono">暂无索引信息</pre>
</div>
</details>
</div>
</section>
</div>
</div>
@@ -493,11 +746,16 @@
<div id="brandModalBackdrop" class="modal-backdrop hidden">
<div class="modal-card">
<div class="modal-head">
<h3 id="brandModalTitle" class="title">数据管理</h3>
<p id="brandModalHint" class="sub"></p>
</div>
<div class="modal-content">
<textarea id="brandModalTextarea" class="hidden"></textarea>
<pre id="brandModalPre" class="hidden"></pre>
<div class="btns">
<div id="brandModalList" class="modal-list hidden"></div>
</div>
<div class="modal-actions">
<button id="brandModalCancelBtn" type="button">关闭</button>
<button id="brandModalSaveBtn" type="button" class="primary">保存</button>
</div>
@@ -526,6 +784,7 @@
const brandModalHintEl = document.getElementById("brandModalHint");
const brandModalTextareaEl = document.getElementById("brandModalTextarea");
const brandModalPreEl = document.getElementById("brandModalPre");
const brandModalListEl = document.getElementById("brandModalList");
const brandModalSaveBtnEl = document.getElementById("brandModalSaveBtn");
const brandModalCancelBtnEl = document.getElementById("brandModalCancelBtn");
const sourceOrderStatsEl = document.getElementById("sourceOrderStats");
@@ -734,7 +993,7 @@
async function runMysqlInit() {
if (syncRunning || mysqlInitRunning) return;
const confirmed = window.confirm(
"初始化外部 MySQL 会创建数据库,并重建 mobilemodels 相关表、视图和 seed 数据。是否继续?"
"初始化外部 MySQL 会创建数据库,并重建 mobilemodels 主表、相关索引和 seed 数据,同时清理历史兼容对象。是否继续?"
);
if (!confirmed) return;
@@ -1183,7 +1442,38 @@
renderSourceOrder();
}
function openBrandModal({ title, hint, text, editable, onSave }) {
function renderModalList(items = []) {
if (!items.length) {
brandModalListEl.innerHTML = `<div class="modal-list-empty">暂无数据</div>`;
return;
}
brandModalListEl.innerHTML = `
<div class="modal-list-grid">
${items.map((item) => `
<section class="modal-list-item">
<div class="modal-list-head">
<div class="modal-list-title">${escapeHtml(item.title || "-")}</div>
${item.meta ? `<div class="modal-list-meta">${escapeHtml(item.meta)}</div>` : ""}
</div>
${item.tags && item.tags.length ? `
<div class="modal-list-tags">
${item.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
</div>
` : ""}
</section>
`).join("")}
</div>
`;
}
function resizeBrandModalTextarea() {
if (brandModalTextareaEl.classList.contains("hidden")) return;
brandModalTextareaEl.style.height = "auto";
brandModalTextareaEl.style.height = `${Math.max(420, brandModalTextareaEl.scrollHeight + 24)}px`;
}
function openBrandModal({ title, hint, text, editable, onSave, listItems }) {
modalSaveHandler = onSave || null;
brandModalTitleEl.textContent = title || "数据管理";
brandModalHintEl.textContent = hint || "";
@@ -1192,20 +1482,31 @@
brandModalTextareaEl.classList.remove("hidden");
brandModalTextareaEl.value = text || "";
brandModalPreEl.classList.add("hidden");
brandModalListEl.classList.add("hidden");
brandModalSaveBtnEl.classList.remove("hidden");
requestAnimationFrame(resizeBrandModalTextarea);
} else if (listItems) {
brandModalListEl.classList.remove("hidden");
renderModalList(listItems);
brandModalPreEl.classList.add("hidden");
brandModalTextareaEl.classList.add("hidden");
brandModalSaveBtnEl.classList.add("hidden");
} else {
brandModalPreEl.classList.remove("hidden");
brandModalPreEl.textContent = text || "";
brandModalTextareaEl.classList.add("hidden");
brandModalListEl.classList.add("hidden");
brandModalSaveBtnEl.classList.add("hidden");
}
brandModalBackdropEl.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function closeBrandModal() {
modalSaveHandler = null;
brandModalBackdropEl.classList.add("hidden");
document.body.classList.remove("modal-open");
}
function applyManagedBrandConfigUpdate(newConfig) {
@@ -1218,8 +1519,14 @@
function openBrandListModal() {
openBrandModal({
title: "品牌列表",
hint: "当前独立维护的品牌列表(含同义词与父级厂商。",
text: JSON.stringify(managedBrandConfig.brands, null, 2),
hint: "当前独立维护的品牌列表,按品牌查看同义词与所属厂商。",
listItems: [...managedBrandConfig.brands]
.sort((a, b) => a.name.localeCompare(b.name))
.map((brand) => ({
title: brand.name,
meta: `所属厂商:${managedBrandToManufacturer.get(brand.name) || brand.manufacturer || "-"}`,
tags: normalizeAliasList(brand.name, brand.aliases || []),
})),
editable: false,
});
}
@@ -1231,8 +1538,14 @@
}));
openBrandModal({
title: "厂商列表",
hint: "当前独立维护的厂商列表(含所属品牌。",
text: JSON.stringify(rows, null, 2),
hint: "当前独立维护的厂商列表,按厂商查看归属品牌。",
listItems: rows
.sort((a, b) => a.manufacturer.localeCompare(b.manufacturer))
.map((row) => ({
title: row.manufacturer,
meta: `品牌数:${row.brands.length}`,
tags: row.brands,
})),
editable: false,
});
}
@@ -1469,6 +1782,7 @@
brandStatsEl.textContent = `保存失败: ${err.message}`;
}
});
brandModalTextareaEl.addEventListener("input", resizeBrandModalTextarea);
function switchManageTab(tab) {
const isBrand = tab === "brand";