2748 lines
94 KiB
HTML
2748 lines
94 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.docs-mode {
|
||
padding-top: var(--nav-height);
|
||
}
|
||
.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);
|
||
}
|
||
body.docs-mode .top-nav {
|
||
position: fixed !important;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 1000;
|
||
}
|
||
.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: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
.wrap {
|
||
max-width: 1200px;
|
||
margin: 24px auto;
|
||
padding: 0 16px 32px;
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
gap: 16px;
|
||
}
|
||
.query-wrap {
|
||
align-items: start;
|
||
}
|
||
.query-sidebar {
|
||
position: sticky;
|
||
top: calc(var(--nav-height) + 24px);
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.query-main {
|
||
min-width: 0;
|
||
}
|
||
.sidebar-card {
|
||
padding: 16px;
|
||
border-color: #d4def1;
|
||
box-shadow:
|
||
0 16px 32px rgba(26, 42, 78, 0.07),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
.sidebar-card .title {
|
||
font-size: 15px;
|
||
color: #173053;
|
||
}
|
||
.sidebar-card .sub {
|
||
margin-bottom: 0;
|
||
}
|
||
.sidebar-card .helper-box {
|
||
margin-top: 0;
|
||
}
|
||
.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;
|
||
}
|
||
.page-tabs-wrap {
|
||
max-width: 1200px;
|
||
margin: 18px auto 0;
|
||
padding: 0 16px;
|
||
}
|
||
.page-tabs {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.page-tab {
|
||
border: 1px solid #ccd7ea;
|
||
border-radius: 999px;
|
||
background: #fff;
|
||
color: #26436f;
|
||
padding: 9px 16px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
box-shadow: 0 4px 12px rgba(36, 56, 89, 0.04);
|
||
}
|
||
.page-tab.active {
|
||
background: #0f6fff;
|
||
border-color: #0f6fff;
|
||
color: #fff;
|
||
box-shadow: 0 6px 16px rgba(15, 111, 255, 0.2);
|
||
}
|
||
.page-panel.hidden {
|
||
display: none;
|
||
}
|
||
.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);
|
||
}
|
||
.search-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 16px;
|
||
border-color: #cfdaf0;
|
||
background:
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.98)),
|
||
#fff;
|
||
box-shadow:
|
||
0 20px 40px rgba(26, 42, 78, 0.08),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||
}
|
||
.search-card .title {
|
||
font-size: 18px;
|
||
color: #16263f;
|
||
}
|
||
.search-card .sub {
|
||
margin-bottom: 4px;
|
||
color: #53627a;
|
||
}
|
||
.search-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(420px, 1.08fr) minmax(0, 0.92fr);
|
||
gap: 8px 18px;
|
||
align-items: stretch;
|
||
}
|
||
.search-layout-single {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.search-layout-single .search-copy {
|
||
grid-column: auto;
|
||
}
|
||
.search-copy {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.search-copy .sub:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.search-header-row {
|
||
grid-column: 1 / -1;
|
||
display: grid;
|
||
grid-template-columns: inherit;
|
||
gap: 18px;
|
||
align-items: end;
|
||
}
|
||
.search-field-label {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: #324056;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
}
|
||
.search-main-fields,
|
||
.search-side-fields {
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 6px;
|
||
align-content: stretch;
|
||
align-self: start;
|
||
}
|
||
.search-inline-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 1fr) 76px;
|
||
gap: 10px;
|
||
align-items: end;
|
||
justify-content: stretch;
|
||
min-height: 44px;
|
||
}
|
||
.search-layout-single .search-inline-row {
|
||
grid-template-columns: minmax(0, 1fr) 76px;
|
||
}
|
||
.search-input-stack {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.search-input-stack label,
|
||
.search-input-stack .search-field-label {
|
||
margin: 0;
|
||
}
|
||
.search-inline-row .btns {
|
||
justify-content: flex-end;
|
||
}
|
||
.search-card .helper-box {
|
||
background: linear-gradient(180deg, #fbfdff, #f4f8ff);
|
||
border-color: #d9e4f8;
|
||
}
|
||
.search-card input {
|
||
min-height: 44px;
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
border-color: #cbd8ee;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: inset 0 1px 2px rgba(25, 36, 61, 0.04);
|
||
}
|
||
.search-card input:focus {
|
||
outline: none;
|
||
border-color: #0f6fff;
|
||
box-shadow: 0 0 0 4px rgba(15, 111, 255, 0.12);
|
||
}
|
||
.search-card .platform-picker {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: 0;
|
||
padding: 3px;
|
||
height: 44px;
|
||
border: 1px solid #dbe5f5;
|
||
border-radius: 12px;
|
||
background: #fdfefe;
|
||
align-items: stretch;
|
||
}
|
||
.search-card .platform-card {
|
||
min-height: 34px;
|
||
height: 34px;
|
||
border-radius: 9px;
|
||
border-color: transparent;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
gap: 8px;
|
||
padding: 0 9px;
|
||
flex-direction: row;
|
||
justify-content: center;
|
||
}
|
||
.search-card .platform-card.active {
|
||
border-color: #c6d8fb;
|
||
background: #edf4ff;
|
||
box-shadow:
|
||
0 4px 10px rgba(15, 111, 255, 0.1),
|
||
inset 0 0 0 1px rgba(15, 111, 255, 0.1);
|
||
}
|
||
.search-card .btns {
|
||
margin-top: 0;
|
||
display: flex;
|
||
align-items: stretch;
|
||
height: 44px;
|
||
}
|
||
.search-card .btns .primary {
|
||
min-height: 44px;
|
||
height: 44px;
|
||
width: 76px;
|
||
min-width: 76px;
|
||
padding: 0 12px;
|
||
border-radius: 14px;
|
||
font-size: 14px;
|
||
line-height: 44px;
|
||
letter-spacing: 0.01em;
|
||
box-shadow: 0 10px 20px rgba(15, 111, 255, 0.18);
|
||
white-space: nowrap;
|
||
}
|
||
.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;
|
||
}
|
||
label {
|
||
display: block;
|
||
margin: 10px 0 6px;
|
||
font-size: 13px;
|
||
color: #324056;
|
||
font-weight: 600;
|
||
}
|
||
input, select, textarea {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
background: #fff;
|
||
color: var(--text);
|
||
}
|
||
textarea { min-height: 110px; resize: vertical; }
|
||
.btns {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.btns + .sub,
|
||
.btns + .field-tip {
|
||
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; }
|
||
.helper-box {
|
||
margin-top: 12px;
|
||
padding: 12px;
|
||
border-radius: 12px;
|
||
background: #f7faff;
|
||
border: 1px solid #d9e5fb;
|
||
}
|
||
.helper-title {
|
||
margin: 0 0 6px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #26497b;
|
||
}
|
||
.helper-box p {
|
||
margin: 0 0 4px;
|
||
color: var(--sub);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.helper-box ul {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
color: var(--sub);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.helper-box li + li {
|
||
margin-top: 4px;
|
||
}
|
||
.credential-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
.credential-item {
|
||
border: 1px solid #d9e5fb;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
padding: 10px 12px;
|
||
}
|
||
.credential-label {
|
||
display: block;
|
||
margin: 0 0 4px;
|
||
color: #5b6a7f;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.credential-value {
|
||
margin: 0;
|
||
color: #183153;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
word-break: break-all;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.credential-grid { grid-template-columns: 1fr; }
|
||
.search-card {
|
||
padding: 16px;
|
||
}
|
||
.search-card .platform-picker {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
code {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 0.92em;
|
||
padding: 1px 6px;
|
||
border-radius: 6px;
|
||
background: #edf3ff;
|
||
color: #294f88;
|
||
border: 1px solid #d7e3fb;
|
||
}
|
||
.field-tip {
|
||
margin: 6px 0 0;
|
||
color: var(--sub);
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
.platform-picker {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-top: 6px;
|
||
}
|
||
.platform-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
min-height: 110px;
|
||
border: 1px solid #d4deef;
|
||
border-radius: 14px;
|
||
background: #fbfcff;
|
||
color: #29486f;
|
||
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||
}
|
||
.platform-card:hover {
|
||
border-color: #b9cae8;
|
||
background: #f7faff;
|
||
transform: translateY(-1px);
|
||
}
|
||
.platform-card.active {
|
||
border-color: #0f6fff;
|
||
background: #eef5ff;
|
||
box-shadow: 0 0 0 3px rgba(15, 111, 255, 0.08);
|
||
color: #15437d;
|
||
}
|
||
.platform-icon {
|
||
width: 22px;
|
||
height: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
}
|
||
.platform-icon svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
display: block;
|
||
}
|
||
.platform-name {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.pill {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
margin-right: 6px;
|
||
background: #edf2fd;
|
||
color: #355286;
|
||
}
|
||
.pill-btn {
|
||
border: 1px solid #b9cae8;
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
background: #f5f9ff;
|
||
color: #254575;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.pill-btn:hover {
|
||
background: #ebf3ff;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
.result-head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.table-wrap {
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
min-width: 840px;
|
||
}
|
||
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;
|
||
}
|
||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||
details summary { cursor: pointer; color: #294f88; }
|
||
.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;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.collapse-card summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
.collapse-card summary::after {
|
||
content: "展开";
|
||
flex: 0 0 auto;
|
||
border: 1px solid #c8d6ee;
|
||
border-radius: 999px;
|
||
padding: 3px 10px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #47648f;
|
||
background: #f6f9ff;
|
||
}
|
||
.collapse-card[open] summary::after {
|
||
content: "收起";
|
||
}
|
||
.collapse-card[open] summary {
|
||
border-bottom: 1px solid var(--line);
|
||
background: #fbfcff;
|
||
}
|
||
.collapse-body {
|
||
padding: 14px;
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
.collapse-body .credential-grid {
|
||
margin-top: 0;
|
||
}
|
||
.collapse-section {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.section-label {
|
||
margin: 0;
|
||
color: #203554;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1.4;
|
||
}
|
||
.collapse-section .title {
|
||
margin-bottom: 0;
|
||
font-size: 14px;
|
||
}
|
||
.tag {
|
||
display: inline-block;
|
||
margin: 2px 6px 2px 0;
|
||
border-radius: 8px;
|
||
padding: 2px 7px;
|
||
font-size: 12px;
|
||
background: #eef3fb;
|
||
color: #2d4a7a;
|
||
}
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(10, 20, 38, 0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
z-index: 999;
|
||
}
|
||
.modal-card {
|
||
width: min(920px, 100%);
|
||
max-height: 90vh;
|
||
overflow: auto;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: 14px;
|
||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
|
||
}
|
||
.modal-backdrop.hidden {
|
||
display: none !important;
|
||
}
|
||
.modal-card textarea {
|
||
min-height: 360px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
.modal-card pre {
|
||
min-height: 240px;
|
||
}
|
||
pre {
|
||
margin: 0;
|
||
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;
|
||
}
|
||
.doc-grid {
|
||
max-width: 1200px;
|
||
margin: 24px auto;
|
||
padding: 0 16px 32px;
|
||
display: grid;
|
||
grid-template-columns: 240px minmax(0, 1fr);
|
||
gap: 16px;
|
||
}
|
||
.doc-sidebar {
|
||
align-self: start;
|
||
position: sticky;
|
||
top: calc(var(--nav-height) + 24px);
|
||
padding: 12px;
|
||
max-height: calc(100vh - var(--nav-height) - 40px);
|
||
overflow: hidden;
|
||
display: grid;
|
||
grid-template-rows: minmax(0, 1fr);
|
||
}
|
||
.doc-preview {
|
||
min-width: 0;
|
||
padding: 18px;
|
||
}
|
||
.doc-toc {
|
||
min-height: 0;
|
||
overflow: auto;
|
||
display: grid;
|
||
gap: 8px;
|
||
padding-right: 4px;
|
||
}
|
||
.doc-toc-section {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 8px 0 10px;
|
||
border-bottom: 1px solid #e7edf7;
|
||
}
|
||
.doc-toc-section:last-child {
|
||
border-bottom: 0;
|
||
padding-bottom: 0;
|
||
}
|
||
.doc-toc-title {
|
||
padding: 0 4px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
color: #587095;
|
||
text-transform: uppercase;
|
||
}
|
||
.doc-toc-links {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.doc-toc-link {
|
||
display: block;
|
||
padding: 7px 10px;
|
||
border-radius: 10px;
|
||
color: #33527f;
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
}
|
||
.doc-toc-link:hover {
|
||
background: #f4f8ff;
|
||
border-color: #e0e8f6;
|
||
}
|
||
.doc-toc-link.level-2 {
|
||
font-weight: 600;
|
||
color: #28466f;
|
||
}
|
||
.doc-toc-link.level-3 {
|
||
margin-left: 0;
|
||
font-size: 12px;
|
||
}
|
||
.doc-toc-link.level-4 {
|
||
margin-left: 12px;
|
||
font-size: 12px;
|
||
}
|
||
.markdown-body {
|
||
color: #223047;
|
||
font-size: 14px;
|
||
line-height: 1.75;
|
||
}
|
||
.markdown-body > :first-child {
|
||
margin-top: 0;
|
||
}
|
||
.markdown-body > :last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.markdown-body h1,
|
||
.markdown-body h2,
|
||
.markdown-body h3,
|
||
.markdown-body h4 {
|
||
margin: 1.2em 0 0.55em;
|
||
color: #1b2a42;
|
||
line-height: 1.35;
|
||
}
|
||
.markdown-body h1 { font-size: 32px; }
|
||
.markdown-body h2 { font-size: 24px; }
|
||
.markdown-body h3 { font-size: 19px; }
|
||
.markdown-body h4 { font-size: 16px; }
|
||
.markdown-body p,
|
||
.markdown-body ul,
|
||
.markdown-body ol,
|
||
.markdown-body blockquote {
|
||
margin: 0 0 1em;
|
||
}
|
||
.markdown-body ul,
|
||
.markdown-body ol {
|
||
padding-left: 22px;
|
||
}
|
||
.markdown-body li + li {
|
||
margin-top: 4px;
|
||
}
|
||
.markdown-body blockquote {
|
||
padding: 10px 14px;
|
||
border-left: 4px solid #cddcf6;
|
||
border-radius: 0 10px 10px 0;
|
||
background: #f7faff;
|
||
color: #52627a;
|
||
}
|
||
.markdown-body pre {
|
||
margin: 0 0 1em;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-size: 13px;
|
||
line-height: 1.65;
|
||
background: #f6f8fb;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: 14px;
|
||
overflow: auto;
|
||
}
|
||
.markdown-body pre code {
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
font-size: inherit;
|
||
}
|
||
.markdown-body code {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 0.92em;
|
||
padding: 1px 6px;
|
||
border-radius: 6px;
|
||
background: #edf3ff;
|
||
color: #294f88;
|
||
border: 1px solid #d7e3fb;
|
||
}
|
||
.markdown-body hr {
|
||
border: 0;
|
||
border-top: 1px solid #d8e1ef;
|
||
margin: 1.2em 0;
|
||
}
|
||
.markdown-body a {
|
||
color: #0f6fff;
|
||
text-decoration: none;
|
||
}
|
||
.markdown-body a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.markdown-body h2,
|
||
.markdown-body h3,
|
||
.markdown-body h4 {
|
||
scroll-margin-top: calc(var(--nav-height) + 20px);
|
||
}
|
||
.doc-section + .doc-section {
|
||
margin-top: 14px;
|
||
}
|
||
.doc-section h3 {
|
||
margin: 0 0 8px;
|
||
font-size: 14px;
|
||
}
|
||
@media (max-width: 1020px) {
|
||
.wrap { grid-template-columns: 1fr; }
|
||
.query-sidebar {
|
||
position: static;
|
||
}
|
||
.doc-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.doc-sidebar {
|
||
position: static;
|
||
max-height: none;
|
||
}
|
||
.search-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.search-header-row {
|
||
grid-template-columns: 1fr;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
@media (max-width: 640px) {
|
||
.search-inline-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.platform-picker { grid-template-columns: 1fr; }
|
||
}
|
||
</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" id="topNavQuery">设备查询</a>
|
||
<a href="/web/brand_management.html" class="item">数据管理</a>
|
||
<a href="/web/device_query.html?view=docs" class="item" id="topNavDocs">使用文档</a>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="page-tabs-wrap">
|
||
<div class="page-tabs" role="tablist" aria-label="设备查询视图">
|
||
<button type="button" class="page-tab active" id="tabSqlQueryBtn" data-tab="sql-query">设备标识</button>
|
||
<button type="button" class="page-tab" id="tabDeviceNameSqlBtn" data-tab="device-name-sql-query">设备名称</button>
|
||
<button type="button" class="page-tab" id="tabIndexQueryBtn" data-tab="index-query">标识索引</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="indexQueryPanel" class="page-panel hidden">
|
||
<div class="wrap query-wrap">
|
||
<aside class="query-sidebar">
|
||
<details class="card collapse-card sidebar-card" open>
|
||
<summary>使用提示</summary>
|
||
<div class="collapse-body">
|
||
<ul class="compact-note-list">
|
||
<li>基于 <code>dist/device_index.json</code> 快速识别,适合联调和比对。</li>
|
||
<li>Android / iOS / HarmonyOS 一般只需要平台和 <code>model_raw</code>。</li>
|
||
<li>也支持直接输入常见设备名,例如 <code>iPad mini 7</code>。</li>
|
||
<li>归一化规则:转小写,只保留字母数字和中文,去掉空格与标点。</li>
|
||
</ul>
|
||
</div>
|
||
</details>
|
||
</aside>
|
||
|
||
<section class="grid query-main">
|
||
<article class="card search-card">
|
||
<div class="search-layout">
|
||
<div class="search-copy">
|
||
<h1 class="title">标识索引查询</h1>
|
||
<p class="sub">输入设备标识或常见设备名称即可检索索引。</p>
|
||
</div>
|
||
<div class="search-side-fields" id="reportQueryFields">
|
||
<p class="search-field-label">平台</p>
|
||
<input id="reportPlatform" type="hidden" value="android" />
|
||
<div class="platform-picker" role="radiogroup" aria-label="平台">
|
||
<button type="button" class="platform-card active" data-platform="android" aria-pressed="true">
|
||
<span class="platform-icon" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" fill="none">
|
||
<defs>
|
||
<path id="android-icon-a" d="M0 0h33.684v18.947H0z"/>
|
||
</defs>
|
||
<g fill="none" fill-rule="evenodd">
|
||
<path d="M0 0h40v40H0z"/>
|
||
<g opacity="1" transform="translate(3.158 11.158)">
|
||
<mask id="android-icon-b" fill="#fff">
|
||
<use xlink:href="#android-icon-a"/>
|
||
</mask>
|
||
<path fill="#07C160" d="M24.594 14.156a1.402 1.402 0 1 1-.003-2.803 1.402 1.402 0 0 1 .003 2.803m-15.504 0a1.402 1.402 0 1 1-.002-2.803 1.402 1.402 0 0 1 .002 2.803M25.097 5.72L27.9.874a.584.584 0 0 0-1.01-.583L24.05 5.2c-2.17-.989-4.608-1.54-7.21-1.54-2.6 0-5.038.552-7.209 1.54L6.794.291a.584.584 0 0 0-1.01.582l2.803 4.848C3.774 8.335.482 13.2 0 18.947h33.684C33.202 13.2 29.91 8.335 25.097 5.721" mask="url(#android-icon-b)"/>
|
||
</g>
|
||
</g>
|
||
</svg>
|
||
</span>
|
||
<span class="platform-name">Android</span>
|
||
</button>
|
||
<button type="button" class="platform-card" data-platform="ios" aria-pressed="false">
|
||
<span class="platform-icon" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" fill="none">
|
||
<defs>
|
||
<path id="ios-icon-a" d="M0 0h27v24.104H0z"/>
|
||
</defs>
|
||
<g fill="none" fill-rule="evenodd" transform="translate(7 4)">
|
||
<g transform="translate(0 7.658)">
|
||
<mask id="ios-icon-b" fill="#fff">
|
||
<use xlink:href="#ios-icon-a"/>
|
||
</mask>
|
||
<path fill="#111827" d="M20.06.016c-2.73-.194-5.046 1.46-6.339 1.46-1.311 0-3.332-1.418-5.475-1.38-2.814.04-5.408 1.57-6.86 3.983-2.922 4.86-.745 12.06 2.102 16.004 1.392 1.927 3.053 4.098 5.235 4.02 2.1-.08 2.895-1.302 5.433-1.302 2.536 0 3.252 1.301 5.473 1.262 2.26-.04 3.692-1.967 5.075-3.904 1.6-2.237 2.257-4.403 2.296-4.517-.05-.018-4.406-1.619-4.45-6.426-.039-4.019 3.425-5.951 3.583-6.044C24.167.398 21.129.092 20.059.016z" mask="url(#ios-icon-b)"/>
|
||
</g>
|
||
<path fill="#111827" d="M18.375 5.07c1.16-1.342 1.94-3.21 1.725-5.07-1.669.063-3.687 1.065-4.884 2.406-1.072 1.188-2.013 3.091-1.757 4.912 1.86.138 3.758-.904 4.916-2.247z"/>
|
||
</g>
|
||
</svg>
|
||
</span>
|
||
<span class="platform-name">iOS</span>
|
||
</button>
|
||
<button type="button" class="platform-card" data-platform="harmony" aria-pressed="false">
|
||
<span class="platform-icon" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||
<g fill="#D81E06" fill-rule="evenodd">
|
||
<path fill-rule="nonzero" d="M0 0h40v40H0z" opacity="0"/>
|
||
<path d="M15.2 23.2c-.4-.2-.8-.3-1.3-.3s-.9.1-1.3.3c-.4.2-.7.5-.9.9-.2.4-.3.8-.3 1.3s.1.9.3 1.3c.2.4.5.7.9.9.4.2.8.3 1.3.3s.9-.1 1.3-.3c.4-.2.7-.5.9-.9.2-.4.3-.8.3-1.3s-.1-.9-.3-1.3c-.2-.4-.5-.7-.9-.9zM11.6 5C8 5 5 7.9 5 11.6v16.8C5 32 7.9 35 11.6 35h16.8c3.6 0 6.6-2.9 6.6-6.6V11.6C35 8 32.1 5 28.4 5H11.6zm-.9 5.3h1.5v3.2h3.5v-3.2h1.5v8h-1.5v-3.4h-3.5v3.4h-1.5v-8zm6.1 22h-5.7V31h5.7v1.3zm.7-4.8c-.4.6-.9 1.1-1.5 1.5-.6.4-1.3.5-2.1.5s-1.5-.2-2.1-.5c-.6-.4-1.1-.9-1.5-1.5-.4-.6-.5-1.3-.5-2.1s.2-1.5.5-2.1c.4-.6.9-1.1 1.5-1.5.6-.4 1.3-.5 2.1-.5s1.5.2 2.1.5c.6.4 1.1.9 1.5 1.5.4.6.5 1.3.5 2.1s-.2 1.5-.5 2.1zm10.6 1.1c-.2.4-.6.6-1 .8-.4.2-.9.3-1.4.3-.5 0-1.3-.1-1.9-.4-.5-.3-.9-.6-1.1-1.1V28h.1l.7-.4.2-.1.2.3.6.6c.3.2.7.3 1 .3.3 0 .7 0 1-.3.2-.2.3-.4.3-.7 0-.3 0-.4-.2-.5-.2-.2-.4-.3-.8-.4l-1.1-.3c-1.3-.4-2-1.1-2-2.3 0-1.2.1-.9.4-1.3.2-.4.6-.6 1-.8.4-.2.9-.3 1.4-.3.5 0 1.2.1 1.7.4.4.2.8.6 1 1v.3c.1 0 0 .1 0 .1l-.9.6-.2-.3c-.1-.2-.3-.4-.5-.5-.3-.2-.6-.2-.9-.2-.3 0-.7 0-.9.3-.2.2-.3.4-.3.7 0 .3 0 .4.2.5.2.2.4.3.8.4l1.1.3c1.3.4 1.9 1.1 1.9 2.3 0 1.2-.1.9-.3 1.3l-.1-.4zm-.2-10.4V13l-2 3.2h-.7l-2-3.2v5.1h-1.4v-8h1.4l2.5 4 2.5-4h1.3v8h-1.4l-.2.1z"/>
|
||
</g>
|
||
</svg>
|
||
</span>
|
||
<span class="platform-name">HarmonyOS</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="search-main-fields">
|
||
<div class="search-inline-row">
|
||
<div class="search-input-stack">
|
||
<p class="search-field-label">设备标识 / 设备名称 <code>model_raw</code></p>
|
||
<input id="reportModelRaw" placeholder="例如 SM-G9980 / iPhone14,2 / NOH-AL00 / iPad mini 7" />
|
||
</div>
|
||
<div class="btns">
|
||
<button class="primary" id="queryBtn">查询</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<article class="card">
|
||
<h2 class="title">索引结果</h2>
|
||
<div id="summary" class="sub">输入设备标识或设备名称后开始检索索引。</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h2 class="title">候选列表</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>分数</th>
|
||
<th>设备</th>
|
||
<th>品牌 / 厂商</th>
|
||
<th>类型</th>
|
||
<th>命中字段</th>
|
||
<th>来源详情</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="resultBody">
|
||
<tr><td colspan="7" class="sub">暂无结果</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
|
||
<details class="card collapse-card" open>
|
||
<summary>调试数据</summary>
|
||
<div class="collapse-body">
|
||
<div class="collapse-section">
|
||
<h2 class="title">JSON</h2>
|
||
<pre id="jsonOutput">{}</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sqlQueryPanel" class="page-panel">
|
||
<div class="wrap query-wrap">
|
||
<aside class="query-sidebar">
|
||
<details class="card collapse-card sidebar-card" open>
|
||
<summary>使用提示</summary>
|
||
<div class="collapse-body">
|
||
<div class="collapse-section">
|
||
<p class="section-label">查询入口</p>
|
||
<ul class="compact-note-list">
|
||
<li>直接查询 MySQL 主表 <code>mobilemodels.mm_device_catalog</code>。</li>
|
||
<li>查询入参使用客户端原始上报的 <code>model_raw</code>。</li>
|
||
<li>查询键固定为归一化后的 <code>alias_norm</code>,主推 SQL 为 <code>WHERE alias_norm = ?</code>。</li>
|
||
</ul>
|
||
</div>
|
||
<div class="collapse-section">
|
||
<p class="section-label">设备管理取字段</p>
|
||
<ul class="compact-note-list">
|
||
<li>设备展示名:<code>device_name</code></li>
|
||
<li>品牌展示:<code>market_brand</code>;为空时使用 <code>brand</code></li>
|
||
<li>厂商归属:<code>parent_brand</code>;为空时使用 <code>manufacturer_brand</code></li>
|
||
<li>设备类型:<code>device_type</code></li>
|
||
</ul>
|
||
</div>
|
||
<div class="collapse-section">
|
||
<p class="section-label">排查字段</p>
|
||
<ul class="compact-note-list">
|
||
<li><code>alias_norm</code> 用于服务端归一化查询键,不作为展示字段。</li>
|
||
<li><code>source_file</code>、<code>source_rank</code> 用于确认来源和排序。</li>
|
||
<li><code>code</code>、<code>code_alias</code>、<code>ver_name</code> 用于扩展匹配或人工排查。</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="card collapse-card sidebar-card" open>
|
||
<summary>查询规则</summary>
|
||
<div class="collapse-body">
|
||
<div class="collapse-section">
|
||
<p class="section-label">设备标识查询链路</p>
|
||
<pre>客户端原始值
|
||
-> 归一化
|
||
-> alias_norm
|
||
-> WHERE alias_norm = ?</pre>
|
||
</div>
|
||
<div class="collapse-section">
|
||
<p class="section-label">归一化规则</p>
|
||
<p class="section-label">处理步骤</p>
|
||
<ul class="compact-note-list">
|
||
<li>先执行 <code>lower()</code></li>
|
||
<li>再执行正则替换:<code>/[^0-9a-z\u4e00-\u9fff]+/g</code> -> <code>""</code></li>
|
||
</ul>
|
||
<p class="section-label">JavaScript 示例</p>
|
||
<pre><code>text.toLowerCase().replace(/[^0-9a-z\u4e00-\u9fff]+/g, "")</code></pre>
|
||
</div>
|
||
<div class="collapse-section">
|
||
<p class="section-label">归一化示例</p>
|
||
<pre>SM-G9980 -> smg9980
|
||
iPhone14,2 -> iphone142
|
||
NOH-AL00 -> nohal00</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="card collapse-card sidebar-card" open>
|
||
<summary>只读连接参数</summary>
|
||
<div class="collapse-body">
|
||
<div id="sqlReadonlyInfo" class="credential-grid">
|
||
<div class="credential-item">
|
||
<span class="credential-label">Host</span>
|
||
<p class="credential-value">加载中...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</aside>
|
||
|
||
<section class="grid query-main">
|
||
<article class="card search-card">
|
||
<div class="search-layout">
|
||
<div class="search-copy">
|
||
<h1 class="title">设备标识查询</h1>
|
||
<p class="sub">输入 <code>model_raw</code> 后直接查询设备标识。</p>
|
||
</div>
|
||
<div class="search-side-fields">
|
||
<p class="search-field-label">平台</p>
|
||
<input id="sqlPlatform" type="hidden" value="android" />
|
||
<div class="platform-picker" role="radiogroup" aria-label="设备标识查询平台">
|
||
<button type="button" class="platform-card sql-platform-card active" data-platform="android" aria-pressed="true">
|
||
<span class="platform-icon" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" fill="none">
|
||
<defs>
|
||
<path id="android-icon-c" d="M0 0h33.684v18.947H0z"/>
|
||
</defs>
|
||
<g fill="none" fill-rule="evenodd">
|
||
<path d="M0 0h40v40H0z"/>
|
||
<g opacity="1" transform="translate(3.158 11.158)">
|
||
<mask id="android-icon-d" fill="#fff">
|
||
<use xlink:href="#android-icon-c"/>
|
||
</mask>
|
||
<path fill="#07C160" d="M24.594 14.156a1.402 1.402 0 1 1-.003-2.803 1.402 1.402 0 0 1 .003 2.803m-15.504 0a1.402 1.402 0 1 1-.002-2.803 1.402 1.402 0 0 1 .002 2.803M25.097 5.72L27.9.874a.584.584 0 0 0-1.01-.583L24.05 5.2c-2.17-.989-4.608-1.54-7.21-1.54-2.6 0-5.038.552-7.209 1.54L6.794.291a.584.584 0 0 0-1.01.582l2.803 4.848C3.774 8.335.482 13.2 0 18.947h33.684C33.202 13.2 29.91 8.335 25.097 5.721" mask="url(#android-icon-d)"/>
|
||
</g>
|
||
</g>
|
||
</svg>
|
||
</span>
|
||
<span class="platform-name">Android</span>
|
||
</button>
|
||
<button type="button" class="platform-card sql-platform-card" data-platform="ios" aria-pressed="false">
|
||
<span class="platform-icon" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" fill="none">
|
||
<defs>
|
||
<path id="ios-icon-c" d="M0 0h27v24.104H0z"/>
|
||
</defs>
|
||
<g fill="none" fill-rule="evenodd" transform="translate(7 4)">
|
||
<g transform="translate(0 7.658)">
|
||
<mask id="ios-icon-d" fill="#fff">
|
||
<use xlink:href="#ios-icon-c"/>
|
||
</mask>
|
||
<path fill="#111827" d="M20.06.016c-2.73-.194-5.046 1.46-6.339 1.46-1.311 0-3.332-1.418-5.475-1.38-2.814.04-5.408 1.57-6.86 3.983-2.922 4.86-.745 12.06 2.102 16.004 1.392 1.927 3.053 4.098 5.235 4.02 2.1-.08 2.895-1.302 5.433-1.302 2.536 0 3.252 1.301 5.473 1.262 2.26-.04 3.692-1.967 5.075-3.904 1.6-2.237 2.257-4.403 2.296-4.517-.05-.018-4.406-1.619-4.45-6.426-.039-4.019 3.425-5.951 3.583-6.044C24.167.398 21.129.092 20.059.016z" mask="url(#ios-icon-d)"/>
|
||
</g>
|
||
<path fill="#111827" d="M18.375 5.07c1.16-1.342 1.94-3.21 1.725-5.07-1.669.063-3.687 1.065-4.884 2.406-1.072 1.188-2.013 3.091-1.757 4.912 1.86.138 3.758-.904 4.916-2.247z"/>
|
||
</g>
|
||
</svg>
|
||
</span>
|
||
<span class="platform-name">iOS</span>
|
||
</button>
|
||
<button type="button" class="platform-card sql-platform-card" data-platform="harmony" aria-pressed="false">
|
||
<span class="platform-icon" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||
<g fill="#D81E06" fill-rule="evenodd">
|
||
<path fill-rule="nonzero" d="M0 0h40v40H0z" opacity="0"/>
|
||
<path d="M15.2 23.2c-.4-.2-.8-.3-1.3-.3s-.9.1-1.3.3c-.4.2-.7.5-.9.9-.2.4-.3.8-.3 1.3s.1.9.3 1.3c.2.4.5.7.9.9.4.2.8.3 1.3.3s.9-.1 1.3-.3c.4-.2.7-.5.9-.9.2-.4.3-.8.3-1.3s-.1-.9-.3-1.3c-.2-.4-.5-.7-.9-.9zM11.6 5C8 5 5 7.9 5 11.6v16.8C5 32 7.9 35 11.6 35h16.8c3.6 0 6.6-2.9 6.6-6.6V11.6C35 8 32.1 5 28.4 5H11.6zm-.9 5.3h1.5v3.2h3.5v-3.2h1.5v8h-1.5v-3.4h-3.5v3.4h-1.5v-8zm6.1 22h-5.7V31h5.7v1.3zm.7-4.8c-.4.6-.9 1.1-1.5 1.5-.6.4-1.3.5-2.1.5s-1.5-.2-2.1-.5c-.6-.4-1.1-.9-1.5-1.5-.4-.6-.5-1.3-.5-2.1s.2-1.5.5-2.1c.4-.6.9-1.1 1.5-1.5.6-.4 1.3-.5 2.1-.5s1.5.2 2.1.5c.6.4 1.1.9 1.5 1.5.4.6.5 1.3.5 2.1s-.2 1.5-.5 2.1zm10.6 1.1c-.2.4-.6.6-1 .8-.4.2-.9.3-1.4.3-.5 0-1.3-.1-1.9-.4-.5-.3-.9-.6-1.1-1.1V28h.1l.7-.4.2-.1.2.3.6.6c.3.2.7.3 1 .3.3 0 .7 0 1-.3.2-.2.3-.4.3-.7 0-.3 0-.4-.2-.5-.2-.2-.4-.3-.8-.4l-1.1-.3c-1.3-.4-2-1.1-2-2.3 0-1.2.1-.9.4-1.3.2-.4.6-.6 1-.8.4-.2.9-.3 1.4-.3.5 0 1.2.1 1.7.4.4.2.8.6 1 1v.3c.1 0 0 .1 0 .1l-.9.6-.2-.3c-.1-.2-.3-.4-.5-.5-.3-.2-.6-.2-.9-.2-.3 0-.7 0-.9.3-.2.2-.3.4-.3.7 0 .3 0 .4.2.5.2.2.4.3.8.4l1.1.3c1.3.4 1.9 1.1 1.9 2.3 0 1.2-.1.9-.3 1.3l-.1-.4zm-.2-10.4V13l-2 3.2h-.7l-2-3.2v5.1h-1.4v-8h1.4l2.5 4 2.5-4h1.3v8h-1.4l-.2.1z"/>
|
||
</g>
|
||
</svg>
|
||
</span>
|
||
<span class="platform-name">HarmonyOS</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="search-main-fields">
|
||
<div class="search-inline-row">
|
||
<div class="search-input-stack">
|
||
<p class="search-field-label">设备标识 <code>model_raw</code></p>
|
||
<input id="sqlModelRaw" placeholder="例如 SM-G9980 / iPhone14,2 / NOH-AL00" />
|
||
</div>
|
||
<div class="btns">
|
||
<button class="primary" id="sqlQueryBtn">查询</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<article class="card">
|
||
<h2 class="title">设备标识结果</h2>
|
||
<div id="sqlSummary" class="sub">输入设备标识 <code>model_raw</code> 后开始查询。</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h2 class="title">返回结果</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>命中标识</th>
|
||
<th>设备</th>
|
||
<th>品牌 / 厂商</th>
|
||
<th>类型</th>
|
||
<th>来源</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="sqlResultBody">
|
||
<tr><td colspan="6" class="sub">暂无结果</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
|
||
<details class="card collapse-card" open>
|
||
<summary>调试信息</summary>
|
||
<div class="collapse-body">
|
||
<div class="collapse-section">
|
||
<h2 class="title">执行 SQL</h2>
|
||
<pre id="sqlStatement">-- 暂无 SQL</pre>
|
||
</div>
|
||
<div class="collapse-section">
|
||
<h2 class="title">返回 JSON</h2>
|
||
<pre id="sqlJsonOutput">{}</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="deviceNameSqlQueryPanel" class="page-panel hidden">
|
||
<div class="wrap query-wrap">
|
||
<aside class="query-sidebar">
|
||
<details class="card collapse-card sidebar-card" open>
|
||
<summary>使用提示</summary>
|
||
<div class="collapse-body">
|
||
<ul class="compact-note-list">
|
||
<li>这里只接受设备名称,例如 <code>iPad mini 7</code>、<code>iPhone SE 3</code>。</li>
|
||
<li>会先尝试别名映射,再做 <code>device_name</code> / <code>ver_name</code> 查询。</li>
|
||
<li>底层仍查询 <code>mobilemodels.mm_device_catalog</code>,用于和设备标识查询对齐。</li>
|
||
<li>只读连接参数固定展示在左侧,执行 SQL 和返回 JSON 保留在下方调试信息。</li>
|
||
</ul>
|
||
</div>
|
||
</details>
|
||
<details class="card collapse-card sidebar-card" open>
|
||
<summary>只读连接参数</summary>
|
||
<div class="collapse-body">
|
||
<div id="deviceNameSqlReadonlyInfo" class="credential-grid">
|
||
<div class="credential-item">
|
||
<span class="credential-label">Host</span>
|
||
<p class="credential-value">加载中...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</aside>
|
||
|
||
<section class="grid query-main">
|
||
<article class="card search-card">
|
||
<div class="search-layout-single">
|
||
<div>
|
||
<h1 class="title">设备名称查询</h1>
|
||
<p class="sub">输入设备名称即可查询。</p>
|
||
</div>
|
||
<div class="search-inline-row">
|
||
<div class="search-input-stack">
|
||
<label for="deviceNameSqlInput">设备名称</label>
|
||
<input id="deviceNameSqlInput" placeholder="例如 iPad mini 7 / iPhone SE 3 / Xiaomi Pad 8" />
|
||
</div>
|
||
<div class="btns">
|
||
<button class="primary" id="deviceNameSqlQueryBtn">查询</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<article class="card">
|
||
<h2 class="title">查询结果</h2>
|
||
<div id="deviceNameSqlSummary" class="sub">输入设备名称后开始查询。</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h2 class="title">返回结果</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>命中标识</th>
|
||
<th>设备</th>
|
||
<th>品牌 / 厂商</th>
|
||
<th>类型</th>
|
||
<th>来源</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="deviceNameSqlResultBody">
|
||
<tr><td colspan="6" class="sub">暂无结果</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
|
||
<details class="card collapse-card" open>
|
||
<summary>调试信息</summary>
|
||
<div class="collapse-body">
|
||
<div class="collapse-section">
|
||
<h2 class="title">执行 SQL</h2>
|
||
<pre id="deviceNameSqlStatement">-- 暂无 SQL</pre>
|
||
</div>
|
||
<div class="collapse-section">
|
||
<h2 class="title">返回 JSON</h2>
|
||
<pre id="deviceNameSqlJsonOutput">{}</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="docsPanel" class="page-panel hidden">
|
||
<div class="doc-grid">
|
||
<aside class="card doc-sidebar">
|
||
<nav id="docPanelToc" class="doc-toc" aria-label="文档目录"></nav>
|
||
</aside>
|
||
|
||
<article class="card doc-preview">
|
||
<div id="docPanelContent" class="markdown-body">加载中...</div>
|
||
</article>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const BRAND_CONFIG_STORAGE_KEY = "mobilemodels_brand_config_v2";
|
||
const SOURCE_CONFIG_STORAGE_KEY = "mobilemodels_source_config_v1";
|
||
const QUERY_FORM_STORAGE_KEY = "mobilemodels_query_form_v1";
|
||
const QUERY_PAGE_TAB_STORAGE_KEY = "mobilemodels_query_page_tab_v2";
|
||
|
||
let indexData = null;
|
||
let managedBrandConfig = null;
|
||
let managedSourceConfig = null;
|
||
|
||
let recordById = new Map();
|
||
let managedBrandAliasToBrand = new Map();
|
||
let managedBrandToManufacturer = new Map();
|
||
let managedManufacturerToBrands = new Map();
|
||
let sourceOrder = [];
|
||
let sourceRankMap = new Map();
|
||
let sourceWeightMap = new Map();
|
||
const MODEL_RAW_SUGGESTIONS = {
|
||
android: "SM-G9980",
|
||
ios: "iPhone14,2",
|
||
harmony: "NOH-AL00",
|
||
};
|
||
|
||
const summaryEl = document.getElementById("summary");
|
||
const resultBodyEl = document.getElementById("resultBody");
|
||
const jsonOutputEl = document.getElementById("jsonOutput");
|
||
const sqlSummaryEl = document.getElementById("sqlSummary");
|
||
const sqlResultBodyEl = document.getElementById("sqlResultBody");
|
||
const sqlStatementEl = document.getElementById("sqlStatement");
|
||
const sqlJsonOutputEl = document.getElementById("sqlJsonOutput");
|
||
const sqlReadonlyInfoEl = document.getElementById("sqlReadonlyInfo");
|
||
const deviceNameSqlSummaryEl = document.getElementById("deviceNameSqlSummary");
|
||
const deviceNameSqlResultBodyEl = document.getElementById("deviceNameSqlResultBody");
|
||
const deviceNameSqlStatementEl = document.getElementById("deviceNameSqlStatement");
|
||
const deviceNameSqlJsonOutputEl = document.getElementById("deviceNameSqlJsonOutput");
|
||
const deviceNameSqlReadonlyInfoEl = document.getElementById("deviceNameSqlReadonlyInfo");
|
||
const docPanelContentEl = document.getElementById("docPanelContent");
|
||
const docPanelTocEl = document.getElementById("docPanelToc");
|
||
const indexQueryPanelEl = document.getElementById("indexQueryPanel");
|
||
const sqlQueryPanelEl = document.getElementById("sqlQueryPanel");
|
||
const deviceNameSqlQueryPanelEl = document.getElementById("deviceNameSqlQueryPanel");
|
||
const docsPanelEl = document.getElementById("docsPanel");
|
||
const topNavQueryEl = document.getElementById("topNavQuery");
|
||
const topNavDocsEl = document.getElementById("topNavDocs");
|
||
|
||
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 normalizeText(text) {
|
||
return (text || "")
|
||
.toLowerCase()
|
||
.replace(/[^0-9a-z\u4e00-\u9fff]+/g, "");
|
||
}
|
||
|
||
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 parseAppleSeriesGeneration(name) {
|
||
const text = String(name || "").trim().replace(/\s+/g, " ");
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
const ordinalMatch = text.match(/^(.*?)(?:\s*\((\d+)(?:st|nd|rd|th)\s+generation\)|\s+(\d+))$/i);
|
||
if (ordinalMatch) {
|
||
const baseLabel = String(ordinalMatch[1] || "").trim().replace(/\s+/g, " ");
|
||
const generation = Number(ordinalMatch[2] || ordinalMatch[3] || 0);
|
||
if (baseLabel && generation > 0) {
|
||
return {
|
||
baseLabel,
|
||
baseNorm: normalizeText(baseLabel),
|
||
generation,
|
||
chipLike: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
const chipLike = /\((?:[^)]*\b(?:a\d{1,2}|m\d{1,2})\b[^)]*)\)$/i.test(text);
|
||
const baseLabel = text.replace(/\s*\([^)]*\)\s*$/g, "").trim().replace(/\s+/g, " ");
|
||
if (!baseLabel) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
baseLabel,
|
||
baseNorm: normalizeText(baseLabel),
|
||
generation: null,
|
||
chipLike,
|
||
};
|
||
}
|
||
|
||
function buildSyntheticAppleGenerationAliases(records) {
|
||
const groups = new Map();
|
||
const aliasesByRecordId = new Map();
|
||
|
||
for (const record of records || []) {
|
||
const brand = record.market_brand || record.manufacturer_brand || record.brand || "";
|
||
if (brand !== "Apple") {
|
||
continue;
|
||
}
|
||
|
||
const parsed = parseAppleSeriesGeneration(record.device_name);
|
||
if (!parsed || !parsed.baseNorm) {
|
||
continue;
|
||
}
|
||
|
||
if (!groups.has(parsed.baseNorm)) {
|
||
groups.set(parsed.baseNorm, {
|
||
baseLabel: parsed.baseLabel,
|
||
items: [],
|
||
});
|
||
}
|
||
|
||
groups.get(parsed.baseNorm).items.push({
|
||
id: record.id,
|
||
deviceName: record.device_name,
|
||
baseLabel: parsed.baseLabel,
|
||
generation: parsed.generation,
|
||
chipLike: parsed.chipLike,
|
||
});
|
||
}
|
||
|
||
for (const group of groups.values()) {
|
||
const explicitGenerations = [...new Set(
|
||
group.items
|
||
.map((item) => item.generation)
|
||
.filter((value) => Number.isInteger(value) && value > 0)
|
||
)].sort((a, b) => a - b);
|
||
const maxExplicitGeneration = explicitGenerations.length
|
||
? explicitGenerations[explicitGenerations.length - 1]
|
||
: 0;
|
||
|
||
for (const item of group.items) {
|
||
let generation = item.generation;
|
||
if (!generation && item.deviceName === group.baseLabel && maxExplicitGeneration >= 2) {
|
||
generation = 1;
|
||
} else if (!generation && item.chipLike && maxExplicitGeneration >= 1) {
|
||
generation = maxExplicitGeneration + 1;
|
||
}
|
||
|
||
if (!generation || generation <= 0) {
|
||
continue;
|
||
}
|
||
|
||
const syntheticAliases = aliasesByRecordId.get(item.id) || [];
|
||
syntheticAliases.push(`${group.baseLabel} ${generation}`);
|
||
aliasesByRecordId.set(item.id, syntheticAliases);
|
||
}
|
||
}
|
||
|
||
return aliasesByRecordId;
|
||
}
|
||
|
||
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;
|
||
try {
|
||
localStorage.setItem(BRAND_CONFIG_STORAGE_KEY, JSON.stringify(managedBrandConfig));
|
||
} catch (err) {
|
||
console.warn("保存品牌配置失败:", err);
|
||
}
|
||
}
|
||
|
||
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 (err) {
|
||
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) {
|
||
const source = String(sourceFile || "").trim().toLowerCase();
|
||
if (!source) return false;
|
||
if (source === "local/manual_catalog.json") return true;
|
||
return !(/_en\.md$/i.test(source) || /_global_en\.md$/i.test(source));
|
||
}
|
||
|
||
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;
|
||
try {
|
||
localStorage.setItem(SOURCE_CONFIG_STORAGE_KEY, JSON.stringify(managedSourceConfig));
|
||
} catch (err) {
|
||
console.warn("保存来源配置失败:", err);
|
||
}
|
||
}
|
||
|
||
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 (err) {
|
||
managedSourceConfig = fallback;
|
||
saveManagedSourceConfig();
|
||
}
|
||
}
|
||
|
||
function rebuildSourceWeights() {
|
||
sourceOrder = (managedSourceConfig && Array.isArray(managedSourceConfig.order))
|
||
? [...managedSourceConfig.order]
|
||
: [];
|
||
sourceRankMap = new Map();
|
||
sourceWeightMap = new Map();
|
||
const total = sourceOrder.length;
|
||
for (let idx = 0; idx < total; idx += 1) {
|
||
const source = sourceOrder[idx];
|
||
sourceRankMap.set(source, idx + 1);
|
||
const weight = total > 1 ? (((total - idx) / total) * 6) : 6;
|
||
sourceWeightMap.set(source, Number(weight.toFixed(3)));
|
||
}
|
||
}
|
||
|
||
function applyManagedBrandConfigToRecords() {
|
||
recordById = new Map();
|
||
if (!indexData.lookup || typeof indexData.lookup !== "object") {
|
||
indexData.lookup = {};
|
||
}
|
||
const syntheticAliasesByRecordId = buildSyntheticAppleGenerationAliases(indexData.records || []);
|
||
for (const r of indexData.records || []) {
|
||
const extraAliases = syntheticAliasesByRecordId.get(r.id) || [];
|
||
r.aliases = normalizeAliasList(r.device_name, [...(Array.isArray(r.aliases) ? r.aliases : []), ...extraAliases]);
|
||
for (const alias of extraAliases) {
|
||
const key = normalizeText(alias);
|
||
if (!key) continue;
|
||
const ids = Array.isArray(indexData.lookup[key]) ? indexData.lookup[key] : [];
|
||
if (!ids.includes(r.id)) {
|
||
ids.push(r.id);
|
||
}
|
||
indexData.lookup[key] = ids;
|
||
}
|
||
|
||
const mappedBrand = r.market_brand || r.manufacturer_brand || r.brand;
|
||
const mappedManufacturer =
|
||
managedBrandToManufacturer.get(mappedBrand)
|
||
|| r.parent_brand
|
||
|| r.manufacturer_brand
|
||
|| r.brand
|
||
|| mappedBrand;
|
||
r.mapped_brand = mappedBrand;
|
||
r.mapped_manufacturer = mappedManufacturer;
|
||
recordById.set(r.id, r);
|
||
}
|
||
}
|
||
|
||
function rebuildCaches() {
|
||
loadManagedBrandConfig();
|
||
loadManagedSourceConfig();
|
||
rebuildManagedBrandIndexes();
|
||
rebuildSourceWeights();
|
||
applyManagedBrandConfigToRecords();
|
||
}
|
||
|
||
function canonicalizeBrand(inputBrand, allRawNames) {
|
||
const inputNorm = normalizeText(inputBrand);
|
||
if (inputNorm && managedBrandAliasToBrand.has(inputNorm)) {
|
||
const canonicalBrand = managedBrandAliasToBrand.get(inputNorm);
|
||
return {
|
||
canonicalBrand,
|
||
canonicalManufacturer: managedBrandToManufacturer.get(canonicalBrand) || canonicalBrand,
|
||
source: "brand_exact",
|
||
};
|
||
}
|
||
|
||
const aliasesByLength = [...managedBrandAliasToBrand.entries()]
|
||
.sort((a, b) => b[0].length - a[0].length);
|
||
|
||
if (inputNorm) {
|
||
for (const [aliasNorm, brandName] of aliasesByLength) {
|
||
if (aliasNorm && inputNorm.includes(aliasNorm)) {
|
||
return {
|
||
canonicalBrand: brandName,
|
||
canonicalManufacturer: managedBrandToManufacturer.get(brandName) || brandName,
|
||
source: "brand_contains",
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
const brandHits = new Map();
|
||
const inspectTexts = [inputBrand || "", ...(allRawNames || [])];
|
||
for (const text of inspectTexts) {
|
||
const n = normalizeText(text);
|
||
if (!n) continue;
|
||
for (const [aliasNorm, brandName] of aliasesByLength) {
|
||
if (aliasNorm && n.includes(aliasNorm)) {
|
||
brandHits.set(brandName, (brandHits.get(brandName) || 0) + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (brandHits.size > 0) {
|
||
const topBrand = [...brandHits.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
||
return {
|
||
canonicalBrand: topBrand,
|
||
canonicalManufacturer: managedBrandToManufacturer.get(topBrand) || topBrand,
|
||
source: "name_inference",
|
||
};
|
||
}
|
||
|
||
return {
|
||
canonicalBrand: null,
|
||
canonicalManufacturer: null,
|
||
source: "none",
|
||
};
|
||
}
|
||
|
||
function splitPossibleValuePairs(line) {
|
||
const out = [line];
|
||
const m = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_\-.]*)\s*[:=]\s*(.+)\s*$/);
|
||
if (m) {
|
||
out.push(m[2]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stripSuffixHints(text) {
|
||
const variants = new Set([text]);
|
||
variants.add(text.replace(/[((][^))]*[))]/g, " "));
|
||
variants.add(text.replace(/\b\d+\s*(gb|g|tb)\b/gi, " "));
|
||
variants.add(text.replace(/\b(dualsim|simfree|global|china|cn|eu|us|jp|hk|tw)\b/gi, " "));
|
||
variants.add(text.replace(/\/(ds|dsn|global|cn|eu|us)$/i, ""));
|
||
return [...variants].map((v) => v.trim()).filter(Boolean);
|
||
}
|
||
|
||
function brandSpecificVariants(text, canonicalBrand, platform) {
|
||
const v = new Set([text]);
|
||
|
||
if (canonicalBrand === "Apple" || platform === "ios") {
|
||
const appleMatch = text.match(/(iPhone\d+,\d+|iPad\d+,\d+|iPod\d+,\d+)/i);
|
||
if (appleMatch) {
|
||
v.add(appleMatch[1]);
|
||
}
|
||
v.add(text.replace(/apple/ig, " "));
|
||
}
|
||
|
||
if (canonicalBrand === "Samsung") {
|
||
v.add(text.replace(/^\s*SM[-\s]?/i, "SM-"));
|
||
v.add(text.replace(/^\s*SM[-\s]?/i, ""));
|
||
}
|
||
|
||
if (canonicalBrand === "HUAWEI" || canonicalBrand === "HONOR") {
|
||
v.add(text.replace(/^\s*(HUAWEI|HONOR)\s+/i, ""));
|
||
}
|
||
|
||
if (["Xiaomi", "Redmi", "POCO"].includes(canonicalBrand)) {
|
||
v.add(text.replace(/^\s*MI\s+/i, "Mi "));
|
||
v.add(text.replace(/^\s*(Xiaomi|Redmi|POCO)\s+/i, ""));
|
||
}
|
||
|
||
if (platform === "android" || platform === "harmony") {
|
||
const modelLike = text.match(/\b([A-Za-z]{1,4}[-]?[A-Za-z0-9]{3,})\b/g);
|
||
for (const m of modelLike || []) {
|
||
v.add(m);
|
||
}
|
||
}
|
||
|
||
return [...v].map((s) => s.trim()).filter(Boolean);
|
||
}
|
||
|
||
function generateQueryKeys(rawInputs, canonicalBrand, platform) {
|
||
const keys = new Set();
|
||
const tokenToKeys = [];
|
||
|
||
for (const raw of rawInputs) {
|
||
for (const candidate of splitPossibleValuePairs(raw)) {
|
||
const stripped = stripSuffixHints(candidate);
|
||
for (const item of stripped) {
|
||
const brandVs = brandSpecificVariants(item, canonicalBrand, platform);
|
||
for (const vv of brandVs) {
|
||
const key = normalizeText(vv);
|
||
if (!key) continue;
|
||
keys.add(key);
|
||
tokenToKeys.push({ token: raw, key });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
keys: [...keys],
|
||
tokenToKeys,
|
||
};
|
||
}
|
||
|
||
function platformBrandBoost(platform, recordManufacturer) {
|
||
if (platform === "ios") {
|
||
return recordManufacturer === "Apple" ? 6 : -3;
|
||
}
|
||
if (platform === "harmony") {
|
||
return ["HUAWEI", "HONOR", "HUAWEI Smart Selection"].includes(recordManufacturer) ? 4 : 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function queryRecords(input) {
|
||
const rawInputs = [input.primaryName, ...(input.extraNames || [])]
|
||
.map((s) => (s || "").trim())
|
||
.filter(Boolean);
|
||
|
||
if (!rawInputs.length) {
|
||
return {
|
||
error: "至少输入一条设备名或型号。"
|
||
};
|
||
}
|
||
|
||
const brandInfo = canonicalizeBrand(input.brand, rawInputs);
|
||
const canonicalBrand = brandInfo.canonicalBrand;
|
||
const canonicalManufacturer = brandInfo.canonicalManufacturer;
|
||
|
||
const brandForVariantRules = canonicalBrand;
|
||
const { keys, tokenToKeys } = generateQueryKeys(rawInputs, brandForVariantRules, input.platform);
|
||
const hitMap = new Map();
|
||
|
||
for (const key of keys) {
|
||
const ids = indexData.lookup[key] || [];
|
||
for (const id of ids) {
|
||
const record = recordById.get(id);
|
||
if (!record) continue;
|
||
|
||
if (!hitMap.has(id)) {
|
||
hitMap.set(id, {
|
||
id,
|
||
record,
|
||
score: 0,
|
||
keys: new Set(),
|
||
rawTokens: new Set(),
|
||
});
|
||
}
|
||
const item = hitMap.get(id);
|
||
item.keys.add(key);
|
||
}
|
||
}
|
||
|
||
for (const item of hitMap.values()) {
|
||
for (const t of tokenToKeys) {
|
||
if (item.keys.has(t.key)) {
|
||
item.rawTokens.add(t.token);
|
||
}
|
||
}
|
||
|
||
item.score += item.keys.size * 25;
|
||
item.score += item.rawTokens.size * 8;
|
||
|
||
if (canonicalBrand) {
|
||
if (item.record.mapped_brand === canonicalBrand) {
|
||
item.score += 16;
|
||
} else if (canonicalManufacturer && item.record.mapped_manufacturer === canonicalManufacturer) {
|
||
item.score += 4;
|
||
} else {
|
||
item.score -= 6;
|
||
}
|
||
}
|
||
|
||
if (canonicalManufacturer) {
|
||
if (item.record.mapped_manufacturer === canonicalManufacturer) {
|
||
item.score += 10;
|
||
} else {
|
||
item.score -= 2;
|
||
}
|
||
}
|
||
|
||
const sourceRank = sourceRankMap.get(item.record.source_file) || (sourceOrder.length + 1);
|
||
const sourceWeight = sourceWeightMap.get(item.record.source_file) || 0;
|
||
item.source_rank = sourceRank;
|
||
item.source_weight = sourceWeight;
|
||
item.score += sourceWeight;
|
||
|
||
item.score += platformBrandBoost(input.platform, item.record.mapped_manufacturer);
|
||
}
|
||
|
||
const allMatches = [...hitMap.values()].sort((a, b) =>
|
||
(b.score - a.score)
|
||
|| ((b.source_weight || 0) - (a.source_weight || 0))
|
||
|| ((a.source_rank || Number.MAX_SAFE_INTEGER) - (b.source_rank || Number.MAX_SAFE_INTEGER))
|
||
);
|
||
const brandStrictMatches = canonicalBrand
|
||
? allMatches.filter((x) => x.record.mapped_brand === canonicalBrand)
|
||
: allMatches;
|
||
const manufacturerStrictMatches = canonicalManufacturer
|
||
? allMatches.filter((x) => x.record.mapped_manufacturer === canonicalManufacturer)
|
||
: allMatches;
|
||
|
||
const topBase = brandStrictMatches.length
|
||
? brandStrictMatches
|
||
: (manufacturerStrictMatches.length ? manufacturerStrictMatches : allMatches);
|
||
const preferredTopBase = topBase.filter((x) => isCnSourceFile(x.record && x.record.source_file));
|
||
const topSourceBase = preferredTopBase.length ? preferredTopBase : topBase;
|
||
const top = topSourceBase.slice(0, 1);
|
||
|
||
const best = top.length ? top[0] : null;
|
||
|
||
return {
|
||
query: {
|
||
query_mode: input.queryMode || "general",
|
||
platform: input.platform,
|
||
brand_input: input.brand || null,
|
||
canonical_brand: canonicalBrand,
|
||
canonical_manufacturer: canonicalManufacturer,
|
||
brand_source: brandInfo.source,
|
||
source_order_count: sourceOrder.length,
|
||
raw_inputs: rawInputs,
|
||
normalized_keys: keys,
|
||
report_payload: input.reportPayload || null,
|
||
},
|
||
stats: {
|
||
total_hits: allMatches.length,
|
||
brand_strict_hits: brandStrictMatches.length,
|
||
manufacturer_strict_hits: manufacturerStrictMatches.length,
|
||
returned: top.length,
|
||
},
|
||
best: best
|
||
? {
|
||
score: best.score,
|
||
...best.record,
|
||
market_name: best.record.device_name,
|
||
source_rank: best.source_rank || null,
|
||
source_weight: best.source_weight || 0,
|
||
matched_keys: [...best.keys],
|
||
matched_inputs: [...best.rawTokens],
|
||
}
|
||
: null,
|
||
results: top.map((x) => ({
|
||
score: x.score,
|
||
...x.record,
|
||
market_name: x.record.device_name,
|
||
source_rank: x.source_rank || null,
|
||
source_weight: x.source_weight || 0,
|
||
matched_keys: [...x.keys],
|
||
matched_inputs: [...x.rawTokens],
|
||
})),
|
||
};
|
||
}
|
||
|
||
function renderResult(output) {
|
||
if (output.error) {
|
||
summaryEl.textContent = output.error;
|
||
resultBodyEl.innerHTML = `<tr><td colspan="7" class="sub">${output.error}</td></tr>`;
|
||
jsonOutputEl.textContent = JSON.stringify(output, null, 2);
|
||
return;
|
||
}
|
||
|
||
const q = output.query;
|
||
const s = output.stats;
|
||
const bestText = output.best
|
||
? `识别结果: ${(output.best.market_name || output.best.device_name)} / ${output.best.mapped_brand || output.best.brand} / ${output.best.device_type}`
|
||
: "识别结果: 暂无明确结果";
|
||
const reportText = q.report_payload
|
||
? `<div class="mono">上报字段: ${escapeHtml(JSON.stringify(q.report_payload))}</div>`
|
||
: "";
|
||
|
||
summaryEl.innerHTML = `
|
||
<div class="result-head">
|
||
<span class="pill">最佳分数: ${output.best ? output.best.score : "-"}</span>
|
||
<span class="pill">平台: ${q.platform}</span>
|
||
<span class="pill">品牌归一: ${q.canonical_brand || "(未识别)"}</span>
|
||
<span class="pill">父级厂商: ${q.canonical_manufacturer || "(未识别)"}</span>
|
||
<span class="pill">总命中: ${s.total_hits}</span>
|
||
</div>
|
||
${reportText}
|
||
<p class="sub">${bestText}</p>
|
||
`;
|
||
|
||
if (!output.results.length) {
|
||
resultBodyEl.innerHTML = `<tr><td colspan="7" class="sub">没有找到合适候选,请检查上报值,或补充更多字段后再试。</td></tr>`;
|
||
} else {
|
||
resultBodyEl.innerHTML = output.results.map((r, i) => {
|
||
const matchedBy = r.matched_inputs.map((x) => `<div class="mono">${escapeHtml(x)}</div>`).join("");
|
||
const source = `
|
||
<div class="mono">${escapeHtml(r.source_file)}</div>
|
||
<div class="mono">rank=${r.source_rank || "-"} / weight=${Number(r.source_weight || 0).toFixed(3)}</div>
|
||
<div>${escapeHtml(r.section || "")}</div>
|
||
`;
|
||
const brandCell = `
|
||
<div>品牌: <strong>${escapeHtml(r.mapped_brand || r.market_brand || r.brand || "-")}</strong></div>
|
||
<div>厂商: <strong>${escapeHtml(r.mapped_manufacturer || r.parent_brand || r.manufacturer_brand || "-")}</strong></div>
|
||
`;
|
||
const details = `
|
||
<details>
|
||
<summary>查看别名(${r.aliases.length}) / 命中键(${r.matched_keys.length})</summary>
|
||
<pre>${escapeHtml(JSON.stringify({ matched_keys: r.matched_keys, aliases: r.aliases }, null, 2))}</pre>
|
||
</details>
|
||
`;
|
||
return `
|
||
<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${r.score}</td>
|
||
<td>${escapeHtml(r.market_name || r.device_name)}${details}</td>
|
||
<td>${brandCell}</td>
|
||
<td>${escapeHtml(r.device_type)}</td>
|
||
<td>${matchedBy}</td>
|
||
<td>${source}</td>
|
||
</tr>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
jsonOutputEl.textContent = JSON.stringify(output, null, 2);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return (text || "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function renderInlineMarkdown(text) {
|
||
let html = escapeHtml(text || "");
|
||
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||
return html;
|
||
}
|
||
|
||
function slugify(text) {
|
||
return String(text || "")
|
||
.toLowerCase()
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/[^0-9a-z\u4e00-\u9fff]+/g, "-")
|
||
.replace(/^-+|-+$/g, "") || "section";
|
||
}
|
||
|
||
function renderMarkdown(text) {
|
||
const normalized = String(text || "").replace(/\r\n/g, "\n");
|
||
const lines = normalized.split("\n");
|
||
const chunks = [];
|
||
const headingIds = new Map();
|
||
let i = 0;
|
||
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
const trimmed = line.trim();
|
||
|
||
if (!trimmed) {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (trimmed.startsWith("```")) {
|
||
const lang = trimmed.slice(3).trim();
|
||
const codeLines = [];
|
||
i += 1;
|
||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||
codeLines.push(lines[i]);
|
||
i += 1;
|
||
}
|
||
if (i < lines.length) i += 1;
|
||
const className = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||
chunks.push(`<pre><code${className}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||
continue;
|
||
}
|
||
|
||
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
||
if (heading) {
|
||
const level = heading[1].length;
|
||
const renderedText = renderInlineMarkdown(heading[2]);
|
||
const baseId = slugify(heading[2]);
|
||
const count = headingIds.get(baseId) || 0;
|
||
headingIds.set(baseId, count + 1);
|
||
const headingId = count ? `${baseId}-${count + 1}` : baseId;
|
||
chunks.push(`<h${level} id="${headingId}">${renderedText}</h${level}>`);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/^---+$/.test(trimmed) || /^___+$/.test(trimmed)) {
|
||
chunks.push("<hr />");
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/^>\s?/.test(trimmed)) {
|
||
const quoteLines = [];
|
||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
|
||
i += 1;
|
||
}
|
||
chunks.push(`<blockquote>${renderInlineMarkdown(quoteLines.join(" "))}</blockquote>`);
|
||
continue;
|
||
}
|
||
|
||
if (/^[-*]\s+/.test(trimmed)) {
|
||
const items = [];
|
||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||
i += 1;
|
||
}
|
||
chunks.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
|
||
continue;
|
||
}
|
||
|
||
if (/^\d+\.\s+/.test(trimmed)) {
|
||
const items = [];
|
||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||
i += 1;
|
||
}
|
||
chunks.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ol>`);
|
||
continue;
|
||
}
|
||
|
||
const paragraphLines = [];
|
||
while (i < lines.length) {
|
||
const candidate = lines[i];
|
||
const candidateTrim = candidate.trim();
|
||
if (!candidateTrim) break;
|
||
if (
|
||
candidateTrim.startsWith("```")
|
||
|| /^(#{1,4})\s+/.test(candidateTrim)
|
||
|| /^[-*]\s+/.test(candidateTrim)
|
||
|| /^\d+\.\s+/.test(candidateTrim)
|
||
|| /^>\s?/.test(candidateTrim)
|
||
|| /^---+$/.test(candidateTrim)
|
||
|| /^___+$/.test(candidateTrim)
|
||
) {
|
||
break;
|
||
}
|
||
paragraphLines.push(candidateTrim);
|
||
i += 1;
|
||
}
|
||
chunks.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
|
||
}
|
||
|
||
return chunks.join("");
|
||
}
|
||
|
||
function buildDocPanelToc(container) {
|
||
const headings = Array.from(container.querySelectorAll("h2, h3, h4"));
|
||
if (!headings.length) {
|
||
docPanelTocEl.innerHTML = `<div class="sub">当前文档没有可用目录。</div>`;
|
||
return;
|
||
}
|
||
const groups = [];
|
||
let currentGroup = null;
|
||
headings.forEach((heading) => {
|
||
const level = Number(heading.tagName.slice(1));
|
||
const item = {
|
||
id: heading.id,
|
||
text: escapeHtml(heading.textContent || ""),
|
||
level,
|
||
};
|
||
if (level === 2 || !currentGroup) {
|
||
currentGroup = { heading: item, children: [] };
|
||
groups.push(currentGroup);
|
||
return;
|
||
}
|
||
currentGroup.children.push(item);
|
||
});
|
||
docPanelTocEl.innerHTML = groups.map((group) => `
|
||
<section class="doc-toc-section">
|
||
<div class="doc-toc-title">${group.heading.text}</div>
|
||
<div class="doc-toc-links">
|
||
<a class="doc-toc-link level-${group.heading.level}" href="#${group.heading.id}">${group.heading.text}</a>
|
||
${group.children.map((item) => `<a class="doc-toc-link level-${item.level}" href="#${item.id}">${item.text}</a>`).join("")}
|
||
</div>
|
||
</section>
|
||
`).join("");
|
||
}
|
||
|
||
function activateTopTab(tabName) {
|
||
document.querySelectorAll(".page-tab").forEach((el) => {
|
||
const active = el.dataset.tab === tabName;
|
||
el.classList.toggle("active", active);
|
||
el.setAttribute("aria-pressed", active ? "true" : "false");
|
||
});
|
||
indexQueryPanelEl.classList.toggle("hidden", tabName !== "index-query");
|
||
sqlQueryPanelEl.classList.toggle("hidden", tabName !== "sql-query");
|
||
deviceNameSqlQueryPanelEl.classList.toggle("hidden", tabName !== "device-name-sql-query");
|
||
try {
|
||
localStorage.setItem(QUERY_PAGE_TAB_STORAGE_KEY, tabName);
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
function loadActiveTopTab() {
|
||
try {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const view = params.get("view") || "";
|
||
const docsMode = view === "docs";
|
||
document.body.classList.toggle("docs-mode", docsMode);
|
||
docsPanelEl.classList.toggle("hidden", !docsMode);
|
||
document.querySelector(".page-tabs-wrap").classList.toggle("hidden", docsMode);
|
||
if (docsMode) {
|
||
indexQueryPanelEl.classList.add("hidden");
|
||
sqlQueryPanelEl.classList.add("hidden");
|
||
deviceNameSqlQueryPanelEl.classList.add("hidden");
|
||
topNavQueryEl.classList.remove("active");
|
||
topNavDocsEl.classList.add("active");
|
||
return;
|
||
}
|
||
|
||
topNavQueryEl.classList.add("active");
|
||
topNavDocsEl.classList.remove("active");
|
||
const tabName = localStorage.getItem(QUERY_PAGE_TAB_STORAGE_KEY) || "sql-query";
|
||
activateTopTab(tabName);
|
||
} catch {
|
||
topNavQueryEl.classList.add("active");
|
||
topNavDocsEl.classList.remove("active");
|
||
document.body.classList.remove("docs-mode");
|
||
docsPanelEl.classList.add("hidden");
|
||
activateTopTab("sql-query");
|
||
}
|
||
}
|
||
|
||
async function loadDocInPanel(path, title) {
|
||
docPanelContentEl.innerHTML = "<p>加载中...</p>";
|
||
|
||
try {
|
||
const resp = await fetch(path, { cache: "no-store" });
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP ${resp.status}`);
|
||
}
|
||
const text = await resp.text();
|
||
docPanelContentEl.innerHTML = renderMarkdown(text);
|
||
buildDocPanelToc(docPanelContentEl);
|
||
} catch (err) {
|
||
docPanelContentEl.innerHTML = `<p>加载失败</p><pre>${escapeHtml(err.message || String(err))}</pre>`;
|
||
docPanelTocEl.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
async function loadIndexFromPath() {
|
||
try {
|
||
const resp = await fetch("../dist/device_index.json", { cache: "no-cache" });
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP ${resp.status}`);
|
||
}
|
||
indexData = await resp.json();
|
||
rebuildCaches();
|
||
} catch (err) {
|
||
console.warn("自动加载索引失败:", err);
|
||
}
|
||
}
|
||
|
||
function syncPlatformUI(hiddenInputId, cardSelector, modelInputId) {
|
||
const platformValue = document.getElementById(hiddenInputId).value;
|
||
const modelRawInput = document.getElementById(modelInputId);
|
||
document.querySelectorAll(cardSelector).forEach((el) => {
|
||
const isActive = el.dataset.platform === platformValue;
|
||
el.classList.toggle("active", isActive);
|
||
el.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||
});
|
||
modelRawInput.placeholder = MODEL_RAW_SUGGESTIONS[platformValue] || MODEL_RAW_SUGGESTIONS.android;
|
||
}
|
||
|
||
function syncReportPlatformUI() {
|
||
syncPlatformUI("reportPlatform", ".platform-picker .platform-card:not(.sql-platform-card)", "reportModelRaw");
|
||
}
|
||
|
||
function syncSqlPlatformUI() {
|
||
syncPlatformUI("sqlPlatform", ".sql-platform-card", "sqlModelRaw");
|
||
}
|
||
|
||
function renderReadonlyInfo(data) {
|
||
const items = [
|
||
["Host", data.mysql_host || "-"],
|
||
["Port", data.mysql_port || "-"],
|
||
["Database", data.mysql_database || "-"],
|
||
["User", data.mysql_reader_user || "-"],
|
||
["Password", data.mysql_reader_password || "-"],
|
||
];
|
||
const html = items.map(([label, value]) => `
|
||
<div class="credential-item">
|
||
<span class="credential-label">${escapeHtml(label)}</span>
|
||
<p class="credential-value">${escapeHtml(value)}</p>
|
||
</div>
|
||
`).join("");
|
||
sqlReadonlyInfoEl.innerHTML = html;
|
||
deviceNameSqlReadonlyInfoEl.innerHTML = html;
|
||
}
|
||
|
||
async function loadReadonlyInfo() {
|
||
try {
|
||
const data = await fetchJson("/api/status", { cache: "no-store" });
|
||
renderReadonlyInfo(data);
|
||
} catch (err) {
|
||
const html = `
|
||
<div class="credential-item">
|
||
<span class="credential-label">只读连接参数</span>
|
||
<p class="credential-value">暂时无法读取连接参数</p>
|
||
</div>
|
||
`;
|
||
sqlReadonlyInfoEl.innerHTML = html;
|
||
deviceNameSqlReadonlyInfoEl.innerHTML = html;
|
||
}
|
||
}
|
||
|
||
function buildReportModeInput() {
|
||
const platform = document.getElementById("reportPlatform").value;
|
||
const modelRawInput = document.getElementById("reportModelRaw");
|
||
const suggestedModelRaw = MODEL_RAW_SUGGESTIONS[platform] || MODEL_RAW_SUGGESTIONS.android;
|
||
const modelRaw = modelRawInput.value.trim() || suggestedModelRaw;
|
||
|
||
const reportPayload = {
|
||
platform,
|
||
model_raw: modelRaw || null,
|
||
};
|
||
|
||
if (!modelRaw) {
|
||
return {
|
||
error: "请填写 `model_raw`。",
|
||
};
|
||
}
|
||
|
||
return {
|
||
input: {
|
||
queryMode: "report",
|
||
platform,
|
||
brand: "",
|
||
primaryName: modelRaw,
|
||
extraNames: [`model_raw=${modelRaw}`],
|
||
reportPayload,
|
||
},
|
||
};
|
||
}
|
||
|
||
function readQueryFormState() {
|
||
return {
|
||
reportPlatform: document.getElementById("reportPlatform").value,
|
||
reportModelRaw: document.getElementById("reportModelRaw").value,
|
||
sqlPlatform: document.getElementById("sqlPlatform").value,
|
||
sqlModelRaw: document.getElementById("sqlModelRaw").value,
|
||
deviceNameSqlInput: document.getElementById("deviceNameSqlInput").value,
|
||
};
|
||
}
|
||
|
||
function applyQueryFormState(state) {
|
||
if (!state || typeof state !== "object") return;
|
||
const setValue = (id, value) => {
|
||
const el = document.getElementById(id);
|
||
if (!el || typeof value !== "string") return;
|
||
el.value = value;
|
||
};
|
||
setValue("reportPlatform", state.reportPlatform);
|
||
setValue("reportModelRaw", state.reportModelRaw);
|
||
setValue("sqlPlatform", state.sqlPlatform);
|
||
setValue("sqlModelRaw", state.sqlModelRaw);
|
||
setValue("deviceNameSqlInput", state.deviceNameSqlInput);
|
||
}
|
||
|
||
function saveQueryFormState() {
|
||
try {
|
||
localStorage.setItem(QUERY_FORM_STORAGE_KEY, JSON.stringify(readQueryFormState()));
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
function loadQueryFormState() {
|
||
try {
|
||
const raw = localStorage.getItem(QUERY_FORM_STORAGE_KEY);
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw);
|
||
applyQueryFormState(parsed);
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
function bindQueryFormPersistence() {
|
||
const ids = [
|
||
"reportPlatform",
|
||
"reportModelRaw",
|
||
"sqlPlatform",
|
||
"sqlModelRaw",
|
||
"deviceNameSqlInput",
|
||
];
|
||
for (const id of ids) {
|
||
const el = document.getElementById(id);
|
||
if (!el) continue;
|
||
el.addEventListener("input", saveQueryFormState);
|
||
el.addEventListener("change", saveQueryFormState);
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll(".platform-picker .platform-card:not(.sql-platform-card)").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const input = document.getElementById("reportPlatform");
|
||
const nextValue = el.dataset.platform || "android";
|
||
if (input.value === nextValue) return;
|
||
input.value = nextValue;
|
||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll(".sql-platform-card").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const input = document.getElementById("sqlPlatform");
|
||
const nextValue = el.dataset.platform || "android";
|
||
if (input.value === nextValue) return;
|
||
input.value = nextValue;
|
||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||
});
|
||
});
|
||
|
||
document.getElementById("reportPlatform").addEventListener("change", () => {
|
||
syncReportPlatformUI();
|
||
saveQueryFormState();
|
||
});
|
||
document.getElementById("sqlPlatform").addEventListener("change", () => {
|
||
syncSqlPlatformUI();
|
||
saveQueryFormState();
|
||
});
|
||
|
||
document.getElementById("reportModelRaw").addEventListener("keydown", (e) => {
|
||
if (e.key !== "Enter") return;
|
||
e.preventDefault();
|
||
document.getElementById("queryBtn").click();
|
||
});
|
||
document.getElementById("sqlModelRaw").addEventListener("keydown", (e) => {
|
||
if (e.key !== "Enter") return;
|
||
e.preventDefault();
|
||
document.getElementById("sqlQueryBtn").click();
|
||
});
|
||
document.getElementById("deviceNameSqlInput").addEventListener("keydown", (e) => {
|
||
if (e.key !== "Enter") return;
|
||
e.preventDefault();
|
||
document.getElementById("deviceNameSqlQueryBtn").click();
|
||
});
|
||
|
||
function renderSqlResult(output, options = {}) {
|
||
const summaryEl = options.summaryEl || sqlSummaryEl;
|
||
const statementEl = options.statementEl || sqlStatementEl;
|
||
const resultBodyEl = options.resultBodyEl || sqlResultBodyEl;
|
||
const jsonOutputEl = options.jsonOutputEl || sqlJsonOutputEl;
|
||
const strategyLabel = options.defaultStrategyLabel || "alias_norm 精确匹配";
|
||
const description = options.summaryDescription || "当前模式直接查询 MySQL 主表 <code>mm_device_catalog</code>。";
|
||
|
||
if (output.error) {
|
||
summaryEl.textContent = output.error;
|
||
statementEl.textContent = "-- SQL 执行失败";
|
||
resultBodyEl.innerHTML = `<tr><td colspan="6" class="sub">${escapeHtml(output.error)}</td></tr>`;
|
||
jsonOutputEl.textContent = JSON.stringify(output, null, 2);
|
||
return;
|
||
}
|
||
|
||
summaryEl.innerHTML = `
|
||
<div class="result-head">
|
||
<span class="pill">查询键: ${escapeHtml(output.alias_norm || "-")}</span>
|
||
<span class="pill">命中: ${Number(output.row_count || 0)}</span>
|
||
<span class="pill">limit: ${Number(output.limit || 0)}</span>
|
||
<span class="pill">命中方式: ${escapeHtml(output.match_strategy_label || strategyLabel)}</span>
|
||
</div>
|
||
<p class="sub">${description}</p>
|
||
${Array.isArray(output.resolved_device_names) && output.resolved_device_names.length
|
||
? `<div class="mono">设备名别名映射: ${escapeHtml(output.resolved_device_names.join(" | "))}</div>`
|
||
: ""}
|
||
`;
|
||
statementEl.textContent = output.sql || "-- 暂无 SQL";
|
||
|
||
if (!Array.isArray(output.rows) || !output.rows.length) {
|
||
resultBodyEl.innerHTML = `<tr><td colspan="6" class="sub">没有查到结果。</td></tr>`;
|
||
} else {
|
||
resultBodyEl.innerHTML = output.rows.map((row, idx) => `
|
||
<tr>
|
||
<td>${idx + 1}</td>
|
||
<td>
|
||
<div class="mono">${escapeHtml(row.model || "-")}</div>
|
||
<div class="mono">alias_norm=${escapeHtml(row.alias_norm || "-")}</div>
|
||
</td>
|
||
<td>${escapeHtml(row.device_name || "-")}</td>
|
||
<td>
|
||
<div>品牌: <strong>${escapeHtml(row.market_brand || row.brand || "-")}</strong></div>
|
||
<div>厂商: <strong>${escapeHtml(row.parent_brand || row.manufacturer_brand || "-")}</strong></div>
|
||
</td>
|
||
<td>${escapeHtml(row.device_type || "-")}</td>
|
||
<td>
|
||
<div class="mono">${escapeHtml(row.source_file || "-")}</div>
|
||
<div class="mono">rank=${escapeHtml(row.source_rank || "-")}</div>
|
||
</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
jsonOutputEl.textContent = JSON.stringify(output, null, 2);
|
||
}
|
||
|
||
document.getElementById("queryBtn").addEventListener("click", () => {
|
||
if (!indexData) {
|
||
renderResult({ error: "数据还在准备中,请稍后再试。" });
|
||
return;
|
||
}
|
||
|
||
const reportBuilt = buildReportModeInput();
|
||
if (reportBuilt.error) {
|
||
renderResult({ error: reportBuilt.error });
|
||
return;
|
||
}
|
||
|
||
const queryInput = reportBuilt.input;
|
||
const output = queryRecords(queryInput);
|
||
|
||
renderResult(output);
|
||
saveQueryFormState();
|
||
});
|
||
|
||
document.getElementById("sqlQueryBtn").addEventListener("click", async () => {
|
||
const platform = document.getElementById("sqlPlatform").value;
|
||
const suggestedModelRaw = MODEL_RAW_SUGGESTIONS[platform] || MODEL_RAW_SUGGESTIONS.android;
|
||
const modelRaw = document.getElementById("sqlModelRaw").value.trim() || suggestedModelRaw;
|
||
|
||
sqlSummaryEl.textContent = "正在查询 MySQL,请稍候。";
|
||
sqlStatementEl.textContent = "-- 查询进行中...";
|
||
sqlResultBodyEl.innerHTML = `<tr><td colspan="6" class="sub">查询进行中...</td></tr>`;
|
||
|
||
try {
|
||
const output = await fetchJson("/api/query-sql", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
platform,
|
||
model_raw: modelRaw,
|
||
limit: 20,
|
||
}),
|
||
});
|
||
renderSqlResult(output);
|
||
saveQueryFormState();
|
||
} catch (err) {
|
||
renderSqlResult({ error: err.message || String(err) });
|
||
}
|
||
});
|
||
|
||
document.getElementById("deviceNameSqlQueryBtn").addEventListener("click", async () => {
|
||
const deviceName = document.getElementById("deviceNameSqlInput").value.trim();
|
||
|
||
deviceNameSqlSummaryEl.textContent = "正在查询 MySQL,请稍候。";
|
||
deviceNameSqlStatementEl.textContent = "-- 查询进行中...";
|
||
deviceNameSqlResultBodyEl.innerHTML = `<tr><td colspan="6" class="sub">查询进行中...</td></tr>`;
|
||
|
||
try {
|
||
const output = await fetchJson("/api/query-sql-device-name", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
device_name: deviceName,
|
||
limit: 20,
|
||
}),
|
||
});
|
||
renderSqlResult(output, {
|
||
summaryEl: deviceNameSqlSummaryEl,
|
||
statementEl: deviceNameSqlStatementEl,
|
||
resultBodyEl: deviceNameSqlResultBodyEl,
|
||
jsonOutputEl: deviceNameSqlJsonOutputEl,
|
||
defaultStrategyLabel: "device_name 模糊匹配",
|
||
summaryDescription: "当前模式仅支持设备名称搜索,直接查询 MySQL 主表 <code>mm_device_catalog</code>。",
|
||
});
|
||
saveQueryFormState();
|
||
} catch (err) {
|
||
renderSqlResult({ error: err.message || String(err) }, {
|
||
summaryEl: deviceNameSqlSummaryEl,
|
||
statementEl: deviceNameSqlStatementEl,
|
||
resultBodyEl: deviceNameSqlResultBodyEl,
|
||
jsonOutputEl: deviceNameSqlJsonOutputEl,
|
||
});
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll(".page-tab").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("view");
|
||
window.history.replaceState({}, "", url.toString());
|
||
document.body.classList.remove("docs-mode");
|
||
docsPanelEl.classList.add("hidden");
|
||
document.querySelector(".page-tabs-wrap").classList.remove("hidden");
|
||
topNavQueryEl.classList.add("active");
|
||
topNavDocsEl.classList.remove("active");
|
||
activateTopTab(el.dataset.tab || "sql-query");
|
||
});
|
||
});
|
||
|
||
loadQueryFormState();
|
||
bindQueryFormPersistence();
|
||
loadActiveTopTab();
|
||
syncReportPlatformUI();
|
||
syncSqlPlatformUI();
|
||
loadReadonlyInfo();
|
||
loadDocInPanel("/README.md", "项目使用文档");
|
||
loadIndexFromPath();
|
||
</script>
|
||
</body>
|
||
</html>
|