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

1280 lines
45 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: #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>