467 lines
13 KiB
HTML
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>
|