Files
MobileModels/web/brand_management.html
T
2026-04-14 18:43:19 +08:00

2314 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MobileModels 数据管理</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--text: #1c2430;
--sub: #566173;
--line: #d9e0ea;
--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: var(--nav-height);
display: flex;
align-items: center;
gap: 8px;
}
.top-nav .brand {
color: #f4f8ff;
text-decoration: none;
font-weight: 700;
margin-right: 8px;
padding: 4px 8px;
border-radius: 6px;
}
.top-nav .brand:hover {
background: rgba(255, 255, 255, 0.08);
}
.top-nav .item {
color: #d6e3f7;
text-decoration: none;
font-size: 14px;
padding: 6px 10px;
border-radius: 6px;
}
.top-nav .item:hover {
background: rgba(255, 255, 255, 0.08);
}
.top-nav .item.active {
background: rgba(255, 255, 255, 0.16);
color: #fff;
font-weight: 600;
}
.wrap {
max-width: 1200px;
margin: 24px auto;
padding: 0 16px 32px;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
box-shadow: 0 6px 18px rgba(36, 56, 89, 0.06);
}
.title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
}
.sub {
margin: 0 0 14px;
color: var(--sub);
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;
font-size: 13px;
color: #324056;
font-weight: 600;
}
input {
width: 100%;
padding: 10px;
border: 1px solid var(--line);
border-radius: 10px;
font-size: 14px;
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;
margin-top: 12px;
flex-wrap: wrap;
}
.btns + .sub {
margin-top: 10px;
}
button {
border: 0;
border-radius: 10px;
padding: 10px 14px;
font-size: 14px;
cursor: pointer;
background: #e8eef9;
color: #1b2d49;
font-weight: 600;
}
button.primary { background: var(--brand); color: #fff; }
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.result-head {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.brand-toolbar {
display: grid;
gap: 12px;
}
.brand-toolbar + .sub {
margin-top: 10px;
}
.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;
border: 1px solid var(--line);
border-radius: 10px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 800px;
table-layout: fixed;
}
th, td {
border-bottom: 1px solid var(--line);
text-align: left;
padding: 8px;
vertical-align: top;
}
th {
background: #f7f9fd;
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.tag {
display: inline-block;
margin: 2px 6px 2px 0;
border-radius: 8px;
padding: 2px 7px;
font-size: 12px;
background: #eef3fb;
color: #2d4a7a;
white-space: nowrap;
}
.section-divider {
margin: 14px 0;
border: 0;
border-top: 1px dashed var(--line);
}
.source-order-wrap {
display: grid;
gap: 10px;
}
.source-order-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
flex-wrap: wrap;
}
.source-order-toolbar .btns {
margin-top: 0;
}
.source-order-toolbar .sub {
margin: 0;
font-size: 14px;
}
.source-order-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.source-order-item {
border: 1px solid #cfd9ea;
border-radius: 12px;
background: #fff;
padding: 12px 14px;
display: grid;
grid-template-columns: 18px 56px minmax(0, 1fr);
gap: 12px;
align-items: center;
cursor: grab;
box-shadow: 0 4px 12px rgba(36, 56, 89, 0.04);
}
.source-order-item.dragging {
opacity: 0.45;
}
.source-order-item.drag-target {
border-color: #0f6fff;
background: #eff5ff;
}
.drag-handle {
color: #7d8ea8;
font-size: 16px;
user-select: none;
letter-spacing: 1px;
}
.source-rank {
font-size: 13px;
font-weight: 700;
color: #3a5a8b;
background: #edf4ff;
border: 1px solid #cadeff;
border-radius: 999px;
padding: 5px 10px;
text-align: center;
}
.source-name {
font-size: 15px;
color: #1f355a;
word-break: break-all;
}
.manage-tabs {
display: grid;
gap: 8px;
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;
color: #274a7d;
}
.tab-btn.active {
background: #0f6fff;
color: #fff;
border-color: #0f6fff;
}
.manage-panel.hidden {
display: none;
}
.panel-stack {
display: grid;
gap: 16px;
}
.section-card {
padding: 14px;
}
.section-card .title:last-child,
.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;
}
.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: 12px 14px 14px;
display: grid;
gap: 10px;
}
.sync-log {
min-height: 240px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
background: #f6f8fb;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
margin: 0;
}
.sync-log.compact {
min-height: 180px;
}
.sync-schedule-card {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fbfcff;
}
.sync-schedule-card .title {
margin-bottom: 10px;
font-size: 15px;
}
.sync-schedule-grid {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(180px, 240px);
gap: 10px;
align-items: end;
}
.sync-schedule-grid .full-row {
grid-column: 1 / -1;
}
.sync-schedule-card .btns {
margin-top: 10px;
}
.sync-schedule-card .btns + .sub {
margin-top: 8px;
}
.check-row {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
min-height: 38px;
}
.check-row input {
width: 16px;
height: 16px;
margin: 0;
flex: 0 0 auto;
}
.hidden { display: none !important; }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(10, 20, 38, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
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: 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: block;
}
.modal-list {
width: 100%;
height: 100%;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: #fcfdff;
padding: 12px;
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: 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: 100%;
height: 100%;
width: 100%;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
background: #f6f8fb;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
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;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #eef2f8;
flex: 0 0 auto;
}
@media (max-width: 1020px) {
.wrap {
grid-template-columns: 1fr;
}
.manage-tabs {
position: static;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.brand-toolbar-row {
grid-template-columns: 1fr;
}
.source-order-toolbar {
align-items: stretch;
}
.source-order-toolbar .btns {
width: 100%;
}
.brand-toolbar-btn,
.brand-toolbar-btn.stat-btn,
.brand-toolbar-btn.action-btn {
width: 100%;
min-width: 0;
}
.modal-form-grid {
grid-template-columns: 1fr;
}
.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>
<nav class="top-nav">
<div class="top-nav-inner">
<a href="/web/device_query.html" class="brand">MobileModels</a>
<a href="/web/device_query.html" class="item">设备查询</a>
<a href="/web/brand_management.html" class="item active">数据管理</a>
<a href="/web/device_query.html?view=docs" class="item">使用文档</a>
</div>
</nav>
<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>
</aside>
<div class="manage-content">
<section id="brandTabPanel" class="manage-panel">
<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>
<div id="brandStats" class="sub">索引未加载。</div>
</article>
<article class="card section-card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>品牌</th>
<th>品牌同义词</th>
<th>父级厂商</th>
</tr>
</thead>
<tbody id="brandRelationBody">
<tr><td colspan="3" class="sub">暂无关系数据</td></tr>
</tbody>
</table>
</div>
</article>
</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">
<div class="source-order-wrap">
<div class="source-order-toolbar">
<div class="btns">
<button id="saveSourceOrderBtn" type="button" class="primary">保存来源排序</button>
<button id="resetSourceOrderBtn" type="button">重置来源排序</button>
</div>
<div id="sourceOrderStats" class="sub">来源列表未加载。</div>
</div>
<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">
<details class="card collapse-card" open>
<summary>配置</summary>
<div class="collapse-body">
<div class="sync-schedule-card">
<h4 class="title">MySQL 自动装载</h4>
<div class="sync-schedule-grid">
<label class="check-row">
<input id="mysqlAutoLoadEnabled" type="checkbox" />
<span>启用 MySQL 自动装载</span>
</label>
</div>
<div class="btns">
<button id="saveMysqlSettingsBtn" type="button">保存 MySQL 设置</button>
</div>
<div id="mysqlSettingsStatus" class="sub">正在读取 MySQL 自动装载设置。</div>
</div>
<div class="sync-schedule-card">
<h4 class="title">外部 MySQL 初始化</h4>
<div class="btns">
<button id="initMysqlBtn" type="button">初始化外部 MySQL</button>
</div>
</div>
<div class="sync-schedule-card">
<h4 class="title">每日自动同步</h4>
<div class="sync-schedule-grid">
<label class="check-row">
<input id="scheduleEnabled" type="checkbox" />
<span>启用每日自动同步</span>
</label>
<div>
<label for="scheduleTimeInput">每日同步时间</label>
<input id="scheduleTimeInput" type="time" step="60" value="03:00" />
</div>
<div class="full-row">
<label for="githubProxyPrefixInput">GitHub 加速前缀</label>
<input id="githubProxyPrefixInput" type="text" placeholder="例如 https://ghfast.top/" />
</div>
</div>
<div class="btns">
<button id="saveSyncScheduleBtn" type="button" class="primary">保存同步设置</button>
</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>
</article>
<details class="card collapse-card" open>
<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">
<div class="btns">
<button id="reloadIndexBtn" type="button" class="primary">重新加载索引</button>
</div>
<div id="indexStatus" class="sub">索引尚未加载。</div>
</article>
<details class="card collapse-card" open>
<summary>索引详情</summary>
<div class="collapse-body">
<pre id="indexSummary" class="sync-log compact mono">暂无索引信息</pre>
</div>
</details>
</div>
</section>
</div>
</div>
<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 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>
<button id="brandModalSaveBtn" type="button" class="primary">保存</button>
</div>
</div>
</div>
<script>
const BRAND_CONFIG_STORAGE_KEY = "mobilemodels_brand_config_v2";
const SOURCE_CONFIG_STORAGE_KEY = "mobilemodels_source_config_v1";
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");
const brandCountBtnEl = document.getElementById("brandCountBtn");
const manufacturerCountBtnEl = document.getElementById("manufacturerCountBtn");
const brandRelationBodyEl = document.getElementById("brandRelationBody");
const brandModalBackdropEl = document.getElementById("brandModalBackdrop");
const brandModalTitleEl = document.getElementById("brandModalTitle");
const brandModalHintEl = document.getElementById("brandModalHint");
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");
const syncStatusEl = document.getElementById("syncStatus");
const syncLogEl = document.getElementById("syncLog");
const syncUpstreamBtnEl = document.getElementById("syncUpstreamBtn");
const initMysqlBtnEl = document.getElementById("initMysqlBtn");
const refreshSyncStatusBtnEl = document.getElementById("refreshSyncStatusBtn");
const mysqlAutoLoadEnabledEl = document.getElementById("mysqlAutoLoadEnabled");
const saveMysqlSettingsBtnEl = document.getElementById("saveMysqlSettingsBtn");
const mysqlSettingsStatusEl = document.getElementById("mysqlSettingsStatus");
const scheduleEnabledEl = document.getElementById("scheduleEnabled");
const scheduleTimeInputEl = document.getElementById("scheduleTimeInput");
const githubProxyPrefixInputEl = document.getElementById("githubProxyPrefixInput");
const saveSyncScheduleBtnEl = document.getElementById("saveSyncScheduleBtn");
const scheduleStatusEl = document.getElementById("scheduleStatus");
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;
let mysqlInitRunning = false;
let mysqlSettingsSaving = false;
let scheduleSaving = false;
function normalizeText(text) {
return (text || "").toLowerCase().replace(/[^0-9a-z\u4e00-\u9fff]+/g, "");
}
function escapeHtml(text) {
return (text || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function fetchJson(url, options = {}) {
const resp = await fetch(url, options);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error((data && data.error) || `HTTP ${resp.status}`);
}
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;
initMysqlBtnEl.disabled = busy || !syncSupported;
refreshSyncStatusBtnEl.disabled = busy;
saveMysqlSettingsBtnEl.disabled = busy || mysqlSettingsSaving || !syncSupported;
saveSyncScheduleBtnEl.disabled = busy || scheduleSaving;
}
function renderIndexStatus(message, details) {
indexStatusEl.textContent = message || "索引尚未加载。";
indexSummaryEl.textContent = details || "暂无索引信息";
}
function renderSyncLog(data, fallbackTitle) {
if (!data) {
syncLogEl.textContent = fallbackTitle || "暂无同步记录";
return;
}
const lines = [];
if (fallbackTitle) lines.push(fallbackTitle);
if (data.data_root) lines.push(`数据目录: ${data.data_root}`);
if (data.project_root) lines.push(`项目目录: ${data.project_root}`);
if (data.workspace_root) lines.push(`工作空间目录: ${data.workspace_root}`);
if (data.storage_mode) lines.push(`存储模式: ${data.storage_mode}`);
if (data.upstream_repo_url) lines.push(`上游仓库: ${data.upstream_repo_url}`);
if (data.github_proxy_prefix) lines.push(`GitHub 加速前缀: ${data.github_proxy_prefix}`);
if (data.effective_upstream_repo_url) lines.push(`实际同步地址: ${data.effective_upstream_repo_url}`);
if (data.upstream_branch) lines.push(`上游分支: ${data.upstream_branch}`);
if (data.last_sync_time) lines.push(`最近同步时间: ${data.last_sync_time}`);
if (data.last_upstream_commit) lines.push(`最近同步提交: ${data.last_upstream_commit}`);
if (data.index_file) lines.push(`索引文件: ${data.index_file}`);
if (data.index_mtime) lines.push(`索引更新时间: ${data.index_mtime}`);
if (data.mysql_seed_file) lines.push(`MySQL Seed: ${data.mysql_seed_file}`);
if (data.mysql_seed_mtime) lines.push(`MySQL Seed 更新时间: ${data.mysql_seed_mtime}`);
if (data.mysql_host && data.mysql_port) lines.push(`MySQL 地址: ${data.mysql_host}:${data.mysql_port}`);
if (data.mysql_database) lines.push(`MySQL 数据库: ${data.mysql_database}`);
if (data.mysql_reader_user) lines.push(`MySQL 只读账号: ${data.mysql_reader_user}`);
if (typeof data.mysql_auto_load === "boolean") lines.push(`MySQL 自动装载: ${data.mysql_auto_load ? "enabled" : "disabled"}`);
if (typeof data.mysql_ready === "boolean") lines.push(`MySQL 状态: ${data.mysql_ready ? "ready" : "not ready"}`);
if (data.mysql_status) lines.push(`MySQL 详情: ${data.mysql_status}`);
if (data.output) {
lines.push("");
lines.push("任务输出:");
lines.push(data.output);
}
syncLogEl.textContent = lines.join("\n").trim() || "暂无同步记录";
}
function renderScheduleStatus(data, options = {}) {
const preserveMessage = !!options.preserveMessage;
const enabled = !!(data && data.sync_schedule_enabled);
const dailyTime = (data && data.sync_schedule_time) || "03:00";
const githubProxyPrefix = (data && data.github_proxy_prefix) || "";
scheduleEnabledEl.checked = enabled;
scheduleTimeInputEl.value = dailyTime;
githubProxyPrefixInputEl.value = githubProxyPrefix;
if (preserveMessage) return;
const lines = [
`每日自动同步: ${enabled ? "已启用" : "未启用"}`,
`同步时间: ${dailyTime}`,
];
if (data && data.sync_schedule_timezone) lines.push(`容器时区: ${data.sync_schedule_timezone}`);
if (githubProxyPrefix) lines.push(`GitHub 加速前缀: ${githubProxyPrefix}`);
if (data && data.effective_upstream_repo_url) lines.push(`实际同步地址: ${data.effective_upstream_repo_url}`);
if (data && data.sync_schedule_next_run) lines.push(`下次执行: ${data.sync_schedule_next_run}`);
if (data && data.sync_schedule_last_run_time) lines.push(`最近自动执行: ${data.sync_schedule_last_run_time}`);
if (data && data.sync_schedule_last_run_status) lines.push(`最近执行结果: ${data.sync_schedule_last_run_status}`);
if (data && data.sync_schedule_last_run_message) lines.push(`结果详情: ${data.sync_schedule_last_run_message}`);
scheduleStatusEl.textContent = lines.join("");
}
function renderMysqlSettingsStatus(data, options = {}) {
const preserveMessage = !!options.preserveMessage;
const enabled = !!(data && data.mysql_auto_load);
mysqlAutoLoadEnabledEl.checked = enabled;
if (preserveMessage) return;
const lines = [
`自动装载: ${enabled ? "已启用" : "未启用"}`,
];
if (data && data.mysql_config_updated_at) lines.push(`最近更新: ${data.mysql_config_updated_at}`);
if (data && data.mysql_config_file) lines.push(`配置文件: ${data.mysql_config_file}`);
mysqlSettingsStatusEl.textContent = lines.join("");
}
async function loadSyncStatus(options = {}) {
const preserveLog = !!options.preserveLog;
const preserveScheduleMessage = !!options.preserveScheduleMessage;
const preserveMysqlSettingsMessage = !!options.preserveMysqlSettingsMessage;
syncStatusEl.textContent = "正在检测同步能力。";
try {
const data = await fetchJson("/api/status", { cache: "no-store" });
syncSupported = !!data.supports_upstream_sync;
syncStatusEl.textContent = syncSupported
? "已连接 Docker Compose 服务,可以直接从页面同步原始数据、索引和 MySQL。"
: "当前服务不支持原始数据同步。";
renderMysqlSettingsStatus(data, { preserveMessage: preserveMysqlSettingsMessage });
renderScheduleStatus(data, { preserveMessage: preserveScheduleMessage });
if (!preserveLog) {
renderSyncLog(data, "服务状态");
}
} catch (err) {
syncSupported = false;
syncStatusEl.textContent = `当前页面未连接支持同步的 Docker Compose 服务:${err.message}`;
if (!preserveMysqlSettingsMessage) {
mysqlSettingsStatusEl.textContent = `MySQL 设置读取失败: ${err.message}`;
}
if (!preserveScheduleMessage) {
scheduleStatusEl.textContent = `自动同步设置读取失败: ${err.message}`;
}
if (!preserveLog) {
syncLogEl.textContent = "请使用 `docker compose up --build -d` 启动完整服务后,再使用这个功能。";
}
} finally {
updateSyncButtons();
}
}
async function runUpstreamSync() {
if (syncRunning || mysqlInitRunning) return;
syncRunning = true;
updateSyncButtons();
syncStatusEl.textContent = "正在同步原始数据、重建索引并刷新 MySQL,请稍候。";
syncLogEl.textContent = "同步进行中...";
try {
const data = await fetchJson("/api/sync-upstream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
syncSupported = true;
syncStatusEl.textContent = "同步完成,页面索引已刷新。";
renderSyncLog(data, "同步完成");
await loadIndexFromPath();
} catch (err) {
syncSupported = false;
syncStatusEl.textContent = `同步失败: ${err.message}`;
syncLogEl.textContent = `同步失败\n${err.message}`;
} finally {
syncRunning = false;
await loadSyncStatus({ preserveLog: true });
}
}
async function runMysqlInit() {
if (syncRunning || mysqlInitRunning) return;
const confirmed = window.confirm(
"初始化外部 MySQL 会创建数据库,并重建 mobilemodels 主表、相关索引和 seed 数据,同时清理历史兼容对象。是否继续?"
);
if (!confirmed) return;
mysqlInitRunning = true;
updateSyncButtons();
syncStatusEl.textContent = "正在初始化外部 MySQL,请稍候。";
syncLogEl.textContent = "MySQL 初始化进行中...";
try {
const data = await fetchJson("/api/init-mysql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
syncSupported = true;
syncStatusEl.textContent = "外部 MySQL 初始化完成。";
renderSyncLog(data, "MySQL 初始化完成");
} catch (err) {
syncStatusEl.textContent = `MySQL 初始化失败: ${err.message}`;
syncLogEl.textContent = `MySQL 初始化失败\n${err.message}`;
} finally {
mysqlInitRunning = false;
await loadSyncStatus({ preserveLog: true });
}
}
async function saveMysqlSettings() {
mysqlSettingsSaving = true;
updateSyncButtons();
mysqlSettingsStatusEl.textContent = "正在保存 MySQL 自动装载设置...";
try {
const payload = await fetchJson("/api/mysql-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
auto_load: !!mysqlAutoLoadEnabledEl.checked,
}),
});
mysqlSettingsStatusEl.textContent = payload.message || "MySQL 自动装载设置已保存。";
await loadSyncStatus({ preserveLog: true, preserveMysqlSettingsMessage: true });
renderMysqlSettingsStatus(payload);
} catch (err) {
mysqlSettingsStatusEl.textContent = `保存失败: ${err.message}`;
} finally {
mysqlSettingsSaving = false;
updateSyncButtons();
}
}
async function saveSyncSchedule() {
scheduleSaving = true;
updateSyncButtons();
scheduleStatusEl.textContent = "正在保存每日自动同步设置...";
try {
const payload = await fetchJson("/api/sync-schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: !!scheduleEnabledEl.checked,
daily_time: scheduleTimeInputEl.value || "03:00",
github_proxy_prefix: githubProxyPrefixInputEl.value || "",
}),
});
scheduleStatusEl.textContent = payload.message || "每日自动同步设置已保存。";
await loadSyncStatus({ preserveLog: true, preserveScheduleMessage: true });
renderScheduleStatus(payload);
} catch (err) {
scheduleStatusEl.textContent = `保存失败: ${err.message}`;
} finally {
scheduleSaving = false;
updateSyncButtons();
}
}
function normalizeAliasList(name, aliases) {
const out = [];
const seen = new Set();
const raw = [name, ...(Array.isArray(aliases) ? aliases : [])];
for (const item of raw) {
const text = (item || "").trim();
const norm = normalizeText(text);
if (!text || !norm || seen.has(norm)) continue;
seen.add(norm);
out.push(text);
}
return out;
}
function sanitizeManagedBrandConfig(rawConfig) {
const manufacturersMap = new Map();
const brandsMap = new Map();
const rawManufacturers = (rawConfig && Array.isArray(rawConfig.manufacturers))
? rawConfig.manufacturers
: [];
const rawBrands = (rawConfig && Array.isArray(rawConfig.brands))
? rawConfig.brands
: [];
for (const raw of rawManufacturers) {
const name = (typeof raw === "string" ? raw : (raw && raw.name)) || "";
const manufacturerName = String(name).trim();
if (!manufacturerName) continue;
manufacturersMap.set(manufacturerName, { name: manufacturerName });
}
for (const raw of rawBrands) {
const name = (typeof raw === "string" ? raw : (raw && raw.name)) || "";
const brandName = String(name).trim();
if (!brandName) continue;
const manufacturerRaw = (raw && (raw.manufacturer || raw.vendor || raw.parent || raw.brand)) || brandName;
const manufacturerName = String(manufacturerRaw).trim() || brandName;
const aliases = normalizeAliasList(brandName, raw && raw.aliases);
if (!manufacturersMap.has(manufacturerName)) {
manufacturersMap.set(manufacturerName, { name: manufacturerName });
}
const existing = brandsMap.get(brandName);
if (existing) {
existing.manufacturer = manufacturerName || existing.manufacturer;
existing.aliases = normalizeAliasList(brandName, [...existing.aliases, ...aliases]);
} else {
brandsMap.set(brandName, {
name: brandName,
manufacturer: manufacturerName,
aliases,
});
}
}
for (const raw of rawManufacturers) {
const name = (typeof raw === "string" ? raw : (raw && raw.name)) || "";
const brandName = String(name).trim();
if (!brandName) continue;
const parentManufacturerRaw = raw && raw.brand;
if (!parentManufacturerRaw) continue;
const manufacturerName = String(parentManufacturerRaw).trim() || brandName;
const aliases = normalizeAliasList(brandName, raw && raw.aliases);
if (!manufacturersMap.has(manufacturerName)) {
manufacturersMap.set(manufacturerName, { name: manufacturerName });
}
const existing = brandsMap.get(brandName);
if (existing) {
existing.manufacturer = manufacturerName;
existing.aliases = normalizeAliasList(brandName, [...existing.aliases, ...aliases]);
} else {
brandsMap.set(brandName, {
name: brandName,
manufacturer: manufacturerName,
aliases,
});
}
}
const brands = [...brandsMap.values()]
.map((b) => ({
...b,
manufacturer: b.manufacturer || b.name,
aliases: normalizeAliasList(b.name, b.aliases),
}))
.sort((a, b) => a.name.localeCompare(b.name));
for (const brand of brands) {
if (!manufacturersMap.has(brand.manufacturer)) {
manufacturersMap.set(brand.manufacturer, { name: brand.manufacturer });
}
}
const manufacturers = [...manufacturersMap.values()].sort((a, b) => a.name.localeCompare(b.name));
return { brands, manufacturers };
}
function initializeManagedBrandConfigFromIndex() {
const bm = (indexData && indexData.brand_management) || {};
const brandToManufacturer = new Map();
const brandAliasMap = new Map();
const manufacturers = new Set();
const addAliases = (brandName, aliases) => {
const brand = String(brandName || "").trim();
if (!brand) return;
const existing = brandAliasMap.get(brand) || [];
brandAliasMap.set(brand, normalizeAliasList(brand, [...existing, ...(aliases || [])]));
};
const setBrandManufacturer = (brandName, manufacturerName) => {
const brand = String(brandName || "").trim();
if (!brand) return;
const manufacturer = String(manufacturerName || "").trim() || brand;
manufacturers.add(manufacturer);
const prev = brandToManufacturer.get(brand);
if (!prev || prev === brand) {
brandToManufacturer.set(brand, manufacturer);
}
};
for (const [brand, aliases] of Object.entries(bm.market_brand_aliases || {})) {
addAliases(brand, aliases);
}
for (const [brand, aliases] of Object.entries(bm.manufacturer_aliases || {})) {
addAliases(brand, aliases);
}
for (const [brand, aliases] of Object.entries(bm.parent_aliases || {})) {
addAliases(brand, aliases);
}
for (const [brand, manufacturer] of Object.entries(bm.market_brand_to_manufacturer || {})) {
setBrandManufacturer(brand, manufacturer);
}
for (const [brand, manufacturer] of Object.entries(bm.manufacturer_to_parent || {})) {
setBrandManufacturer(brand, manufacturer);
}
for (const [manufacturer, children] of Object.entries(bm.parent_to_children || {})) {
setBrandManufacturer(manufacturer, manufacturer);
for (const child of children || []) {
setBrandManufacturer(child, manufacturer);
}
}
for (const record of indexData.records || []) {
const brand = record.market_brand || record.manufacturer_brand || record.brand;
const manufacturer = record.parent_brand || record.manufacturer_brand || record.brand || brand;
setBrandManufacturer(brand, manufacturer);
}
const brands = [...brandToManufacturer.entries()].map(([brand, manufacturer]) => ({
name: brand,
manufacturer,
aliases: normalizeAliasList(brand, brandAliasMap.get(brand) || []),
}));
return sanitizeManagedBrandConfig({
brands,
manufacturers: [...manufacturers].map((name) => ({ name })),
});
}
function saveManagedBrandConfig() {
if (!managedBrandConfig) return;
localStorage.setItem(BRAND_CONFIG_STORAGE_KEY, JSON.stringify(managedBrandConfig));
}
function loadManagedBrandConfig() {
const fallback = initializeManagedBrandConfigFromIndex();
try {
const raw = localStorage.getItem(BRAND_CONFIG_STORAGE_KEY);
if (!raw) {
managedBrandConfig = fallback;
saveManagedBrandConfig();
return;
}
managedBrandConfig = sanitizeManagedBrandConfig(JSON.parse(raw));
} catch {
managedBrandConfig = fallback;
saveManagedBrandConfig();
}
}
function rebuildManagedBrandIndexes() {
managedBrandAliasToBrand = new Map();
managedBrandToManufacturer = new Map();
managedManufacturerToBrands = new Map();
for (const manufacturer of managedBrandConfig.manufacturers || []) {
if (!manufacturer || !manufacturer.name) continue;
managedManufacturerToBrands.set(manufacturer.name, []);
}
for (const brand of managedBrandConfig.brands || []) {
const aliases = normalizeAliasList(brand.name, brand.aliases);
brand.aliases = aliases;
const manufacturer = String(brand.manufacturer || brand.name).trim() || brand.name;
brand.manufacturer = manufacturer;
managedBrandToManufacturer.set(brand.name, manufacturer);
if (!managedManufacturerToBrands.has(manufacturer)) {
managedManufacturerToBrands.set(manufacturer, []);
}
managedManufacturerToBrands.get(manufacturer).push(brand.name);
for (const alias of aliases) {
const key = normalizeText(alias);
if (key) managedBrandAliasToBrand.set(key, brand.name);
}
}
for (const [manufacturer, brands] of managedManufacturerToBrands.entries()) {
managedManufacturerToBrands.set(manufacturer, [...new Set(brands)].sort((a, b) => a.localeCompare(b)));
}
}
function isCnSourceFile(sourceFile) {
return /_cn\.md$/i.test(sourceFile || "");
}
function buildInitialSourceOrder() {
const sourceSet = new Set(
(indexData && Array.isArray(indexData.records) ? indexData.records : [])
.map((r) => (r && r.source_file) || "")
.filter(Boolean)
);
const cn = [];
const other = [];
for (const source of sourceSet) {
if (isCnSourceFile(source)) {
cn.push(source);
} else {
other.push(source);
}
}
cn.sort((a, b) => a.localeCompare(b));
other.sort((a, b) => a.localeCompare(b));
return [...cn, ...other];
}
function sanitizeManagedSourceConfig(rawConfig) {
const initialOrder = buildInitialSourceOrder();
const sourceSet = new Set(initialOrder);
const rawOrder = (rawConfig && Array.isArray(rawConfig.order)) ? rawConfig.order : [];
const order = [];
const seen = new Set();
for (const item of rawOrder) {
const source = String(item || "").trim();
if (!source || seen.has(source) || !sourceSet.has(source)) continue;
seen.add(source);
order.push(source);
}
for (const source of initialOrder) {
if (seen.has(source)) continue;
seen.add(source);
order.push(source);
}
return { order };
}
function saveManagedSourceConfig() {
if (!managedSourceConfig) return;
localStorage.setItem(SOURCE_CONFIG_STORAGE_KEY, JSON.stringify(managedSourceConfig));
}
function loadManagedSourceConfig() {
const fallback = sanitizeManagedSourceConfig({ order: buildInitialSourceOrder() });
try {
const raw = localStorage.getItem(SOURCE_CONFIG_STORAGE_KEY);
if (!raw) {
managedSourceConfig = fallback;
saveManagedSourceConfig();
return;
}
managedSourceConfig = sanitizeManagedSourceConfig(JSON.parse(raw));
} catch {
managedSourceConfig = fallback;
saveManagedSourceConfig();
}
}
function moveSourceOrderItem(fromIndex, toIndex) {
if (!managedSourceConfig || !Array.isArray(managedSourceConfig.order)) return;
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
if (fromIndex >= managedSourceConfig.order.length || toIndex >= managedSourceConfig.order.length) return;
const next = [...managedSourceConfig.order];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
managedSourceConfig.order = next;
}
function bindSourceOrderDragEvents() {
const items = sourceOrderListEl.querySelectorAll(".source-order-item");
for (const item of items) {
item.addEventListener("dragstart", (e) => {
draggingSourceIndex = Number(item.dataset.index);
item.classList.add("dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(draggingSourceIndex));
}
});
item.addEventListener("dragend", () => {
draggingSourceIndex = -1;
sourceOrderListEl.querySelectorAll(".source-order-item")
.forEach((el) => el.classList.remove("dragging", "drag-target"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
sourceOrderListEl.querySelectorAll(".source-order-item")
.forEach((el) => el.classList.remove("drag-target"));
item.classList.add("drag-target");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
const toIndex = Number(item.dataset.index);
const fromIndex = draggingSourceIndex >= 0
? draggingSourceIndex
: Number(e.dataTransfer && e.dataTransfer.getData("text/plain"));
if (!Number.isNaN(fromIndex) && !Number.isNaN(toIndex)) {
moveSourceOrderItem(fromIndex, toIndex);
renderSourceOrder();
}
});
}
}
function renderSourceOrder() {
if (!indexData || !managedSourceConfig) {
sourceOrderStatsEl.textContent = "来源列表未加载。";
sourceOrderListEl.innerHTML = `<li class="sub">暂无来源数据</li>`;
return;
}
const total = managedSourceConfig.order.length;
sourceOrderStatsEl.innerHTML = `
<span class="mono">共 ${total} 个来源,序号越小权重越高。</span>
`;
if (!total) {
sourceOrderListEl.innerHTML = `<li class="sub">暂无来源数据</li>`;
return;
}
sourceOrderListEl.innerHTML = managedSourceConfig.order.map((source, idx) => `
<li class="source-order-item" draggable="true" data-index="${idx}">
<span class="drag-handle">::</span>
<span class="source-rank">#${idx + 1}</span>
<span class="source-name mono">${escapeHtml(source)}</span>
</li>
`).join("");
bindSourceOrderDragEvents();
}
function saveSourceOrder() {
if (!managedSourceConfig) return;
saveManagedSourceConfig();
renderSourceOrder();
}
function resetSourceOrder() {
managedSourceConfig = sanitizeManagedSourceConfig({ order: buildInitialSourceOrder() });
saveManagedSourceConfig();
renderSourceOrder();
}
function renderModalList(items = []) {
if (!items.length) {
brandModalListEl.innerHTML = `<div class="modal-list-empty">暂无数据</div>`;
return;
}
brandModalListEl.innerHTML = `
${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("")}
`;
}
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, formHtml, collect }) {
modalSaveHandler = onSave || null;
modalCollectHandler = collect || null;
brandModalTitleEl.textContent = title || "数据管理";
brandModalHintEl.textContent = hint || "";
if (editable) {
brandModalTextareaEl.classList.remove("hidden");
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");
}
brandModalBackdropEl.classList.remove("hidden");
document.body.classList.add("modal-open");
}
function closeBrandModal() {
modalSaveHandler = null;
modalCollectHandler = null;
brandModalFormEl.innerHTML = "";
brandModalBackdropEl.classList.add("hidden");
document.body.classList.remove("modal-open");
}
function applyManagedBrandConfigUpdate(newConfig) {
managedBrandConfig = sanitizeManagedBrandConfig(newConfig);
saveManagedBrandConfig();
rebuildManagedBrandIndexes();
renderBrandStats();
}
function openBrandListModal() {
openBrandModal({
title: "品牌列表",
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,
});
}
function openManufacturerListModal() {
const rows = (managedBrandConfig.manufacturers || []).map((item) => ({
manufacturer: item.name,
brands: managedManufacturerToBrands.get(item.name) || [],
}));
openBrandModal({
title: "厂商列表",
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,
});
}
function openEditBrandListModal() {
const names = managedBrandConfig.brands.map((b) => b.name);
openBrandModal({
title: "编辑品牌列表",
hint: "JSON 数组格式: [\"Xiaomi\",\"Redmi\",\"POCO\"]",
text: JSON.stringify(names, null, 2),
editable: true,
onSave: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("品牌列表必须是 JSON 数组。");
}
const existingMap = new Map(managedBrandConfig.brands.map((b) => [b.name, b]));
const seen = new Set();
const brands = [];
for (const item of parsed) {
const name = String(typeof item === "string" ? item : (item && item.name) || "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
const existing = existingMap.get(name);
brands.push({
name,
manufacturer: existing ? existing.manufacturer : name,
aliases: existing ? existing.aliases : [name],
});
}
if (!brands.length) {
throw new Error("品牌列表为空,请至少保留一个品牌。");
}
const next = sanitizeManagedBrandConfig({
brands,
manufacturers: managedBrandConfig.manufacturers,
});
applyManagedBrandConfigUpdate(next);
},
});
}
function openEditBrandRelationsModal() {
const relations = managedBrandConfig.brands.map((b) => ({
brand: b.name,
manufacturer: b.manufacturer,
}));
openBrandModal({
title: "编辑品牌-厂商关系",
hint: "JSON 数组格式: [{\"brand\":\"Redmi\",\"manufacturer\":\"Xiaomi\"}]",
text: JSON.stringify(relations, null, 2),
editable: true,
onSave: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("关系数据必须是 JSON 数组。");
}
const existingBrandMap = new Map(managedBrandConfig.brands.map((b) => [b.name, b]));
const seen = new Set();
const brands = [];
for (const item of parsed) {
const brandName = String(item.brand || item.name || "").trim();
const manufacturerName = String(item.manufacturer || item.parent || "").trim();
if (!brandName || !manufacturerName || seen.has(brandName)) continue;
seen.add(brandName);
const existing = existingBrandMap.get(brandName);
brands.push({
name: brandName,
manufacturer: manufacturerName,
aliases: existing ? existing.aliases : [brandName],
});
}
if (!brands.length) {
throw new Error("关系列表为空,请至少保留一条。");
}
const next = sanitizeManagedBrandConfig({
brands,
manufacturers: managedBrandConfig.manufacturers,
});
applyManagedBrandConfigUpdate(next);
},
});
}
function openEditBrandAliasesModal() {
const payload = managedBrandConfig.brands.map((b) => ({
brand: b.name,
aliases: b.aliases || [],
}));
openBrandModal({
title: "编辑品牌同义词归一",
hint: "JSON 数组格式: [{\"brand\":\"HONOR\",\"aliases\":[\"荣耀\",\"honor\",\"HONOR\"]}]",
text: JSON.stringify(payload, null, 2),
editable: true,
onSave: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("同义词数据必须是 JSON 数组。");
}
const parsedMap = new Map();
for (const item of parsed) {
const name = String(item.brand || item.name || "").trim();
if (!name) continue;
parsedMap.set(name, normalizeAliasList(name, item.aliases));
}
const brands = managedBrandConfig.brands.map((b) => ({
...b,
aliases: parsedMap.get(b.name) || normalizeAliasList(b.name, b.aliases),
}));
const existingSet = new Set(brands.map((b) => b.name));
for (const [name, aliases] of parsedMap.entries()) {
if (existingSet.has(name)) continue;
brands.push({
name,
manufacturer: name,
aliases,
});
}
const next = sanitizeManagedBrandConfig({
brands,
manufacturers: managedBrandConfig.manufacturers,
});
applyManagedBrandConfigUpdate(next);
},
});
}
function renderBrandStats() {
if (!indexData || !managedBrandConfig) {
brandStatsEl.textContent = "索引未加载。";
brandRelationBodyEl.innerHTML = `<tr><td colspan="3" class="sub">暂无关系数据</td></tr>`;
return;
}
const brandCount = managedBrandConfig.brands.length;
const manufacturerCount = managedBrandConfig.manufacturers.length;
brandCountBtnEl.textContent = `品牌数: ${brandCount}`;
manufacturerCountBtnEl.textContent = `厂商数: ${manufacturerCount}`;
brandStatsEl.innerHTML = `
<div class="mono">点击“品牌数/厂商数”可弹窗查看完整列表。</div>
<div class="mono">关系规则:一个品牌只属于一个父级厂商,一个厂商可包含多个品牌。</div>
`;
const sortedBrands = [...managedBrandConfig.brands].sort((a, b) => a.name.localeCompare(b.name));
if (!sortedBrands.length) {
brandRelationBodyEl.innerHTML = `<tr><td colspan="3" class="sub">暂无关系数据</td></tr>`;
return;
}
brandRelationBodyEl.innerHTML = sortedBrands.map((brand) => {
const aliases = normalizeAliasList(brand.name, brand.aliases || []);
const manufacturer = managedBrandToManufacturer.get(brand.name) || brand.manufacturer || "(未设置)";
return `
<tr>
<td><strong>${escapeHtml(brand.name)}</strong></td>
<td>${aliases.map((a) => `<span class="tag">${escapeHtml(a)}</span>`).join("")}</td>
<td><span class="tag">${escapeHtml(manufacturer)}</span></td>
</tr>
`;
}).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 ...", "加载中...");
const resp = await fetch("../dist/device_index.json", { cache: "no-cache" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
indexData = await resp.json();
loadManagedBrandConfig();
loadManagedSourceConfig();
rebuildManagedBrandIndexes();
renderBrandStats();
renderSourceOrder();
renderManualCatalog();
const sourceCount = Array.isArray(managedSourceConfig && managedSourceConfig.order)
? managedSourceConfig.order.length
: 0;
const brandCount = managedBrandConfig && Array.isArray(managedBrandConfig.brands)
? managedBrandConfig.brands.length
: 0;
const manufacturerCount = managedBrandConfig && Array.isArray(managedBrandConfig.manufacturers)
? managedBrandConfig.manufacturers.length
: 0;
renderIndexStatus(
`索引加载成功: records=${indexData.total_records}, lookup=${Object.keys(indexData.lookup || {}).length}`,
[
`generated_on: ${indexData.generated_on || "(unknown)"}`,
`total_records: ${indexData.total_records || 0}`,
`lookup_keys: ${Object.keys(indexData.lookup || {}).length}`,
`brand_count: ${brandCount}`,
`manufacturer_count: ${manufacturerCount}`,
`source_count: ${sourceCount}`,
].join("\n")
);
} catch (err) {
renderIndexStatus(`索引加载失败: ${err.message}`, `索引加载失败\n${err.message}`);
brandStatsEl.textContent = "索引不可用,暂无品牌关系数据。";
sourceOrderStatsEl.textContent = "索引不可用,暂无来源数据。";
brandRelationBodyEl.innerHTML = `<tr><td colspan="3" class="sub">暂无关系数据</td></tr>`;
sourceOrderListEl.innerHTML = `<li class="sub">暂无来源数据</li>`;
}
}
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);
manufacturerCountBtnEl.addEventListener("click", openManufacturerListModal);
syncUpstreamBtnEl.addEventListener("click", runUpstreamSync);
initMysqlBtnEl.addEventListener("click", runMysqlInit);
refreshSyncStatusBtnEl.addEventListener("click", loadSyncStatus);
saveMysqlSettingsBtnEl.addEventListener("click", saveMysqlSettings);
saveSyncScheduleBtnEl.addEventListener("click", saveSyncSchedule);
reloadIndexBtnEl.addEventListener("click", loadIndexFromPath);
brandModalCancelBtnEl.addEventListener("click", closeBrandModal);
brandModalBackdropEl.addEventListener("click", (e) => {
if (e.target === brandModalBackdropEl) closeBrandModal();
});
brandModalSaveBtnEl.addEventListener("click", async () => {
if (!modalSaveHandler) return;
try {
const payload = modalCollectHandler ? modalCollectHandler() : brandModalTextareaEl.value;
await modalSaveHandler(payload);
closeBrandModal();
} catch (err) {
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>
</html>