Add manual catalog management
This commit is contained in:
@@ -167,7 +167,7 @@ docker compose down -v
|
|||||||
|
|
||||||
### 3.3 数据管理页
|
### 3.3 数据管理页
|
||||||
|
|
||||||
数据管理页按左侧导航分为四部分。
|
数据管理页按左侧导航分为五部分。
|
||||||
|
|
||||||
#### 品牌列表
|
#### 品牌列表
|
||||||
|
|
||||||
@@ -184,6 +184,30 @@ docker compose down -v
|
|||||||
- 厂商归属调整
|
- 厂商归属调整
|
||||||
- 品牌展示不符合预期时排查
|
- 品牌展示不符合预期时排查
|
||||||
|
|
||||||
|
#### 手动补录
|
||||||
|
|
||||||
|
这里维护本地覆盖库。
|
||||||
|
|
||||||
|
这里可做的事:
|
||||||
|
|
||||||
|
- 新增独立品牌
|
||||||
|
- 在品牌下补录设备
|
||||||
|
- 编辑或删除本地补录记录
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 上游暂未收录的新品牌
|
||||||
|
- 学习机、教育终端、定制设备
|
||||||
|
- 需要立即参与页面查询和 MySQL 查询的补录数据
|
||||||
|
|
||||||
|
使用说明:
|
||||||
|
|
||||||
|
1. 品牌先建在本地覆盖库
|
||||||
|
2. 设备标识填客户端真实上报值
|
||||||
|
3. 保存后自动重建索引和 MySQL seed
|
||||||
|
4. 如果开启 MySQL 自动装载,会继续自动刷新 MySQL
|
||||||
|
5. 本地覆盖库不会被“原始数据同步”覆盖
|
||||||
|
|
||||||
#### 数据来源
|
#### 数据来源
|
||||||
|
|
||||||
这里维护来源优先级。
|
这里维护来源优先级。
|
||||||
@@ -339,7 +363,12 @@ NOH-AL00 -> nohal00
|
|||||||
|
|
||||||
### 5.1 数据来源
|
### 5.1 数据来源
|
||||||
|
|
||||||
项目的原始数据主要来自 `workspace/brands/*.md`。这些原始 markdown 是后续索引与 MySQL 数据生成的基础。
|
项目的数据来源分为两部分:
|
||||||
|
|
||||||
|
- 上游原始数据:`workspace/brands/*.md`
|
||||||
|
- 本地覆盖库:`workspace/local/manual_catalog.json`
|
||||||
|
|
||||||
|
上游原始数据用于同步官方或社区维护的数据,本地覆盖库用于补录当前业务需要但上游暂未收录的品牌和设备。
|
||||||
|
|
||||||
### 5.2 数据生成链路
|
### 5.2 数据生成链路
|
||||||
|
|
||||||
@@ -347,9 +376,10 @@ NOH-AL00 -> nohal00
|
|||||||
|
|
||||||
1. 同步上游原始 markdown
|
1. 同步上游原始 markdown
|
||||||
2. 解析 `workspace/brands/*.md`
|
2. 解析 `workspace/brands/*.md`
|
||||||
3. 构建 `dist/device_index.json`
|
3. 合并 `workspace/local/manual_catalog.json`
|
||||||
4. 导出 `dist/mobilemodels_mysql_seed.sql`
|
4. 构建 `dist/device_index.json`
|
||||||
5. 加载 MySQL schema 与 seed
|
5. 导出 `dist/mobilemodels_mysql_seed.sql`
|
||||||
|
6. 按配置决定是否自动装载 MySQL
|
||||||
|
|
||||||
### 5.3 关键产物
|
### 5.3 关键产物
|
||||||
|
|
||||||
@@ -366,6 +396,22 @@ NOH-AL00 -> nohal00
|
|||||||
|
|
||||||
适用于品牌展示或厂商归属不符合预期的情况。
|
适用于品牌展示或厂商归属不符合预期的情况。
|
||||||
|
|
||||||
|
#### 手动补录品牌或设备
|
||||||
|
|
||||||
|
适用于上游未收录,但业务需要立即支持的设备。
|
||||||
|
|
||||||
|
维护方式:
|
||||||
|
|
||||||
|
1. 在 `数据管理 -> 手动补录` 中新增品牌或设备
|
||||||
|
2. 保存后自动刷新索引与 MySQL seed
|
||||||
|
3. 如关闭了 MySQL 自动装载,需按需手动初始化或刷新外部 MySQL
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本地覆盖库不会被上游同步覆盖
|
||||||
|
- 本地补录来源默认优先级更高
|
||||||
|
- 适合维护学习机、教育设备、定制终端
|
||||||
|
|
||||||
#### 调整来源顺序
|
#### 调整来源顺序
|
||||||
|
|
||||||
适用于多个来源的优先级需要重新定义的情况。
|
适用于多个来源的优先级需要重新定义的情况。
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ FILE_DEFAULT_DEVICE_TYPE: Dict[str, str] = {
|
|||||||
"zhixuan": "phone",
|
"zhixuan": "phone",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MANUAL_SOURCE_FILE = "local/manual_catalog.json"
|
||||||
|
|
||||||
|
|
||||||
BRAND_ALIASES: Dict[str, List[str]] = {
|
BRAND_ALIASES: Dict[str, List[str]] = {
|
||||||
"360": ["360", "360手机", "奇酷", "qiku"],
|
"360": ["360", "360手机", "奇酷", "qiku"],
|
||||||
@@ -239,6 +241,10 @@ class DeviceRecord:
|
|||||||
section: str
|
section: str
|
||||||
|
|
||||||
|
|
||||||
|
MANUAL_BRAND_ALIAS_OVERRIDES: Dict[str, List[str]] = {}
|
||||||
|
MANUAL_PARENT_BRAND_OVERRIDES: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def normalize_text(text: str) -> str:
|
def normalize_text(text: str) -> str:
|
||||||
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text.lower())
|
return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", text.lower())
|
||||||
|
|
||||||
@@ -249,6 +255,7 @@ def canonical_brand(file_stem: str) -> str:
|
|||||||
|
|
||||||
def brand_aliases(brand: str) -> List[str]:
|
def brand_aliases(brand: str) -> List[str]:
|
||||||
aliases = set(BRAND_ALIASES.get(brand, []))
|
aliases = set(BRAND_ALIASES.get(brand, []))
|
||||||
|
aliases.update(MANUAL_BRAND_ALIAS_OVERRIDES.get(brand, []))
|
||||||
aliases.add(brand)
|
aliases.add(brand)
|
||||||
return sorted(aliases)
|
return sorted(aliases)
|
||||||
|
|
||||||
@@ -265,9 +272,108 @@ def has_keyword(text: str, keywords: Iterable[str]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_parent_brand(manufacturer_brand: str) -> str:
|
def resolve_parent_brand(manufacturer_brand: str) -> str:
|
||||||
|
if manufacturer_brand in MANUAL_PARENT_BRAND_OVERRIDES:
|
||||||
|
return MANUAL_PARENT_BRAND_OVERRIDES[manufacturer_brand]
|
||||||
return MANUFACTURER_PARENT_BRAND.get(manufacturer_brand, manufacturer_brand)
|
return MANUFACTURER_PARENT_BRAND.get(manufacturer_brand, manufacturer_brand)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_manual_overrides() -> None:
|
||||||
|
MANUAL_BRAND_ALIAS_OVERRIDES.clear()
|
||||||
|
MANUAL_PARENT_BRAND_OVERRIDES.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_alias_list(*groups: object) -> List[str]:
|
||||||
|
aliases: List[str] = []
|
||||||
|
seen: Set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
if group is None:
|
||||||
|
continue
|
||||||
|
items = group if isinstance(group, (list, tuple, set)) else [group]
|
||||||
|
for item in items:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
key = normalize_text(text)
|
||||||
|
if not text or not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
aliases.append(text)
|
||||||
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
|
def load_manual_catalog(repo_root: Path) -> dict[str, object]:
|
||||||
|
path = repo_root / MANUAL_SOURCE_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return {"brands": [], "devices": []}
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError(f"{MANUAL_SOURCE_FILE} 必须是 JSON 对象。")
|
||||||
|
brands = payload.get("brands")
|
||||||
|
devices = payload.get("devices")
|
||||||
|
return {
|
||||||
|
"brands": brands if isinstance(brands, list) else [],
|
||||||
|
"devices": devices if isinstance(devices, list) else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_manual_brand_overrides(manual_catalog: dict[str, object]) -> dict[str, str]:
|
||||||
|
reset_manual_overrides()
|
||||||
|
brand_parent_map: dict[str, str] = {}
|
||||||
|
for raw_brand in manual_catalog.get("brands", []):
|
||||||
|
if not isinstance(raw_brand, dict):
|
||||||
|
continue
|
||||||
|
brand_name = str(raw_brand.get("name") or "").strip()
|
||||||
|
if not brand_name:
|
||||||
|
continue
|
||||||
|
aliases = normalize_alias_list(brand_name, raw_brand.get("aliases"))
|
||||||
|
parent_brand = str(raw_brand.get("parent_brand") or brand_name).strip() or brand_name
|
||||||
|
MANUAL_BRAND_ALIAS_OVERRIDES[brand_name] = aliases
|
||||||
|
MANUAL_PARENT_BRAND_OVERRIDES[brand_name] = parent_brand
|
||||||
|
brand_parent_map[brand_name] = parent_brand
|
||||||
|
return brand_parent_map
|
||||||
|
|
||||||
|
|
||||||
|
def parse_manual_catalog(repo_root: Path, manual_catalog: dict[str, object]) -> List[DeviceRecord]:
|
||||||
|
brand_parent_map = apply_manual_brand_overrides(manual_catalog)
|
||||||
|
records: List[DeviceRecord] = []
|
||||||
|
|
||||||
|
for raw_device in manual_catalog.get("devices", []):
|
||||||
|
if not isinstance(raw_device, dict):
|
||||||
|
continue
|
||||||
|
brand = str(raw_device.get("brand") or "").strip()
|
||||||
|
device_name = str(raw_device.get("device_name") or "").strip()
|
||||||
|
if not brand or not device_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
aliases = normalize_alias_list(
|
||||||
|
raw_device.get("models"),
|
||||||
|
device_name,
|
||||||
|
raw_device.get("aliases"),
|
||||||
|
)
|
||||||
|
if not aliases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_type = str(raw_device.get("device_type") or "").strip() or "other"
|
||||||
|
section = str(raw_device.get("section") or "手动补录").strip() or "手动补录"
|
||||||
|
record_id = str(raw_device.get("id") or "").strip() or f"manual:{normalize_text(brand)}:{normalize_text(device_name)}"
|
||||||
|
parent_brand = brand_parent_map.get(brand, str(raw_device.get("parent_brand") or brand).strip() or brand)
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
DeviceRecord(
|
||||||
|
id=record_id,
|
||||||
|
device_name=device_name,
|
||||||
|
brand=brand,
|
||||||
|
manufacturer_brand=brand,
|
||||||
|
parent_brand=parent_brand,
|
||||||
|
market_brand=brand,
|
||||||
|
device_type=device_type,
|
||||||
|
aliases=aliases,
|
||||||
|
source_file=MANUAL_SOURCE_FILE,
|
||||||
|
section=section,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
def infer_market_brand(
|
def infer_market_brand(
|
||||||
manufacturer_brand: str,
|
manufacturer_brand: str,
|
||||||
device_name: str,
|
device_name: str,
|
||||||
@@ -647,10 +753,13 @@ class DeviceMapper:
|
|||||||
def build_records(repo_root: Path) -> List[DeviceRecord]:
|
def build_records(repo_root: Path) -> List[DeviceRecord]:
|
||||||
brands_dir = repo_root / "brands"
|
brands_dir = repo_root / "brands"
|
||||||
records: List[DeviceRecord] = []
|
records: List[DeviceRecord] = []
|
||||||
|
manual_catalog = load_manual_catalog(repo_root)
|
||||||
|
apply_manual_brand_overrides(manual_catalog)
|
||||||
|
|
||||||
for md_path in sorted(brands_dir.glob("*.md")):
|
for md_path in sorted(brands_dir.glob("*.md")):
|
||||||
records.extend(parse_brand_file(md_path))
|
records.extend(parse_brand_file(md_path))
|
||||||
|
|
||||||
|
records.extend(parse_manual_catalog(repo_root, manual_catalog))
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from device_mapper import (
|
from device_mapper import (
|
||||||
|
MANUAL_SOURCE_FILE,
|
||||||
MARKET_BRAND_ALIASES,
|
MARKET_BRAND_ALIASES,
|
||||||
MARKET_BRAND_TO_MANUFACTURER,
|
MARKET_BRAND_TO_MANUFACTURER,
|
||||||
build_records,
|
build_records,
|
||||||
@@ -28,9 +29,11 @@ def is_cn_source_file(source_file: str) -> bool:
|
|||||||
|
|
||||||
def build_source_order(records: list[object]) -> list[str]:
|
def build_source_order(records: list[object]) -> list[str]:
|
||||||
source_files = sorted({record.source_file for record in records})
|
source_files = sorted({record.source_file for record in records})
|
||||||
|
manual = [source for source in source_files if source == MANUAL_SOURCE_FILE]
|
||||||
|
source_files = [source for source in source_files if source != MANUAL_SOURCE_FILE]
|
||||||
cn = [source for source in source_files if is_cn_source_file(source)]
|
cn = [source for source in source_files if is_cn_source_file(source)]
|
||||||
other = [source for source in source_files if not is_cn_source_file(source)]
|
other = [source for source in source_files if not is_cn_source_file(source)]
|
||||||
return sorted(cn) + sorted(other)
|
return manual + sorted(cn) + sorted(other)
|
||||||
|
|
||||||
|
|
||||||
def build_source_weights(records: list[object]) -> tuple[dict[str, int], dict[str, float]]:
|
def build_source_weights(records: list[object]) -> tuple[dict[str, int], dict[str, float]]:
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ sync_missing_dir_entries() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrate_legacy_manual_catalog() {
|
||||||
|
legacy_dir="$DATA_ROOT/workspace/brands/local"
|
||||||
|
target_dir="$DATA_ROOT/workspace/local"
|
||||||
|
legacy_file="$legacy_dir/manual_catalog.json"
|
||||||
|
target_file="$target_dir/manual_catalog.json"
|
||||||
|
|
||||||
|
[ -d "$legacy_dir" ] || return
|
||||||
|
|
||||||
|
mkdir -p "$target_dir"
|
||||||
|
if [ -f "$legacy_file" ] && [ ! -f "$target_file" ]; then
|
||||||
|
cp -a "$legacy_file" "$target_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Older builds accidentally copied local overlay files under brands/local.
|
||||||
|
# Once the real target exists, drop the misplaced directory to avoid confusion.
|
||||||
|
rm -rf "$legacy_dir"
|
||||||
|
}
|
||||||
|
|
||||||
init_path() {
|
init_path() {
|
||||||
rel_path="$1"
|
rel_path="$1"
|
||||||
src_path="$APP_ROOT/$rel_path"
|
src_path="$APP_ROOT/$rel_path"
|
||||||
@@ -82,3 +100,5 @@ for rel_path in \
|
|||||||
do
|
do
|
||||||
init_path "$rel_path"
|
init_path "$rel_path"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
migrate_legacy_manual_catalog
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from http import HTTPStatus
|
|||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from device_mapper import build_records
|
||||||
from project_layout import PROJECT_ROOT, WORKSPACE_ROOT
|
from project_layout import PROJECT_ROOT, WORKSPACE_ROOT
|
||||||
from sync_upstream_mobilemodels import DEFAULT_BRANCH, DEFAULT_REPO_URL
|
from sync_upstream_mobilemodels import DEFAULT_BRANCH, DEFAULT_REPO_URL
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ INDEX_PATH = PROJECT_ROOT / "dist/device_index.json"
|
|||||||
MYSQL_SEED_PATH = PROJECT_ROOT / "dist/mobilemodels_mysql_seed.sql"
|
MYSQL_SEED_PATH = PROJECT_ROOT / "dist/mobilemodels_mysql_seed.sql"
|
||||||
MYSQL_LOADER = PROJECT_ROOT / "tools/load_mysql_seed.py"
|
MYSQL_LOADER = PROJECT_ROOT / "tools/load_mysql_seed.py"
|
||||||
DATA_ROOT = Path(os.environ.get("MOBILEMODELS_DATA_ROOT", "/data"))
|
DATA_ROOT = Path(os.environ.get("MOBILEMODELS_DATA_ROOT", "/data"))
|
||||||
|
MANUAL_CATALOG_PATH = WORKSPACE_ROOT / "local/manual_catalog.json"
|
||||||
SYNC_METADATA_PATH = DATA_ROOT / "state/sync_status.json"
|
SYNC_METADATA_PATH = DATA_ROOT / "state/sync_status.json"
|
||||||
SCHEDULE_CONFIG_PATH = DATA_ROOT / "state/sync_schedule.json"
|
SCHEDULE_CONFIG_PATH = DATA_ROOT / "state/sync_schedule.json"
|
||||||
MYSQL_CONFIG_PATH = DATA_ROOT / "state/mysql_settings.json"
|
MYSQL_CONFIG_PATH = DATA_ROOT / "state/mysql_settings.json"
|
||||||
@@ -35,6 +37,7 @@ NORMALIZE_RE = re.compile(r"[^0-9a-z\u4e00-\u9fff]+")
|
|||||||
SCHEDULE_TIME_RE = re.compile(r"^(?:[01]?\d|2[0-3]):[0-5]\d$")
|
SCHEDULE_TIME_RE = re.compile(r"^(?:[01]?\d|2[0-3]):[0-5]\d$")
|
||||||
SCHEDULER_POLL_SECONDS = 20
|
SCHEDULER_POLL_SECONDS = 20
|
||||||
INDEX_DEVICE_NAME_ALIAS_MAP: dict[str, list[str]] | None = None
|
INDEX_DEVICE_NAME_ALIAS_MAP: dict[str, list[str]] | None = None
|
||||||
|
DEVICE_TYPES = {"phone", "tablet", "wear", "tv", "computer", "other"}
|
||||||
|
|
||||||
|
|
||||||
def truthy_env(name: str, default: str = "0") -> bool:
|
def truthy_env(name: str, default: str = "0") -> bool:
|
||||||
@@ -303,6 +306,334 @@ def sql_string(value: str) -> str:
|
|||||||
return (value or "").replace("\\", "\\\\").replace("'", "''")
|
return (value or "").replace("\\", "\\\\").replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_alias_list(*groups: object) -> list[str]:
|
||||||
|
aliases: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
if group is None:
|
||||||
|
continue
|
||||||
|
items = group if isinstance(group, list) else [group]
|
||||||
|
for item in items:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
key = normalize_text(text)
|
||||||
|
if not text or not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
aliases.append(text)
|
||||||
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
|
def default_manual_catalog() -> dict[str, object]:
|
||||||
|
return {"brands": [], "devices": []}
|
||||||
|
|
||||||
|
|
||||||
|
def read_manual_catalog() -> dict[str, object]:
|
||||||
|
if not MANUAL_CATALOG_PATH.exists():
|
||||||
|
return default_manual_catalog()
|
||||||
|
try:
|
||||||
|
payload = json.loads(MANUAL_CATALOG_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return default_manual_catalog()
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return default_manual_catalog()
|
||||||
|
brands = payload.get("brands")
|
||||||
|
devices = payload.get("devices")
|
||||||
|
return {
|
||||||
|
"brands": brands if isinstance(brands, list) else [],
|
||||||
|
"devices": devices if isinstance(devices, list) else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_manual_catalog(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
catalog = {
|
||||||
|
"brands": payload.get("brands") if isinstance(payload.get("brands"), list) else [],
|
||||||
|
"devices": payload.get("devices") if isinstance(payload.get("devices"), list) else [],
|
||||||
|
}
|
||||||
|
MANUAL_CATALOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
MANUAL_CATALOG_PATH.write_text(json.dumps(catalog, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_manual_brand(raw: object) -> dict[str, object]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RuntimeError("品牌数据格式无效。")
|
||||||
|
name = str(raw.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise RuntimeError("品牌名称不能为空。")
|
||||||
|
parent_brand = str(raw.get("parent_brand") or name).strip() or name
|
||||||
|
aliases = normalize_alias_list(name, raw.get("aliases"))
|
||||||
|
now = local_now().isoformat(timespec="seconds")
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"parent_brand": parent_brand,
|
||||||
|
"aliases": aliases,
|
||||||
|
"updated_at": str(raw.get("updated_at") or now),
|
||||||
|
"created_at": str(raw.get("created_at") or now),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_manual_device(raw: object, allowed_brands: set[str]) -> dict[str, object]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RuntimeError("设备数据格式无效。")
|
||||||
|
brand = str(raw.get("brand") or "").strip()
|
||||||
|
if not brand:
|
||||||
|
raise RuntimeError("所属品牌不能为空。")
|
||||||
|
if brand not in allowed_brands:
|
||||||
|
raise RuntimeError(f"所属品牌不存在: {brand}")
|
||||||
|
device_name = str(raw.get("device_name") or "").strip()
|
||||||
|
if not device_name:
|
||||||
|
raise RuntimeError("设备名称不能为空。")
|
||||||
|
device_type = str(raw.get("device_type") or "").strip().lower() or "other"
|
||||||
|
if device_type not in DEVICE_TYPES:
|
||||||
|
raise RuntimeError("设备类型无效。")
|
||||||
|
models = normalize_alias_list(raw.get("models"))
|
||||||
|
if not models:
|
||||||
|
raise RuntimeError("设备标识至少保留一个。")
|
||||||
|
aliases = normalize_alias_list(device_name, raw.get("aliases"))
|
||||||
|
section = str(raw.get("section") or "手动补录").strip() or "手动补录"
|
||||||
|
stable_id = f"manual:{normalize_text(brand)}:{normalize_text(device_name)}"
|
||||||
|
now = local_now().isoformat(timespec="seconds")
|
||||||
|
return {
|
||||||
|
"id": str(raw.get("id") or stable_id).strip() or stable_id,
|
||||||
|
"brand": brand,
|
||||||
|
"device_name": device_name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"models": models,
|
||||||
|
"aliases": aliases,
|
||||||
|
"section": section,
|
||||||
|
"updated_at": str(raw.get("updated_at") or now),
|
||||||
|
"created_at": str(raw.get("created_at") or now),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manual_catalog(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
brands_raw = payload.get("brands") if isinstance(payload.get("brands"), list) else []
|
||||||
|
devices_raw = payload.get("devices") if isinstance(payload.get("devices"), list) else []
|
||||||
|
|
||||||
|
brands: list[dict[str, object]] = []
|
||||||
|
seen_brand_keys: set[str] = set()
|
||||||
|
for raw_brand in brands_raw:
|
||||||
|
brand = canonical_manual_brand(raw_brand)
|
||||||
|
brand_key = normalize_text(str(brand["name"]))
|
||||||
|
if brand_key in seen_brand_keys:
|
||||||
|
raise RuntimeError(f"品牌重复: {brand['name']}")
|
||||||
|
seen_brand_keys.add(brand_key)
|
||||||
|
brands.append(brand)
|
||||||
|
|
||||||
|
builtin_brands = load_builtin_brand_names()
|
||||||
|
allowed_brands = builtin_brands | {str(item["name"]) for item in brands}
|
||||||
|
devices: list[dict[str, object]] = []
|
||||||
|
seen_device_ids: set[str] = set()
|
||||||
|
for raw_device in devices_raw:
|
||||||
|
device = canonical_manual_device(raw_device, allowed_brands)
|
||||||
|
if device["id"] in seen_device_ids:
|
||||||
|
raise RuntimeError(f"设备 ID 重复: {device['id']}")
|
||||||
|
seen_device_ids.add(str(device["id"]))
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
return {"brands": brands, "devices": devices}
|
||||||
|
|
||||||
|
|
||||||
|
def load_builtin_brand_names() -> set[str]:
|
||||||
|
records = build_records(WORKSPACE_ROOT)
|
||||||
|
names = {
|
||||||
|
str(record.brand).strip()
|
||||||
|
for record in records
|
||||||
|
if str(record.source_file) != "local/manual_catalog.json"
|
||||||
|
}
|
||||||
|
names.update(
|
||||||
|
str(record.market_brand).strip()
|
||||||
|
for record in records
|
||||||
|
if str(record.source_file) != "local/manual_catalog.json"
|
||||||
|
)
|
||||||
|
names.update(
|
||||||
|
str(record.parent_brand).strip()
|
||||||
|
for record in records
|
||||||
|
if str(record.source_file) != "local/manual_catalog.json"
|
||||||
|
)
|
||||||
|
return {name for name in names if name}
|
||||||
|
|
||||||
|
|
||||||
|
def count_manual_alias_conflicts(device: dict[str, object]) -> int:
|
||||||
|
records = build_records(WORKSPACE_ROOT)
|
||||||
|
alias_set = {
|
||||||
|
normalize_text(alias)
|
||||||
|
for alias in normalize_alias_list(device.get("models"), device.get("aliases"), device.get("device_name"))
|
||||||
|
}
|
||||||
|
conflicts: set[str] = set()
|
||||||
|
for record in records:
|
||||||
|
if str(record.source_file) == "local/manual_catalog.json":
|
||||||
|
continue
|
||||||
|
for alias in getattr(record, "aliases", []):
|
||||||
|
alias_norm = normalize_text(str(alias))
|
||||||
|
if alias_norm and alias_norm in alias_set:
|
||||||
|
conflicts.add(alias_norm)
|
||||||
|
return len(conflicts)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_generated_outputs() -> dict[str, object]:
|
||||||
|
build_proc = run_command(
|
||||||
|
[
|
||||||
|
"python3",
|
||||||
|
str(PROJECT_ROOT / "tools/device_mapper.py"),
|
||||||
|
"--repo-root",
|
||||||
|
str(WORKSPACE_ROOT),
|
||||||
|
"build",
|
||||||
|
"--output",
|
||||||
|
str(INDEX_PATH),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
build_output = "\n".join(part for part in [build_proc.stdout.strip(), build_proc.stderr.strip()] if part).strip()
|
||||||
|
if build_proc.returncode != 0:
|
||||||
|
raise RuntimeError(build_output or "重建设备索引失败。")
|
||||||
|
|
||||||
|
seed_proc = run_command(
|
||||||
|
[
|
||||||
|
"python3",
|
||||||
|
str(PROJECT_ROOT / "tools/export_mysql_seed.py"),
|
||||||
|
"--repo-root",
|
||||||
|
str(WORKSPACE_ROOT),
|
||||||
|
"--output",
|
||||||
|
str(MYSQL_SEED_PATH),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
seed_output = "\n".join(part for part in [seed_proc.stdout.strip(), seed_proc.stderr.strip()] if part).strip()
|
||||||
|
if seed_proc.returncode != 0:
|
||||||
|
raise RuntimeError(seed_output or "导出 MySQL seed 失败。")
|
||||||
|
|
||||||
|
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
|
||||||
|
return {
|
||||||
|
"index_updated": True,
|
||||||
|
"mysql_seed_updated": True,
|
||||||
|
"mysql_loaded": mysql_loaded,
|
||||||
|
"message": "本地覆盖库已保存,索引与 MySQL seed 已刷新。" if mysql_loaded else "本地覆盖库已保存,索引与 MySQL seed 已刷新,MySQL 未自动装载。",
|
||||||
|
"build_output": build_output,
|
||||||
|
"mysql_seed_output": seed_output,
|
||||||
|
"mysql_message": mysql_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def manual_catalog_payload() -> dict[str, object]:
|
||||||
|
catalog = validate_manual_catalog(read_manual_catalog())
|
||||||
|
brands = sorted(catalog["brands"], key=lambda item: str(item["name"]).lower())
|
||||||
|
devices = sorted(catalog["devices"], key=lambda item: (str(item["brand"]).lower(), str(item["device_name"]).lower()))
|
||||||
|
return {
|
||||||
|
"brands": brands,
|
||||||
|
"devices": devices,
|
||||||
|
"stats": {
|
||||||
|
"brand_count": len(brands),
|
||||||
|
"device_count": len(devices),
|
||||||
|
},
|
||||||
|
"catalog_file": str(MANUAL_CATALOG_PATH.relative_to(PROJECT_ROOT)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_manual_brand(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
if not SYNC_LOCK.acquire(blocking=False):
|
||||||
|
raise RuntimeError("已有同步或数据重建任务在执行,请稍后再试。")
|
||||||
|
try:
|
||||||
|
catalog = validate_manual_catalog(read_manual_catalog())
|
||||||
|
incoming = canonical_manual_brand(payload)
|
||||||
|
brand_key = normalize_text(str(incoming["name"]))
|
||||||
|
existing_by_key = {normalize_text(str(item["name"])): item for item in catalog["brands"]}
|
||||||
|
existing = existing_by_key.get(brand_key)
|
||||||
|
if existing:
|
||||||
|
incoming["created_at"] = existing.get("created_at") or incoming["created_at"]
|
||||||
|
catalog["brands"] = [
|
||||||
|
item for item in catalog["brands"] if normalize_text(str(item["name"])) != brand_key
|
||||||
|
]
|
||||||
|
catalog["brands"].append(incoming)
|
||||||
|
validated = validate_manual_catalog(catalog)
|
||||||
|
write_manual_catalog(validated)
|
||||||
|
rebuild_result = rebuild_generated_outputs()
|
||||||
|
return {
|
||||||
|
"saved_brand": incoming,
|
||||||
|
"catalog": manual_catalog_payload(),
|
||||||
|
**rebuild_result,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
SYNC_LOCK.release()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_manual_device(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
if not SYNC_LOCK.acquire(blocking=False):
|
||||||
|
raise RuntimeError("已有同步或数据重建任务在执行,请稍后再试。")
|
||||||
|
try:
|
||||||
|
catalog = validate_manual_catalog(read_manual_catalog())
|
||||||
|
allowed_brands = load_builtin_brand_names() | {str(item["name"]) for item in catalog["brands"]}
|
||||||
|
device_payload = canonical_manual_device(payload, allowed_brands)
|
||||||
|
existing = next((item for item in catalog["devices"] if str(item.get("id")) == str(device_payload["id"])), None)
|
||||||
|
if existing:
|
||||||
|
device_payload["created_at"] = existing.get("created_at") or device_payload["created_at"]
|
||||||
|
catalog["devices"] = [item for item in catalog["devices"] if str(item.get("id")) != str(device_payload["id"])]
|
||||||
|
catalog["devices"].append(device_payload)
|
||||||
|
validated = validate_manual_catalog(catalog)
|
||||||
|
write_manual_catalog(validated)
|
||||||
|
rebuild_result = rebuild_generated_outputs()
|
||||||
|
return {
|
||||||
|
"saved_device": device_payload,
|
||||||
|
"alias_conflict_count": count_manual_alias_conflicts(device_payload),
|
||||||
|
"catalog": manual_catalog_payload(),
|
||||||
|
**rebuild_result,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
SYNC_LOCK.release()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_manual_brand(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
if not SYNC_LOCK.acquire(blocking=False):
|
||||||
|
raise RuntimeError("已有同步或数据重建任务在执行,请稍后再试。")
|
||||||
|
try:
|
||||||
|
brand_name = str(payload.get("name") or "").strip()
|
||||||
|
if not brand_name:
|
||||||
|
raise RuntimeError("品牌名称不能为空。")
|
||||||
|
catalog = validate_manual_catalog(read_manual_catalog())
|
||||||
|
active_devices = [item for item in catalog["devices"] if str(item.get("brand") or "").strip() == brand_name]
|
||||||
|
if active_devices:
|
||||||
|
raise RuntimeError("该品牌下仍有关联设备,请先删除设备后再删除品牌。")
|
||||||
|
next_brands = [item for item in catalog["brands"] if str(item.get("name") or "").strip() != brand_name]
|
||||||
|
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()
|
||||||
|
return {
|
||||||
|
"deleted_brand": brand_name,
|
||||||
|
"catalog": manual_catalog_payload(),
|
||||||
|
**rebuild_result,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
SYNC_LOCK.release()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_manual_device(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
if not SYNC_LOCK.acquire(blocking=False):
|
||||||
|
raise RuntimeError("已有同步或数据重建任务在执行,请稍后再试。")
|
||||||
|
try:
|
||||||
|
device_id = str(payload.get("id") or "").strip()
|
||||||
|
if not device_id:
|
||||||
|
raise RuntimeError("设备 ID 不能为空。")
|
||||||
|
catalog = validate_manual_catalog(read_manual_catalog())
|
||||||
|
next_devices = [item for item in catalog["devices"] if str(item.get("id") or "").strip() != device_id]
|
||||||
|
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()
|
||||||
|
return {
|
||||||
|
"deleted_device": device_id,
|
||||||
|
"catalog": manual_catalog_payload(),
|
||||||
|
**rebuild_result,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
SYNC_LOCK.release()
|
||||||
|
|
||||||
|
|
||||||
def parse_apple_series_generation(name: str) -> dict[str, object] | None:
|
def parse_apple_series_generation(name: str) -> dict[str, object] | None:
|
||||||
text = re.sub(r"\s+", " ", str(name or "").strip())
|
text = re.sub(r"\s+", " ", str(name or "").strip())
|
||||||
if not text:
|
if not text:
|
||||||
@@ -935,6 +1266,14 @@ class MobileModelsHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
return
|
return
|
||||||
|
if self.path == "/api/manual-catalog":
|
||||||
|
try:
|
||||||
|
self._send_json(manual_catalog_payload())
|
||||||
|
except RuntimeError as err:
|
||||||
|
self._send_json({"error": str(err)}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
except Exception as err:
|
||||||
|
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return
|
||||||
return super().do_GET()
|
return super().do_GET()
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
@@ -1032,6 +1371,28 @@ class MobileModelsHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
return
|
return
|
||||||
|
if self.path in {"/api/manual-brand", "/api/manual-device", "/api/manual-brand-delete", "/api/manual-device-delete"}:
|
||||||
|
try:
|
||||||
|
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
raw_body = self.rfile.read(content_length) if content_length > 0 else b"{}"
|
||||||
|
req = json.loads(raw_body.decode("utf-8") or "{}")
|
||||||
|
if not isinstance(req, dict):
|
||||||
|
raise RuntimeError("请求体必须是 JSON 对象。")
|
||||||
|
if self.path == "/api/manual-brand":
|
||||||
|
payload = upsert_manual_brand(req)
|
||||||
|
elif self.path == "/api/manual-device":
|
||||||
|
payload = upsert_manual_device(req)
|
||||||
|
elif self.path == "/api/manual-brand-delete":
|
||||||
|
payload = delete_manual_brand(req)
|
||||||
|
else:
|
||||||
|
payload = delete_manual_device(req)
|
||||||
|
self._send_json(payload)
|
||||||
|
except RuntimeError as err:
|
||||||
|
status = HTTPStatus.CONFLICT if "已有同步或数据重建任务" in str(err) else HTTPStatus.BAD_REQUEST
|
||||||
|
self._send_json({"error": str(err)}, status=status)
|
||||||
|
except Exception as err:
|
||||||
|
self._send_json({"error": str(err)}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
self._send_json({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)
|
self._send_json({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
|||||||
+503
-9
@@ -78,7 +78,7 @@
|
|||||||
padding: 0 16px 32px;
|
padding: 0 16px 32px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 20px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
@@ -129,6 +129,17 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
.btns {
|
.btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -207,6 +218,7 @@
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
min-width: 800px;
|
min-width: 800px;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
th, td {
|
th, td {
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -219,6 +231,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
.tag {
|
.tag {
|
||||||
@@ -229,6 +242,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: #eef3fb;
|
background: #eef3fb;
|
||||||
color: #2d4a7a;
|
color: #2d4a7a;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.section-divider {
|
.section-divider {
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
@@ -306,12 +320,18 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--nav-height) + 20px);
|
top: calc(var(--nav-height) + 20px);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
align-self: start;
|
||||||
}
|
}
|
||||||
.manage-content {
|
.manage-content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: 1px solid #c8d6ee;
|
border: 1px solid #c8d6ee;
|
||||||
background: #f7faff;
|
background: #f7faff;
|
||||||
@@ -327,7 +347,7 @@
|
|||||||
}
|
}
|
||||||
.panel-stack {
|
.panel-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.section-card {
|
.section-card {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -336,6 +356,55 @@
|
|||||||
.section-card .sub:last-child {
|
.section-card .sub:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
.plain-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--sub);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.plain-list li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.action-cell {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.action-cell .btns {
|
||||||
|
margin-top: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.manual-brand-table col:nth-child(1) { width: 160px; }
|
||||||
|
.manual-brand-table col:nth-child(2) { width: 180px; }
|
||||||
|
.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(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-brand-table th,
|
||||||
|
.manual-brand-table td,
|
||||||
|
.manual-device-table th,
|
||||||
|
.manual-device-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.manual-brand-table td,
|
||||||
|
.manual-device-table td {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.manual-brand-table .tag,
|
||||||
|
.manual-device-table .tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.manual-actions-bar {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.manual-actions-bar .btns {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.collapse-card {
|
.collapse-card {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -431,7 +500,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.hidden { display: none; }
|
.hidden { display: none !important; }
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -474,7 +543,7 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: block;
|
||||||
}
|
}
|
||||||
.modal-list {
|
.modal-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -552,6 +621,49 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.modal-form {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
.modal-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.modal-form-grid .full-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.field-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
.modal-form label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.modal-form textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.field-tip {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6a788e;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -566,7 +678,7 @@
|
|||||||
}
|
}
|
||||||
.manage-tabs {
|
.manage-tabs {
|
||||||
position: static;
|
position: static;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -585,6 +697,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.modal-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
padding: calc(var(--nav-height) + 12px) 12px 12px;
|
padding: calc(var(--nav-height) + 12px) 12px 12px;
|
||||||
}
|
}
|
||||||
@@ -608,6 +723,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<aside class="card manage-tabs">
|
<aside class="card manage-tabs">
|
||||||
<button id="tabBrandBtn" type="button" class="tab-btn active">品牌列表</button>
|
<button id="tabBrandBtn" type="button" class="tab-btn active">品牌列表</button>
|
||||||
|
<button id="tabManualBtn" type="button" class="tab-btn">手动补录</button>
|
||||||
<button id="tabSourceBtn" type="button" class="tab-btn">数据来源</button>
|
<button id="tabSourceBtn" type="button" class="tab-btn">数据来源</button>
|
||||||
<button id="tabSyncBtn" type="button" class="tab-btn">原始数据同步</button>
|
<button id="tabSyncBtn" type="button" class="tab-btn">原始数据同步</button>
|
||||||
<button id="tabIndexBtn" type="button" class="tab-btn">索引数据</button>
|
<button id="tabIndexBtn" type="button" class="tab-btn">索引数据</button>
|
||||||
@@ -648,6 +764,92 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="manualTabPanel" class="manage-panel hidden">
|
||||||
|
<div class="panel-stack">
|
||||||
|
<details class="card collapse-card" open>
|
||||||
|
<summary>说明</summary>
|
||||||
|
<div class="collapse-body">
|
||||||
|
<ul class="plain-list">
|
||||||
|
<li>本页维护的是本地覆盖库,适合补录上游暂未收录的品牌和设备。</li>
|
||||||
|
<li>保存后会重建索引和 MySQL seed;是否自动刷新 MySQL 取决于左侧同步配置中的自动装载开关。</li>
|
||||||
|
<li>本地覆盖库不会被“原始数据同步”覆盖,适合补录学习机、教育终端、定制设备。</li>
|
||||||
|
</ul>
|
||||||
|
<div id="manualStatus" class="sub">正在读取本地覆盖库。</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card collapse-card" open>
|
||||||
|
<summary>品牌管理</summary>
|
||||||
|
<div class="collapse-body">
|
||||||
|
<div class="manual-actions-bar">
|
||||||
|
<div class="btns">
|
||||||
|
<button id="addManualBrandBtn" type="button" class="primary">新增品牌</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="manual-brand-table">
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>品牌</th>
|
||||||
|
<th>父级厂商</th>
|
||||||
|
<th>品牌别名</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manualBrandBody">
|
||||||
|
<tr><td colspan="5" class="sub">暂无手动品牌</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card collapse-card" open>
|
||||||
|
<summary>设备管理</summary>
|
||||||
|
<div class="collapse-body">
|
||||||
|
<div class="manual-actions-bar">
|
||||||
|
<div class="btns">
|
||||||
|
<button id="addManualDeviceBtn" type="button" class="primary">新增设备</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="manual-device-table">
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>品牌</th>
|
||||||
|
<th>设备名称</th>
|
||||||
|
<th>设备类型</th>
|
||||||
|
<th>设备标识</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="manualDeviceBody">
|
||||||
|
<tr><td colspan="6" class="sub">暂无手动设备</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="sourceTabPanel" class="manage-panel hidden">
|
<section id="sourceTabPanel" class="manage-panel hidden">
|
||||||
<div class="panel-stack">
|
<div class="panel-stack">
|
||||||
<article class="card section-card">
|
<article class="card section-card">
|
||||||
@@ -763,6 +965,7 @@
|
|||||||
<textarea id="brandModalTextarea" class="hidden"></textarea>
|
<textarea id="brandModalTextarea" class="hidden"></textarea>
|
||||||
<pre id="brandModalPre" class="hidden"></pre>
|
<pre id="brandModalPre" class="hidden"></pre>
|
||||||
<div id="brandModalList" class="modal-list hidden"></div>
|
<div id="brandModalList" class="modal-list hidden"></div>
|
||||||
|
<div id="brandModalForm" class="modal-form hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="brandModalCancelBtn" type="button">关闭</button>
|
<button id="brandModalCancelBtn" type="button">关闭</button>
|
||||||
@@ -778,10 +981,12 @@
|
|||||||
let indexData = null;
|
let indexData = null;
|
||||||
let managedBrandConfig = null;
|
let managedBrandConfig = null;
|
||||||
let managedSourceConfig = null;
|
let managedSourceConfig = null;
|
||||||
|
let manualCatalog = { brands: [], devices: [] };
|
||||||
let managedBrandAliasToBrand = new Map();
|
let managedBrandAliasToBrand = new Map();
|
||||||
let managedBrandToManufacturer = new Map();
|
let managedBrandToManufacturer = new Map();
|
||||||
let managedManufacturerToBrands = new Map();
|
let managedManufacturerToBrands = new Map();
|
||||||
let modalSaveHandler = null;
|
let modalSaveHandler = null;
|
||||||
|
let modalCollectHandler = null;
|
||||||
let draggingSourceIndex = -1;
|
let draggingSourceIndex = -1;
|
||||||
|
|
||||||
const brandStatsEl = document.getElementById("brandStats");
|
const brandStatsEl = document.getElementById("brandStats");
|
||||||
@@ -794,15 +999,18 @@
|
|||||||
const brandModalTextareaEl = document.getElementById("brandModalTextarea");
|
const brandModalTextareaEl = document.getElementById("brandModalTextarea");
|
||||||
const brandModalPreEl = document.getElementById("brandModalPre");
|
const brandModalPreEl = document.getElementById("brandModalPre");
|
||||||
const brandModalListEl = document.getElementById("brandModalList");
|
const brandModalListEl = document.getElementById("brandModalList");
|
||||||
|
const brandModalFormEl = document.getElementById("brandModalForm");
|
||||||
const brandModalSaveBtnEl = document.getElementById("brandModalSaveBtn");
|
const brandModalSaveBtnEl = document.getElementById("brandModalSaveBtn");
|
||||||
const brandModalCancelBtnEl = document.getElementById("brandModalCancelBtn");
|
const brandModalCancelBtnEl = document.getElementById("brandModalCancelBtn");
|
||||||
const sourceOrderStatsEl = document.getElementById("sourceOrderStats");
|
const sourceOrderStatsEl = document.getElementById("sourceOrderStats");
|
||||||
const sourceOrderListEl = document.getElementById("sourceOrderList");
|
const sourceOrderListEl = document.getElementById("sourceOrderList");
|
||||||
const tabBrandBtnEl = document.getElementById("tabBrandBtn");
|
const tabBrandBtnEl = document.getElementById("tabBrandBtn");
|
||||||
|
const tabManualBtnEl = document.getElementById("tabManualBtn");
|
||||||
const tabSourceBtnEl = document.getElementById("tabSourceBtn");
|
const tabSourceBtnEl = document.getElementById("tabSourceBtn");
|
||||||
const tabSyncBtnEl = document.getElementById("tabSyncBtn");
|
const tabSyncBtnEl = document.getElementById("tabSyncBtn");
|
||||||
const tabIndexBtnEl = document.getElementById("tabIndexBtn");
|
const tabIndexBtnEl = document.getElementById("tabIndexBtn");
|
||||||
const brandTabPanelEl = document.getElementById("brandTabPanel");
|
const brandTabPanelEl = document.getElementById("brandTabPanel");
|
||||||
|
const manualTabPanelEl = document.getElementById("manualTabPanel");
|
||||||
const sourceTabPanelEl = document.getElementById("sourceTabPanel");
|
const sourceTabPanelEl = document.getElementById("sourceTabPanel");
|
||||||
const syncTabPanelEl = document.getElementById("syncTabPanel");
|
const syncTabPanelEl = document.getElementById("syncTabPanel");
|
||||||
const indexTabPanelEl = document.getElementById("indexTabPanel");
|
const indexTabPanelEl = document.getElementById("indexTabPanel");
|
||||||
@@ -822,6 +1030,11 @@
|
|||||||
const reloadIndexBtnEl = document.getElementById("reloadIndexBtn");
|
const reloadIndexBtnEl = document.getElementById("reloadIndexBtn");
|
||||||
const indexStatusEl = document.getElementById("indexStatus");
|
const indexStatusEl = document.getElementById("indexStatus");
|
||||||
const indexSummaryEl = document.getElementById("indexSummary");
|
const indexSummaryEl = document.getElementById("indexSummary");
|
||||||
|
const manualStatusEl = document.getElementById("manualStatus");
|
||||||
|
const manualBrandBodyEl = document.getElementById("manualBrandBody");
|
||||||
|
const manualDeviceBodyEl = document.getElementById("manualDeviceBody");
|
||||||
|
const addManualBrandBtnEl = document.getElementById("addManualBrandBtn");
|
||||||
|
const addManualDeviceBtnEl = document.getElementById("addManualDeviceBtn");
|
||||||
|
|
||||||
let syncSupported = false;
|
let syncSupported = false;
|
||||||
let syncRunning = false;
|
let syncRunning = false;
|
||||||
@@ -851,6 +1064,47 @@
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toMultilineText(items) {
|
||||||
|
return (Array.isArray(items) ? items : [])
|
||||||
|
.map((item) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultilineValue(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(text) {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (!raw) return "-";
|
||||||
|
const date = new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return raw.replace("T", " ").replace(/([+-]\d{2}:\d{2}|Z)$/i, "");
|
||||||
|
}
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hour = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const second = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableBrandNames() {
|
||||||
|
const names = new Set();
|
||||||
|
for (const brand of (managedBrandConfig && managedBrandConfig.brands) || []) {
|
||||||
|
if (brand && brand.name) names.add(brand.name);
|
||||||
|
}
|
||||||
|
for (const brand of (manualCatalog && manualCatalog.brands) || []) {
|
||||||
|
if (brand && brand.name) names.add(brand.name);
|
||||||
|
}
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
function updateSyncButtons() {
|
function updateSyncButtons() {
|
||||||
const busy = syncRunning || mysqlInitRunning;
|
const busy = syncRunning || mysqlInitRunning;
|
||||||
syncUpstreamBtnEl.disabled = busy || !syncSupported;
|
syncUpstreamBtnEl.disabled = busy || !syncSupported;
|
||||||
@@ -1480,8 +1734,9 @@
|
|||||||
brandModalTextareaEl.style.height = `${Math.max(420, brandModalTextareaEl.scrollHeight + 24)}px`;
|
brandModalTextareaEl.style.height = `${Math.max(420, brandModalTextareaEl.scrollHeight + 24)}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBrandModal({ title, hint, text, editable, onSave, listItems }) {
|
function openBrandModal({ title, hint, text, editable, onSave, listItems, formHtml, collect }) {
|
||||||
modalSaveHandler = onSave || null;
|
modalSaveHandler = onSave || null;
|
||||||
|
modalCollectHandler = collect || null;
|
||||||
brandModalTitleEl.textContent = title || "数据管理";
|
brandModalTitleEl.textContent = title || "数据管理";
|
||||||
brandModalHintEl.textContent = hint || "";
|
brandModalHintEl.textContent = hint || "";
|
||||||
|
|
||||||
@@ -1490,19 +1745,29 @@
|
|||||||
brandModalTextareaEl.value = text || "";
|
brandModalTextareaEl.value = text || "";
|
||||||
brandModalPreEl.classList.add("hidden");
|
brandModalPreEl.classList.add("hidden");
|
||||||
brandModalListEl.classList.add("hidden");
|
brandModalListEl.classList.add("hidden");
|
||||||
|
brandModalFormEl.classList.add("hidden");
|
||||||
brandModalSaveBtnEl.classList.remove("hidden");
|
brandModalSaveBtnEl.classList.remove("hidden");
|
||||||
requestAnimationFrame(resizeBrandModalTextarea);
|
requestAnimationFrame(resizeBrandModalTextarea);
|
||||||
|
} else if (formHtml) {
|
||||||
|
brandModalFormEl.classList.remove("hidden");
|
||||||
|
brandModalFormEl.innerHTML = formHtml;
|
||||||
|
brandModalPreEl.classList.add("hidden");
|
||||||
|
brandModalListEl.classList.add("hidden");
|
||||||
|
brandModalTextareaEl.classList.add("hidden");
|
||||||
|
brandModalSaveBtnEl.classList.remove("hidden");
|
||||||
} else if (listItems) {
|
} else if (listItems) {
|
||||||
brandModalListEl.classList.remove("hidden");
|
brandModalListEl.classList.remove("hidden");
|
||||||
renderModalList(listItems);
|
renderModalList(listItems);
|
||||||
brandModalPreEl.classList.add("hidden");
|
brandModalPreEl.classList.add("hidden");
|
||||||
brandModalTextareaEl.classList.add("hidden");
|
brandModalTextareaEl.classList.add("hidden");
|
||||||
|
brandModalFormEl.classList.add("hidden");
|
||||||
brandModalSaveBtnEl.classList.add("hidden");
|
brandModalSaveBtnEl.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
brandModalPreEl.classList.remove("hidden");
|
brandModalPreEl.classList.remove("hidden");
|
||||||
brandModalPreEl.textContent = text || "";
|
brandModalPreEl.textContent = text || "";
|
||||||
brandModalTextareaEl.classList.add("hidden");
|
brandModalTextareaEl.classList.add("hidden");
|
||||||
brandModalListEl.classList.add("hidden");
|
brandModalListEl.classList.add("hidden");
|
||||||
|
brandModalFormEl.classList.add("hidden");
|
||||||
brandModalSaveBtnEl.classList.add("hidden");
|
brandModalSaveBtnEl.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,6 +1777,8 @@
|
|||||||
|
|
||||||
function closeBrandModal() {
|
function closeBrandModal() {
|
||||||
modalSaveHandler = null;
|
modalSaveHandler = null;
|
||||||
|
modalCollectHandler = null;
|
||||||
|
brandModalFormEl.innerHTML = "";
|
||||||
brandModalBackdropEl.classList.add("hidden");
|
brandModalBackdropEl.classList.add("hidden");
|
||||||
document.body.classList.remove("modal-open");
|
document.body.classList.remove("modal-open");
|
||||||
}
|
}
|
||||||
@@ -1722,6 +1989,224 @@
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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。`;
|
||||||
|
|
||||||
|
if (!brands.length) {
|
||||||
|
manualBrandBodyEl.innerHTML = `<tr><td colspan="5" class="sub">暂无手动品牌</td></tr>`;
|
||||||
|
} else {
|
||||||
|
manualBrandBodyEl.innerHTML = brands.map((brand) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(brand.name || "-")}</strong></td>
|
||||||
|
<td><span class="tag">${escapeHtml(brand.parent_brand || brand.name || "-")}</span></td>
|
||||||
|
<td>${normalizeAliasList(brand.name, brand.aliases || []).map((alias) => `<span class="tag">${escapeHtml(alias)}</span>`).join("")}</td>
|
||||||
|
<td>${escapeHtml(formatDateTime(brand.updated_at))}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<div class="btns">
|
||||||
|
<button type="button" data-manual-brand-edit="${escapeHtml(brand.name)}">编辑</button>
|
||||||
|
<button type="button" data-manual-brand-delete="${escapeHtml(brand.name)}">删除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!devices.length) {
|
||||||
|
manualDeviceBodyEl.innerHTML = `<tr><td colspan="6" class="sub">暂无手动设备</td></tr>`;
|
||||||
|
} else {
|
||||||
|
manualDeviceBodyEl.innerHTML = devices.map((device) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(device.brand || "-")}</strong></td>
|
||||||
|
<td>${escapeHtml(device.device_name || "-")}</td>
|
||||||
|
<td><span class="tag">${escapeHtml(device.device_type || "-")}</span></td>
|
||||||
|
<td>${(Array.isArray(device.models) ? device.models : []).map((model) => `<span class="tag mono">${escapeHtml(model)}</span>`).join("")}</td>
|
||||||
|
<td>${escapeHtml(formatDateTime(device.updated_at))}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<div class="btns">
|
||||||
|
<button type="button" data-manual-device-edit="${escapeHtml(device.id)}">编辑</button>
|
||||||
|
<button type="button" data-manual-device-delete="${escapeHtml(device.id)}">删除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
manualBrandBodyEl.querySelectorAll("[data-manual-brand-edit]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const brand = brands.find((item) => item.name === button.dataset.manualBrandEdit);
|
||||||
|
if (brand) openManualBrandModal(brand);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
manualBrandBodyEl.querySelectorAll("[data-manual-brand-delete]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const brandName = button.dataset.manualBrandDelete;
|
||||||
|
if (!window.confirm(`确认删除品牌“${brandName}”吗?`)) return;
|
||||||
|
await deleteManualBrand(brandName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
manualDeviceBodyEl.querySelectorAll("[data-manual-device-edit]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const device = devices.find((item) => item.id === button.dataset.manualDeviceEdit);
|
||||||
|
if (device) openManualDeviceModal(device);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
manualDeviceBodyEl.querySelectorAll("[data-manual-device-delete]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const deviceId = button.dataset.manualDeviceDelete;
|
||||||
|
if (!window.confirm("确认删除这条手动设备吗?")) return;
|
||||||
|
await deleteManualDevice(deviceId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManualCatalog() {
|
||||||
|
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 : [],
|
||||||
|
};
|
||||||
|
renderManualCatalog();
|
||||||
|
} catch (err) {
|
||||||
|
manualCatalog = { brands: [], devices: [] };
|
||||||
|
manualStatusEl.textContent = `本地覆盖库读取失败: ${err.message}`;
|
||||||
|
renderManualCatalog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManualBrandModal(brand = null) {
|
||||||
|
const current = brand || { name: "", parent_brand: "", aliases: [] };
|
||||||
|
openBrandModal({
|
||||||
|
title: brand ? "编辑品牌" : "新增品牌",
|
||||||
|
hint: "本地覆盖库中的品牌不会被上游同步覆盖。",
|
||||||
|
formHtml: `
|
||||||
|
<div class="modal-form-grid">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="manualBrandName">品牌名称</label>
|
||||||
|
<input id="manualBrandName" type="text" value="${escapeHtml(current.name || "")}" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="manualParentBrand">父级厂商</label>
|
||||||
|
<input id="manualParentBrand" type="text" value="${escapeHtml(current.parent_brand || current.name || "")}" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group full-row">
|
||||||
|
<label for="manualBrandAliases">品牌别名</label>
|
||||||
|
<textarea id="manualBrandAliases" placeholder="每行一个别名">${escapeHtml(toMultilineText(current.aliases || []))}</textarea>
|
||||||
|
<div class="field-tip">品牌本名会自动补入,无需重复填写。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
collect: () => ({
|
||||||
|
name: document.getElementById("manualBrandName").value,
|
||||||
|
parent_brand: document.getElementById("manualParentBrand").value,
|
||||||
|
aliases: parseMultilineValue(document.getElementById("manualBrandAliases").value),
|
||||||
|
created_at: current.created_at,
|
||||||
|
updated_at: current.updated_at,
|
||||||
|
}),
|
||||||
|
onSave: async (payload) => {
|
||||||
|
const result = await fetchJson("/api/manual-brand", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
manualCatalog = result.catalog || manualCatalog;
|
||||||
|
manualStatusEl.textContent = result.message || "品牌已保存。";
|
||||||
|
renderManualCatalog();
|
||||||
|
await loadIndexFromPath();
|
||||||
|
await loadSyncStatus({ preserveLog: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManualDeviceModal(device = null) {
|
||||||
|
const current = device || { id: "", brand: "", device_name: "", device_type: "tablet", models: [], aliases: [], section: "手动补录" };
|
||||||
|
const brandOptions = availableBrandNames()
|
||||||
|
.map((name) => `<option value="${escapeHtml(name)}" ${name === current.brand ? "selected" : ""}>${escapeHtml(name)}</option>`)
|
||||||
|
.join("");
|
||||||
|
openBrandModal({
|
||||||
|
title: device ? "编辑设备" : "新增设备",
|
||||||
|
hint: "设备标识用于 model_raw 命中,至少保留一个。",
|
||||||
|
formHtml: `
|
||||||
|
<div class="modal-form-grid">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="manualDeviceBrand">所属品牌</label>
|
||||||
|
<select id="manualDeviceBrand">${brandOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="manualDeviceType">设备类型</label>
|
||||||
|
<select id="manualDeviceType">
|
||||||
|
${["phone", "tablet", "wear", "tv", "computer", "other"].map((type) => `<option value="${type}" ${type === current.device_type ? "selected" : ""}>${type}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-group full-row">
|
||||||
|
<label for="manualDeviceName">设备名称</label>
|
||||||
|
<input id="manualDeviceName" type="text" value="${escapeHtml(current.device_name || "")}" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group full-row">
|
||||||
|
<label for="manualDeviceModels">设备标识</label>
|
||||||
|
<textarea id="manualDeviceModels" placeholder="每行一个 model_raw">${escapeHtml(toMultilineText(current.models || []))}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field-group full-row">
|
||||||
|
<label for="manualDeviceAliases">额外别名</label>
|
||||||
|
<textarea id="manualDeviceAliases" placeholder="每行一个额外别名">${escapeHtml(toMultilineText(current.aliases || []))}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
collect: () => ({
|
||||||
|
id: current.id,
|
||||||
|
brand: document.getElementById("manualDeviceBrand").value,
|
||||||
|
device_name: document.getElementById("manualDeviceName").value,
|
||||||
|
device_type: document.getElementById("manualDeviceType").value,
|
||||||
|
models: parseMultilineValue(document.getElementById("manualDeviceModels").value),
|
||||||
|
aliases: parseMultilineValue(document.getElementById("manualDeviceAliases").value),
|
||||||
|
section: "手动补录",
|
||||||
|
created_at: current.created_at,
|
||||||
|
updated_at: current.updated_at,
|
||||||
|
}),
|
||||||
|
onSave: async (payload) => {
|
||||||
|
const result = await fetchJson("/api/manual-device", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
manualCatalog = result.catalog || manualCatalog;
|
||||||
|
manualStatusEl.textContent = `${result.message || "设备已保存。"}${typeof result.alias_conflict_count === "number" ? ` 命中现有别名冲突 ${result.alias_conflict_count} 个。` : ""}`;
|
||||||
|
renderManualCatalog();
|
||||||
|
await loadIndexFromPath();
|
||||||
|
await loadSyncStatus({ preserveLog: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteManualBrand(name) {
|
||||||
|
const result = await fetchJson("/api/manual-brand-delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
manualCatalog = result.catalog || manualCatalog;
|
||||||
|
manualStatusEl.textContent = result.message || `品牌 ${name} 已删除。`;
|
||||||
|
renderManualCatalog();
|
||||||
|
await loadIndexFromPath();
|
||||||
|
await loadSyncStatus({ preserveLog: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteManualDevice(id) {
|
||||||
|
const result = await fetchJson("/api/manual-device-delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
manualCatalog = result.catalog || manualCatalog;
|
||||||
|
manualStatusEl.textContent = result.message || "设备已删除。";
|
||||||
|
renderManualCatalog();
|
||||||
|
await loadIndexFromPath();
|
||||||
|
await loadSyncStatus({ preserveLog: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadIndexFromPath() {
|
async function loadIndexFromPath() {
|
||||||
try {
|
try {
|
||||||
renderIndexStatus("正在加载 dist/device_index.json ...", "加载中...");
|
renderIndexStatus("正在加载 dist/device_index.json ...", "加载中...");
|
||||||
@@ -1733,6 +2218,7 @@
|
|||||||
rebuildManagedBrandIndexes();
|
rebuildManagedBrandIndexes();
|
||||||
renderBrandStats();
|
renderBrandStats();
|
||||||
renderSourceOrder();
|
renderSourceOrder();
|
||||||
|
renderManualCatalog();
|
||||||
const sourceCount = Array.isArray(managedSourceConfig && managedSourceConfig.order)
|
const sourceCount = Array.isArray(managedSourceConfig && managedSourceConfig.order)
|
||||||
? managedSourceConfig.order.length
|
? managedSourceConfig.order.length
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1765,6 +2251,8 @@
|
|||||||
document.getElementById("editBrandListBtn").addEventListener("click", openEditBrandListModal);
|
document.getElementById("editBrandListBtn").addEventListener("click", openEditBrandListModal);
|
||||||
document.getElementById("editBrandRelationsBtn").addEventListener("click", openEditBrandRelationsModal);
|
document.getElementById("editBrandRelationsBtn").addEventListener("click", openEditBrandRelationsModal);
|
||||||
document.getElementById("editBrandAliasesBtn").addEventListener("click", openEditBrandAliasesModal);
|
document.getElementById("editBrandAliasesBtn").addEventListener("click", openEditBrandAliasesModal);
|
||||||
|
addManualBrandBtnEl.addEventListener("click", () => openManualBrandModal());
|
||||||
|
addManualDeviceBtnEl.addEventListener("click", () => openManualDeviceModal());
|
||||||
document.getElementById("saveSourceOrderBtn").addEventListener("click", saveSourceOrder);
|
document.getElementById("saveSourceOrderBtn").addEventListener("click", saveSourceOrder);
|
||||||
document.getElementById("resetSourceOrderBtn").addEventListener("click", resetSourceOrder);
|
document.getElementById("resetSourceOrderBtn").addEventListener("click", resetSourceOrder);
|
||||||
brandCountBtnEl.addEventListener("click", openBrandListModal);
|
brandCountBtnEl.addEventListener("click", openBrandListModal);
|
||||||
@@ -1780,39 +2268,45 @@
|
|||||||
brandModalBackdropEl.addEventListener("click", (e) => {
|
brandModalBackdropEl.addEventListener("click", (e) => {
|
||||||
if (e.target === brandModalBackdropEl) closeBrandModal();
|
if (e.target === brandModalBackdropEl) closeBrandModal();
|
||||||
});
|
});
|
||||||
brandModalSaveBtnEl.addEventListener("click", () => {
|
brandModalSaveBtnEl.addEventListener("click", async () => {
|
||||||
if (!modalSaveHandler) return;
|
if (!modalSaveHandler) return;
|
||||||
try {
|
try {
|
||||||
modalSaveHandler(brandModalTextareaEl.value);
|
const payload = modalCollectHandler ? modalCollectHandler() : brandModalTextareaEl.value;
|
||||||
|
await modalSaveHandler(payload);
|
||||||
closeBrandModal();
|
closeBrandModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
brandStatsEl.textContent = `保存失败: ${err.message}`;
|
manualStatusEl.textContent = `保存失败: ${err.message}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
brandModalTextareaEl.addEventListener("input", resizeBrandModalTextarea);
|
brandModalTextareaEl.addEventListener("input", resizeBrandModalTextarea);
|
||||||
|
|
||||||
function switchManageTab(tab) {
|
function switchManageTab(tab) {
|
||||||
const isBrand = tab === "brand";
|
const isBrand = tab === "brand";
|
||||||
|
const isManual = tab === "manual";
|
||||||
const isSource = tab === "source";
|
const isSource = tab === "source";
|
||||||
const isSync = tab === "sync";
|
const isSync = tab === "sync";
|
||||||
const isIndex = tab === "index";
|
const isIndex = tab === "index";
|
||||||
tabBrandBtnEl.classList.toggle("active", isBrand);
|
tabBrandBtnEl.classList.toggle("active", isBrand);
|
||||||
|
tabManualBtnEl.classList.toggle("active", isManual);
|
||||||
tabSourceBtnEl.classList.toggle("active", isSource);
|
tabSourceBtnEl.classList.toggle("active", isSource);
|
||||||
tabSyncBtnEl.classList.toggle("active", isSync);
|
tabSyncBtnEl.classList.toggle("active", isSync);
|
||||||
tabIndexBtnEl.classList.toggle("active", isIndex);
|
tabIndexBtnEl.classList.toggle("active", isIndex);
|
||||||
brandTabPanelEl.classList.toggle("hidden", !isBrand);
|
brandTabPanelEl.classList.toggle("hidden", !isBrand);
|
||||||
|
manualTabPanelEl.classList.toggle("hidden", !isManual);
|
||||||
sourceTabPanelEl.classList.toggle("hidden", !isSource);
|
sourceTabPanelEl.classList.toggle("hidden", !isSource);
|
||||||
syncTabPanelEl.classList.toggle("hidden", !isSync);
|
syncTabPanelEl.classList.toggle("hidden", !isSync);
|
||||||
indexTabPanelEl.classList.toggle("hidden", !isIndex);
|
indexTabPanelEl.classList.toggle("hidden", !isIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
tabBrandBtnEl.addEventListener("click", () => switchManageTab("brand"));
|
tabBrandBtnEl.addEventListener("click", () => switchManageTab("brand"));
|
||||||
|
tabManualBtnEl.addEventListener("click", () => switchManageTab("manual"));
|
||||||
tabSourceBtnEl.addEventListener("click", () => switchManageTab("source"));
|
tabSourceBtnEl.addEventListener("click", () => switchManageTab("source"));
|
||||||
tabSyncBtnEl.addEventListener("click", () => switchManageTab("sync"));
|
tabSyncBtnEl.addEventListener("click", () => switchManageTab("sync"));
|
||||||
tabIndexBtnEl.addEventListener("click", () => switchManageTab("index"));
|
tabIndexBtnEl.addEventListener("click", () => switchManageTab("index"));
|
||||||
|
|
||||||
switchManageTab("brand");
|
switchManageTab("brand");
|
||||||
loadIndexFromPath();
|
loadIndexFromPath();
|
||||||
|
loadManualCatalog();
|
||||||
loadSyncStatus();
|
loadSyncStatus();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"brands": [
|
||||||
|
{
|
||||||
|
"name": "学而思",
|
||||||
|
"parent_brand": "好未来",
|
||||||
|
"aliases": [
|
||||||
|
"学而思"
|
||||||
|
],
|
||||||
|
"updated_at": "2026-04-14T17:05:28+08:00",
|
||||||
|
"created_at": "2026-04-14T17:05:27+08:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"devices": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user