feat: publish TaurusX order coverage dashboard
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a privacy-minimized AgentStage dataset from the TaurusX order workbook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
|
||||
DEFAULT_SOURCE = Path(
|
||||
"/Users/wangyuan/Documents/SHAREit/运营/TaurusX搬单/武汉自投投放下单_AM&Media Buy.xlsx"
|
||||
)
|
||||
TARGET_PLATFORM = "TaurusxDSP"
|
||||
REQUIRED_COLUMNS = {"状态", "PromotionAppName", "pkg", "投放平台", "投放国家"}
|
||||
|
||||
PLATFORM_ALIASES = {
|
||||
"mtg": "MTG",
|
||||
"mintegral": "MTG",
|
||||
"bigo": "Bigo",
|
||||
"kwai": "Kwai",
|
||||
"kwa": "Kwai",
|
||||
"lacuna": "Lacuna",
|
||||
"p_api_adsterra": "Adsterra",
|
||||
"adsterra": "Adsterra",
|
||||
"m_api_self_propellerads": "PropellerAds",
|
||||
"properllerads": "PropellerAds",
|
||||
"propellerads": "PropellerAds",
|
||||
"rollerads": "RollerAds",
|
||||
"mediago": "MediaGo",
|
||||
"media go": "MediaGo",
|
||||
"taurusxdsp": TARGET_PLATFORM,
|
||||
"taurusx dsp": TARGET_PLATFORM,
|
||||
"taurusx": TARGET_PLATFORM,
|
||||
"webeye/taurusx": TARGET_PLATFORM,
|
||||
"unity": "Unity Ads",
|
||||
"unity ads": "Unity Ads",
|
||||
"loopme": "LoopMe",
|
||||
"smartyads": "SmartyAds",
|
||||
"tiktok/pangel": "TikTok/Pangle",
|
||||
"tiktok/pangle": "TikTok/Pangle",
|
||||
"pi": "PI",
|
||||
}
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", str(value)).strip()
|
||||
|
||||
|
||||
def normalize_platform(value: Any) -> str:
|
||||
raw = clean_text(value)
|
||||
lowered = raw.casefold()
|
||||
# Account annotations occur in several Kwai spellings.
|
||||
if lowered.startswith("kwai") or lowered.startswith("kwa "):
|
||||
return "Kwai"
|
||||
return PLATFORM_ALIASES.get(lowered, raw)
|
||||
|
||||
|
||||
def normalize_status(value: Any) -> str:
|
||||
raw = clean_text(value)
|
||||
if raw == "投放中":
|
||||
return "投放中"
|
||||
if raw == "暂停":
|
||||
return "暂停"
|
||||
if not raw or raw.casefold() == "none":
|
||||
return "未标注"
|
||||
return "其他/不可投"
|
||||
|
||||
|
||||
def parse_workbook(source: Path) -> tuple[list[dict[str, str]], dict[str, int]]:
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"Workbook not found: {source}")
|
||||
|
||||
# Do not use read_only=True: DingTalk exports have an incorrect A1 dimension.
|
||||
workbook = openpyxl.load_workbook(source, read_only=False, data_only=True)
|
||||
sheet = workbook.active
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
headers = [clean_text(value) for value in next(rows)]
|
||||
missing = REQUIRED_COLUMNS.difference(headers)
|
||||
if missing:
|
||||
raise ValueError(f"Missing required columns: {', '.join(sorted(missing))}")
|
||||
indexes = {name: headers.index(name) for name in REQUIRED_COLUMNS}
|
||||
|
||||
records: list[dict[str, str]] = []
|
||||
counters = Counter(
|
||||
source_rows=0,
|
||||
valid_rows=0,
|
||||
excluded_examples=0,
|
||||
excluded_incomplete=0,
|
||||
excluded_invalid_pkg=0,
|
||||
)
|
||||
for row in rows:
|
||||
counters["source_rows"] += 1
|
||||
status_raw = clean_text(row[indexes["状态"]])
|
||||
if status_raw == "示例":
|
||||
counters["excluded_examples"] += 1
|
||||
continue
|
||||
|
||||
product = clean_text(row[indexes["PromotionAppName"]])
|
||||
pkg = clean_text(row[indexes["pkg"]])
|
||||
country = clean_text(row[indexes["投放国家"]]).upper()
|
||||
platform = normalize_platform(row[indexes["投放平台"]])
|
||||
if not pkg or not country or not platform:
|
||||
counters["excluded_incomplete"] += 1
|
||||
continue
|
||||
if re.match(r"^https?://", pkg, flags=re.IGNORECASE):
|
||||
counters["excluded_invalid_pkg"] += 1
|
||||
continue
|
||||
|
||||
records.append(
|
||||
{
|
||||
"product": product or "未命名产品",
|
||||
"pkg": pkg,
|
||||
"country": country,
|
||||
"platform": platform,
|
||||
"status": normalize_status(status_raw),
|
||||
}
|
||||
)
|
||||
counters["valid_rows"] += 1
|
||||
|
||||
workbook.close()
|
||||
return records, dict(counters)
|
||||
|
||||
|
||||
def aggregate(records: list[dict[str, str]], counters: dict[str, int], source: Path) -> dict[str, Any]:
|
||||
groups: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for row in records:
|
||||
key = (row["pkg"].casefold(), row["country"])
|
||||
group = groups.setdefault(
|
||||
key,
|
||||
{
|
||||
"pkg": row["pkg"],
|
||||
"country": row["country"],
|
||||
"products": set(),
|
||||
"platforms": defaultdict(lambda: {"statuses": Counter(), "order_count": 0}),
|
||||
"order_count": 0,
|
||||
},
|
||||
)
|
||||
group["products"].add(row["product"])
|
||||
detail = group["platforms"][row["platform"]]
|
||||
detail["statuses"][row["status"]] += 1
|
||||
detail["order_count"] += 1
|
||||
group["order_count"] += 1
|
||||
|
||||
output_records: list[dict[str, Any]] = []
|
||||
for group in groups.values():
|
||||
platform_names = sorted(group["platforms"], key=lambda name: (name == TARGET_PLATFORM, name.casefold()))
|
||||
target_present = TARGET_PLATFORM in group["platforms"]
|
||||
other_platforms = [name for name in platform_names if name != TARGET_PLATFORM]
|
||||
platform_details = []
|
||||
for name in platform_names:
|
||||
detail = group["platforms"][name]
|
||||
platform_details.append(
|
||||
{
|
||||
"platform": name,
|
||||
"statuses": dict(sorted(detail["statuses"].items())),
|
||||
"order_count": detail["order_count"],
|
||||
}
|
||||
)
|
||||
output_records.append(
|
||||
{
|
||||
"product": " / ".join(sorted(group["products"])),
|
||||
"pkg": group["pkg"],
|
||||
"country": group["country"],
|
||||
"platforms": platform_names,
|
||||
"other_platforms": other_platforms,
|
||||
"platform_details": platform_details,
|
||||
"target_present": target_present,
|
||||
"missing_target": bool(other_platforms) and not target_present,
|
||||
"order_count": group["order_count"],
|
||||
}
|
||||
)
|
||||
|
||||
output_records.sort(
|
||||
key=lambda row: (
|
||||
not row["missing_target"],
|
||||
row["country"],
|
||||
row["pkg"].casefold(),
|
||||
)
|
||||
)
|
||||
comparison_records = [row for row in output_records if row["other_platforms"]]
|
||||
covered_records = [row for row in comparison_records if row["target_present"]]
|
||||
missing_records = [row for row in comparison_records if row["missing_target"]]
|
||||
|
||||
all_platforms = sorted({row["platform"] for row in records}, key=lambda name: (name == TARGET_PLATFORM, name.casefold()))
|
||||
platform_summary = []
|
||||
for platform in all_platforms:
|
||||
platform_rows = [row for row in output_records if platform in row["platforms"]]
|
||||
platform_summary.append(
|
||||
{
|
||||
"platform": platform,
|
||||
"key_count": len(platform_rows),
|
||||
"order_count": sum(
|
||||
next(detail["order_count"] for detail in row["platform_details"] if detail["platform"] == platform)
|
||||
for row in platform_rows
|
||||
),
|
||||
"target_overlap_count": sum(row["target_present"] for row in platform_rows),
|
||||
}
|
||||
)
|
||||
platform_summary.sort(key=lambda row: (-row["key_count"], row["platform"].casefold()))
|
||||
|
||||
country_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in comparison_records:
|
||||
country_groups[row["country"]].append(row)
|
||||
country_summary = [
|
||||
{
|
||||
"country": country,
|
||||
"comparison_count": len(rows),
|
||||
"covered_count": sum(row["target_present"] for row in rows),
|
||||
"missing_count": sum(row["missing_target"] for row in rows),
|
||||
"coverage_rate": round(sum(row["target_present"] for row in rows) / len(rows), 4),
|
||||
}
|
||||
for country, rows in country_groups.items()
|
||||
]
|
||||
country_summary.sort(key=lambda row: (-row["missing_count"], row["country"]))
|
||||
|
||||
generated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
comparison_count = len(comparison_records)
|
||||
return {
|
||||
"meta": {
|
||||
"title": "TaurusX订单",
|
||||
"source": "文稿 / SHAREit / 运营 / TaurusX搬单 / 武汉自投投放下单_AM&Media Buy.xlsx",
|
||||
"generated_at": generated_at,
|
||||
"target_platform": TARGET_PLATFORM,
|
||||
**counters,
|
||||
"pkg_country_count": len(output_records),
|
||||
"comparison_count": comparison_count,
|
||||
"covered_count": len(covered_records),
|
||||
"missing_count": len(missing_records),
|
||||
"target_only_count": sum(not row["other_platforms"] and row["target_present"] for row in output_records),
|
||||
"coverage_rate": round(len(covered_records) / comparison_count, 4) if comparison_count else 0,
|
||||
},
|
||||
"platform_summary": platform_summary,
|
||||
"country_summary": country_summary,
|
||||
"records": output_records,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE, help="Source XLSX workbook")
|
||||
parser.add_argument("--output", type=Path, default=Path(__file__).with_name("data.json"), help="Output JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
records, counters = parse_workbook(args.source)
|
||||
payload = aggregate(records, counters, args.source)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(
|
||||
f"Wrote {args.output}: {payload['meta']['pkg_country_count']} pkg-country keys, "
|
||||
f"{payload['meta']['missing_count']} missing {TARGET_PLATFORM}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>TaurusX订单</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #15201d;
|
||||
--muted: #6d7773;
|
||||
--line: #dfe6e2;
|
||||
--surface: #ffffff;
|
||||
--canvas: #f3f6f3;
|
||||
--green: #0e7355;
|
||||
--green-soft: #ddf2e9;
|
||||
--amber: #a65c00;
|
||||
--amber-soft: #fff0d5;
|
||||
--red: #b5312d;
|
||||
--red-soft: #fde8e5;
|
||||
--blue: #2b63b7;
|
||||
--shadow: 0 10px 30px rgba(30, 52, 43, .08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--canvas); }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 34px max(24px, calc((100vw - 1440px) / 2));
|
||||
color: #f7fffb;
|
||||
background: linear-gradient(125deg, #12392f 0%, #0d6049 60%, #187a5e 100%);
|
||||
}
|
||||
.hero::after {
|
||||
content: "TX";
|
||||
position: absolute;
|
||||
right: 4%; top: -54px;
|
||||
font: 800 190px/1 Inter, sans-serif;
|
||||
letter-spacing: -18px;
|
||||
color: rgba(255,255,255,.055);
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
.eyebrow { margin-bottom: 9px; color: #aee2cf; font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: clamp(30px, 4vw, 47px); letter-spacing: -.04em; line-height: 1.08; }
|
||||
.hero p { max-width: 760px; margin: 13px 0 0; color: #d2e9e0; line-height: 1.7; }
|
||||
.hero-meta { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 20px; color: #bcdacf; font-size: 12px; }
|
||||
.page { max-width: 1440px; margin: 0 auto; padding: 24px; }
|
||||
.notice {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
margin-bottom: 18px; padding: 13px 15px;
|
||||
border: 1px solid #bdd9cd; border-radius: 12px;
|
||||
background: #edf8f3; color: #285d4c; line-height: 1.55;
|
||||
}
|
||||
.notice b { color: #114f3d; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
|
||||
.card, .panel { border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.kpi { min-height: 126px; padding: 18px; }
|
||||
.kpi .label { color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.kpi .value { margin-top: 10px; font-size: 31px; font-weight: 760; letter-spacing: -.04em; }
|
||||
.kpi .sub { margin-top: 8px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.kpi.danger { border-top: 4px solid var(--red); }
|
||||
.kpi.success { border-top: 4px solid var(--green); }
|
||||
.coverage-ring {
|
||||
--value: 0deg;
|
||||
display: inline-grid; width: 48px; height: 48px; margin-left: 8px;
|
||||
place-items: center; border-radius: 50%; vertical-align: middle;
|
||||
background: conic-gradient(var(--green) var(--value), #e5ebe8 0);
|
||||
}
|
||||
.coverage-ring::after { content: ""; width: 34px; height: 34px; border-radius: 50%; background: white; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(300px, .8fr); gap: 16px; margin-top: 16px; }
|
||||
.panel { padding: 18px; }
|
||||
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 15px; }
|
||||
.panel h2 { margin: 0; font-size: 17px; letter-spacing: -.02em; }
|
||||
.panel-desc { margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||
.platform-bars { display: grid; gap: 11px; }
|
||||
.bar-row { display: grid; grid-template-columns: 118px 1fr 38px; gap: 10px; align-items: center; }
|
||||
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 650; }
|
||||
.bar-track { height: 8px; overflow: hidden; border-radius: 99px; background: #edf1ef; }
|
||||
.bar-fill { height: 100%; min-width: 2px; border-radius: inherit; background: linear-gradient(90deg, #38a27e, #0e7355); }
|
||||
.bar-fill.target { background: linear-gradient(90deg, #e2a642, #bd6d00); }
|
||||
.bar-value { color: var(--muted); font-size: 12px; text-align: right; }
|
||||
.country-list { display: grid; gap: 9px; }
|
||||
.country-row { display: grid; grid-template-columns: 36px 1fr auto; gap: 8px; align-items: center; }
|
||||
.country-code { font-weight: 750; }
|
||||
.country-track { height: 7px; border-radius: 99px; background: #f0f2f1; overflow: hidden; }
|
||||
.country-missing { height: 100%; border-radius: inherit; background: #dc817b; }
|
||||
.country-count { min-width: 35px; color: var(--red); font-size: 12px; text-align: right; }
|
||||
.workspace { margin-top: 16px; padding: 0; overflow: hidden; }
|
||||
.workspace-head { padding: 18px 18px 0; }
|
||||
.tabs { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: #edf1ef; }
|
||||
.tab { padding: 8px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.tab.active { background: white; color: var(--ink); box-shadow: 0 2px 8px rgba(18,57,47,.09); }
|
||||
.filters { display: grid; grid-template-columns: minmax(220px, 1.3fr) repeat(3, minmax(135px, .55fr)) auto; gap: 10px; margin-top: 16px; }
|
||||
.control { width: 100%; height: 40px; border: 1px solid var(--line); border-radius: 9px; outline: none; background: white; color: var(--ink); }
|
||||
input.control { padding: 0 12px; }
|
||||
select.control { padding: 0 30px 0 10px; }
|
||||
.control:focus { border-color: #63a68e; box-shadow: 0 0 0 3px rgba(14,115,85,.1); }
|
||||
.button { height: 40px; padding: 0 14px; border: 1px solid #b8c9c2; border-radius: 9px; background: #f6faf8; color: #185a45; font-weight: 700; }
|
||||
.button:hover { background: #eaf5f0; }
|
||||
.result-meta { display: flex; justify-content: space-between; gap: 12px; padding: 14px 18px 10px; color: var(--muted); font-size: 12px; }
|
||||
.table-wrap { width: 100%; overflow: auto; border-top: 1px solid var(--line); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { position: sticky; top: 0; z-index: 1; padding: 11px 12px; background: #f7f9f8; color: #5e6965; font-size: 11px; font-weight: 750; text-align: left; white-space: nowrap; }
|
||||
td { padding: 12px; border-top: 1px solid #edf0ee; vertical-align: top; }
|
||||
tr:hover td { background: #fbfcfb; }
|
||||
.pkg { max-width: 330px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.product { max-width: 240px; font-weight: 650; }
|
||||
.country-pill { display: inline-block; min-width: 34px; padding: 4px 7px; border-radius: 7px; background: #edf2f7; color: #395065; font-size: 11px; font-weight: 750; text-align: center; }
|
||||
.badge, .platform-pill { display: inline-flex; align-items: center; border-radius: 999px; white-space: nowrap; }
|
||||
.badge { padding: 5px 9px; font-size: 11px; font-weight: 750; }
|
||||
.badge.missing { color: var(--red); background: var(--red-soft); }
|
||||
.badge.covered { color: var(--green); background: var(--green-soft); }
|
||||
.badge.target-only { color: var(--amber); background: var(--amber-soft); }
|
||||
.platforms { display: flex; flex-wrap: wrap; gap: 5px; min-width: 220px; }
|
||||
.platform-pill { padding: 4px 8px; background: #eff3f1; color: #4d5e57; font-size: 11px; }
|
||||
.platform-pill.target { background: var(--green-soft); color: var(--green); font-weight: 750; }
|
||||
.matrix-table th:first-child, .matrix-table td:first-child { position: sticky; left: 0; z-index: 2; background: white; }
|
||||
.matrix-table th:first-child { z-index: 3; background: #f7f9f8; }
|
||||
.matrix-dot { display: block; width: 18px; height: 18px; margin: auto; border-radius: 5px; background: #e9eeeb; }
|
||||
.matrix-dot.yes { background: var(--green); box-shadow: inset 0 0 0 5px #dff1ea; }
|
||||
.matrix-dot.target { background: var(--amber); box-shadow: inset 0 0 0 5px #ffefd2; }
|
||||
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 14px 18px 18px; }
|
||||
.page-button { min-width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 8px; background: white; color: var(--ink); }
|
||||
.page-button:disabled { cursor: default; opacity: .42; }
|
||||
.empty { padding: 46px 20px !important; color: var(--muted); text-align: center; }
|
||||
.loading { padding: 90px 24px; color: var(--muted); text-align: center; }
|
||||
.error { max-width: 740px; margin: 80px auto; padding: 24px; border: 1px solid #e4aaa6; border-radius: 14px; background: #fff4f2; color: #8c2c28; }
|
||||
.hidden { display: none !important; }
|
||||
footer { padding: 22px 0 8px; color: #7b8581; font-size: 11px; text-align: center; }
|
||||
@media (max-width: 1050px) {
|
||||
.kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.split { grid-template-columns: 1fr; }
|
||||
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.hero { padding: 28px 18px; }
|
||||
.page { padding: 14px; }
|
||||
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.kpi { min-height: 112px; padding: 14px; }
|
||||
.kpi .value { font-size: 26px; }
|
||||
.filters { grid-template-columns: 1fr; }
|
||||
.panel-head { flex-direction: column; }
|
||||
.tabs { width: 100%; }
|
||||
.tab { flex: 1; padding-inline: 7px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="eyebrow">AgentStage · Order Coverage</div>
|
||||
<h1>TaurusX订单</h1>
|
||||
<p>以 <strong>TaurusxDSP</strong> 为目标平台,按「Pkg + 投放国家」聚合各平台订单,快速定位其他平台已出现、但尚未搬入 TaurusxDSP 的订单组合。</p>
|
||||
<div class="hero-meta"><span id="sourceLabel">数据源加载中</span><span id="updatedLabel">更新时间加载中</span></div>
|
||||
</header>
|
||||
|
||||
<main class="page" id="app">
|
||||
<div class="loading">正在读取订单覆盖数据…</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
const state = { data: null, filtered: [], page: 1, view: 'detail' };
|
||||
const nf = new Intl.NumberFormat('zh-CN');
|
||||
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
function statusOf(record) {
|
||||
if (record.missing_target) return { key: 'missing', label: '待搬单' };
|
||||
if (record.target_present && !record.other_platforms.length) return { key: 'target-only', label: '仅 TaurusxDSP' };
|
||||
if (record.target_present) return { key: 'covered', label: '已覆盖' };
|
||||
return { key: 'other', label: '其他' };
|
||||
}
|
||||
|
||||
function percent(value) { return `${(value * 100).toFixed(1)}%`; }
|
||||
function formatTime(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
const { meta } = state.data;
|
||||
document.getElementById('sourceLabel').textContent = `数据源:${meta.source}`;
|
||||
document.getElementById('updatedLabel').textContent = `数据生成:${formatTime(meta.generated_at)}`;
|
||||
const app = document.getElementById('app');
|
||||
app.textContent = '';
|
||||
|
||||
const notice = el('div', 'notice');
|
||||
notice.append(el('span', '', '◎'));
|
||||
const noticeText = el('div');
|
||||
const strong = el('b', '', '判断口径:');
|
||||
noticeText.append(strong, document.createTextNode('同一 Pkg + 国家存在至少一个非 TaurusxDSP 平台,且平台集合中没有 TaurusxDSP,即标记为“待搬单”。所有历史订单状态均计入平台出现关系。'));
|
||||
notice.append(noticeText);
|
||||
app.append(notice);
|
||||
|
||||
const kpis = el('section', 'kpis');
|
||||
kpis.append(
|
||||
kpi('待对比 Pkg_国家', nf.format(meta.comparison_count), `共 ${nf.format(meta.pkg_country_count)} 个唯一组合`),
|
||||
kpi('待搬单', nf.format(meta.missing_count), '其他平台已有,TaurusxDSP 未出现', 'danger'),
|
||||
kpi('已覆盖', nf.format(meta.covered_count), '其他平台与 TaurusxDSP 均出现', 'success'),
|
||||
kpi('TaurusxDSP 覆盖率', percent(meta.coverage_rate), `${nf.format(meta.covered_count)} / ${nf.format(meta.comparison_count)}`),
|
||||
kpi('有效源记录', nf.format(meta.valid_rows), `排除示例/缺失/无效 Pkg ${meta.excluded_examples + meta.excluded_incomplete + (meta.excluded_invalid_pkg || 0)} 行`)
|
||||
);
|
||||
app.append(kpis);
|
||||
|
||||
const split = el('section', 'split');
|
||||
split.append(renderPlatformPanel(), renderCountryPanel());
|
||||
app.append(split, renderWorkspace(), el('footer', '', 'TaurusX订单 · 数据仅包含覆盖分析所需字段,不含链接、负责人、价格或 KPI'));
|
||||
populateFilters();
|
||||
bindEvents();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function kpi(label, value, sub, modifier = '') {
|
||||
const card = el('article', `card kpi ${modifier}`.trim());
|
||||
card.append(el('div', 'label', label), el('div', 'value', value), el('div', 'sub', sub));
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderPlatformPanel() {
|
||||
const panel = el('article', 'panel');
|
||||
const head = el('div', 'panel-head');
|
||||
const title = el('div');
|
||||
title.append(el('h2', '', '平台覆盖规模'), el('div', 'panel-desc', '按唯一 Pkg + 国家组合计数,已合并平台名称大小写与账号备注'));
|
||||
head.append(title);
|
||||
panel.append(head);
|
||||
const bars = el('div', 'platform-bars');
|
||||
const rows = state.data.platform_summary.slice(0, 12);
|
||||
const max = Math.max(...rows.map(row => row.key_count), 1);
|
||||
rows.forEach(row => {
|
||||
const line = el('div', 'bar-row');
|
||||
const label = el('div', 'bar-label', row.platform);
|
||||
label.title = row.platform;
|
||||
const track = el('div', 'bar-track');
|
||||
const fill = el('div', `bar-fill ${row.platform === state.data.meta.target_platform ? 'target' : ''}`.trim());
|
||||
fill.style.width = `${Math.max(1.5, row.key_count / max * 100)}%`;
|
||||
track.append(fill);
|
||||
line.append(label, track, el('div', 'bar-value', nf.format(row.key_count)));
|
||||
bars.append(line);
|
||||
});
|
||||
panel.append(bars);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function renderCountryPanel() {
|
||||
const panel = el('article', 'panel');
|
||||
const head = el('div', 'panel-head');
|
||||
const title = el('div');
|
||||
title.append(el('h2', '', '缺口国家 Top 10'), el('div', 'panel-desc', '待搬单组合最多的国家'));
|
||||
head.append(title);
|
||||
panel.append(head);
|
||||
const list = el('div', 'country-list');
|
||||
const rows = state.data.country_summary.slice(0, 10);
|
||||
const max = Math.max(...rows.map(row => row.missing_count), 1);
|
||||
rows.forEach(row => {
|
||||
const line = el('div', 'country-row');
|
||||
const track = el('div', 'country-track');
|
||||
const fill = el('div', 'country-missing');
|
||||
fill.style.width = `${row.missing_count / max * 100}%`;
|
||||
track.append(fill);
|
||||
line.append(el('div', 'country-code', row.country), track, el('div', 'country-count', nf.format(row.missing_count)));
|
||||
list.append(line);
|
||||
});
|
||||
panel.append(list);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function renderWorkspace() {
|
||||
const panel = el('section', 'panel workspace');
|
||||
const header = el('div', 'workspace-head');
|
||||
const head = el('div', 'panel-head');
|
||||
const title = el('div');
|
||||
title.append(el('h2', '', 'Pkg_投放国家覆盖明细'), el('div', 'panel-desc', '默认只展示待搬单记录;可切换到矩阵视图查看平台交叉覆盖'));
|
||||
const tabs = el('div', 'tabs');
|
||||
[['detail','明细'], ['matrix','平台矩阵']].forEach(([key, label]) => {
|
||||
const button = el('button', `tab ${key === state.view ? 'active' : ''}`.trim(), label);
|
||||
button.type = 'button'; button.dataset.view = key;
|
||||
tabs.append(button);
|
||||
});
|
||||
head.append(title, tabs);
|
||||
|
||||
const filters = el('div', 'filters');
|
||||
const search = el('input', 'control');
|
||||
search.id = 'search'; search.type = 'search'; search.placeholder = '搜索 Pkg 或产品名'; search.autocomplete = 'off';
|
||||
filters.append(search, selectControl('countryFilter', '全部国家'), selectControl('platformFilter', '全部平台'), selectControl('statusFilter', ''), el('button', 'button', '导出当前结果'));
|
||||
filters.lastChild.id = 'exportButton'; filters.lastChild.type = 'button';
|
||||
header.append(head, filters);
|
||||
panel.append(header, el('div', 'result-meta'), el('div', 'table-wrap'), el('div', 'pagination'));
|
||||
return panel;
|
||||
}
|
||||
|
||||
function selectControl(id, firstLabel) {
|
||||
const select = el('select', 'control');
|
||||
select.id = id;
|
||||
if (firstLabel) select.append(new Option(firstLabel, 'all'));
|
||||
return select;
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
const country = document.getElementById('countryFilter');
|
||||
[...new Set(state.data.records.map(row => row.country))].sort().forEach(value => country.append(new Option(value, value)));
|
||||
const platform = document.getElementById('platformFilter');
|
||||
state.data.platform_summary.map(row => row.platform).sort((a,b) => a.localeCompare(b)).forEach(value => platform.append(new Option(value, value)));
|
||||
const status = document.getElementById('statusFilter');
|
||||
[['missing','只看待搬单'], ['all','全部覆盖状态'], ['covered','只看已覆盖'], ['target-only','仅 TaurusxDSP']].forEach(([value,label]) => status.append(new Option(label, value)));
|
||||
status.value = 'missing';
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
['search', 'countryFilter', 'platformFilter', 'statusFilter'].forEach(id => {
|
||||
document.getElementById(id).addEventListener(id === 'search' ? 'input' : 'change', () => { state.page = 1; applyFilters(); });
|
||||
});
|
||||
document.getElementById('exportButton').addEventListener('click', exportCsv);
|
||||
document.querySelectorAll('.tab').forEach(button => button.addEventListener('click', () => {
|
||||
state.view = button.dataset.view;
|
||||
document.querySelectorAll('.tab').forEach(node => node.classList.toggle('active', node === button));
|
||||
renderResults();
|
||||
}));
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const query = document.getElementById('search').value.trim().toLocaleLowerCase();
|
||||
const country = document.getElementById('countryFilter').value;
|
||||
const platform = document.getElementById('platformFilter').value;
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
state.filtered = state.data.records.filter(row => {
|
||||
const recordStatus = statusOf(row).key;
|
||||
return (!query || `${row.pkg} ${row.product}`.toLocaleLowerCase().includes(query))
|
||||
&& (country === 'all' || row.country === country)
|
||||
&& (platform === 'all' || row.platforms.includes(platform))
|
||||
&& (status === 'all' || recordStatus === status);
|
||||
});
|
||||
renderResults();
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const totalPages = Math.max(1, Math.ceil(state.filtered.length / PAGE_SIZE));
|
||||
state.page = Math.min(state.page, totalPages);
|
||||
const start = (state.page - 1) * PAGE_SIZE;
|
||||
const rows = state.filtered.slice(start, start + PAGE_SIZE);
|
||||
const meta = document.querySelector('.result-meta');
|
||||
meta.textContent = '';
|
||||
meta.append(el('span', '', `筛选结果 ${nf.format(state.filtered.length)} 条`), el('span', '', `第 ${state.page} / ${totalPages} 页`));
|
||||
if (state.view === 'detail') renderDetailTable(rows); else renderMatrixTable(rows);
|
||||
renderPagination(totalPages);
|
||||
}
|
||||
|
||||
function renderDetailTable(rows) {
|
||||
const wrap = document.querySelector('.table-wrap');
|
||||
wrap.textContent = '';
|
||||
const table = el('table');
|
||||
const thead = el('thead');
|
||||
const header = el('tr');
|
||||
['搬单状态', '产品', 'Pkg', '国家', '已出现平台', '源订单行数'].forEach(label => header.append(el('th', '', label)));
|
||||
thead.append(header); table.append(thead);
|
||||
const tbody = el('tbody');
|
||||
if (!rows.length) {
|
||||
const cell = el('td', 'empty', '没有符合当前条件的记录'); cell.colSpan = 6;
|
||||
const row = el('tr'); row.append(cell); tbody.append(row);
|
||||
}
|
||||
rows.forEach(record => {
|
||||
const row = el('tr');
|
||||
const status = statusOf(record);
|
||||
const badgeCell = el('td'); badgeCell.append(el('span', `badge ${status.key}`, status.label));
|
||||
const product = el('td', 'product', record.product);
|
||||
const pkg = el('td', 'pkg', record.pkg);
|
||||
const country = el('td'); country.append(el('span', 'country-pill', record.country));
|
||||
const platforms = el('td');
|
||||
const pills = el('div', 'platforms');
|
||||
record.platforms.forEach(name => pills.append(el('span', `platform-pill ${name === state.data.meta.target_platform ? 'target' : ''}`.trim(), name)));
|
||||
platforms.append(pills);
|
||||
row.append(badgeCell, product, pkg, country, platforms, el('td', '', nf.format(record.order_count)));
|
||||
tbody.append(row);
|
||||
});
|
||||
table.append(tbody); wrap.append(table);
|
||||
}
|
||||
|
||||
function renderMatrixTable(rows) {
|
||||
const wrap = document.querySelector('.table-wrap');
|
||||
wrap.textContent = '';
|
||||
const platforms = state.data.platform_summary.map(row => row.platform);
|
||||
const table = el('table', 'matrix-table');
|
||||
const thead = el('thead'); const header = el('tr');
|
||||
header.append(el('th', '', 'Pkg_国家'));
|
||||
platforms.forEach(name => header.append(el('th', '', name)));
|
||||
thead.append(header); table.append(thead);
|
||||
const tbody = el('tbody');
|
||||
if (!rows.length) {
|
||||
const cell = el('td', 'empty', '没有符合当前条件的记录'); cell.colSpan = platforms.length + 1;
|
||||
const row = el('tr'); row.append(cell); tbody.append(row);
|
||||
}
|
||||
rows.forEach(record => {
|
||||
const row = el('tr');
|
||||
const key = el('td');
|
||||
key.append(el('div', 'pkg', record.pkg), el('div', 'panel-desc', record.country));
|
||||
row.append(key);
|
||||
platforms.forEach(name => {
|
||||
const cell = el('td');
|
||||
const active = record.platforms.includes(name);
|
||||
const dot = el('span', `matrix-dot ${active ? 'yes' : ''} ${active && name === state.data.meta.target_platform ? 'target' : ''}`.trim());
|
||||
dot.title = active ? `${name}:已出现` : `${name}:未出现`;
|
||||
cell.append(dot); row.append(cell);
|
||||
});
|
||||
tbody.append(row);
|
||||
});
|
||||
table.append(tbody); wrap.append(table);
|
||||
}
|
||||
|
||||
function renderPagination(totalPages) {
|
||||
const box = document.querySelector('.pagination');
|
||||
box.textContent = '';
|
||||
const prev = el('button', 'page-button', '‹'); prev.type = 'button'; prev.disabled = state.page <= 1;
|
||||
const next = el('button', 'page-button', '›'); next.type = 'button'; next.disabled = state.page >= totalPages;
|
||||
prev.addEventListener('click', () => { state.page -= 1; renderResults(); document.querySelector('.workspace').scrollIntoView({ behavior: 'smooth' }); });
|
||||
next.addEventListener('click', () => { state.page += 1; renderResults(); document.querySelector('.workspace').scrollIntoView({ behavior: 'smooth' }); });
|
||||
box.append(prev, el('span', 'panel-desc', `${state.page} / ${totalPages}`), next);
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const headers = ['搬单状态', '产品', 'Pkg', '投放国家', '已出现平台', '是否有TaurusxDSP', '源订单行数'];
|
||||
const csvRows = [headers, ...state.filtered.map(record => [
|
||||
statusOf(record).label,
|
||||
record.product,
|
||||
record.pkg,
|
||||
record.country,
|
||||
record.platforms.join(' / '),
|
||||
record.target_present ? '是' : '否',
|
||||
record.order_count
|
||||
])];
|
||||
const csv = '\ufeff' + csvRows.map(row => row.map(value => `"${String(value).replaceAll('"', '""')}"`).join(',')).join('\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url; link.download = `TaurusX订单_${new Date().toISOString().slice(0,10)}.csv`;
|
||||
document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
fetch(`data.json?v=${Date.now()}`, { cache: 'no-store' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.meta || !Array.isArray(data.records)) throw new Error('data.json 格式不正确');
|
||||
state.data = data;
|
||||
renderShell();
|
||||
})
|
||||
.catch(error => {
|
||||
const app = document.getElementById('app');
|
||||
app.textContent = '';
|
||||
const box = el('div', 'error');
|
||||
box.append(el('h2', '', '数据加载失败'), el('p', '', `无法读取 data.json:${error.message}`));
|
||||
app.append(box);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user