Files
MobileModels/delivery/web/device_query.html
2026-03-19 13:00:40 +08:00

1888 lines
66 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;
}
* { 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>当前读链路使用只读账号,便于模拟第三方直接查库的效果。</li>
<li>推荐新接入优先对接 `mm_device_catalog`,兼容链路再使用视图。</li>
</ul>
</div>
<div class="helper-box">
<p class="helper-title">只读连接信息</p>
<p>可直接用于第三方连库联调。</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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">${escapeHtml(err.message || String(err))}</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>