Files
MobileModels/web/device_query.html
T
2026-04-24 10:15:55 +08:00

2748 lines
94 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MobileModels 设备查询</title>
<style>
:root {
--bg: #f5f7fb;
--card: #ffffff;
--text: #1c2430;
--sub: #566173;
--line: #d9e0ea;
--brand: #0f6fff;
--ok: #0a7f3f;
--warn: #b16a00;
--nav-height: 52px;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding-top: var(--nav-height);
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(circle at 0 0, #eef4ff 0, var(--bg) 40%), var(--bg);
color: var(--text);
}
body.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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>