Files
MobileModels/web/device_query.html
T

1315 lines
43 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;
}
.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; }
.status {
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
line-height: 1.45;
background: #eef2f8;
color: #2f405a;
}
.status.ok { background: #e9f8ef; color: var(--ok); }
.status.warn { background: #fff6e8; color: var(--warn); }
.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;
}
@media (max-width: 1020px) {
.wrap { 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 active">设备查询</a>
<a href="/web/brand_management.html" class="item">数据管理</a>
</div>
</nav>
<div class="wrap">
<section class="card">
<h1 class="title">设备查询</h1>
<p class="sub">输入客户端可取到的数据(可多条),页面会先做品牌同义词归一,再映射到父级厂商并进行候选匹配。数据来源权重排序在“数据管理”页维护。</p>
<label for="queryMode">查询方式</label>
<select id="queryMode">
<option value="general">通用多字段模式(原有)</option>
<option value="report">客户端上报模式(platform + model_raw</option>
</select>
<div id="generalQueryFields">
<label for="platform">平台</label>
<select id="platform">
<option value="unknown">未知</option>
<option value="ios">iOS</option>
<option value="android">Android</option>
<option value="harmony">HarmonyOS</option>
</select>
<label for="brand">客户端品牌(可选)</label>
<input id="brand" placeholder="例如 Apple / 小米 / samsung" />
<label for="primaryName">主设备名称(建议填)</label>
<input id="primaryName" placeholder="例如 iPhone14,5 / M2102J2SC / L55M5-AD" />
<label for="extraNames">其他可拿到的字段(可多行)</label>
<textarea id="extraNames" placeholder="每行一条,例如:
model=SM-G9910
device=star2qltechn
marketName=Galaxy S21"></textarea>
</div>
<div id="reportQueryFields" class="hidden">
<label for="reportPlatform">platform(已知)</label>
<select id="reportPlatform">
<option value="android">Android</option>
<option value="ios">iOS</option>
<option value="harmony">鸿蒙</option>
</select>
<label for="reportModelRaw">model_raw(原样上报)</label>
<input id="reportModelRaw" placeholder="Android: Build.MODEL / iOS: utsname.machine" />
<div id="harmonyExtraFields" class="hidden">
<label for="reportHarmonyOsFullName">鸿蒙 deviceInfo.osFullName(系统版本)</label>
<input id="reportHarmonyOsFullName" placeholder="例如 HarmonyOS 4.2.0" />
<label for="reportHarmonyMarketName">鸿蒙 deviceInfo.marketName(市场名)</label>
<input id="reportHarmonyMarketName" placeholder="例如 HUAWEI Mate 60 Pro" />
</div>
<p class="sub">上报模式会优先按 platform + model_raw 查询;鸿蒙会同时使用 marketName / osFullName 参与候选排序。</p>
</div>
<label for="indexFile">可选:手动上传 device_index.json</label>
<input id="indexFile" type="file" accept="application/json" />
<div class="btns">
<button class="primary" id="queryBtn">开始查询</button>
<button id="loadBtn">重新加载索引</button>
</div>
<div id="status" class="status">索引尚未加载。</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>Score</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>
<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";
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 statusEl = document.getElementById("status");
const summaryEl = document.getElementById("summary");
const resultBodyEl = document.getElementById("resultBody");
const jsonOutputEl = document.getElementById("jsonOutput");
function normalizeText(text) {
return (text || "")
.toLowerCase()
.replace(/[^0-9a-z\u4e00-\u9fff]+/g, "");
}
function setStatus(text, level = "") {
statusEl.textContent = text;
statusEl.className = "status" + (level ? ` ${level}` : "");
}
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) {
setStatus(`保存品牌配置失败: ${err.message}`, "warn");
}
}
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) {
setStatus(`保存来源配置失败: ${err.message}`, "warn");
}
}
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} (score ${output.best.score})`
: "最佳: 无";
const reportText = q.report_payload
? `<div class="mono">report: ${escapeHtml(JSON.stringify(q.report_payload))}</div>`
: "";
summaryEl.innerHTML = `
<div class="result-head">
<span class="pill">模式: ${q.query_mode || "general"}</span>
<span class="pill">品牌输入: ${q.brand_input || "(空)"}</span>
<span class="pill">品牌归一: ${q.canonical_brand || "(未识别)"}</span>
<span class="pill">父级厂商: ${q.canonical_manufacturer || "(未识别)"}</span>
<span class="pill">平台: ${q.platform}</span>
<span class="pill">总命中: ${s.total_hits}</span>
<span class="pill">品牌命中: ${s.brand_strict_hits}</span>
<span class="pill">厂商命中: ${s.manufacturer_strict_hits}</span>
<span class="pill">来源权重: ${sourceOrder.length ? "已启用" : "未启用"}</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;");
}
async function loadIndexFromPath() {
try {
setStatus("正在加载 dist/device_index.json ...");
const resp = await fetch("../dist/device_index.json", { cache: "no-cache" });
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
indexData = await resp.json();
rebuildCaches();
setStatus(
`索引加载成功: records=${indexData.total_records}, lookup=${Object.keys(indexData.lookup || {}).length}`,
"ok"
);
} catch (err) {
setStatus(
`自动加载失败(${err.message})。请使用本地 HTTP 服务打开页面,或手动上传 dist/device_index.json。`,
"warn"
);
}
}
function toggleQueryModeUI() {
const mode = document.getElementById("queryMode").value;
const reportPlatform = document.getElementById("reportPlatform").value;
document.getElementById("generalQueryFields").classList.toggle("hidden", mode !== "general");
document.getElementById("reportQueryFields").classList.toggle("hidden", mode !== "report");
document.getElementById("harmonyExtraFields").classList.toggle(
"hidden",
!(mode === "report" && reportPlatform === "harmony")
);
}
function buildReportModeInput() {
const platform = document.getElementById("reportPlatform").value;
const modelRaw = document.getElementById("reportModelRaw").value.trim();
const osFullName = document.getElementById("reportHarmonyOsFullName").value.trim();
const marketName = document.getElementById("reportHarmonyMarketName").value.trim();
const reportPayload = {
platform,
model_raw: modelRaw || null,
osFullName: osFullName || null,
marketName: marketName || null,
};
if (platform === "harmony") {
if (!modelRaw && !marketName) {
return {
error: "鸿蒙上报模式至少需要 model_raw 或 marketName 其中一个。",
};
}
return {
input: {
queryMode: "report",
platform,
brand: "",
primaryName: marketName || modelRaw,
extraNames: [
modelRaw ? `model_raw=${modelRaw}` : "",
marketName ? `marketName=${marketName}` : "",
osFullName ? `osFullName=${osFullName}` : "",
].filter(Boolean),
reportPayload,
},
};
}
if (!modelRaw) {
return {
error: "Android / iOS 上报模式下,model_raw 不能为空。",
};
}
return {
input: {
queryMode: "report",
platform,
brand: "",
primaryName: modelRaw,
extraNames: [`model_raw=${modelRaw}`],
reportPayload,
},
};
}
function readQueryFormState() {
return {
queryMode: document.getElementById("queryMode").value,
platform: document.getElementById("platform").value,
brand: document.getElementById("brand").value,
primaryName: document.getElementById("primaryName").value,
extraNames: document.getElementById("extraNames").value,
reportPlatform: document.getElementById("reportPlatform").value,
reportModelRaw: document.getElementById("reportModelRaw").value,
reportHarmonyOsFullName: document.getElementById("reportHarmonyOsFullName").value,
reportHarmonyMarketName: document.getElementById("reportHarmonyMarketName").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("queryMode", state.queryMode);
setValue("platform", state.platform);
setValue("brand", state.brand);
setValue("primaryName", state.primaryName);
setValue("extraNames", state.extraNames);
setValue("reportPlatform", state.reportPlatform);
setValue("reportModelRaw", state.reportModelRaw);
setValue("reportHarmonyOsFullName", state.reportHarmonyOsFullName);
setValue("reportHarmonyMarketName", state.reportHarmonyMarketName);
}
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 = [
"queryMode",
"platform",
"brand",
"primaryName",
"extraNames",
"reportPlatform",
"reportModelRaw",
"reportHarmonyOsFullName",
"reportHarmonyMarketName",
];
for (const id of ids) {
const el = document.getElementById(id);
if (!el) continue;
el.addEventListener("input", saveQueryFormState);
el.addEventListener("change", saveQueryFormState);
}
}
document.getElementById("loadBtn").addEventListener("click", loadIndexFromPath);
document.getElementById("queryMode").addEventListener("change", () => {
toggleQueryModeUI();
saveQueryFormState();
});
document.getElementById("reportPlatform").addEventListener("change", () => {
toggleQueryModeUI();
saveQueryFormState();
});
document.getElementById("indexFile").addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
try {
const text = await file.text();
indexData = JSON.parse(text);
rebuildCaches();
setStatus(
`手动加载成功: records=${indexData.total_records}, lookup=${Object.keys(indexData.lookup || {}).length}`,
"ok"
);
} catch (err) {
setStatus(`手动加载失败: ${err.message}`, "warn");
}
});
document.getElementById("queryBtn").addEventListener("click", () => {
if (!indexData) {
setStatus("请先加载索引。", "warn");
return;
}
const mode = document.getElementById("queryMode").value;
let queryInput = null;
if (mode === "report") {
const reportBuilt = buildReportModeInput();
if (reportBuilt.error) {
renderResult({ error: reportBuilt.error });
return;
}
queryInput = reportBuilt.input;
} else {
const platform = document.getElementById("platform").value;
const brand = document.getElementById("brand").value.trim();
const primaryName = document.getElementById("primaryName").value.trim();
const extraNames = document.getElementById("extraNames").value
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean);
queryInput = {
queryMode: "general",
platform,
brand,
primaryName,
extraNames,
reportPayload: null,
};
}
const output = queryRecords(queryInput);
renderResult(output);
saveQueryFormState();
});
loadQueryFormState();
bindQueryFormPersistence();
toggleQueryModeUI();
loadIndexFromPath();
</script>
</body>
</html>