1315 lines
43 KiB
HTML
1315 lines
43 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;
|
||
}
|
||
.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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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>
|