Add manual catalog management
This commit is contained in:
+503
-9
@@ -78,7 +78,7 @@
|
||||
padding: 0 16px 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.card {
|
||||
@@ -129,6 +129,17 @@
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
.btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -207,6 +218,7 @@
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
min-width: 800px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
@@ -219,6 +231,7 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.tag {
|
||||
@@ -229,6 +242,7 @@
|
||||
font-size: 12px;
|
||||
background: #eef3fb;
|
||||
color: #2d4a7a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-divider {
|
||||
margin: 14px 0;
|
||||
@@ -306,12 +320,18 @@
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + 20px);
|
||||
padding: 12px;
|
||||
align-self: start;
|
||||
}
|
||||
.manage-content {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
.tab-btn {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
border: 1px solid #c8d6ee;
|
||||
background: #f7faff;
|
||||
@@ -327,7 +347,7 @@
|
||||
}
|
||||
.panel-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 16px;
|
||||
}
|
||||
.section-card {
|
||||
padding: 14px;
|
||||
@@ -336,6 +356,55 @@
|
||||
.section-card .sub:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.plain-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--sub);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.plain-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.action-cell {
|
||||
width: 200px;
|
||||
}
|
||||
.action-cell .btns {
|
||||
margin-top: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.manual-brand-table col:nth-child(1) { width: 160px; }
|
||||
.manual-brand-table col:nth-child(2) { width: 180px; }
|
||||
.manual-brand-table col:nth-child(3) { width: 180px; }
|
||||
.manual-brand-table col:nth-child(4) { width: 240px; }
|
||||
.manual-brand-table col:nth-child(5) { width: 200px; }
|
||||
.manual-device-table col:nth-child(1) { width: 160px; }
|
||||
.manual-device-table col:nth-child(2) { width: 240px; }
|
||||
.manual-device-table col:nth-child(3) { width: 140px; }
|
||||
.manual-device-table col:nth-child(4) { width: auto; }
|
||||
.manual-device-table col:nth-child(5) { width: 280px; }
|
||||
.manual-device-table col:nth-child(6) { width: 200px; }
|
||||
.manual-brand-table th,
|
||||
.manual-brand-table td,
|
||||
.manual-device-table th,
|
||||
.manual-device-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.manual-brand-table td,
|
||||
.manual-device-table td {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.manual-brand-table .tag,
|
||||
.manual-device-table .tag {
|
||||
margin: 0;
|
||||
}
|
||||
.manual-actions-bar {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.manual-actions-bar .btns {
|
||||
margin-top: 0;
|
||||
}
|
||||
.collapse-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -431,7 +500,7 @@
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
.hidden { display: none !important; }
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -474,7 +543,7 @@
|
||||
flex: 1 1 auto;
|
||||
min-height: 320px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
display: block;
|
||||
}
|
||||
.modal-list {
|
||||
width: 100%;
|
||||
@@ -552,6 +621,49 @@
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.modal-form {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.modal-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.modal-form-grid .full-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.field-group {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.modal-form label {
|
||||
margin: 0;
|
||||
}
|
||||
.modal-form textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-tip {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #6a788e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -566,7 +678,7 @@
|
||||
}
|
||||
.manage-tabs {
|
||||
position: static;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
@@ -585,6 +697,9 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.modal-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modal-backdrop {
|
||||
padding: calc(var(--nav-height) + 12px) 12px 12px;
|
||||
}
|
||||
@@ -608,6 +723,7 @@
|
||||
<div class="wrap">
|
||||
<aside class="card manage-tabs">
|
||||
<button id="tabBrandBtn" type="button" class="tab-btn active">品牌列表</button>
|
||||
<button id="tabManualBtn" type="button" class="tab-btn">手动补录</button>
|
||||
<button id="tabSourceBtn" type="button" class="tab-btn">数据来源</button>
|
||||
<button id="tabSyncBtn" type="button" class="tab-btn">原始数据同步</button>
|
||||
<button id="tabIndexBtn" type="button" class="tab-btn">索引数据</button>
|
||||
@@ -648,6 +764,92 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="manualTabPanel" class="manage-panel hidden">
|
||||
<div class="panel-stack">
|
||||
<details class="card collapse-card" open>
|
||||
<summary>说明</summary>
|
||||
<div class="collapse-body">
|
||||
<ul class="plain-list">
|
||||
<li>本页维护的是本地覆盖库,适合补录上游暂未收录的品牌和设备。</li>
|
||||
<li>保存后会重建索引和 MySQL seed;是否自动刷新 MySQL 取决于左侧同步配置中的自动装载开关。</li>
|
||||
<li>本地覆盖库不会被“原始数据同步”覆盖,适合补录学习机、教育终端、定制设备。</li>
|
||||
</ul>
|
||||
<div id="manualStatus" class="sub">正在读取本地覆盖库。</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card collapse-card" open>
|
||||
<summary>品牌管理</summary>
|
||||
<div class="collapse-body">
|
||||
<div class="manual-actions-bar">
|
||||
<div class="btns">
|
||||
<button id="addManualBrandBtn" type="button" class="primary">新增品牌</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="manual-brand-table">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品牌</th>
|
||||
<th>父级厂商</th>
|
||||
<th>品牌别名</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualBrandBody">
|
||||
<tr><td colspan="5" class="sub">暂无手动品牌</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card collapse-card" open>
|
||||
<summary>设备管理</summary>
|
||||
<div class="collapse-body">
|
||||
<div class="manual-actions-bar">
|
||||
<div class="btns">
|
||||
<button id="addManualDeviceBtn" type="button" class="primary">新增设备</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="manual-device-table">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>品牌</th>
|
||||
<th>设备名称</th>
|
||||
<th>设备类型</th>
|
||||
<th>设备标识</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualDeviceBody">
|
||||
<tr><td colspan="6" class="sub">暂无手动设备</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sourceTabPanel" class="manage-panel hidden">
|
||||
<div class="panel-stack">
|
||||
<article class="card section-card">
|
||||
@@ -763,6 +965,7 @@
|
||||
<textarea id="brandModalTextarea" class="hidden"></textarea>
|
||||
<pre id="brandModalPre" class="hidden"></pre>
|
||||
<div id="brandModalList" class="modal-list hidden"></div>
|
||||
<div id="brandModalForm" class="modal-form hidden"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="brandModalCancelBtn" type="button">关闭</button>
|
||||
@@ -778,10 +981,12 @@
|
||||
let indexData = null;
|
||||
let managedBrandConfig = null;
|
||||
let managedSourceConfig = null;
|
||||
let manualCatalog = { brands: [], devices: [] };
|
||||
let managedBrandAliasToBrand = new Map();
|
||||
let managedBrandToManufacturer = new Map();
|
||||
let managedManufacturerToBrands = new Map();
|
||||
let modalSaveHandler = null;
|
||||
let modalCollectHandler = null;
|
||||
let draggingSourceIndex = -1;
|
||||
|
||||
const brandStatsEl = document.getElementById("brandStats");
|
||||
@@ -794,15 +999,18 @@
|
||||
const brandModalTextareaEl = document.getElementById("brandModalTextarea");
|
||||
const brandModalPreEl = document.getElementById("brandModalPre");
|
||||
const brandModalListEl = document.getElementById("brandModalList");
|
||||
const brandModalFormEl = document.getElementById("brandModalForm");
|
||||
const brandModalSaveBtnEl = document.getElementById("brandModalSaveBtn");
|
||||
const brandModalCancelBtnEl = document.getElementById("brandModalCancelBtn");
|
||||
const sourceOrderStatsEl = document.getElementById("sourceOrderStats");
|
||||
const sourceOrderListEl = document.getElementById("sourceOrderList");
|
||||
const tabBrandBtnEl = document.getElementById("tabBrandBtn");
|
||||
const tabManualBtnEl = document.getElementById("tabManualBtn");
|
||||
const tabSourceBtnEl = document.getElementById("tabSourceBtn");
|
||||
const tabSyncBtnEl = document.getElementById("tabSyncBtn");
|
||||
const tabIndexBtnEl = document.getElementById("tabIndexBtn");
|
||||
const brandTabPanelEl = document.getElementById("brandTabPanel");
|
||||
const manualTabPanelEl = document.getElementById("manualTabPanel");
|
||||
const sourceTabPanelEl = document.getElementById("sourceTabPanel");
|
||||
const syncTabPanelEl = document.getElementById("syncTabPanel");
|
||||
const indexTabPanelEl = document.getElementById("indexTabPanel");
|
||||
@@ -822,6 +1030,11 @@
|
||||
const reloadIndexBtnEl = document.getElementById("reloadIndexBtn");
|
||||
const indexStatusEl = document.getElementById("indexStatus");
|
||||
const indexSummaryEl = document.getElementById("indexSummary");
|
||||
const manualStatusEl = document.getElementById("manualStatus");
|
||||
const manualBrandBodyEl = document.getElementById("manualBrandBody");
|
||||
const manualDeviceBodyEl = document.getElementById("manualDeviceBody");
|
||||
const addManualBrandBtnEl = document.getElementById("addManualBrandBtn");
|
||||
const addManualDeviceBtnEl = document.getElementById("addManualDeviceBtn");
|
||||
|
||||
let syncSupported = false;
|
||||
let syncRunning = false;
|
||||
@@ -851,6 +1064,47 @@
|
||||
return data;
|
||||
}
|
||||
|
||||
function toMultilineText(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parseMultilineValue(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatDateTime(text) {
|
||||
const raw = String(text || "").trim();
|
||||
if (!raw) return "-";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return raw.replace("T", " ").replace(/([+-]\d{2}:\d{2}|Z)$/i, "");
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
const second = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
function availableBrandNames() {
|
||||
const names = new Set();
|
||||
for (const brand of (managedBrandConfig && managedBrandConfig.brands) || []) {
|
||||
if (brand && brand.name) names.add(brand.name);
|
||||
}
|
||||
for (const brand of (manualCatalog && manualCatalog.brands) || []) {
|
||||
if (brand && brand.name) names.add(brand.name);
|
||||
}
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function updateSyncButtons() {
|
||||
const busy = syncRunning || mysqlInitRunning;
|
||||
syncUpstreamBtnEl.disabled = busy || !syncSupported;
|
||||
@@ -1480,8 +1734,9 @@
|
||||
brandModalTextareaEl.style.height = `${Math.max(420, brandModalTextareaEl.scrollHeight + 24)}px`;
|
||||
}
|
||||
|
||||
function openBrandModal({ title, hint, text, editable, onSave, listItems }) {
|
||||
function openBrandModal({ title, hint, text, editable, onSave, listItems, formHtml, collect }) {
|
||||
modalSaveHandler = onSave || null;
|
||||
modalCollectHandler = collect || null;
|
||||
brandModalTitleEl.textContent = title || "数据管理";
|
||||
brandModalHintEl.textContent = hint || "";
|
||||
|
||||
@@ -1490,19 +1745,29 @@
|
||||
brandModalTextareaEl.value = text || "";
|
||||
brandModalPreEl.classList.add("hidden");
|
||||
brandModalListEl.classList.add("hidden");
|
||||
brandModalFormEl.classList.add("hidden");
|
||||
brandModalSaveBtnEl.classList.remove("hidden");
|
||||
requestAnimationFrame(resizeBrandModalTextarea);
|
||||
} else if (formHtml) {
|
||||
brandModalFormEl.classList.remove("hidden");
|
||||
brandModalFormEl.innerHTML = formHtml;
|
||||
brandModalPreEl.classList.add("hidden");
|
||||
brandModalListEl.classList.add("hidden");
|
||||
brandModalTextareaEl.classList.add("hidden");
|
||||
brandModalSaveBtnEl.classList.remove("hidden");
|
||||
} else if (listItems) {
|
||||
brandModalListEl.classList.remove("hidden");
|
||||
renderModalList(listItems);
|
||||
brandModalPreEl.classList.add("hidden");
|
||||
brandModalTextareaEl.classList.add("hidden");
|
||||
brandModalFormEl.classList.add("hidden");
|
||||
brandModalSaveBtnEl.classList.add("hidden");
|
||||
} else {
|
||||
brandModalPreEl.classList.remove("hidden");
|
||||
brandModalPreEl.textContent = text || "";
|
||||
brandModalTextareaEl.classList.add("hidden");
|
||||
brandModalListEl.classList.add("hidden");
|
||||
brandModalFormEl.classList.add("hidden");
|
||||
brandModalSaveBtnEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
@@ -1512,6 +1777,8 @@
|
||||
|
||||
function closeBrandModal() {
|
||||
modalSaveHandler = null;
|
||||
modalCollectHandler = null;
|
||||
brandModalFormEl.innerHTML = "";
|
||||
brandModalBackdropEl.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
@@ -1722,6 +1989,224 @@
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderManualCatalog() {
|
||||
const brands = Array.isArray(manualCatalog && manualCatalog.brands) ? manualCatalog.brands : [];
|
||||
const devices = Array.isArray(manualCatalog && manualCatalog.devices) ? manualCatalog.devices : [];
|
||||
manualStatusEl.textContent = `本地覆盖库: 品牌 ${brands.length} 个,设备 ${devices.length} 个。保存后会刷新索引和 MySQL seed。`;
|
||||
|
||||
if (!brands.length) {
|
||||
manualBrandBodyEl.innerHTML = `<tr><td colspan="5" class="sub">暂无手动品牌</td></tr>`;
|
||||
} else {
|
||||
manualBrandBodyEl.innerHTML = brands.map((brand) => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(brand.name || "-")}</strong></td>
|
||||
<td><span class="tag">${escapeHtml(brand.parent_brand || brand.name || "-")}</span></td>
|
||||
<td>${normalizeAliasList(brand.name, brand.aliases || []).map((alias) => `<span class="tag">${escapeHtml(alias)}</span>`).join("")}</td>
|
||||
<td>${escapeHtml(formatDateTime(brand.updated_at))}</td>
|
||||
<td class="action-cell">
|
||||
<div class="btns">
|
||||
<button type="button" data-manual-brand-edit="${escapeHtml(brand.name)}">编辑</button>
|
||||
<button type="button" data-manual-brand-delete="${escapeHtml(brand.name)}">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
if (!devices.length) {
|
||||
manualDeviceBodyEl.innerHTML = `<tr><td colspan="6" class="sub">暂无手动设备</td></tr>`;
|
||||
} else {
|
||||
manualDeviceBodyEl.innerHTML = devices.map((device) => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(device.brand || "-")}</strong></td>
|
||||
<td>${escapeHtml(device.device_name || "-")}</td>
|
||||
<td><span class="tag">${escapeHtml(device.device_type || "-")}</span></td>
|
||||
<td>${(Array.isArray(device.models) ? device.models : []).map((model) => `<span class="tag mono">${escapeHtml(model)}</span>`).join("")}</td>
|
||||
<td>${escapeHtml(formatDateTime(device.updated_at))}</td>
|
||||
<td class="action-cell">
|
||||
<div class="btns">
|
||||
<button type="button" data-manual-device-edit="${escapeHtml(device.id)}">编辑</button>
|
||||
<button type="button" data-manual-device-delete="${escapeHtml(device.id)}">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
manualBrandBodyEl.querySelectorAll("[data-manual-brand-edit]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const brand = brands.find((item) => item.name === button.dataset.manualBrandEdit);
|
||||
if (brand) openManualBrandModal(brand);
|
||||
});
|
||||
});
|
||||
manualBrandBodyEl.querySelectorAll("[data-manual-brand-delete]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const brandName = button.dataset.manualBrandDelete;
|
||||
if (!window.confirm(`确认删除品牌“${brandName}”吗?`)) return;
|
||||
await deleteManualBrand(brandName);
|
||||
});
|
||||
});
|
||||
manualDeviceBodyEl.querySelectorAll("[data-manual-device-edit]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const device = devices.find((item) => item.id === button.dataset.manualDeviceEdit);
|
||||
if (device) openManualDeviceModal(device);
|
||||
});
|
||||
});
|
||||
manualDeviceBodyEl.querySelectorAll("[data-manual-device-delete]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const deviceId = button.dataset.manualDeviceDelete;
|
||||
if (!window.confirm("确认删除这条手动设备吗?")) return;
|
||||
await deleteManualDevice(deviceId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadManualCatalog() {
|
||||
manualStatusEl.textContent = "正在读取本地覆盖库。";
|
||||
try {
|
||||
const payload = await fetchJson("/api/manual-catalog", { cache: "no-store" });
|
||||
manualCatalog = {
|
||||
brands: Array.isArray(payload.brands) ? payload.brands : [],
|
||||
devices: Array.isArray(payload.devices) ? payload.devices : [],
|
||||
};
|
||||
renderManualCatalog();
|
||||
} catch (err) {
|
||||
manualCatalog = { brands: [], devices: [] };
|
||||
manualStatusEl.textContent = `本地覆盖库读取失败: ${err.message}`;
|
||||
renderManualCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
function openManualBrandModal(brand = null) {
|
||||
const current = brand || { name: "", parent_brand: "", aliases: [] };
|
||||
openBrandModal({
|
||||
title: brand ? "编辑品牌" : "新增品牌",
|
||||
hint: "本地覆盖库中的品牌不会被上游同步覆盖。",
|
||||
formHtml: `
|
||||
<div class="modal-form-grid">
|
||||
<div class="field-group">
|
||||
<label for="manualBrandName">品牌名称</label>
|
||||
<input id="manualBrandName" type="text" value="${escapeHtml(current.name || "")}" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="manualParentBrand">父级厂商</label>
|
||||
<input id="manualParentBrand" type="text" value="${escapeHtml(current.parent_brand || current.name || "")}" />
|
||||
</div>
|
||||
<div class="field-group full-row">
|
||||
<label for="manualBrandAliases">品牌别名</label>
|
||||
<textarea id="manualBrandAliases" placeholder="每行一个别名">${escapeHtml(toMultilineText(current.aliases || []))}</textarea>
|
||||
<div class="field-tip">品牌本名会自动补入,无需重复填写。</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
collect: () => ({
|
||||
name: document.getElementById("manualBrandName").value,
|
||||
parent_brand: document.getElementById("manualParentBrand").value,
|
||||
aliases: parseMultilineValue(document.getElementById("manualBrandAliases").value),
|
||||
created_at: current.created_at,
|
||||
updated_at: current.updated_at,
|
||||
}),
|
||||
onSave: async (payload) => {
|
||||
const result = await fetchJson("/api/manual-brand", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
manualCatalog = result.catalog || manualCatalog;
|
||||
manualStatusEl.textContent = result.message || "品牌已保存。";
|
||||
renderManualCatalog();
|
||||
await loadIndexFromPath();
|
||||
await loadSyncStatus({ preserveLog: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openManualDeviceModal(device = null) {
|
||||
const current = device || { id: "", brand: "", device_name: "", device_type: "tablet", models: [], aliases: [], section: "手动补录" };
|
||||
const brandOptions = availableBrandNames()
|
||||
.map((name) => `<option value="${escapeHtml(name)}" ${name === current.brand ? "selected" : ""}>${escapeHtml(name)}</option>`)
|
||||
.join("");
|
||||
openBrandModal({
|
||||
title: device ? "编辑设备" : "新增设备",
|
||||
hint: "设备标识用于 model_raw 命中,至少保留一个。",
|
||||
formHtml: `
|
||||
<div class="modal-form-grid">
|
||||
<div class="field-group">
|
||||
<label for="manualDeviceBrand">所属品牌</label>
|
||||
<select id="manualDeviceBrand">${brandOptions}</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="manualDeviceType">设备类型</label>
|
||||
<select id="manualDeviceType">
|
||||
${["phone", "tablet", "wear", "tv", "computer", "other"].map((type) => `<option value="${type}" ${type === current.device_type ? "selected" : ""}>${type}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group full-row">
|
||||
<label for="manualDeviceName">设备名称</label>
|
||||
<input id="manualDeviceName" type="text" value="${escapeHtml(current.device_name || "")}" />
|
||||
</div>
|
||||
<div class="field-group full-row">
|
||||
<label for="manualDeviceModels">设备标识</label>
|
||||
<textarea id="manualDeviceModels" placeholder="每行一个 model_raw">${escapeHtml(toMultilineText(current.models || []))}</textarea>
|
||||
</div>
|
||||
<div class="field-group full-row">
|
||||
<label for="manualDeviceAliases">额外别名</label>
|
||||
<textarea id="manualDeviceAliases" placeholder="每行一个额外别名">${escapeHtml(toMultilineText(current.aliases || []))}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
collect: () => ({
|
||||
id: current.id,
|
||||
brand: document.getElementById("manualDeviceBrand").value,
|
||||
device_name: document.getElementById("manualDeviceName").value,
|
||||
device_type: document.getElementById("manualDeviceType").value,
|
||||
models: parseMultilineValue(document.getElementById("manualDeviceModels").value),
|
||||
aliases: parseMultilineValue(document.getElementById("manualDeviceAliases").value),
|
||||
section: "手动补录",
|
||||
created_at: current.created_at,
|
||||
updated_at: current.updated_at,
|
||||
}),
|
||||
onSave: async (payload) => {
|
||||
const result = await fetchJson("/api/manual-device", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
manualCatalog = result.catalog || manualCatalog;
|
||||
manualStatusEl.textContent = `${result.message || "设备已保存。"}${typeof result.alias_conflict_count === "number" ? ` 命中现有别名冲突 ${result.alias_conflict_count} 个。` : ""}`;
|
||||
renderManualCatalog();
|
||||
await loadIndexFromPath();
|
||||
await loadSyncStatus({ preserveLog: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteManualBrand(name) {
|
||||
const result = await fetchJson("/api/manual-brand-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
manualCatalog = result.catalog || manualCatalog;
|
||||
manualStatusEl.textContent = result.message || `品牌 ${name} 已删除。`;
|
||||
renderManualCatalog();
|
||||
await loadIndexFromPath();
|
||||
await loadSyncStatus({ preserveLog: true });
|
||||
}
|
||||
|
||||
async function deleteManualDevice(id) {
|
||||
const result = await fetchJson("/api/manual-device-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
manualCatalog = result.catalog || manualCatalog;
|
||||
manualStatusEl.textContent = result.message || "设备已删除。";
|
||||
renderManualCatalog();
|
||||
await loadIndexFromPath();
|
||||
await loadSyncStatus({ preserveLog: true });
|
||||
}
|
||||
|
||||
async function loadIndexFromPath() {
|
||||
try {
|
||||
renderIndexStatus("正在加载 dist/device_index.json ...", "加载中...");
|
||||
@@ -1733,6 +2218,7 @@
|
||||
rebuildManagedBrandIndexes();
|
||||
renderBrandStats();
|
||||
renderSourceOrder();
|
||||
renderManualCatalog();
|
||||
const sourceCount = Array.isArray(managedSourceConfig && managedSourceConfig.order)
|
||||
? managedSourceConfig.order.length
|
||||
: 0;
|
||||
@@ -1765,6 +2251,8 @@
|
||||
document.getElementById("editBrandListBtn").addEventListener("click", openEditBrandListModal);
|
||||
document.getElementById("editBrandRelationsBtn").addEventListener("click", openEditBrandRelationsModal);
|
||||
document.getElementById("editBrandAliasesBtn").addEventListener("click", openEditBrandAliasesModal);
|
||||
addManualBrandBtnEl.addEventListener("click", () => openManualBrandModal());
|
||||
addManualDeviceBtnEl.addEventListener("click", () => openManualDeviceModal());
|
||||
document.getElementById("saveSourceOrderBtn").addEventListener("click", saveSourceOrder);
|
||||
document.getElementById("resetSourceOrderBtn").addEventListener("click", resetSourceOrder);
|
||||
brandCountBtnEl.addEventListener("click", openBrandListModal);
|
||||
@@ -1780,39 +2268,45 @@
|
||||
brandModalBackdropEl.addEventListener("click", (e) => {
|
||||
if (e.target === brandModalBackdropEl) closeBrandModal();
|
||||
});
|
||||
brandModalSaveBtnEl.addEventListener("click", () => {
|
||||
brandModalSaveBtnEl.addEventListener("click", async () => {
|
||||
if (!modalSaveHandler) return;
|
||||
try {
|
||||
modalSaveHandler(brandModalTextareaEl.value);
|
||||
const payload = modalCollectHandler ? modalCollectHandler() : brandModalTextareaEl.value;
|
||||
await modalSaveHandler(payload);
|
||||
closeBrandModal();
|
||||
} catch (err) {
|
||||
brandStatsEl.textContent = `保存失败: ${err.message}`;
|
||||
manualStatusEl.textContent = `保存失败: ${err.message}`;
|
||||
}
|
||||
});
|
||||
brandModalTextareaEl.addEventListener("input", resizeBrandModalTextarea);
|
||||
|
||||
function switchManageTab(tab) {
|
||||
const isBrand = tab === "brand";
|
||||
const isManual = tab === "manual";
|
||||
const isSource = tab === "source";
|
||||
const isSync = tab === "sync";
|
||||
const isIndex = tab === "index";
|
||||
tabBrandBtnEl.classList.toggle("active", isBrand);
|
||||
tabManualBtnEl.classList.toggle("active", isManual);
|
||||
tabSourceBtnEl.classList.toggle("active", isSource);
|
||||
tabSyncBtnEl.classList.toggle("active", isSync);
|
||||
tabIndexBtnEl.classList.toggle("active", isIndex);
|
||||
brandTabPanelEl.classList.toggle("hidden", !isBrand);
|
||||
manualTabPanelEl.classList.toggle("hidden", !isManual);
|
||||
sourceTabPanelEl.classList.toggle("hidden", !isSource);
|
||||
syncTabPanelEl.classList.toggle("hidden", !isSync);
|
||||
indexTabPanelEl.classList.toggle("hidden", !isIndex);
|
||||
}
|
||||
|
||||
tabBrandBtnEl.addEventListener("click", () => switchManageTab("brand"));
|
||||
tabManualBtnEl.addEventListener("click", () => switchManageTab("manual"));
|
||||
tabSourceBtnEl.addEventListener("click", () => switchManageTab("source"));
|
||||
tabSyncBtnEl.addEventListener("click", () => switchManageTab("sync"));
|
||||
tabIndexBtnEl.addEventListener("click", () => switchManageTab("index"));
|
||||
|
||||
switchManageTab("brand");
|
||||
loadIndexFromPath();
|
||||
loadManualCatalog();
|
||||
loadSyncStatus();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user