Files
gaokao-volunteer-system/admin/static/dashboard.js
Hermes Agent 0ec5bdfc19
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
fix(dashboard): 从URL参数自动读取token并加载数据
修复管理后台Web登录后dashboard仍提示缺JWT的问题:
- dashboard.js bootstrap() 新增 URL ?t= 参数读取逻辑
- 自动将token填入#token input并触发loadDashboard
- 登录后自动跳转→自动认证→自动加载数据,用户无感知

验证: 浏览器登录admin/Admin123! → dashboard自动加载'已连接·数据已加载'
2026-06-29 12:40:11 +08:00

484 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const TOKEN_KEY = "gaokao_admin_dashboard_token";
const state = {
charts: {},
payload: null,
range: "7d",
loading: false,
titleTapCount: 0,
titleTapTimer: null,
};
function resolveApiBase() {
const raw = document.getElementById("api-base").value.trim();
return raw || window.location.origin;
}
function formatMoney(cents) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
function setMessage(text, tone) {
const el = document.getElementById("message");
el.textContent = text;
el.className = "message" + (tone ? " " + tone : "");
}
function setError(text) {
const el = document.getElementById("error");
el.textContent = text || "";
}
function setStatus(state_, title, meta) {
const dot = document.getElementById("status-dot");
const titleEl = document.getElementById("status-title");
const metaEl = document.getElementById("status-meta");
const wrap = document.getElementById("system-status");
dot.className = "status-dot " + state_;
titleEl.textContent = title;
metaEl.textContent = meta;
wrap.className = "system-status " + state_;
}
function markCardsLoading() {
document.querySelectorAll("[data-card]").forEach((card) => {
card.classList.remove("empty");
card.classList.add("loading");
});
}
function markCardsEmpty() {
document.querySelectorAll("[data-card]").forEach((card) => {
card.classList.remove("loading");
card.classList.add("empty");
});
}
function setChartEmpty(chartId, label) {
const node = document.getElementById(chartId);
if (!node) return;
node.innerHTML = `<div class="chart-empty"><span class="label">${label || "等待加载数据"}</span></div>`;
delete state.charts[chartId];
}
function getToken() {
return document.getElementById("token").value.trim();
}
function setToken(token) {
document.getElementById("token").value = token || "";
if (token) {
window.sessionStorage.setItem(TOKEN_KEY, token);
} else {
window.sessionStorage.removeItem(TOKEN_KEY);
}
}
async function loginAndStoreToken() {
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
if (!username || !password) {
throw new Error("请输入用户名和密码,或直接粘贴 Bearer Token。");
}
const resp = await fetch(`${resolveApiBase()}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!resp.ok) {
throw new Error(`登录失败: HTTP ${resp.status}`);
}
const body = await resp.json();
if (!body.access_token) {
throw new Error("登录响应缺少 access_token");
}
setToken(body.access_token);
return body.access_token;
}
function renderSummary(summary) {
const totalOrders = Number(summary.total_orders ?? 0);
const totalRevenue = Number(summary.total_revenue_cents ?? 0);
const totalUsers = Number(summary.total_users ?? 0);
const ordersToday = Number(summary.orders_today ?? 0);
const orders7d = Number(summary.orders_7d ?? 0);
const orders30d = Number(summary.orders_30d ?? 0);
document.getElementById("total-orders").textContent = String(totalOrders);
document.getElementById("total-revenue").textContent =
formatMoney(totalRevenue);
document.getElementById("total-users").textContent = String(totalUsers);
document.getElementById("orders-subtitle").textContent =
`今日 ${ordersToday} / 7d ${orders7d} / 30d ${orders30d}`;
document.getElementById("revenue-subtitle").textContent =
`今日 ${formatMoney(summary.revenue_today_cents)} / 7d ${formatMoney(summary.revenue_7d_cents)} / 30d ${formatMoney(summary.revenue_30d_cents)}`;
const pending = Number(summary.pending_orders ?? 0);
const pendingOverdue = Number(summary.pending_overdue_24h ?? 0);
const pendingMissing = Number(summary.pending_missing_intake ?? 0);
document.getElementById("pending-orders").textContent = String(pending);
// 待处理多口径: 至少有一个非零才展开
const breakdown = document.getElementById("pending-breakdown");
const overdueTag = document.getElementById("pending-overdue-tag");
const missingTag = document.getElementById("pending-missing-tag");
const hasAny = pendingOverdue > 0 || pendingMissing > 0;
if (breakdown) breakdown.hidden = !hasAny;
if (overdueTag) {
overdueTag.hidden = pendingOverdue <= 0;
overdueTag.classList.toggle("pending-tag-zero", pendingOverdue <= 0);
const overdueEl = document.getElementById("pending-overdue-24h");
if (overdueEl) overdueEl.textContent = String(pendingOverdue);
}
if (missingTag) {
missingTag.hidden = pendingMissing <= 0;
missingTag.classList.toggle("pending-tag-zero", pendingMissing <= 0);
const missingEl = document.getElementById("pending-missing-intake");
if (missingEl) missingEl.textContent = String(pendingMissing);
}
}
function clearCardEmptyStates() {
document.querySelectorAll("[data-card]").forEach((card) => {
card.classList.remove("empty");
card.classList.remove("loading");
});
}
function ensureChart(id) {
const host = document.getElementById(id);
if (!host) return null;
if (!state.charts[id]) {
state.charts[id] = echarts.init(host);
}
return state.charts[id];
}
function renderTrend(trends) {
const points = Array.isArray(trends?.[state.range])
? trends?.[state.range]
: [];
if (points.length === 0) {
setChartEmpty(
"trend-chart",
state.range === "30d" ? "近 30 天暂无订单活动" : "近 7 天暂无订单活动",
);
document.getElementById("trend-title").textContent =
state.range === "30d" ? "30 天趋势" : "7 天趋势";
return;
}
const chart = ensureChart("trend-chart");
document.getElementById("trend-title").textContent =
state.range === "30d" ? "30 天趋势" : "7 天趋势";
chart.setOption({
tooltip: { trigger: "axis" },
legend: { data: ["订单数", "收入(元)"] },
grid: { left: 48, right: 48, top: 48, bottom: 32 },
xAxis: {
type: "category",
boundaryGap: false,
data: points.map((item) => item.date),
},
yAxis: [
{ type: "value", name: "订单数" },
{ type: "value", name: "收入(元)" },
],
series: [
{
name: "订单数",
type: "line",
smooth: true,
data: points.map((item) => item.orders ?? 0),
},
{
name: "收入(元)",
type: "line",
smooth: true,
yAxisIndex: 1,
data: points.map((item) => Number(item.revenue_cents ?? 0) / 100),
},
],
});
}
function renderBarChart(chartId, emptyLabel, dataMap, color) {
const entries = Object.entries(dataMap || {});
if (
entries.length === 0 ||
entries.every(([, value]) => Number(value || 0) === 0)
) {
setChartEmpty(chartId, emptyLabel);
return;
}
const chart = ensureChart(chartId);
chart.setOption({
tooltip: { trigger: "axis" },
grid: { left: 48, right: 24, top: 32, bottom: 48 },
xAxis: {
type: "category",
axisLabel: { interval: 0, rotate: entries.length > 4 ? 20 : 0 },
data: entries.map(([label]) => label),
},
yAxis: { type: "value", minInterval: 1 },
series: [
{
name: chartId,
type: "bar",
data: entries.map(([, value]) => value ?? 0),
itemStyle: { color },
barMaxWidth: 48,
},
],
});
}
function renderDistributions(payload) {
renderBarChart(
"status-chart",
"暂无订单状态分布",
payload.by_status,
"#2563eb",
);
renderBarChart("source-chart", "暂无来源分布", payload.by_source, "#7c3aed");
renderBarChart(
"service-chart",
"暂无服务版本分布",
payload.by_service_version,
"#059669",
);
}
function updateRangeButtons() {
const isThirty = state.range === "30d";
document.getElementById("range-7d").className = isThirty
? "range-btn secondary"
: "range-btn active-range";
document.getElementById("range-30d").className = isThirty
? "range-btn active-range"
: "range-btn secondary";
}
async function fetchDashboard(token) {
const resp = await fetch(`${resolveApiBase()}/api/stats/dashboard`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!resp.ok) {
throw new Error(`数据加载失败HTTP ${resp.status}`);
}
return resp.json();
}
function setDevSeedPanelVisible(visible) {
const panel = document.getElementById("dev-seed-panel");
if (!panel) return;
panel.classList.toggle("hidden", !visible);
}
async function postDevSeed(scenario) {
const token = getToken() || window.sessionStorage.getItem(TOKEN_KEY) || "";
if (!token) {
throw new Error("请先登录后台,再使用隐藏测试造数工具。");
}
const resp = await fetch(`${resolveApiBase()}/api/admin/orders/dev-seed`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ scenario }),
});
const body = await resp.json();
if (!resp.ok) {
throw new Error(
body?.message || body?.detail?.reason || `造数失败HTTP ${resp.status}`,
);
}
return body;
}
function renderDashboard(payload) {
state.payload = payload;
clearCardEmptyStates();
renderSummary(payload.summary || {});
renderTrend(payload.trends || {});
renderDistributions(payload);
updateRangeButtons();
const generatedAt = payload.generated_at || "刚刚";
document.getElementById("generated-at-hint")?.remove();
setStatus(
"online",
"已连接 · 数据已加载",
`更新时间:${generatedAt} · 数据范围:${state.range === "30d" ? "30 天" : "7 天"}`,
);
}
async function loadDashboard({ loginFirst = false } = {}) {
if (state.loading) return;
state.loading = true;
setError("");
setMessage(loginFirst ? "登录中..." : "加载中...", "loading");
setStatus(
"loading",
"正在连接...",
loginFirst ? "正在登录并拉取数据" : "正在拉取最新数据",
);
markCardsLoading();
try {
let token = getToken();
if (!token && loginFirst) {
token = await loginAndStoreToken();
setMessage("登录成功,开始加载仪表盘...", "success");
}
if (!token) {
throw new Error("缺少 JWT请先登录或粘贴 Bearer Token。");
}
const payload = await fetchDashboard(token);
renderDashboard(payload);
setMessage("仪表盘加载成功", "success");
} catch (error) {
setError(error.message || String(error));
setMessage("加载失败,请检查认证或服务状态。", "error");
setStatus("error", "连接失败", error.message || "请检查登录状态或刷新");
markCardsEmpty();
setChartEmpty("trend-chart", "登录后展示订单与收入趋势");
setChartEmpty("status-chart", "暂无订单状态分布");
setChartEmpty("source-chart", "暂无来源分布");
setChartEmpty("service-chart", "暂无服务版本分布");
} finally {
state.loading = false;
}
}
function bindRangeButton(id, range) {
document.getElementById(id).addEventListener("click", () => {
state.range = range;
updateRangeButtons();
if (state.payload) {
renderTrend(state.payload.trends || {});
}
});
}
function logout() {
setToken("");
state.payload = null;
Object.keys(state.charts).forEach((id) => setChartEmpty(id, "等待加载数据"));
markCardsEmpty();
setStatus("loading", "尚未连接", "登录后查看核心经营指标");
setMessage("已退出登录,请重新登录。", "");
}
function bootstrap() {
// 优先从 URL 参数 t 读取 token管理后台 Web 登录页跳转场景)
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get("t");
if (urlToken) {
setToken(urlToken);
window.sessionStorage.setItem(TOKEN_KEY, urlToken);
// 清除 URL 中的 token 参数(安全考虑)
urlParams.delete("t");
const cleanUrl =
window.location.pathname +
(urlParams.toString() ? "?" + urlParams.toString() : "");
window.history.replaceState({}, document.title, cleanUrl);
// 自动加载数据
setTimeout(() => loadDashboard({ loginFirst: false }), 100);
return;
}
const cachedToken = window.sessionStorage.getItem(TOKEN_KEY);
if (cachedToken && !getToken()) {
setToken(cachedToken);
}
const params = new URLSearchParams(window.location.search);
if (params.get("seed-tools") === "1") {
setDevSeedPanelVisible(true);
}
window.addEventListener("resize", () => {
Object.values(state.charts).forEach((chart) => chart && chart.resize());
});
document.getElementById("login-btn").addEventListener("click", () => {
loadDashboard({ loginFirst: true });
});
document.getElementById("refresh-btn").addEventListener("click", () => {
loadDashboard({ loginFirst: false });
});
document.getElementById("quick-refresh-btn").addEventListener("click", () => {
loadDashboard({ loginFirst: false });
});
document.getElementById("quick-logout-btn").addEventListener("click", logout);
bindRangeButton("range-7d", "7d");
bindRangeButton("range-30d", "30d");
const title = document.getElementById("dashboard-title");
title?.addEventListener("click", () => {
state.titleTapCount += 1;
if (state.titleTapTimer) {
window.clearTimeout(state.titleTapTimer);
}
state.titleTapTimer = window.setTimeout(() => {
state.titleTapCount = 0;
}, 1200);
if (state.titleTapCount >= 5) {
state.titleTapCount = 0;
setDevSeedPanelVisible(true);
setMessage("隐藏测试造数工具已展开。", "success");
}
});
document
.getElementById("dev-seed-close-btn")
?.addEventListener("click", () => {
setDevSeedPanelVisible(false);
});
document
.getElementById("dev-seed-overdue-btn")
?.addEventListener("click", async () => {
try {
setMessage("正在补造 1 笔超时待办…", "loading");
const body = await postDevSeed("overdue_pending_once");
setMessage(
`已补造超时待办:${(body.created_ids || []).join(", ") || "1 笔"}`,
"success",
);
await loadDashboard({ loginFirst: false });
} catch (error) {
setError(error.message || String(error));
setMessage("隐藏测试造数失败。", "error");
}
});
document
.getElementById("dev-seed-clean-btn")
?.addEventListener("click", async () => {
try {
setMessage("正在清理演示造数…", "loading");
const body = await postDevSeed("cleanup_demo_seed");
setMessage(
`已清理演示造数:${body.detail?.deleted_count ?? (body.deleted_ids || []).length}`,
"success",
);
await loadDashboard({ loginFirst: false });
} catch (error) {
setError(error.message || String(error));
setMessage("清理演示造数失败。", "error");
}
});
updateRangeButtons();
setStatus("loading", "尚未连接", "登录后查看核心经营指标");
}
window.addEventListener("DOMContentLoaded", bootstrap);