From f6541d291e5cad7983a913f32621e5e4ee4789b6 Mon Sep 17 00:00:00 2001 From: yuanzhen869 Date: Wed, 15 Apr 2026 09:15:48 +0800 Subject: [PATCH] Improve manual catalog UX --- tools/web_server.py | 74 ++++++++++++++++++--- web/brand_management.html | 136 +++++++++++++++++++++++++++++++++----- 2 files changed, 184 insertions(+), 26 deletions(-) diff --git a/tools/web_server.py b/tools/web_server.py index ed0b866..8daa32c 100644 --- a/tools/web_server.py +++ b/tools/web_server.py @@ -33,11 +33,19 @@ SYNC_LOCK = threading.Lock() SCHEDULE_LOCK = threading.Lock() MYSQL_CONFIG_LOCK = threading.Lock() INDEX_ALIAS_LOCK = threading.Lock() +MANUAL_REBUILD_LOCK = threading.Lock() NORMALIZE_RE = re.compile(r"[^0-9a-z\u4e00-\u9fff]+") SCHEDULE_TIME_RE = re.compile(r"^(?:[01]?\d|2[0-3]):[0-5]\d$") SCHEDULER_POLL_SECONDS = 20 INDEX_DEVICE_NAME_ALIAS_MAP: dict[str, list[str]] | None = None DEVICE_TYPES = {"phone", "tablet", "wear", "tv", "computer", "other"} +LAST_MANUAL_MYSQL_LOAD_STATUS: dict[str, object] = { + "running": False, + "last_started_at": None, + "last_finished_at": None, + "last_status": None, + "last_message": None, +} def truthy_env(name: str, default: str = "0") -> bool: @@ -471,7 +479,34 @@ def count_manual_alias_conflicts(device: dict[str, object]) -> int: return len(conflicts) -def rebuild_generated_outputs() -> dict[str, object]: +def _manual_mysql_loader_task() -> None: + with MANUAL_REBUILD_LOCK: + LAST_MANUAL_MYSQL_LOAD_STATUS["running"] = True + LAST_MANUAL_MYSQL_LOAD_STATUS["last_started_at"] = local_now().isoformat(timespec="seconds") + LAST_MANUAL_MYSQL_LOAD_STATUS["last_status"] = "running" + LAST_MANUAL_MYSQL_LOAD_STATUS["last_message"] = "手动补录触发的 MySQL 刷新进行中。" + try: + load_proc = run_command(["python3", str(MYSQL_LOADER)]) + message = "\n".join(part for part in [load_proc.stdout.strip(), load_proc.stderr.strip()] if part).strip() or "MySQL 已刷新。" + LAST_MANUAL_MYSQL_LOAD_STATUS["last_status"] = "success" if load_proc.returncode == 0 else "failed" + LAST_MANUAL_MYSQL_LOAD_STATUS["last_message"] = message + except Exception as err: + LAST_MANUAL_MYSQL_LOAD_STATUS["last_status"] = "failed" + LAST_MANUAL_MYSQL_LOAD_STATUS["last_message"] = str(err) + finally: + LAST_MANUAL_MYSQL_LOAD_STATUS["running"] = False + LAST_MANUAL_MYSQL_LOAD_STATUS["last_finished_at"] = local_now().isoformat(timespec="seconds") + + +def start_manual_mysql_loader() -> bool: + if LAST_MANUAL_MYSQL_LOAD_STATUS.get("running"): + return False + thread = threading.Thread(target=_manual_mysql_loader_task, name="manual-mysql-loader", daemon=True) + thread.start() + return True + + +def rebuild_generated_outputs(*, defer_mysql_load: bool = False) -> dict[str, object]: build_proc = run_command( [ "python3", @@ -504,16 +539,26 @@ def rebuild_generated_outputs() -> dict[str, object]: mysql_loaded = False mysql_message = "MySQL 未刷新。" if mysql_auto_load_enabled(): - load_proc = run_command(["python3", str(MYSQL_LOADER)]) - mysql_message = "\n".join(part for part in [load_proc.stdout.strip(), load_proc.stderr.strip()] if part).strip() or "MySQL 已刷新。" - if load_proc.returncode != 0: - raise RuntimeError(mysql_message) - mysql_loaded = True + if defer_mysql_load: + started = start_manual_mysql_loader() + mysql_message = "MySQL 后台刷新中。" if started else "MySQL 后台刷新已在进行中。" + else: + load_proc = run_command(["python3", str(MYSQL_LOADER)]) + mysql_message = "\n".join(part for part in [load_proc.stdout.strip(), load_proc.stderr.strip()] if part).strip() or "MySQL 已刷新。" + if load_proc.returncode != 0: + raise RuntimeError(mysql_message) + mysql_loaded = True return { "index_updated": True, "mysql_seed_updated": True, "mysql_loaded": mysql_loaded, - "message": "本地覆盖库已保存,索引与 MySQL seed 已刷新。" if mysql_loaded else "本地覆盖库已保存,索引与 MySQL seed 已刷新,MySQL 未自动装载。", + "message": ( + "本地覆盖库已保存,索引与 MySQL seed 已刷新,MySQL 正在后台刷新。" + if defer_mysql_load and mysql_auto_load_enabled() + else "本地覆盖库已保存,索引与 MySQL seed 已刷新。" + if mysql_loaded + else "本地覆盖库已保存,索引与 MySQL seed 已刷新,MySQL 未自动装载。" + ), "build_output": build_output, "mysql_seed_output": seed_output, "mysql_message": mysql_message, @@ -532,6 +577,13 @@ def manual_catalog_payload() -> dict[str, object]: "device_count": len(devices), }, "catalog_file": str(MANUAL_CATALOG_PATH.relative_to(PROJECT_ROOT)), + "mysql_refresh": { + "running": bool(LAST_MANUAL_MYSQL_LOAD_STATUS.get("running")), + "last_started_at": LAST_MANUAL_MYSQL_LOAD_STATUS.get("last_started_at"), + "last_finished_at": LAST_MANUAL_MYSQL_LOAD_STATUS.get("last_finished_at"), + "last_status": LAST_MANUAL_MYSQL_LOAD_STATUS.get("last_status"), + "last_message": LAST_MANUAL_MYSQL_LOAD_STATUS.get("last_message"), + }, } @@ -552,7 +604,7 @@ def upsert_manual_brand(payload: dict[str, object]) -> dict[str, object]: catalog["brands"].append(incoming) validated = validate_manual_catalog(catalog) write_manual_catalog(validated) - rebuild_result = rebuild_generated_outputs() + rebuild_result = rebuild_generated_outputs(defer_mysql_load=True) return { "saved_brand": incoming, "catalog": manual_catalog_payload(), @@ -576,7 +628,7 @@ def upsert_manual_device(payload: dict[str, object]) -> dict[str, object]: catalog["devices"].append(device_payload) validated = validate_manual_catalog(catalog) write_manual_catalog(validated) - rebuild_result = rebuild_generated_outputs() + rebuild_result = rebuild_generated_outputs(defer_mysql_load=True) return { "saved_device": device_payload, "alias_conflict_count": count_manual_alias_conflicts(device_payload), @@ -602,7 +654,7 @@ def delete_manual_brand(payload: dict[str, object]) -> dict[str, object]: if len(next_brands) == len(catalog["brands"]): raise RuntimeError(f"未找到品牌: {brand_name}") write_manual_catalog({"brands": next_brands, "devices": catalog["devices"]}) - rebuild_result = rebuild_generated_outputs() + rebuild_result = rebuild_generated_outputs(defer_mysql_load=True) return { "deleted_brand": brand_name, "catalog": manual_catalog_payload(), @@ -624,7 +676,7 @@ def delete_manual_device(payload: dict[str, object]) -> dict[str, object]: if len(next_devices) == len(catalog["devices"]): raise RuntimeError(f"未找到设备: {device_id}") write_manual_catalog({"brands": catalog["brands"], "devices": next_devices}) - rebuild_result = rebuild_generated_outputs() + rebuild_result = rebuild_generated_outputs(defer_mysql_load=True) return { "deleted_device": device_id, "catalog": manual_catalog_payload(), diff --git a/web/brand_management.html b/web/brand_management.html index 586e5a4..e621460 100644 --- a/web/brand_management.html +++ b/web/brand_management.html @@ -378,12 +378,12 @@ .manual-brand-table col:nth-child(3) { width: 180px; } .manual-brand-table col:nth-child(4) { width: 240px; } .manual-brand-table col:nth-child(5) { width: 200px; } - .manual-device-table col:nth-child(1) { width: 160px; } - .manual-device-table col:nth-child(2) { width: 240px; } + .manual-device-table col:nth-child(1) { width: 140px; } + .manual-device-table col:nth-child(2) { width: 280px; } .manual-device-table col:nth-child(3) { width: 140px; } - .manual-device-table col:nth-child(4) { width: auto; } - .manual-device-table col:nth-child(5) { width: 280px; } - .manual-device-table col:nth-child(6) { width: 200px; } + .manual-device-table col:nth-child(4) { width: 220px; } + .manual-device-table col:nth-child(5) { width: 220px; } + .manual-device-table col:nth-child(6) { width: 220px; } .manual-brand-table th, .manual-brand-table td, .manual-device-table th, @@ -394,10 +394,30 @@ .manual-device-table td { line-height: 1.4; } + .manual-device-table td:nth-child(5) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } .manual-brand-table .tag, .manual-device-table .tag { margin: 0; } + .manual-brand-table th:last-child, + .manual-brand-table td:last-child, + .manual-device-table th:last-child, + .manual-device-table td:last-child { + position: sticky; + right: 0; + z-index: 2; + background: #fff; + box-shadow: -8px 0 12px rgba(36, 56, 89, 0.06); + } + .manual-brand-table th:last-child, + .manual-device-table th:last-child { + background: #f7f9fd; + z-index: 3; + } .manual-actions-bar { margin-bottom: 10px; padding-left: 10px; @@ -405,6 +425,40 @@ .manual-actions-bar .btns { margin-top: 0; } + .status-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0; + } + .status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + } + .status-pill.running { + background: #eef4ff; + color: #1e59c9; + } + .status-pill.success { + background: #eefaf2; + color: #177245; + } + .status-pill.failed { + background: #fff1f0; + color: #b42318; + } + .status-pill.idle { + background: #f4f6fa; + color: #5b6679; + } .collapse-card { padding: 0; overflow: hidden; @@ -774,6 +828,9 @@
  • 保存后会重建索引和 MySQL seed;是否自动刷新 MySQL 取决于左侧同步配置中的自动装载开关。
  • 本地覆盖库不会被“原始数据同步”覆盖,适合补录学习机、教育终端、定制设备。
  • +
    + 等待刷新 +
    正在读取本地覆盖库。
    @@ -982,6 +1039,7 @@ let managedBrandConfig = null; let managedSourceConfig = null; let manualCatalog = { brands: [], devices: [] }; + let manualCatalogPollTimer = null; let managedBrandAliasToBrand = new Map(); let managedBrandToManufacturer = new Map(); let managedManufacturerToBrands = new Map(); @@ -1031,6 +1089,7 @@ const indexStatusEl = document.getElementById("indexStatus"); const indexSummaryEl = document.getElementById("indexSummary"); const manualStatusEl = document.getElementById("manualStatus"); + const manualRefreshBadgeEl = document.getElementById("manualRefreshBadge"); const manualBrandBodyEl = document.getElementById("manualBrandBody"); const manualDeviceBodyEl = document.getElementById("manualDeviceBody"); const addManualBrandBtnEl = document.getElementById("addManualBrandBtn"); @@ -1105,6 +1164,50 @@ return [...names].sort((a, b) => a.localeCompare(b)); } + function buildManualStatusText(payload = manualCatalog) { + const brands = Array.isArray(payload && payload.brands) ? payload.brands : []; + const devices = Array.isArray(payload && payload.devices) ? payload.devices : []; + const refresh = (payload && payload.mysql_refresh) || {}; + const parts = [ + `本地覆盖库: 品牌 ${brands.length} 个,设备 ${devices.length} 个。`, + "保存后会刷新索引和 MySQL seed。", + ]; + if (refresh.running) { + parts.push("MySQL 后台刷新中。"); + } else if (refresh.last_status === "success") { + parts.push("MySQL 后台刷新已完成。"); + } else if (refresh.last_status === "failed") { + parts.push(`MySQL 后台刷新失败: ${refresh.last_message || "请检查容器日志。"}`); + } + return parts.join(" "); + } + + function buildManualStatusBadge(payload = manualCatalog) { + const refresh = (payload && payload.mysql_refresh) || {}; + if (refresh.running) { + return '后台刷新中'; + } + if (refresh.last_status === "success") { + return '后台刷新完成'; + } + if (refresh.last_status === "failed") { + return '后台刷新失败'; + } + return '等待刷新'; + } + + function scheduleManualCatalogPoll() { + if (manualCatalogPollTimer) { + window.clearTimeout(manualCatalogPollTimer); + manualCatalogPollTimer = null; + } + const refresh = (manualCatalog && manualCatalog.mysql_refresh) || {}; + if (!refresh.running) return; + manualCatalogPollTimer = window.setTimeout(() => { + loadManualCatalog({ silent: true }); + }, 2500); + } + function updateSyncButtons() { const busy = syncRunning || mysqlInitRunning; syncUpstreamBtnEl.disabled = busy || !syncSupported; @@ -1992,7 +2095,8 @@ function renderManualCatalog() { const brands = Array.isArray(manualCatalog && manualCatalog.brands) ? manualCatalog.brands : []; const devices = Array.isArray(manualCatalog && manualCatalog.devices) ? manualCatalog.devices : []; - manualStatusEl.textContent = `本地覆盖库: 品牌 ${brands.length} 个,设备 ${devices.length} 个。保存后会刷新索引和 MySQL seed。`; + manualRefreshBadgeEl.innerHTML = buildManualStatusBadge(); + manualStatusEl.textContent = buildManualStatusText(); if (!brands.length) { manualBrandBodyEl.innerHTML = `暂无手动品牌`; @@ -2059,15 +2163,20 @@ await deleteManualDevice(deviceId); }); }); + scheduleManualCatalogPoll(); } - async function loadManualCatalog() { - manualStatusEl.textContent = "正在读取本地覆盖库。"; + async function loadManualCatalog(options = {}) { + const silent = !!options.silent; + if (!silent) { + manualStatusEl.textContent = "正在读取本地覆盖库。"; + } try { const payload = await fetchJson("/api/manual-catalog", { cache: "no-store" }); manualCatalog = { brands: Array.isArray(payload.brands) ? payload.brands : [], devices: Array.isArray(payload.devices) ? payload.devices : [], + mysql_refresh: payload.mysql_refresh || null, }; renderManualCatalog(); } catch (err) { @@ -2113,7 +2222,7 @@ body: JSON.stringify(payload), }); manualCatalog = result.catalog || manualCatalog; - manualStatusEl.textContent = result.message || "品牌已保存。"; + manualStatusEl.textContent = `${result.message || "品牌已保存。"} ${result.mysql_message || ""}`.trim(); renderManualCatalog(); await loadIndexFromPath(); await loadSyncStatus({ preserveLog: true }); @@ -2173,7 +2282,7 @@ body: JSON.stringify(payload), }); manualCatalog = result.catalog || manualCatalog; - manualStatusEl.textContent = `${result.message || "设备已保存。"}${typeof result.alias_conflict_count === "number" ? ` 命中现有别名冲突 ${result.alias_conflict_count} 个。` : ""}`; + manualStatusEl.textContent = `${result.message || "设备已保存。"}${typeof result.alias_conflict_count === "number" ? ` 命中现有别名冲突 ${result.alias_conflict_count} 个。` : ""} ${result.mysql_message || ""}`.trim(); renderManualCatalog(); await loadIndexFromPath(); await loadSyncStatus({ preserveLog: true }); @@ -2188,7 +2297,7 @@ body: JSON.stringify({ name }), }); manualCatalog = result.catalog || manualCatalog; - manualStatusEl.textContent = result.message || `品牌 ${name} 已删除。`; + manualStatusEl.textContent = `${result.message || `品牌 ${name} 已删除。`} ${result.mysql_message || ""}`.trim(); renderManualCatalog(); await loadIndexFromPath(); await loadSyncStatus({ preserveLog: true }); @@ -2201,7 +2310,7 @@ body: JSON.stringify({ id }), }); manualCatalog = result.catalog || manualCatalog; - manualStatusEl.textContent = result.message || "设备已删除。"; + manualStatusEl.textContent = `${result.message || "设备已删除。"} ${result.mysql_message || ""}`.trim(); renderManualCatalog(); await loadIndexFromPath(); await loadSyncStatus({ preserveLog: true }); @@ -2265,9 +2374,6 @@ reloadIndexBtnEl.addEventListener("click", loadIndexFromPath); brandModalCancelBtnEl.addEventListener("click", closeBrandModal); - brandModalBackdropEl.addEventListener("click", (e) => { - if (e.target === brandModalBackdropEl) closeBrandModal(); - }); brandModalSaveBtnEl.addEventListener("click", async () => { if (!modalSaveHandler) return; try {