Files
social-app/backend/src/v1/analytics/web/index.html
T

467 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytics Dashboard</title>
<style>
:root {
--bg: #f3f4f6;
--card: #ffffff;
--text: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--primary: #0f766e;
--danger: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #e6fffb 0%, var(--bg) 240px);
min-height: 100vh;
}
.container {
width: min(1100px, 92vw);
margin: 24px auto 40px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
box-shadow: 0 6px 24px rgba(15, 118, 110, 0.08);
}
.grid {
display: grid;
gap: 12px;
}
.stats {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
margin-bottom: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 170px;
}
input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
}
button {
border: 0;
border-radius: 8px;
padding: 9px 14px;
font-size: 14px;
cursor: pointer;
color: #fff;
background: var(--primary);
}
button[disabled] { opacity: 0.6; cursor: not-allowed; }
.btn-ghost {
background: #374151;
}
.value {
margin-top: 8px;
font-size: 26px;
font-weight: 700;
}
.muted { color: var(--muted); }
.danger { color: var(--danger); }
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 14px;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 8px;
text-align: left;
white-space: nowrap;
}
.chart-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
}
.bar {
height: 10px;
background: linear-gradient(90deg, #14b8a6, #0f766e);
border-radius: 999px;
min-width: 2px;
}
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<div id="loginCard" class="card" style="max-width: 420px; margin: 100px auto;">
<h2 style="margin: 0 0 12px;">Analytics 登录</h2>
<p class="muted" style="margin-top: 0;">输入密码进入聚合分析页面</p>
<form id="loginForm">
<div class="field">
<label for="password">密码</label>
<input id="password" type="password" required />
</div>
<div style="margin-top: 12px; display: flex; gap: 10px;">
<button id="loginBtn" type="submit">登录</button>
</div>
<p id="loginError" class="danger" style="margin-bottom: 0;"></p>
</form>
</div>
<div id="dashboard" class="hidden">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h1 style="margin: 0;">Analytics 聚合看板</h1>
<button id="logoutBtn" class="btn-ghost">退出</button>
</div>
<div class="card" style="margin-bottom: 12px;">
<div class="toolbar">
<div class="field">
<label for="startDate">开始日期</label>
<input id="startDate" type="date" />
</div>
<div class="field">
<label for="endDate">结束日期</label>
<input id="endDate" type="date" />
</div>
<button id="loadBtn" type="button">加载数据</button>
</div>
<div id="status" class="muted"></div>
</div>
<div class="grid stats" id="summaryCards"></div>
<div class="card" style="margin-top: 12px;">
<h3 style="margin-top: 0;">按天趋势</h3>
<div id="dailyBars"></div>
</div>
<div class="card" style="margin-top: 12px; overflow-x: auto;">
<h3 style="margin-top: 0;">按天明细</h3>
<table>
<thead>
<tr>
<th>日期</th>
<th>DAU</th>
<th>登录数</th>
<th>对话完成数</th>
<th>平均停留(ms)</th>
</tr>
</thead>
<tbody id="dailyTable"></tbody>
</table>
</div>
</div>
</div>
<script>
const loginCard = document.getElementById("loginCard");
const dashboard = document.getElementById("dashboard");
const loginForm = document.getElementById("loginForm");
const loginBtn = document.getElementById("loginBtn");
const loginError = document.getElementById("loginError");
const logoutBtn = document.getElementById("logoutBtn");
const loadBtn = document.getElementById("loadBtn");
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const statusEl = document.getElementById("status");
const summaryCards = document.getElementById("summaryCards");
const dailyBars = document.getElementById("dailyBars");
const dailyTable = document.getElementById("dailyTable");
const AUTH_KEY = "analytics_logged_in";
const DATA_BASE_URL_KEY = "analytics_data_base_url";
const AUTH_TOKEN_KEY = "analytics_auth_token";
function formatDate(date) {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
const d = String(date.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function dateRange(startDate, endDate) {
const list = [];
const cursor = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
while (cursor <= end) {
list.push(formatDate(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return list;
}
function parseJsonl(text) {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
async function fetchDayEvents(date) {
const dataBaseUrl = sessionStorage.getItem(DATA_BASE_URL_KEY) || "/api/v1/analytics/data";
const token = sessionStorage.getItem(AUTH_TOKEN_KEY);
const res = await fetch(`${dataBaseUrl}/${date}`, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.status === 404) {
return [];
}
if (!res.ok) {
throw new Error(`读取 ${date} 失败: ${res.status}`);
}
return parseJsonl(await res.text());
}
function aggregateDay(events) {
const users = new Set();
let loginCount = 0;
let chatCount = 0;
let staySum = 0;
let stayCnt = 0;
for (const event of events) {
if (event.user_id) users.add(event.user_id);
if (event.event_type === "session.login") loginCount += 1;
if (event.event_type === "agent.chat_completed") chatCount += 1;
if (event.event_type === "page.view") {
const stay = event.metrics && event.metrics.stay_duration_ms;
if (typeof stay === "number") {
staySum += stay;
stayCnt += 1;
}
}
}
return {
dau: users.size,
loginCount,
chatCount,
avgStay: stayCnt ? staySum / stayCnt : 0,
};
}
function renderSummary(rows) {
const allUsers = new Set();
let totalLogins = 0;
let totalChats = 0;
let staySum = 0;
let stayCnt = 0;
rows.forEach((row) => {
row.users.forEach((u) => allUsers.add(u));
totalLogins += row.loginCount;
totalChats += row.chatCount;
staySum += row.staySum;
stayCnt += row.stayCnt;
});
const cards = [
{ label: "DAU(区间去重)", value: allUsers.size },
{ label: "总登录次数", value: totalLogins },
{ label: "总对话完成数", value: totalChats },
{ label: "平均停留(ms)", value: Math.round(stayCnt ? staySum / stayCnt : 0) },
];
summaryCards.innerHTML = cards
.map((card) => `<div class="card"><div class="muted">${card.label}</div><div class="value">${card.value}</div></div>`)
.join("");
}
function renderDaily(rows) {
const maxLogin = Math.max(1, ...rows.map((r) => r.loginCount));
dailyBars.innerHTML = rows
.map((r) => {
const width = Math.max(2, Math.round((r.loginCount / maxLogin) * 100));
return `<div class="chart-row"><div style="width:92px">${r.date}</div><div class="bar" style="width:${width}%"></div><div class="muted">登录 ${r.loginCount}</div></div>`;
})
.join("");
dailyTable.innerHTML = rows
.map(
(r) => `<tr>
<td>${r.date}</td>
<td>${r.dau}</td>
<td>${r.loginCount}</td>
<td>${r.chatCount}</td>
<td>${Math.round(r.avgStay)}</td>
</tr>`,
)
.join("");
}
async function loadData() {
const startDate = startDateInput.value;
const endDate = endDateInput.value;
if (!startDate || !endDate || startDate > endDate) {
statusEl.textContent = "请选择有效日期区间";
return;
}
loadBtn.disabled = true;
statusEl.textContent = "正在读取并聚合数据...";
try {
const dates = dateRange(startDate, endDate);
const rows = [];
for (const date of dates) {
const events = await fetchDayEvents(date);
const users = new Set();
let loginCount = 0;
let chatCount = 0;
let staySum = 0;
let stayCnt = 0;
for (const event of events) {
if (event.user_id) users.add(event.user_id);
if (event.event_type === "session.login") loginCount += 1;
if (event.event_type === "agent.chat_completed") chatCount += 1;
if (event.event_type === "page.view") {
const stay = event.metrics && event.metrics.stay_duration_ms;
if (typeof stay === "number") {
staySum += stay;
stayCnt += 1;
}
}
}
rows.push({
date,
users,
dau: users.size,
loginCount,
chatCount,
staySum,
stayCnt,
avgStay: stayCnt ? staySum / stayCnt : 0,
});
}
renderSummary(rows);
renderDaily(rows);
statusEl.textContent = `加载完成,共 ${rows.length}`;
} catch (err) {
statusEl.textContent = err.message || "加载失败";
} finally {
loadBtn.disabled = false;
}
}
async function login(password) {
const res = await fetch("/api/v1/analytics/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
throw new Error("密码错误");
}
const payload = await res.json();
const dataBaseUrl = typeof payload.data_base_url === "string" && payload.data_base_url
? payload.data_base_url
: "/api/v1/analytics/data";
const token = typeof payload.token === "string" ? payload.token : "";
if (!token) {
throw new Error("登录响应缺少 token");
}
sessionStorage.setItem(AUTH_KEY, "1");
sessionStorage.setItem(DATA_BASE_URL_KEY, dataBaseUrl);
sessionStorage.setItem(AUTH_TOKEN_KEY, token);
}
function enterDashboard() {
loginCard.classList.add("hidden");
dashboard.classList.remove("hidden");
}
function exitDashboard() {
sessionStorage.removeItem(AUTH_KEY);
sessionStorage.removeItem(DATA_BASE_URL_KEY);
sessionStorage.removeItem(AUTH_TOKEN_KEY);
dashboard.classList.add("hidden");
loginCard.classList.remove("hidden");
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
loginError.textContent = "";
loginBtn.disabled = true;
try {
const password = document.getElementById("password").value;
await login(password);
enterDashboard();
await loadData();
} catch (err) {
loginError.textContent = err.message || "登录失败";
} finally {
loginBtn.disabled = false;
}
});
loadBtn.addEventListener("click", loadData);
logoutBtn.addEventListener("click", exitDashboard);
(function init() {
const today = new Date();
const start = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
startDateInput.value = formatDate(start);
endDateInput.value = formatDate(today);
if (sessionStorage.getItem(AUTH_KEY) === "1") {
if (!sessionStorage.getItem(AUTH_TOKEN_KEY)) {
exitDashboard();
return;
}
enterDashboard();
loadData();
}
})();
</script>
</body>
</html>