feat: publish TaurusX order coverage dashboard

This commit is contained in:
王源
2026-10-14 17:12:54 +08:00
commit 90e485e478
4 changed files with 12573 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store
+263
View File
@@ -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()
+11838
View File
File diff suppressed because it is too large Load Diff
+469
View File
@@ -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>