refactor: separate TaurusX performance page

This commit is contained in:
王源
2026-10-15 17:37:42 +08:00
parent 6e8238e5cf
commit 01b4e3ddae
5 changed files with 2235 additions and 4123 deletions
+1 -3929
View File
File diff suppressed because it is too large Load Diff
+35 -32
View File
@@ -7,6 +7,7 @@ import argparse
import hashlib import hashlib
import json import json
import os import os
import re
import tempfile import tempfile
import time import time
import urllib.parse import urllib.parse
@@ -17,6 +18,7 @@ from typing import Any
API_URL = "https://scaler.taurusx.com/openapi/performance_data" API_URL = "https://scaler.taurusx.com/openapi/performance_data"
TIMEZONE = "+8" TIMEZONE = "+8"
PACKAGE_SUFFIX_TAGS = {"audience", "test", "retargeting", "rtg"}
def build_token(secret_key: str, timestamp: str) -> str: def build_token(secret_key: str, timestamp: str) -> str:
@@ -60,48 +62,46 @@ def request_performance(
return rows return rows
def match_pkg(campaign_name: str, packages: list[str]) -> str: def extract_pkg(campaign_name: str, country: str) -> str:
lowered = campaign_name.casefold() """Extract an Android package or iOS app ID from the TaurusX campaign name."""
for package in packages: candidates = re.findall(
if package.casefold() in lowered: r"(?:[A-Za-z][A-Za-z0-9]*\.)+[A-Za-z0-9_]+|id\d{6,}",
return package campaign_name,
return "" flags=re.IGNORECASE,
)
if not candidates:
return ""
package = max(candidates, key=len)
parts = package.split("_")
while len(parts) > 1 and (
parts[-1].upper() == country.upper()
or parts[-1].casefold() in PACKAGE_SUFFIX_TAGS
or parts[-1].isdigit()
):
parts.pop()
return "_".join(parts)
def build_dataset( def build_dataset(
api_rows: list[dict[str, Any]], api_rows: list[dict[str, Any]],
report_payload: dict[str, Any],
start_date: date, start_date: date,
end_date: date, end_date: date,
) -> dict[str, Any]: ) -> dict[str, Any]:
coverage_records = report_payload.get("records") or []
packages = sorted({str(row["pkg"]) for row in coverage_records}, key=len, reverse=True)
coverage_by_key = {
(str(row["pkg"]).casefold(), str(row["country"]).upper()): row
for row in coverage_records
}
records: list[dict[str, Any]] = [] records: list[dict[str, Any]] = []
for api_row in api_rows: for api_row in api_rows:
campaign_name = str(api_row.get("campaign_name") or "").strip() campaign_name = str(api_row.get("campaign_name") or "").strip()
pkg = match_pkg(campaign_name, packages)
country = str(api_row.get("country") or "").strip().upper() country = str(api_row.get("country") or "").strip().upper()
coverage = coverage_by_key.get((pkg.casefold(), country)) if pkg and country else None
records.append( records.append(
{ {
"date": str(api_row.get("date") or ""), "date": str(api_row.get("date") or ""),
"campaign_id": str(api_row.get("campaign_id") or ""), "campaign_id": str(api_row.get("campaign_id") or ""),
"campaign_name": campaign_name, "campaign_name": campaign_name,
"pkg": pkg, "pkg": extract_pkg(campaign_name, country),
"country": country, "country": country,
"impressions": int(float(api_row.get("impressions") or 0)), "impressions": int(float(api_row.get("impressions") or 0)),
"clicks": int(float(api_row.get("clicks") or 0)), "clicks": int(float(api_row.get("clicks") or 0)),
"conversions": int(float(api_row.get("conversions") or 0)), "conversions": int(float(api_row.get("conversions") or 0)),
"cost": round(float(api_row.get("cost") or 0), 4), "cost": round(float(api_row.get("cost") or 0), 4),
"order_match": coverage is not None,
"adpingids": list(coverage.get("adpingids") or []) if coverage else [],
"order_statuses": list(coverage.get("order_statuses") or []) if coverage else [],
"listed_in_taurusx_orders": bool(coverage and coverage.get("target_present")),
} }
) )
@@ -118,8 +118,7 @@ def build_dataset(
"campaigns": len({row["campaign_id"] for row in records}), "campaigns": len({row["campaign_id"] for row in records}),
"pkg_country_count": len({(row["pkg"].casefold(), row["country"]) for row in records if row["pkg"]}), "pkg_country_count": len({(row["pkg"].casefold(), row["country"]) for row in records if row["pkg"]}),
"countries": len({row["country"] for row in records if row["country"]}), "countries": len({row["country"] for row in records if row["country"]}),
"matched_order_rows": sum(row["order_match"] for row in records), "unparsed_pkg_rows": sum(not row["pkg"] for row in records),
"unmatched_order_rows": sum(not row["order_match"] for row in records),
"impressions": sum(row["impressions"] for row in records), "impressions": sum(row["impressions"] for row in records),
"clicks": sum(row["clicks"] for row in records), "clicks": sum(row["clicks"] for row in records),
"conversions": sum(row["conversions"] for row in records), "conversions": sum(row["conversions"] for row in records),
@@ -139,7 +138,12 @@ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-file", type=Path, default=Path(__file__).with_name("data.json")) parser.add_argument(
"--output",
type=Path,
default=Path(__file__).with_name("taurusx_data.json"),
help="Standalone TaurusX dataset output (default: taurusx_data.json next to this script)",
)
parser.add_argument("--days", type=int, default=7, choices=range(1, 8), metavar="1-7") parser.add_argument("--days", type=int, default=7, choices=range(1, 8), metavar="1-7")
parser.add_argument("--end-date", type=date.fromisoformat, help="Last report date; defaults to yesterday in UTC+8") parser.add_argument("--end-date", type=date.fromisoformat, help="Last report date; defaults to yesterday in UTC+8")
args = parser.parse_args() args = parser.parse_args()
@@ -148,20 +152,19 @@ def main() -> None:
secret_key = os.environ.get("TAURUSX_SECRET_KEY", "").strip() secret_key = os.environ.get("TAURUSX_SECRET_KEY", "").strip()
if not access_key or not secret_key: if not access_key or not secret_key:
raise SystemExit("TAURUSX_ACCESS_KEY and TAURUSX_SECRET_KEY environment variables are required") raise SystemExit("TAURUSX_ACCESS_KEY and TAURUSX_SECRET_KEY environment variables are required")
if not args.data_file.is_file(): if not args.output.parent.is_dir():
raise SystemExit(f"Report data file not found: {args.data_file}") raise SystemExit(f"Output directory not found: {args.output.parent}")
utc8_today = datetime.now(timezone(timedelta(hours=8))).date() utc8_today = datetime.now(timezone(timedelta(hours=8))).date()
end_date = args.end_date or (utc8_today - timedelta(days=1)) end_date = args.end_date or (utc8_today - timedelta(days=1))
start_date = end_date - timedelta(days=args.days - 1) start_date = end_date - timedelta(days=args.days - 1)
report_payload = json.loads(args.data_file.read_text(encoding="utf-8"))
api_rows = request_performance(access_key, secret_key, start_date, end_date) api_rows = request_performance(access_key, secret_key, start_date, end_date)
report_payload["taurusx_perf"] = build_dataset(api_rows, report_payload, start_date, end_date) dataset = build_dataset(api_rows, start_date, end_date)
atomic_write_json(args.data_file, report_payload) atomic_write_json(args.output, dataset)
meta = report_payload["taurusx_perf"]["meta"] meta = dataset["meta"]
print( print(
f"Updated {args.data_file}: {meta['rows']} TaurusX rows, " f"Updated {args.output}: {meta['rows']} TaurusX rows, "
f"{meta['matched_order_rows']} matched orders, {meta['start_date']}..{meta['end_date']}" f"{meta['campaigns']} campaigns, {meta['start_date']}..{meta['end_date']}"
) )
+1 -161
View File
@@ -157,7 +157,7 @@
<div class="eyebrow">AgentStage · Order Coverage</div> <div class="eyebrow">AgentStage · Order Coverage</div>
<h1>TaurusX订单</h1> <h1>TaurusX订单</h1>
<p>以 <strong>TaurusxDSP</strong> 为目标平台,按「Pkg + 投放国家」聚合各平台订单,快速定位其他平台已出现、但尚未搬入 TaurusxDSP 的订单组合。</p> <p>以 <strong>TaurusxDSP</strong> 为目标平台,按「Pkg + 投放国家」聚合各平台订单,快速定位其他平台已出现、但尚未搬入 TaurusxDSP 的订单组合。</p>
<div class="hero-meta"><span id="sourceLabel">数据源加载中</span><span id="updatedLabel">更新时间加载中</span></div> <div class="hero-meta"><span id="sourceLabel">数据源加载中</span><span id="updatedLabel">更新时间加载中</span><a href="taurusx.html" style="color:#e8fff6;font-weight:700;text-decoration:none;border-bottom:1px solid rgba(232,255,246,.55);">TaurusX 投放数据 →</a></div>
</header> </header>
<main class="page" id="app"> <main class="page" id="app">
@@ -697,165 +697,6 @@
document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url);
} }
const txState = { filtered: [], page: 1 };
function renderTaurusxPerf() {
const data = state.data.taurusx_perf;
if (!data || !Array.isArray(data.records)) return;
const panel = el('section', 'panel workspace');
panel.id = 'taurusxPerfPanel';
const header = el('div', 'workspace-head');
const head = el('div', 'panel-head');
const title = el('div');
title.append(
el('h2', '', 'TaurusX 近 7 日投放表现'),
el('div', 'panel-desc', `${data.meta.start_date} 至 ${data.meta.end_date} · UTC${data.meta.timezone} · 拉取于 ${formatTime(data.meta.fetched_at)}`)
);
head.append(title);
const summary = el('section', 'kpis');
summary.append(
kpi('Impressions', nf.format(data.meta.impressions), `${nf.format(data.meta.campaigns)} 个 Campaign`),
kpi('Clicks', nf.format(data.meta.clicks), `${nf.format(data.meta.pkg_country_count)} 个 Pkg_国家`),
kpi('Conversions', nf.format(data.meta.conversions), `${nf.format(data.meta.matched_order_rows)} 条已匹配订单`),
kpi('Cost', money(data.meta.cost), `${nf.format(data.meta.unmatched_order_rows)} 条订单未登记`)
);
summary.style.marginBottom = '16px';
const filters = el('div', 'filters');
filters.style.gridTemplateColumns = 'minmax(210px, 1.2fr) repeat(4, minmax(125px, .55fr)) auto';
const search = el('input', 'control');
search.id = 'txSearch'; search.type = 'search'; search.placeholder = '搜索 Campaign、Pkg、Adping ID'; search.autocomplete = 'off';
const reportDate = selectControl('txDate', '全部日期');
[...new Set(data.records.map(row => row.date))].sort().reverse().forEach(value => reportDate.append(new Option(value, value)));
const country = selectControl('txCountry', '全部国家');
[...new Set(data.records.map(row => row.country))].sort().forEach(value => country.append(new Option(value, value)));
const orderMatch = selectControl('txOrderMatch', '');
[['all','全部订单匹配'], ['matched','订单已登记'], ['unmatched','API有数据·订单未登记']]
.forEach(([value,label]) => orderMatch.append(new Option(label, value)));
const sort = selectControl('txSort', '');
[
['date-desc','日期:新到旧'], ['cost-desc','Cost:高到低'],
['impressions-desc','Impressions:高到低'], ['clicks-desc','Clicks:高到低'],
['conversions-desc','Conversions:高到低'], ['pkg-asc','Pkg:A-Z']
].forEach(([value,label]) => sort.append(new Option(`排序 · ${label}`, value)));
const exportButton = el('button', 'button', '导出 TaurusX 数据');
exportButton.type = 'button';
filters.append(search, reportDate, country, orderMatch, sort, exportButton);
header.append(head, summary, filters);
panel.append(header, el('div', 'result-meta'), el('div', 'table-wrap'), el('div', 'pagination'));
document.getElementById('app').insertBefore(panel, document.querySelector('footer'));
search.addEventListener('input', () => { txState.page = 1; applyTxFilters(); });
[reportDate, country, orderMatch, sort].forEach(control => control.addEventListener('change', () => { txState.page = 1; applyTxFilters(); }));
exportButton.addEventListener('click', exportTxCsv);
applyTxFilters();
}
function applyTxFilters() {
const query = document.getElementById('txSearch').value.trim().toLocaleLowerCase();
const reportDate = document.getElementById('txDate').value;
const country = document.getElementById('txCountry').value;
const orderMatch = document.getElementById('txOrderMatch').value;
const sort = document.getElementById('txSort').value;
txState.filtered = state.data.taurusx_perf.records.filter(row => {
const searchable = `${row.campaign_id} ${row.campaign_name} ${row.pkg} ${row.adpingids.join(' ')}`.toLocaleLowerCase();
return (!query || searchable.includes(query))
&& (reportDate === 'all' || row.date === reportDate)
&& (country === 'all' || row.country === country)
&& (orderMatch === 'all' || (orderMatch === 'matched' ? row.order_match : !row.order_match));
});
const [field, direction] = sort.split('-');
const multiplier = direction === 'asc' ? 1 : -1;
txState.filtered.sort((left, right) => {
const a = left[field]; const b = right[field];
if (typeof a === 'string' || typeof b === 'string') {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }) * multiplier
|| left.campaign_id.localeCompare(right.campaign_id, undefined, { numeric: true });
}
return (Number(a) - Number(b)) * multiplier
|| left.campaign_id.localeCompare(right.campaign_id, undefined, { numeric: true });
});
renderTxResults();
}
function renderTxResults() {
const panel = document.getElementById('taurusxPerfPanel');
const totalPages = Math.max(1, Math.ceil(txState.filtered.length / PAGE_SIZE));
txState.page = Math.min(txState.page, totalPages);
const start = (txState.page - 1) * PAGE_SIZE;
const rows = txState.filtered.slice(start, start + PAGE_SIZE);
const impressions = txState.filtered.reduce((sum, row) => sum + row.impressions, 0);
const clicks = txState.filtered.reduce((sum, row) => sum + row.clicks, 0);
const conversions = txState.filtered.reduce((sum, row) => sum + row.conversions, 0);
const cost = txState.filtered.reduce((sum, row) => sum + row.cost, 0);
const cards = panel.querySelectorAll('.kpi');
const setCard = (index, value, sub) => {
cards[index].querySelector('.value').textContent = value;
cards[index].querySelector('.sub').textContent = sub;
};
setCard(0, nf.format(impressions), `${nf.format(txState.filtered.length)} 条筛选明细`);
setCard(1, nf.format(clicks), `CTR ${impressions ? percent(clicks / impressions) : '-'}`);
setCard(2, nf.format(conversions), `CVR ${clicks ? percent(conversions / clicks) : '-'}`);
setCard(3, money(cost), `${nf.format(txState.filtered.filter(row => !row.order_match).length)} 条订单未登记`);
const meta = panel.querySelector('.result-meta');
meta.textContent = '';
meta.append(el('span', '', `筛选结果 ${nf.format(txState.filtered.length)} 条`), el('span', '', `第 ${txState.page} / ${totalPages} 页`));
const wrap = panel.querySelector('.table-wrap');
wrap.textContent = '';
const table = el('table');
const thead = el('thead'); const header = el('tr');
['日期', 'Campaign ID', 'Campaign', 'Pkg', '国家', 'Adpingid', '订单状态', 'Impressions', 'Clicks', 'Conversions', 'Cost', '订单关联']
.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', '没有符合当前条件的 TaurusX 记录'); cell.colSpan = 12;
const row = el('tr'); row.append(cell); tbody.append(row);
}
rows.forEach(record => {
const row = el('tr');
const country = el('td'); country.append(el('span', 'country-pill', record.country || '-'));
const status = el('td');
const statusPills = el('div', 'platforms');
(record.order_statuses.length ? record.order_statuses : ['未登记']).forEach(value => statusPills.append(el('span', 'platform-pill', value)));
status.append(statusPills);
const match = el('td');
match.append(el('span', `badge ${record.order_match ? 'covered' : 'target-only'}`, record.order_match ? '订单已登记' : 'API有数据·订单未登记'));
row.append(
el('td', '', record.date), el('td', 'pkg', record.campaign_id), el('td', 'product', record.campaign_name),
el('td', 'pkg', record.pkg || '-'), country, el('td', 'pkg', record.adpingids.join(' / ') || '-'), status,
el('td', '', nf.format(record.impressions)), el('td', '', nf.format(record.clicks)),
el('td', '', nf.format(record.conversions)), el('td', '', money(record.cost)), match
);
tbody.append(row);
});
table.append(tbody); wrap.append(table);
const pager = panel.querySelector('.pagination');
pager.textContent = '';
const prev = el('button', 'page-button', '‹'); prev.type = 'button'; prev.disabled = txState.page <= 1;
const next = el('button', 'page-button', '›'); next.type = 'button'; next.disabled = txState.page >= totalPages;
prev.addEventListener('click', () => { txState.page -= 1; renderTxResults(); panel.scrollIntoView({ behavior: 'smooth' }); });
next.addEventListener('click', () => { txState.page += 1; renderTxResults(); panel.scrollIntoView({ behavior: 'smooth' }); });
pager.append(prev, el('span', 'panel-desc', `${txState.page} / ${totalPages}`), next);
}
function exportTxCsv() {
const headers = ['日期', 'Campaign ID', 'Campaign', 'Pkg', '国家', 'Adpingid', '订单状态', 'Impressions', 'Clicks', 'Conversions', 'Cost', '订单关联'];
const rows = [headers, ...txState.filtered.map(record => [
record.date, record.campaign_id, record.campaign_name, record.pkg, record.country,
record.adpingids.join(' / '), record.order_statuses.join(' / '), record.impressions,
record.clicks, record.conversions, record.cost, record.order_match ? '订单已登记' : 'API有数据·订单未登记'
])];
const csv = '\ufeff' + rows.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近7日_${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' }) fetch(`data.json?v=${Date.now()}`, { cache: 'no-store' })
.then(response => { .then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -866,7 +707,6 @@
state.data = data; state.data = data;
renderShell(); renderShell();
renderMtg1013(); renderMtg1013();
renderTaurusxPerf();
}) })
.catch(error => { .catch(error => {
const app = document.getElementById('app'); const app = document.getElementById('app');
+340
View File
@@ -0,0 +1,340 @@
<!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;
--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: 30px 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);
}
.hero-nav { position: relative; z-index: 1; display: flex; justify-content: flex-end; margin-bottom: 16px; }
.nav-link {
display: inline-flex; align-items: center; min-height: 36px; padding: 0 13px;
border: 1px solid rgba(255,255,255,.3); border-radius: 9px;
color: #effbf6; background: rgba(255,255,255,.08); font-size: 12px; font-weight: 700; text-decoration: none;
}
.nav-link:hover { background: rgba(255,255,255,.16); }
.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: 18px; color: #bcdacf; font-size: 12px; }
.page { max-width: 1440px; margin: 0 auto; padding: 24px; }
.kpis { display: grid; grid-template-columns: repeat(4, 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; }
.workspace { margin-top: 16px; padding: 0; overflow: hidden; }
.workspace-head { padding: 18px 18px 0; }
.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; }
.filters { display: grid; grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(125px, .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: 320px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; overflow-wrap: anywhere; }
.product { max-width: 260px; 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 { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 999px; white-space: nowrap; font-size: 11px; font-weight: 750; }
.badge.covered { color: var(--green); background: var(--green-soft); }
.badge.unmatched { color: var(--amber); background: var(--amber-soft); }
.status-list { display: flex; flex-wrap: wrap; gap: 5px; min-width: 130px; }
.status-pill { padding: 4px 8px; border-radius: 999px; background: #eff3f1; color: #4d5e57; font-size: 11px; }
.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; }
footer { padding: 22px 0 8px; color: #7b8581; font-size: 11px; text-align: center; }
@media (max-width: 1050px) {
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 650px) {
.hero { padding: 24px 18px; }
.page { padding: 14px; }
.kpis { grid-template-columns: 1fr; gap: 10px; }
.kpi { min-height: 108px; padding: 14px; }
.kpi .value { font-size: 26px; }
.filters { grid-template-columns: 1fr; }
.result-meta { flex-direction: column; }
}
</style>
</head>
<body>
<header class="hero">
<nav class="hero-nav"><a class="nav-link" href="index.html">← 返回首页</a></nav>
<div class="eyebrow">AgentStage · Performance</div>
<h1>TaurusX 投放数据</h1>
<p>查看 TaurusX 近 7 日 Campaign 投放表现,包含 Impressions、Clicks、Conversions 和 Cost。</p>
<div class="hero-meta"><span id="rangeLabel">日期加载中</span><span id="updatedLabel">更新时间加载中</span></div>
</header>
<main class="page" id="app">
<div class="loading">正在读取 TaurusX 投放数据…</div>
</main>
<script>
'use strict';
const PAGE_SIZE = 40;
const state = { data: null, filtered: [], page: 1 };
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 percent(value) { return `${(value * 100).toFixed(1)}%`; }
function money(value) {
return `$${Number(value || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatTime(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
}
function selectControl(id, firstLabel, firstValue = 'all') {
const select = el('select', 'control');
select.id = id;
if (firstLabel) select.append(new Option(firstLabel, firstValue));
return select;
}
function kpi(label, value, sub) {
const card = el('article', 'card kpi');
card.append(el('div', 'label', label), el('div', 'value', value), el('div', 'sub', sub));
return card;
}
function renderShell() {
const { meta, records } = state.data;
document.getElementById('rangeLabel').textContent = `日期:${meta.start_date} 至 ${meta.end_date} · UTC${meta.timezone}`;
document.getElementById('updatedLabel').textContent = `拉取于:${formatTime(meta.fetched_at)}`;
const app = document.getElementById('app');
app.textContent = '';
const summary = el('section', 'kpis');
summary.append(
kpi('Impressions', nf.format(meta.impressions), `${nf.format(meta.campaigns)} 个 Campaign`),
kpi('Clicks', nf.format(meta.clicks), `${nf.format(meta.pkg_country_count)} 个 Pkg_国家`),
kpi('Conversions', nf.format(meta.conversions), `${nf.format(meta.countries)} 个国家`),
kpi('Cost', money(meta.cost), `${nf.format(meta.rows)} 条投放明细`)
);
const panel = el('section', 'panel workspace');
const header = el('div', 'workspace-head');
const heading = el('div', 'panel-head');
const title = el('div');
title.append(el('h2', '', '近 7 日投放明细'), el('div', 'panel-desc', `${nf.format(meta.rows)} 条数据 · 支持筛选、排序与 CSV 导出`));
heading.append(title);
const filters = el('div', 'filters');
filters.style.gridTemplateColumns = 'minmax(220px, 1.2fr) repeat(3, minmax(125px, .55fr)) auto';
const search = el('input', 'control');
search.id = 'search'; search.type = 'search'; search.placeholder = '搜索 Campaign ID、Campaign 或 Pkg'; search.autocomplete = 'off';
const reportDate = selectControl('dateFilter', '全部日期');
[...new Set(records.map(row => row.date))].sort().reverse().forEach(value => reportDate.append(new Option(value, value)));
const country = selectControl('countryFilter', '全部国家');
[...new Set(records.map(row => row.country).filter(Boolean))].sort().forEach(value => country.append(new Option(value, value)));
const sort = selectControl('sortControl', '');
[
['date-desc','日期:新到旧'], ['cost-desc','Cost:高到低'],
['impressions-desc','Impressions:高到低'], ['clicks-desc','Clicks:高到低'],
['conversions-desc','Conversions:高到低'], ['pkg-asc','Pkg:A-Z']
].forEach(([value, label]) => sort.append(new Option(`排序 · ${label}`, value)));
const exportButton = el('button', 'button', '导出 TaurusX 数据');
exportButton.type = 'button'; exportButton.id = 'exportButton';
filters.append(search, reportDate, country, sort, exportButton);
header.append(heading, filters);
panel.append(header, el('div', 'result-meta'), el('div', 'table-wrap'), el('div', 'pagination'));
app.append(summary, panel, el('footer', '', 'TaurusX 投放数据 · Impressions、Clicks、Conversions、Cost'));
search.addEventListener('input', resetAndFilter);
[reportDate, country, sort].forEach(control => control.addEventListener('change', resetAndFilter));
exportButton.addEventListener('click', exportCsv);
applyFilters();
}
function resetAndFilter() {
state.page = 1;
applyFilters();
}
function applyFilters() {
const query = document.getElementById('search').value.trim().toLocaleLowerCase();
const reportDate = document.getElementById('dateFilter').value;
const country = document.getElementById('countryFilter').value;
const sort = document.getElementById('sortControl').value;
state.filtered = state.data.records.filter(row => {
const searchable = `${row.campaign_id} ${row.campaign_name} ${row.pkg}`.toLocaleLowerCase();
return (!query || searchable.includes(query))
&& (reportDate === 'all' || row.date === reportDate)
&& (country === 'all' || row.country === country);
});
const [field, direction] = sort.split('-');
const multiplier = direction === 'asc' ? 1 : -1;
state.filtered.sort((left, right) => {
const a = left[field]; const b = right[field];
if (typeof a === 'string' || typeof b === 'string') {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }) * multiplier
|| String(left.campaign_id).localeCompare(String(right.campaign_id), undefined, { numeric: true });
}
return (Number(a) - Number(b)) * multiplier
|| String(left.campaign_id).localeCompare(String(right.campaign_id), undefined, { numeric: true });
});
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 impressions = state.filtered.reduce((sum, row) => sum + Number(row.impressions || 0), 0);
const clicks = state.filtered.reduce((sum, row) => sum + Number(row.clicks || 0), 0);
const conversions = state.filtered.reduce((sum, row) => sum + Number(row.conversions || 0), 0);
const cost = state.filtered.reduce((sum, row) => sum + Number(row.cost || 0), 0);
const cards = document.querySelectorAll('.kpi');
const setCard = (index, value, sub) => {
cards[index].querySelector('.value').textContent = value;
cards[index].querySelector('.sub').textContent = sub;
};
setCard(0, nf.format(impressions), `${nf.format(state.filtered.length)} 条筛选明细`);
setCard(1, nf.format(clicks), `CTR ${impressions ? percent(clicks / impressions) : '-'}`);
setCard(2, nf.format(conversions), `CVR ${clicks ? percent(conversions / clicks) : '-'}`);
setCard(3, money(cost), `${nf.format(new Set(state.filtered.map(row => row.country)).size)} 个国家`);
const meta = document.querySelector('.result-meta');
meta.textContent = '';
meta.append(el('span', '', `筛选结果 ${nf.format(state.filtered.length)} 条`), el('span', '', `第 ${state.page} / ${totalPages} 页`));
renderTable(rows);
renderPagination(totalPages);
}
function renderTable(rows) {
const wrap = document.querySelector('.table-wrap');
wrap.textContent = '';
const table = el('table');
const thead = el('thead'); const header = el('tr');
['日期', 'Campaign ID', 'Campaign', 'Pkg', '国家', 'Impressions', 'Clicks', 'Conversions', 'Cost']
.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', '没有符合当前条件的 TaurusX 记录'); cell.colSpan = 9;
const row = el('tr'); row.append(cell); tbody.append(row);
}
rows.forEach(record => {
const row = el('tr');
const country = el('td'); country.append(el('span', 'country-pill', record.country || '-'));
row.append(
el('td', '', record.date || '-'),
el('td', 'pkg', record.campaign_id || '-'),
el('td', 'product', record.campaign_name || '-'),
el('td', 'pkg', record.pkg || '-'),
country,
el('td', '', nf.format(record.impressions || 0)),
el('td', '', nf.format(record.clicks || 0)),
el('td', '', nf.format(record.conversions || 0)),
el('td', '', money(record.cost))
);
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 = ['日期', 'Campaign ID', 'Campaign', 'Pkg', '国家', 'Impressions', 'Clicks', 'Conversions', 'Cost'];
const rows = [headers, ...state.filtered.map(record => [
record.date, record.campaign_id, record.campaign_name, record.pkg, record.country,
record.impressions, record.clicks, record.conversions, record.cost
])];
const csv = '\ufeff' + rows.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近7日_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url);
}
fetch(`taurusx_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('taurusx_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', '', `无法读取 taurusx_data.json:${error.message}`));
app.append(box);
});
</script>
</body>
</html>
+1857
View File
File diff suppressed because it is too large Load Diff