Files
MobileModels/delivery/web/brand_management.html
T

1280 lines
45 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: #fff;
font-weight: 600;
}
.wrap {
max-width: 1200px;
margin: 24px auto;
padding: 0 16px 32px;
}
.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 {
width: 100%;
padding: 10px;
border: 1px solid var(--line);
border-radius: 10px;
font-size: 14px;
background: #fff;
color: var(--text);
}
.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; }
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.result-head {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.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;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 800px;
}
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; }
.tag {
display: inline-block;
margin: 2px 6px 2px 0;
border-radius: 8px;
padding: 2px 7px;
font-size: 12px;
background: #eef3fb;
color: #2d4a7a;
}
.section-divider {
margin: 14px 0;
border: 0;
border-top: 1px dashed var(--line);
}
.source-order-wrap {
border: 1px solid var(--line);
border-radius: 10px;
background: #fbfcff;
padding: 10px;
}
.source-order-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.source-order-item {
border: 1px solid #cfd9ea;
border-radius: 10px;
background: #fff;
padding: 8px 10px;
display: grid;
grid-template-columns: 18px 48px 1fr;
gap: 10px;
align-items: center;
cursor: grab;
}
.source-order-item.dragging {
opacity: 0.45;
}
.source-order-item.drag-target {
border-color: #0f6fff;
background: #eff5ff;
}
.drag-handle {
color: #7d8ea8;
font-size: 14px;
user-select: none;
}
.source-rank {
font-size: 12px;
color: #3a5a8b;
background: #edf4ff;
border: 1px solid #cadeff;
border-radius: 999px;
padding: 2px 8px;
text-align: center;
}
.source-name {
font-size: 13px;
color: #1f355a;
word-break: break-all;
}
.manage-layout {
margin-top: 14px;
display: grid;
grid-template-columns: 180px 1fr;
gap: 12px;
align-items: start;
}
.manage-tabs {
display: grid;
gap: 8px;
position: sticky;
top: 12px;
}
.tab-btn {
width: 100%;
text-align: left;
border: 1px solid #c8d6ee;
background: #f7faff;
color: #274a7d;
}
.tab-btn.active {
background: #0f6fff;
color: #fff;
border-color: #0f6fff;
}
.manage-panel.hidden {
display: none;
}
.sync-log {
min-height: 240px;
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;
margin: 0;
}
.hidden { display: none; }
.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-backdrop.hidden {
display: none !important;
}
.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-card textarea {
width: 100%;
min-height: 360px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
}
.modal-card pre {
min-height: 240px;
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;
margin: 0;
}
@media (max-width: 1020px) {
.manage-layout {
grid-template-columns: 1fr;
}
.manage-tabs {
position: static;
grid-template-columns: repeat(4, minmax(0, 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">设备查询</a>
<a href="/web/brand_management.html" class="item active">数据管理</a>
<a href="/web/device_query.html?view=docs" class="item">相关文档</a>
</div>
</nav>
<div class="wrap">
<section class="card">
<h1 class="title">数据管理</h1>
<div class="manage-layout">
<aside class="manage-tabs">
<button id="tabBrandBtn" type="button" class="tab-btn active">品牌列表</button>
<button id="tabSourceBtn" type="button" class="tab-btn">数据来源</button>
<button id="tabSyncBtn" type="button" class="tab-btn">原始数据同步</button>
<button id="tabIndexBtn" type="button" class="tab-btn">索引数据</button>
</aside>
<div class="manage-content">
<section id="brandTabPanel" class="manage-panel">
<div class="result-head">
<button id="brandCountBtn" type="button" class="pill-btn">品牌数: -</button>
<button id="manufacturerCountBtn" type="button" class="pill-btn">厂商数: -</button>
</div>
<div class="btns">
<button id="editBrandListBtn" type="button">编辑品牌列表</button>
<button id="editBrandRelationsBtn" type="button">编辑品牌-厂商关系</button>
<button id="editBrandAliasesBtn" type="button">编辑品牌同义词</button>
</div>
<div id="brandStats" class="sub">索引未加载。</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>品牌</th>
<th>品牌同义词</th>
<th>父级厂商</th>
</tr>
</thead>
<tbody id="brandRelationBody">
<tr><td colspan="3" class="sub">暂无关系数据</td></tr>
</tbody>
</table>
</div>
</section>
<section id="sourceTabPanel" class="manage-panel hidden">
<h3 class="title">数据来源管理(权重排序)</h3>
<p class="sub">拖拽调整优先级。越靠前权重越高。初始化规则:`_cn.md` 在前,非 `cn` 在后。</p>
<div class="btns">
<button id="saveSourceOrderBtn" type="button" class="primary">保存来源排序</button>
<button id="resetSourceOrderBtn" type="button">重置来源排序</button>
</div>
<div id="sourceOrderStats" class="sub">来源列表未加载。</div>
<div class="source-order-wrap">
<ul id="sourceOrderList" class="source-order-list">
<li class="sub">暂无来源数据</li>
</ul>
</div>
</section>
<section id="syncTabPanel" class="manage-panel hidden">
<h3 class="title">原始数据同步</h3>
<p class="sub">从上游 `KHwang9883/MobileModels` 拉取原始 markdown 数据,并重建 `dist/device_index.json`、刷新 MySQL。请先使用 `docker compose up --build -d` 启动完整服务。</p>
<div class="btns">
<button id="syncUpstreamBtn" type="button" class="primary">开始同步原始数据</button>
<button id="refreshSyncStatusBtn" type="button">刷新同步状态</button>
</div>
<div id="syncStatus" class="sub">正在检测同步能力。</div>
<pre id="syncLog" class="sync-log mono">暂无同步记录</pre>
</section>
<section id="indexTabPanel" class="manage-panel hidden">
<h3 class="title">索引数据</h3>
<p class="sub">这里集中显示 `dist/device_index.json` 的加载状态与基础统计,并提供手动重新加载入口。</p>
<div class="btns">
<button id="reloadIndexBtn" type="button" class="primary">重新加载索引</button>
</div>
<div id="indexStatus" class="sub">索引尚未加载。</div>
<pre id="indexSummary" class="sync-log mono">暂无索引信息</pre>
</section>
</div>
</div>
</section>
</div>
<div id="brandModalBackdrop" class="modal-backdrop hidden">
<div class="modal-card">
<h3 id="brandModalTitle" class="title">数据管理</h3>
<p id="brandModalHint" class="sub"></p>
<textarea id="brandModalTextarea" class="hidden"></textarea>
<pre id="brandModalPre" class="hidden"></pre>
<div class="btns">
<button id="brandModalCancelBtn" type="button">关闭</button>
<button id="brandModalSaveBtn" type="button" class="primary">保存</button>
</div>
</div>
</div>
<script>
const BRAND_CONFIG_STORAGE_KEY = "mobilemodels_brand_config_v2";
const SOURCE_CONFIG_STORAGE_KEY = "mobilemodels_source_config_v1";
let indexData = null;
let managedBrandConfig = null;
let managedSourceConfig = null;
let managedBrandAliasToBrand = new Map();
let managedBrandToManufacturer = new Map();
let managedManufacturerToBrands = new Map();
let modalSaveHandler = null;
let draggingSourceIndex = -1;
const brandStatsEl = document.getElementById("brandStats");
const brandCountBtnEl = document.getElementById("brandCountBtn");
const manufacturerCountBtnEl = document.getElementById("manufacturerCountBtn");
const brandRelationBodyEl = document.getElementById("brandRelationBody");
const brandModalBackdropEl = document.getElementById("brandModalBackdrop");
const brandModalTitleEl = document.getElementById("brandModalTitle");
const brandModalHintEl = document.getElementById("brandModalHint");
const brandModalTextareaEl = document.getElementById("brandModalTextarea");
const brandModalPreEl = document.getElementById("brandModalPre");
const brandModalSaveBtnEl = document.getElementById("brandModalSaveBtn");
const brandModalCancelBtnEl = document.getElementById("brandModalCancelBtn");
const sourceOrderStatsEl = document.getElementById("sourceOrderStats");
const sourceOrderListEl = document.getElementById("sourceOrderList");
const tabBrandBtnEl = document.getElementById("tabBrandBtn");
const tabSourceBtnEl = document.getElementById("tabSourceBtn");
const tabSyncBtnEl = document.getElementById("tabSyncBtn");
const tabIndexBtnEl = document.getElementById("tabIndexBtn");
const brandTabPanelEl = document.getElementById("brandTabPanel");
const sourceTabPanelEl = document.getElementById("sourceTabPanel");
const syncTabPanelEl = document.getElementById("syncTabPanel");
const indexTabPanelEl = document.getElementById("indexTabPanel");
const syncStatusEl = document.getElementById("syncStatus");
const syncLogEl = document.getElementById("syncLog");
const syncUpstreamBtnEl = document.getElementById("syncUpstreamBtn");
const refreshSyncStatusBtnEl = document.getElementById("refreshSyncStatusBtn");
const reloadIndexBtnEl = document.getElementById("reloadIndexBtn");
const indexStatusEl = document.getElementById("indexStatus");
const indexSummaryEl = document.getElementById("indexSummary");
let syncSupported = false;
let syncRunning = false;
function normalizeText(text) {
return (text || "").toLowerCase().replace(/[^0-9a-z\u4e00-\u9fff]+/g, "");
}
function escapeHtml(text) {
return (text || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 updateSyncButtons() {
syncUpstreamBtnEl.disabled = syncRunning || !syncSupported;
refreshSyncStatusBtnEl.disabled = syncRunning;
}
function renderIndexStatus(message, details) {
indexStatusEl.textContent = message || "索引尚未加载。";
indexSummaryEl.textContent = details || "暂无索引信息";
}
function renderSyncLog(data, fallbackTitle) {
if (!data) {
syncLogEl.textContent = fallbackTitle || "暂无同步记录";
return;
}
const lines = [];
if (fallbackTitle) lines.push(fallbackTitle);
if (data.data_root) lines.push(`数据目录: ${data.data_root}`);
if (data.project_root) lines.push(`项目目录: ${data.project_root}`);
if (data.workspace_root) lines.push(`工作空间目录: ${data.workspace_root}`);
if (data.delivery_root) lines.push(`交付目录: ${data.delivery_root}`);
if (data.storage_mode) lines.push(`存储模式: ${data.storage_mode}`);
if (data.upstream_repo_url) lines.push(`上游仓库: ${data.upstream_repo_url}`);
if (data.upstream_branch) lines.push(`上游分支: ${data.upstream_branch}`);
if (data.last_sync_time) lines.push(`最近同步时间: ${data.last_sync_time}`);
if (data.last_upstream_commit) lines.push(`最近同步提交: ${data.last_upstream_commit}`);
if (data.index_file) lines.push(`索引文件: ${data.index_file}`);
if (data.index_mtime) lines.push(`索引更新时间: ${data.index_mtime}`);
if (data.mysql_seed_file) lines.push(`MySQL Seed: ${data.mysql_seed_file}`);
if (data.mysql_seed_mtime) lines.push(`MySQL Seed 更新时间: ${data.mysql_seed_mtime}`);
if (data.mysql_host && data.mysql_port) lines.push(`MySQL 地址: ${data.mysql_host}:${data.mysql_port}`);
if (data.mysql_database) lines.push(`MySQL 数据库: ${data.mysql_database}`);
if (data.mysql_reader_user) lines.push(`MySQL 只读账号: ${data.mysql_reader_user}`);
if (typeof data.mysql_ready === "boolean") lines.push(`MySQL 状态: ${data.mysql_ready ? "ready" : "not ready"}`);
if (data.mysql_status) lines.push(`MySQL 详情: ${data.mysql_status}`);
if (data.output) {
lines.push("");
lines.push("同步输出:");
lines.push(data.output);
}
syncLogEl.textContent = lines.join("\n").trim() || "暂无同步记录";
}
async function loadSyncStatus(options = {}) {
const preserveLog = !!options.preserveLog;
syncStatusEl.textContent = "正在检测同步能力。";
try {
const data = await fetchJson("/api/status", { cache: "no-store" });
syncSupported = !!data.supports_upstream_sync;
syncStatusEl.textContent = syncSupported
? "已连接 Docker Compose 服务,可以直接从页面同步原始数据、索引和 MySQL。"
: "当前服务不支持原始数据同步。";
if (!preserveLog) {
renderSyncLog(data, "服务状态");
}
} catch (err) {
syncSupported = false;
syncStatusEl.textContent = `当前页面未连接支持同步的 Docker Compose 服务:${err.message}`;
if (!preserveLog) {
syncLogEl.textContent = "请使用 `docker compose up --build -d` 启动完整服务后,再使用这个功能。";
}
} finally {
updateSyncButtons();
}
}
async function runUpstreamSync() {
if (syncRunning) return;
syncRunning = true;
updateSyncButtons();
syncStatusEl.textContent = "正在同步原始数据、重建索引并刷新 MySQL,请稍候。";
syncLogEl.textContent = "同步进行中...";
try {
const data = await fetchJson("/api/sync-upstream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
syncSupported = true;
syncStatusEl.textContent = "同步完成,页面索引已刷新。";
renderSyncLog(data, "同步完成");
await loadIndexFromPath();
} catch (err) {
syncSupported = false;
syncStatusEl.textContent = `同步失败: ${err.message}`;
syncLogEl.textContent = `同步失败\n${err.message}`;
} finally {
syncRunning = false;
await loadSyncStatus({ preserveLog: true });
}
}
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;
localStorage.setItem(BRAND_CONFIG_STORAGE_KEY, JSON.stringify(managedBrandConfig));
}
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 {
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;
localStorage.setItem(SOURCE_CONFIG_STORAGE_KEY, JSON.stringify(managedSourceConfig));
}
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 {
managedSourceConfig = fallback;
saveManagedSourceConfig();
}
}
function moveSourceOrderItem(fromIndex, toIndex) {
if (!managedSourceConfig || !Array.isArray(managedSourceConfig.order)) return;
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
if (fromIndex >= managedSourceConfig.order.length || toIndex >= managedSourceConfig.order.length) return;
const next = [...managedSourceConfig.order];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
managedSourceConfig.order = next;
}
function bindSourceOrderDragEvents() {
const items = sourceOrderListEl.querySelectorAll(".source-order-item");
for (const item of items) {
item.addEventListener("dragstart", (e) => {
draggingSourceIndex = Number(item.dataset.index);
item.classList.add("dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(draggingSourceIndex));
}
});
item.addEventListener("dragend", () => {
draggingSourceIndex = -1;
sourceOrderListEl.querySelectorAll(".source-order-item")
.forEach((el) => el.classList.remove("dragging", "drag-target"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
sourceOrderListEl.querySelectorAll(".source-order-item")
.forEach((el) => el.classList.remove("drag-target"));
item.classList.add("drag-target");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
const toIndex = Number(item.dataset.index);
const fromIndex = draggingSourceIndex >= 0
? draggingSourceIndex
: Number(e.dataTransfer && e.dataTransfer.getData("text/plain"));
if (!Number.isNaN(fromIndex) && !Number.isNaN(toIndex)) {
moveSourceOrderItem(fromIndex, toIndex);
renderSourceOrder();
}
});
}
}
function renderSourceOrder() {
if (!indexData || !managedSourceConfig) {
sourceOrderStatsEl.textContent = "来源列表未加载。";
sourceOrderListEl.innerHTML = `<li class="sub">暂无来源数据</li>`;
return;
}
const total = managedSourceConfig.order.length;
sourceOrderStatsEl.innerHTML = `
<span class="mono">共 ${total} 个来源,序号越小权重越高。</span>
`;
if (!total) {
sourceOrderListEl.innerHTML = `<li class="sub">暂无来源数据</li>`;
return;
}
sourceOrderListEl.innerHTML = managedSourceConfig.order.map((source, idx) => `
<li class="source-order-item" draggable="true" data-index="${idx}">
<span class="drag-handle">::</span>
<span class="source-rank">#${idx + 1}</span>
<span class="source-name mono">${escapeHtml(source)}</span>
</li>
`).join("");
bindSourceOrderDragEvents();
}
function saveSourceOrder() {
if (!managedSourceConfig) return;
saveManagedSourceConfig();
renderSourceOrder();
}
function resetSourceOrder() {
managedSourceConfig = sanitizeManagedSourceConfig({ order: buildInitialSourceOrder() });
saveManagedSourceConfig();
renderSourceOrder();
}
function openBrandModal({ title, hint, text, editable, onSave }) {
modalSaveHandler = onSave || null;
brandModalTitleEl.textContent = title || "数据管理";
brandModalHintEl.textContent = hint || "";
if (editable) {
brandModalTextareaEl.classList.remove("hidden");
brandModalTextareaEl.value = text || "";
brandModalPreEl.classList.add("hidden");
brandModalSaveBtnEl.classList.remove("hidden");
} else {
brandModalPreEl.classList.remove("hidden");
brandModalPreEl.textContent = text || "";
brandModalTextareaEl.classList.add("hidden");
brandModalSaveBtnEl.classList.add("hidden");
}
brandModalBackdropEl.classList.remove("hidden");
}
function closeBrandModal() {
modalSaveHandler = null;
brandModalBackdropEl.classList.add("hidden");
}
function applyManagedBrandConfigUpdate(newConfig) {
managedBrandConfig = sanitizeManagedBrandConfig(newConfig);
saveManagedBrandConfig();
rebuildManagedBrandIndexes();
renderBrandStats();
}
function openBrandListModal() {
openBrandModal({
title: "品牌列表",
hint: "当前独立维护的品牌列表(含同义词与父级厂商)。",
text: JSON.stringify(managedBrandConfig.brands, null, 2),
editable: false,
});
}
function openManufacturerListModal() {
const rows = (managedBrandConfig.manufacturers || []).map((item) => ({
manufacturer: item.name,
brands: managedManufacturerToBrands.get(item.name) || [],
}));
openBrandModal({
title: "厂商列表",
hint: "当前独立维护的厂商列表(含所属品牌)。",
text: JSON.stringify(rows, null, 2),
editable: false,
});
}
function openEditBrandListModal() {
const names = managedBrandConfig.brands.map((b) => b.name);
openBrandModal({
title: "编辑品牌列表",
hint: "JSON 数组格式: [\"Xiaomi\",\"Redmi\",\"POCO\"]",
text: JSON.stringify(names, null, 2),
editable: true,
onSave: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("品牌列表必须是 JSON 数组。");
}
const existingMap = new Map(managedBrandConfig.brands.map((b) => [b.name, b]));
const seen = new Set();
const brands = [];
for (const item of parsed) {
const name = String(typeof item === "string" ? item : (item && item.name) || "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
const existing = existingMap.get(name);
brands.push({
name,
manufacturer: existing ? existing.manufacturer : name,
aliases: existing ? existing.aliases : [name],
});
}
if (!brands.length) {
throw new Error("品牌列表为空,请至少保留一个品牌。");
}
const next = sanitizeManagedBrandConfig({
brands,
manufacturers: managedBrandConfig.manufacturers,
});
applyManagedBrandConfigUpdate(next);
},
});
}
function openEditBrandRelationsModal() {
const relations = managedBrandConfig.brands.map((b) => ({
brand: b.name,
manufacturer: b.manufacturer,
}));
openBrandModal({
title: "编辑品牌-厂商关系",
hint: "JSON 数组格式: [{\"brand\":\"Redmi\",\"manufacturer\":\"Xiaomi\"}]",
text: JSON.stringify(relations, null, 2),
editable: true,
onSave: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("关系数据必须是 JSON 数组。");
}
const existingBrandMap = new Map(managedBrandConfig.brands.map((b) => [b.name, b]));
const seen = new Set();
const brands = [];
for (const item of parsed) {
const brandName = String(item.brand || item.name || "").trim();
const manufacturerName = String(item.manufacturer || item.parent || "").trim();
if (!brandName || !manufacturerName || seen.has(brandName)) continue;
seen.add(brandName);
const existing = existingBrandMap.get(brandName);
brands.push({
name: brandName,
manufacturer: manufacturerName,
aliases: existing ? existing.aliases : [brandName],
});
}
if (!brands.length) {
throw new Error("关系列表为空,请至少保留一条。");
}
const next = sanitizeManagedBrandConfig({
brands,
manufacturers: managedBrandConfig.manufacturers,
});
applyManagedBrandConfigUpdate(next);
},
});
}
function openEditBrandAliasesModal() {
const payload = managedBrandConfig.brands.map((b) => ({
brand: b.name,
aliases: b.aliases || [],
}));
openBrandModal({
title: "编辑品牌同义词归一",
hint: "JSON 数组格式: [{\"brand\":\"HONOR\",\"aliases\":[\"荣耀\",\"honor\",\"HONOR\"]}]",
text: JSON.stringify(payload, null, 2),
editable: true,
onSave: (raw) => {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error("同义词数据必须是 JSON 数组。");
}
const parsedMap = new Map();
for (const item of parsed) {
const name = String(item.brand || item.name || "").trim();
if (!name) continue;
parsedMap.set(name, normalizeAliasList(name, item.aliases));
}
const brands = managedBrandConfig.brands.map((b) => ({
...b,
aliases: parsedMap.get(b.name) || normalizeAliasList(b.name, b.aliases),
}));
const existingSet = new Set(brands.map((b) => b.name));
for (const [name, aliases] of parsedMap.entries()) {
if (existingSet.has(name)) continue;
brands.push({
name,
manufacturer: name,
aliases,
});
}
const next = sanitizeManagedBrandConfig({
brands,
manufacturers: managedBrandConfig.manufacturers,
});
applyManagedBrandConfigUpdate(next);
},
});
}
function renderBrandStats() {
if (!indexData || !managedBrandConfig) {
brandStatsEl.textContent = "索引未加载。";
brandRelationBodyEl.innerHTML = `<tr><td colspan="3" class="sub">暂无关系数据</td></tr>`;
return;
}
const brandCount = managedBrandConfig.brands.length;
const manufacturerCount = managedBrandConfig.manufacturers.length;
brandCountBtnEl.textContent = `品牌数: ${brandCount}`;
manufacturerCountBtnEl.textContent = `厂商数: ${manufacturerCount}`;
brandStatsEl.innerHTML = `
<div class="mono">点击“品牌数/厂商数”可弹窗查看完整列表。</div>
<div class="mono">关系规则:一个品牌只属于一个父级厂商,一个厂商可包含多个品牌。</div>
`;
const sortedBrands = [...managedBrandConfig.brands].sort((a, b) => a.name.localeCompare(b.name));
if (!sortedBrands.length) {
brandRelationBodyEl.innerHTML = `<tr><td colspan="3" class="sub">暂无关系数据</td></tr>`;
return;
}
brandRelationBodyEl.innerHTML = sortedBrands.map((brand) => {
const aliases = normalizeAliasList(brand.name, brand.aliases || []);
const manufacturer = managedBrandToManufacturer.get(brand.name) || brand.manufacturer || "(未设置)";
return `
<tr>
<td><strong>${escapeHtml(brand.name)}</strong></td>
<td>${aliases.map((a) => `<span class="tag">${escapeHtml(a)}</span>`).join("")}</td>
<td><span class="tag">${escapeHtml(manufacturer)}</span></td>
</tr>
`;
}).join("");
}
async function loadIndexFromPath() {
try {
renderIndexStatus("正在加载 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();
loadManagedBrandConfig();
loadManagedSourceConfig();
rebuildManagedBrandIndexes();
renderBrandStats();
renderSourceOrder();
const sourceCount = Array.isArray(managedSourceConfig && managedSourceConfig.order)
? managedSourceConfig.order.length
: 0;
const brandCount = managedBrandConfig && Array.isArray(managedBrandConfig.brands)
? managedBrandConfig.brands.length
: 0;
const manufacturerCount = managedBrandConfig && Array.isArray(managedBrandConfig.manufacturers)
? managedBrandConfig.manufacturers.length
: 0;
renderIndexStatus(
`索引加载成功: records=${indexData.total_records}, lookup=${Object.keys(indexData.lookup || {}).length}`,
[
`generated_on: ${indexData.generated_on || "(unknown)"}`,
`total_records: ${indexData.total_records || 0}`,
`lookup_keys: ${Object.keys(indexData.lookup || {}).length}`,
`brand_count: ${brandCount}`,
`manufacturer_count: ${manufacturerCount}`,
`source_count: ${sourceCount}`,
].join("\n")
);
} catch (err) {
renderIndexStatus(`索引加载失败: ${err.message}`, `索引加载失败\n${err.message}`);
brandStatsEl.textContent = "索引不可用,暂无品牌关系数据。";
sourceOrderStatsEl.textContent = "索引不可用,暂无来源数据。";
brandRelationBodyEl.innerHTML = `<tr><td colspan="3" class="sub">暂无关系数据</td></tr>`;
sourceOrderListEl.innerHTML = `<li class="sub">暂无来源数据</li>`;
}
}
document.getElementById("editBrandListBtn").addEventListener("click", openEditBrandListModal);
document.getElementById("editBrandRelationsBtn").addEventListener("click", openEditBrandRelationsModal);
document.getElementById("editBrandAliasesBtn").addEventListener("click", openEditBrandAliasesModal);
document.getElementById("saveSourceOrderBtn").addEventListener("click", saveSourceOrder);
document.getElementById("resetSourceOrderBtn").addEventListener("click", resetSourceOrder);
brandCountBtnEl.addEventListener("click", openBrandListModal);
manufacturerCountBtnEl.addEventListener("click", openManufacturerListModal);
syncUpstreamBtnEl.addEventListener("click", runUpstreamSync);
refreshSyncStatusBtnEl.addEventListener("click", loadSyncStatus);
reloadIndexBtnEl.addEventListener("click", loadIndexFromPath);
brandModalCancelBtnEl.addEventListener("click", closeBrandModal);
brandModalBackdropEl.addEventListener("click", (e) => {
if (e.target === brandModalBackdropEl) closeBrandModal();
});
brandModalSaveBtnEl.addEventListener("click", () => {
if (!modalSaveHandler) return;
try {
modalSaveHandler(brandModalTextareaEl.value);
closeBrandModal();
} catch (err) {
brandStatsEl.textContent = `保存失败: ${err.message}`;
}
});
function switchManageTab(tab) {
const isBrand = tab === "brand";
const isSource = tab === "source";
const isSync = tab === "sync";
const isIndex = tab === "index";
tabBrandBtnEl.classList.toggle("active", isBrand);
tabSourceBtnEl.classList.toggle("active", isSource);
tabSyncBtnEl.classList.toggle("active", isSync);
tabIndexBtnEl.classList.toggle("active", isIndex);
brandTabPanelEl.classList.toggle("hidden", !isBrand);
sourceTabPanelEl.classList.toggle("hidden", !isSource);
syncTabPanelEl.classList.toggle("hidden", !isSync);
indexTabPanelEl.classList.toggle("hidden", !isIndex);
}
tabBrandBtnEl.addEventListener("click", () => switchManageTab("brand"));
tabSourceBtnEl.addEventListener("click", () => switchManageTab("source"));
tabSyncBtnEl.addEventListener("click", () => switchManageTab("sync"));
tabIndexBtnEl.addEventListener("click", () => switchManageTab("index"));
switchManageTab("brand");
loadIndexFromPath();
loadSyncStatus();
</script>
</body>
</html>