1887 lines
66 KiB
HTML
1887 lines
66 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;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
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);
|
||
}
|
||
.top-nav {
|
||
background: linear-gradient(180deg, #1f2a3a, #1a2431);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
.top-nav-inner {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 0 16px;
|
||
height: 52px;
|
||
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: 360px 1fr;
|
||
gap: 16px;
|
||
}
|
||
.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);
|
||
}
|
||
.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;
|
||
}
|
||
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; }
|
||
}
|
||
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: 58px;
|
||
height: 58px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.platform-icon svg {
|
||
width: 34px;
|
||
height: 34px;
|
||
display: block;
|
||
}
|
||
.platform-name {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
.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; }
|
||
.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;
|
||
gap: 16px;
|
||
}
|
||
.doc-link-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
}
|
||
.doc-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid #d7e2f3;
|
||
background: #f8fbff;
|
||
color: #204470;
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
.doc-link:hover {
|
||
background: #eef5ff;
|
||
}
|
||
.doc-link.active {
|
||
background: #0f6fff;
|
||
border-color: #0f6fff;
|
||
color: #fff;
|
||
}
|
||
.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; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.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">SQL 查询</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">
|
||
<section class="card">
|
||
<h1 class="title">设备识别</h1>
|
||
<p class="sub">当前模式基于 `dist/device_index.json` 内存索引查询,适合页面快速识别与联调。</p>
|
||
|
||
<div class="helper-box">
|
||
<ul>
|
||
<li>Android / iOS:通常只需要 <code>platform</code> 和 <code>model_raw</code>。</li>
|
||
<li>HarmonyOS:通常只需要 <code>platform</code> 和 <code>model_raw</code>。</li>
|
||
<li>请直接使用客户端原始上报值,不要手动改写。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div id="reportQueryFields">
|
||
<label for="reportPlatform">平台</label>
|
||
<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>
|
||
|
||
<label for="reportModelRaw">设备标识 <code>model_raw</code></label>
|
||
<input id="reportModelRaw" placeholder="例如 SM-G9980 / iPhone14,2 / NOH-AL00" />
|
||
</div>
|
||
|
||
<div class="btns">
|
||
<button class="primary" id="queryBtn">开始识别</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="grid">
|
||
<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>
|
||
|
||
<article class="card">
|
||
<h2 class="title">调试数据 JSON</h2>
|
||
<pre id="jsonOutput">{}</pre>
|
||
</article>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sqlQueryPanel" class="page-panel">
|
||
<div class="wrap">
|
||
<section class="card">
|
||
<h1 class="title">SQL 查询</h1>
|
||
<p class="sub">当前模式直接查询 MySQL 主表 `mobilemodels.mm_device_catalog`,结果与线上 SQL 接入保持一致。</p>
|
||
|
||
<div class="helper-box">
|
||
<ul>
|
||
<li>输入客户端原始上报值,服务端会先归一化为 <code>alias_norm</code>,再查询 MySQL。</li>
|
||
<li>当前读链路使用只读账号,便于核对 SQL 查询结果与连接配置。</li>
|
||
<li>推荐新接入优先对接 `mm_device_catalog`,兼容链路再使用视图。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="helper-box">
|
||
<p class="helper-title">只读连接参数</p>
|
||
<div id="sqlReadonlyInfo" class="credential-grid">
|
||
<div class="credential-item">
|
||
<span class="credential-label">Host</span>
|
||
<p class="credential-value">加载中...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<label for="sqlPlatform">平台</label>
|
||
<input id="sqlPlatform" type="hidden" value="android" />
|
||
<div class="platform-picker" role="radiogroup" aria-label="SQL 查询平台">
|
||
<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>
|
||
|
||
<label for="sqlModelRaw">设备标识</label>
|
||
<input id="sqlModelRaw" placeholder="例如 SM-G9980 / iPhone14,2 / NOH-AL00" />
|
||
|
||
<div class="btns">
|
||
<button class="primary" id="sqlQueryBtn">执行 SQL 查询</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="grid">
|
||
<article class="card">
|
||
<h2 class="title">SQL 查询结果</h2>
|
||
<div id="sqlSummary" class="sub">输入设备标识后开始查询 MySQL。</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>
|
||
|
||
<article class="card">
|
||
<h2 class="title">执行 SQL</h2>
|
||
<pre id="sqlStatement">-- 暂无 SQL</pre>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h2 class="title">返回 JSON</h2>
|
||
<pre id="sqlJsonOutput">{}</pre>
|
||
</article>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="docsPanel" class="page-panel hidden">
|
||
<div class="doc-grid">
|
||
<article class="card">
|
||
<h1 class="title">相关文档</h1>
|
||
<p class="sub">这里统一整理页面调试、MySQL 接入和兼容查询相关说明,直接在当前页面查看,不再跳转到单独页面。</p>
|
||
<div class="doc-link-list">
|
||
<button type="button" class="doc-link active" data-doc-path="/docs/mysql-query-design.md" data-doc-title="MySQL 设计说明">MySQL 设计说明</button>
|
||
<button type="button" class="doc-link" data-doc-path="/docs/web-ui.md" data-doc-title="Web 使用说明">Web 使用说明</button>
|
||
<button type="button" class="doc-link" data-doc-path="/README.md" data-doc-title="项目 README">项目 README</button>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h2 class="title" id="docPanelTitle">MySQL 设计说明</h2>
|
||
<p class="sub" id="docPanelPath">/docs/mysql-query-design.md</p>
|
||
<pre id="docPanelContent">加载中...</pre>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<div class="doc-section">
|
||
<h3>主推查询表</h3>
|
||
<pre>mobilemodels.mm_device_catalog</pre>
|
||
</div>
|
||
<div class="doc-section">
|
||
<h3>兼容视图</h3>
|
||
<pre>mobilemodels.mm_device_lookup
|
||
mobilemodels.mm_device_record
|
||
mobilemodels.models
|
||
python_services_test.models</pre>
|
||
</div>
|
||
<div class="doc-section">
|
||
<h3>归一化规则</h3>
|
||
<pre>全部转小写
|
||
只保留 [0-9a-z中文]
|
||
去掉空格、横线、下划线和其他标点
|
||
|
||
SM-G9980 -> smg9980
|
||
iPhone14,2 -> iphone142
|
||
NOH-AL00 -> nohal00</pre>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<div class="doc-section">
|
||
<h3>主推 SQL</h3>
|
||
<pre>SELECT
|
||
model,
|
||
record_id,
|
||
alias_norm,
|
||
device_name,
|
||
brand,
|
||
manufacturer_brand,
|
||
parent_brand,
|
||
market_brand,
|
||
device_type,
|
||
source_file,
|
||
section,
|
||
source_rank,
|
||
source_weight,
|
||
code,
|
||
code_alias,
|
||
ver_name
|
||
FROM mobilemodels.mm_device_catalog
|
||
WHERE alias_norm = ?
|
||
ORDER BY source_rank ASC, record_id ASC
|
||
LIMIT 20;</pre>
|
||
</div>
|
||
<div class="doc-section">
|
||
<h3>兼容旧结构 SQL</h3>
|
||
<pre>SELECT
|
||
model,
|
||
dtype,
|
||
brand,
|
||
brand_title,
|
||
code,
|
||
code_alias,
|
||
model_name,
|
||
ver_name
|
||
FROM python_services_test.models
|
||
WHERE model = ?
|
||
LIMIT 20;</pre>
|
||
</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 docPanelTitleEl = document.getElementById("docPanelTitle");
|
||
const docPanelPathEl = document.getElementById("docPanelPath");
|
||
const docPanelContentEl = document.getElementById("docPanelContent");
|
||
const indexQueryPanelEl = document.getElementById("indexQueryPanel");
|
||
const sqlQueryPanelEl = document.getElementById("sqlQueryPanel");
|
||
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 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) {
|
||
return /_cn\.md$/i.test(sourceFile || "");
|
||
}
|
||
|
||
function buildInitialSourceOrder() {
|
||
const sourceSet = new Set(
|
||
(indexData && Array.isArray(indexData.records) ? indexData.records : [])
|
||
.map((r) => (r && r.source_file) || "")
|
||
.filter(Boolean)
|
||
);
|
||
const cn = [];
|
||
const other = [];
|
||
for (const source of sourceSet) {
|
||
if (isCnSourceFile(source)) {
|
||
cn.push(source);
|
||
} else {
|
||
other.push(source);
|
||
}
|
||
}
|
||
cn.sort((a, b) => a.localeCompare(b));
|
||
other.sort((a, b) => a.localeCompare(b));
|
||
return [...cn, ...other];
|
||
}
|
||
|
||
function sanitizeManagedSourceConfig(rawConfig) {
|
||
const initialOrder = buildInitialSourceOrder();
|
||
const sourceSet = new Set(initialOrder);
|
||
const rawOrder = (rawConfig && Array.isArray(rawConfig.order)) ? rawConfig.order : [];
|
||
const order = [];
|
||
const seen = new Set();
|
||
|
||
for (const item of rawOrder) {
|
||
const source = String(item || "").trim();
|
||
if (!source || seen.has(source) || !sourceSet.has(source)) continue;
|
||
seen.add(source);
|
||
order.push(source);
|
||
}
|
||
for (const source of initialOrder) {
|
||
if (seen.has(source)) continue;
|
||
seen.add(source);
|
||
order.push(source);
|
||
}
|
||
return { order };
|
||
}
|
||
|
||
function saveManagedSourceConfig() {
|
||
if (!managedSourceConfig) return;
|
||
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();
|
||
for (const r of indexData.records || []) {
|
||
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 top = topBase.slice(0, 200);
|
||
|
||
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 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");
|
||
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";
|
||
docsPanelEl.classList.toggle("hidden", !docsMode);
|
||
document.querySelector(".page-tabs-wrap").classList.toggle("hidden", docsMode);
|
||
if (docsMode) {
|
||
indexQueryPanelEl.classList.add("hidden");
|
||
sqlQueryPanelEl.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");
|
||
docsPanelEl.classList.add("hidden");
|
||
activateTopTab("sql-query");
|
||
}
|
||
}
|
||
|
||
async function loadDocInPanel(path, title) {
|
||
docPanelTitleEl.textContent = title || "相关文档";
|
||
docPanelPathEl.textContent = path || "";
|
||
docPanelContentEl.textContent = "加载中...";
|
||
|
||
document.querySelectorAll(".doc-link[data-doc-path]").forEach((el) => {
|
||
const active = el.dataset.docPath === path;
|
||
el.classList.toggle("active", active);
|
||
});
|
||
|
||
try {
|
||
const resp = await fetch(path, { cache: "no-store" });
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP ${resp.status}`);
|
||
}
|
||
const text = await resp.text();
|
||
docPanelContentEl.textContent = text;
|
||
} catch (err) {
|
||
docPanelContentEl.textContent = `加载失败\n${err.message || err}`;
|
||
}
|
||
}
|
||
|
||
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 || "-"],
|
||
];
|
||
sqlReadonlyInfoEl.innerHTML = items.map(([label, value]) => `
|
||
<div class="credential-item">
|
||
<span class="credential-label">${escapeHtml(label)}</span>
|
||
<p class="credential-value">${escapeHtml(value)}</p>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
async function loadReadonlyInfo() {
|
||
try {
|
||
const data = await fetchJson("/api/status", { cache: "no-store" });
|
||
renderReadonlyInfo(data);
|
||
} catch (err) {
|
||
sqlReadonlyInfoEl.innerHTML = `
|
||
<div class="credential-item">
|
||
<span class="credential-label">只读连接参数</span>
|
||
<p class="credential-value">暂时无法读取连接参数</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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",
|
||
];
|
||
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();
|
||
});
|
||
|
||
function renderSqlResult(output) {
|
||
if (output.error) {
|
||
sqlSummaryEl.textContent = output.error;
|
||
sqlStatementEl.textContent = "-- SQL 执行失败";
|
||
sqlResultBodyEl.innerHTML = `<tr><td colspan="6" class="sub">${escapeHtml(output.error)}</td></tr>`;
|
||
sqlJsonOutputEl.textContent = JSON.stringify(output, null, 2);
|
||
return;
|
||
}
|
||
|
||
sqlSummaryEl.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>
|
||
</div>
|
||
<p class="sub">当前模式直接查询 MySQL 主表 <code>mm_device_catalog</code>。</p>
|
||
`;
|
||
sqlStatementEl.textContent = output.sql || "-- 暂无 SQL";
|
||
|
||
if (!Array.isArray(output.rows) || !output.rows.length) {
|
||
sqlResultBodyEl.innerHTML = `<tr><td colspan="6" class="sub">没有查到结果。</td></tr>`;
|
||
} else {
|
||
sqlResultBodyEl.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.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("");
|
||
}
|
||
|
||
sqlJsonOutputEl.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.querySelectorAll(".page-tab").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("view");
|
||
window.history.replaceState({}, "", url.toString());
|
||
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");
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll(".doc-link[data-doc-path]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
loadDocInPanel(el.dataset.docPath || "", el.dataset.docTitle || "相关文档");
|
||
});
|
||
});
|
||
|
||
loadQueryFormState();
|
||
bindQueryFormPersistence();
|
||
loadActiveTopTab();
|
||
syncReportPlatformUI();
|
||
syncSqlPlatformUI();
|
||
loadReadonlyInfo();
|
||
loadDocInPanel("/docs/mysql-query-design.md", "MySQL 设计说明");
|
||
loadIndexFromPath();
|
||
</script>
|
||
</body>
|
||
</html>
|