2420 lines
88 KiB
HTML
2420 lines
88 KiB
HTML
<!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: 140px; }
|
||
.manual-device-table col:nth-child(2) { width: 280px; }
|
||
.manual-device-table col:nth-child(3) { width: 140px; }
|
||
.manual-device-table col:nth-child(4) { width: 220px; }
|
||
.manual-device-table col:nth-child(5) { width: 220px; }
|
||
.manual-device-table col:nth-child(6) { width: 220px; }
|
||
.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-device-table td:nth-child(5) {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.manual-brand-table .tag,
|
||
.manual-device-table .tag {
|
||
margin: 0;
|
||
}
|
||
.manual-brand-table th:last-child,
|
||
.manual-brand-table td:last-child,
|
||
.manual-device-table th:last-child,
|
||
.manual-device-table td:last-child {
|
||
position: sticky;
|
||
right: 0;
|
||
z-index: 2;
|
||
background: #fff;
|
||
box-shadow: -8px 0 12px rgba(36, 56, 89, 0.06);
|
||
}
|
||
.manual-brand-table th:last-child,
|
||
.manual-device-table th:last-child {
|
||
background: #f7f9fd;
|
||
z-index: 3;
|
||
}
|
||
.manual-actions-bar {
|
||
margin-bottom: 10px;
|
||
padding-left: 10px;
|
||
}
|
||
.manual-actions-bar .btns {
|
||
margin-top: 0;
|
||
}
|
||
.status-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 0;
|
||
}
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 28px;
|
||
padding: 0 12px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.status-pill.running {
|
||
background: #eef4ff;
|
||
color: #1e59c9;
|
||
}
|
||
.status-pill.success {
|
||
background: #eefaf2;
|
||
color: #177245;
|
||
}
|
||
.status-pill.failed {
|
||
background: #fff1f0;
|
||
color: #b42318;
|
||
}
|
||
.status-pill.idle {
|
||
background: #f4f6fa;
|
||
color: #5b6679;
|
||
}
|
||
.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="manualRefreshBadge" class="status-row">
|
||
<span class="status-pill idle">等待刷新</span>
|
||
</div>
|
||
<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 manualCatalogPollTimer = null;
|
||
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 manualRefreshBadgeEl = document.getElementById("manualRefreshBadge");
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 buildManualStatusText(payload = manualCatalog) {
|
||
const brands = Array.isArray(payload && payload.brands) ? payload.brands : [];
|
||
const devices = Array.isArray(payload && payload.devices) ? payload.devices : [];
|
||
const refresh = (payload && payload.mysql_refresh) || {};
|
||
const parts = [
|
||
`本地覆盖库: 品牌 ${brands.length} 个,设备 ${devices.length} 个。`,
|
||
"保存后会刷新索引和 MySQL seed。",
|
||
];
|
||
if (refresh.running) {
|
||
parts.push("MySQL 后台刷新中。");
|
||
} else if (refresh.last_status === "success") {
|
||
parts.push("MySQL 后台刷新已完成。");
|
||
} else if (refresh.last_status === "failed") {
|
||
parts.push(`MySQL 后台刷新失败: ${refresh.last_message || "请检查容器日志。"}`);
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function buildManualStatusBadge(payload = manualCatalog) {
|
||
const refresh = (payload && payload.mysql_refresh) || {};
|
||
if (refresh.running) {
|
||
return '<span class="status-pill running">后台刷新中</span>';
|
||
}
|
||
if (refresh.last_status === "success") {
|
||
return '<span class="status-pill success">后台刷新完成</span>';
|
||
}
|
||
if (refresh.last_status === "failed") {
|
||
return '<span class="status-pill failed">后台刷新失败</span>';
|
||
}
|
||
return '<span class="status-pill idle">等待刷新</span>';
|
||
}
|
||
|
||
function scheduleManualCatalogPoll() {
|
||
if (manualCatalogPollTimer) {
|
||
window.clearTimeout(manualCatalogPollTimer);
|
||
manualCatalogPollTimer = null;
|
||
}
|
||
const refresh = (manualCatalog && manualCatalog.mysql_refresh) || {};
|
||
if (!refresh.running) return;
|
||
manualCatalogPollTimer = window.setTimeout(() => {
|
||
loadManualCatalog({ silent: true });
|
||
}, 2500);
|
||
}
|
||
|
||
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 : [];
|
||
manualRefreshBadgeEl.innerHTML = buildManualStatusBadge();
|
||
manualStatusEl.textContent = buildManualStatusText();
|
||
|
||
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);
|
||
});
|
||
});
|
||
scheduleManualCatalogPoll();
|
||
}
|
||
|
||
async function loadManualCatalog(options = {}) {
|
||
const silent = !!options.silent;
|
||
if (!silent) {
|
||
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 : [],
|
||
mysql_refresh: payload.mysql_refresh || null,
|
||
};
|
||
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 || "品牌已保存。"} ${result.mysql_message || ""}`.trim();
|
||
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} 个。` : ""} ${result.mysql_message || ""}`.trim();
|
||
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} 已删除。`} ${result.mysql_message || ""}`.trim();
|
||
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 || "设备已删除。"} ${result.mysql_message || ""}`.trim();
|
||
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);
|
||
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>
|