2026-06-13 14:49:58 +08:00
|
|
|
|
const TOKEN_KEY = "gaokao_admin_dashboard_token";
|
|
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
charts: {},
|
|
|
|
|
|
payload: null,
|
|
|
|
|
|
range: "7d",
|
2026-06-16 20:33:06 +08:00
|
|
|
|
loading: false,
|
2026-06-17 10:42:14 +08:00
|
|
|
|
titleTapCount: 0,
|
|
|
|
|
|
titleTapTimer: null,
|
2026-06-13 14:49:58 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 20:33:06 +08:00
|
|
|
|
function setMessage(text, tone) {
|
2026-06-13 14:49:58 +08:00
|
|
|
|
const el = document.getElementById("message");
|
|
|
|
|
|
el.textContent = text;
|
2026-06-16 20:33:06 +08:00
|
|
|
|
el.className = "message" + (tone ? " " + tone : "");
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setError(text) {
|
2026-06-16 20:33:06 +08:00
|
|
|
|
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];
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-06-16 20:33:06 +08:00
|
|
|
|
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);
|
2026-06-13 14:49:58 +08:00
|
|
|
|
document.getElementById("orders-subtitle").textContent =
|
2026-06-16 20:33:06 +08:00
|
|
|
|
`今日 ${ordersToday} / 7d ${orders7d} / 30d ${orders30d}`;
|
2026-06-13 14:49:58 +08:00
|
|
|
|
document.getElementById("revenue-subtitle").textContent =
|
|
|
|
|
|
`今日 ${formatMoney(summary.revenue_today_cents)} / 7d ${formatMoney(summary.revenue_7d_cents)} / 30d ${formatMoney(summary.revenue_30d_cents)}`;
|
2026-06-16 20:33:06 +08:00
|
|
|
|
|
|
|
|
|
|
const pending = Number(summary.pending_orders ?? 0);
|
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions
## 后端 admin/stats.py
- compute_summary 在单条聚合 SQL 中加两个新字段:
- pending_overdue_24h: status='pending' AND created_at < (now - 24h)
(超时未付,需主动催付)
- pending_missing_intake: status='pending' AND
(order_intakes 无记录 OR order_intakes.status='draft')
(资料待补,需主动跟进)
- LEFT JOIN order_intakes AS i ON i.order_id = orders.id
- 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配,
(overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES)
导致 overdue_cutoff datetime 字符串误入 IN 子句,
'completed' 误入 created_at < ? 子句, 收入少算 30%。
已修正为 (*_REVENUE_STATUSES, overdue_cutoff)
- 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义
## 前端 admin/static/dashboard.html
- KPI 4 待处理订单卡加 .pending-breakdown 区域
- 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补)
- 仅在对应字段 > 0 时才显示
- CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效
## 前端 admin/static/dashboard.js
- renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染
- 至少一个非零才展开 breakdown 区域
## 测试
- conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表
- test_routes_stats_dashboard.py:
- test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段
- test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入)
- 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前)
- 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合)
- test_app.py 加 pending-breakdown 结构断言
- pytest 52 passed
## 真实环境验证
- admin/test-pass-123 登录后:
pending=7 overdue=0 missing_intake=7
- 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill
'超时' pill 因 overdue=0 自动隐藏
- 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
|
|
|
|
const pendingOverdue = Number(summary.pending_overdue_24h ?? 0);
|
|
|
|
|
|
const pendingMissing = Number(summary.pending_missing_intake ?? 0);
|
2026-06-16 20:33:06 +08:00
|
|
|
|
document.getElementById("pending-orders").textContent = String(pending);
|
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions
## 后端 admin/stats.py
- compute_summary 在单条聚合 SQL 中加两个新字段:
- pending_overdue_24h: status='pending' AND created_at < (now - 24h)
(超时未付,需主动催付)
- pending_missing_intake: status='pending' AND
(order_intakes 无记录 OR order_intakes.status='draft')
(资料待补,需主动跟进)
- LEFT JOIN order_intakes AS i ON i.order_id = orders.id
- 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配,
(overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES)
导致 overdue_cutoff datetime 字符串误入 IN 子句,
'completed' 误入 created_at < ? 子句, 收入少算 30%。
已修正为 (*_REVENUE_STATUSES, overdue_cutoff)
- 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义
## 前端 admin/static/dashboard.html
- KPI 4 待处理订单卡加 .pending-breakdown 区域
- 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补)
- 仅在对应字段 > 0 时才显示
- CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效
## 前端 admin/static/dashboard.js
- renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染
- 至少一个非零才展开 breakdown 区域
## 测试
- conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表
- test_routes_stats_dashboard.py:
- test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段
- test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入)
- 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前)
- 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合)
- test_app.py 加 pending-breakdown 结构断言
- pytest 52 passed
## 真实环境验证
- admin/test-pass-123 登录后:
pending=7 overdue=0 missing_intake=7
- 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill
'超时' pill 因 overdue=0 自动隐藏
- 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
|
|
|
|
|
|
|
|
|
|
// 待处理多口径: 至少有一个非零才展开
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-16 20:33:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearCardEmptyStates() {
|
|
|
|
|
|
document.querySelectorAll("[data-card]").forEach((card) => {
|
|
|
|
|
|
card.classList.remove("empty");
|
|
|
|
|
|
card.classList.remove("loading");
|
|
|
|
|
|
});
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureChart(id) {
|
2026-06-16 20:33:06 +08:00
|
|
|
|
const host = document.getElementById(id);
|
|
|
|
|
|
if (!host) return null;
|
2026-06-13 14:49:58 +08:00
|
|
|
|
if (!state.charts[id]) {
|
2026-06-16 20:33:06 +08:00
|
|
|
|
state.charts[id] = echarts.init(host);
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
return state.charts[id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderTrend(trends) {
|
2026-06-16 15:02:01 +08:00
|
|
|
|
const points = Array.isArray(trends?.[state.range])
|
|
|
|
|
|
? trends?.[state.range]
|
|
|
|
|
|
: [];
|
2026-06-16 20:33:06 +08:00
|
|
|
|
if (points.length === 0) {
|
|
|
|
|
|
setChartEmpty(
|
|
|
|
|
|
"trend-chart",
|
|
|
|
|
|
state.range === "30d" ? "近 30 天暂无订单活动" : "近 7 天暂无订单活动",
|
|
|
|
|
|
);
|
|
|
|
|
|
document.getElementById("trend-title").textContent =
|
|
|
|
|
|
state.range === "30d" ? "30 天趋势" : "7 天趋势";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-13 14:49:58 +08:00
|
|
|
|
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),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 20:33:06 +08:00
|
|
|
|
function renderBarChart(chartId, emptyLabel, dataMap, color) {
|
2026-06-13 14:49:58 +08:00
|
|
|
|
const entries = Object.entries(dataMap || {});
|
2026-06-16 20:33:06 +08:00
|
|
|
|
if (
|
|
|
|
|
|
entries.length === 0 ||
|
|
|
|
|
|
entries.every(([, value]) => Number(value || 0) === 0)
|
|
|
|
|
|
) {
|
|
|
|
|
|
setChartEmpty(chartId, emptyLabel);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-13 14:49:58 +08:00
|
|
|
|
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: [
|
|
|
|
|
|
{
|
2026-06-16 20:33:06 +08:00
|
|
|
|
name: chartId,
|
2026-06-13 14:49:58 +08:00
|
|
|
|
type: "bar",
|
|
|
|
|
|
data: entries.map(([, value]) => value ?? 0),
|
|
|
|
|
|
itemStyle: { color },
|
|
|
|
|
|
barMaxWidth: 48,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderDistributions(payload) {
|
2026-06-16 20:33:06 +08:00
|
|
|
|
renderBarChart(
|
|
|
|
|
|
"status-chart",
|
|
|
|
|
|
"暂无订单状态分布",
|
|
|
|
|
|
payload.by_status,
|
|
|
|
|
|
"#2563eb",
|
|
|
|
|
|
);
|
|
|
|
|
|
renderBarChart("source-chart", "暂无来源分布", payload.by_source, "#7c3aed");
|
2026-06-13 14:49:58 +08:00
|
|
|
|
renderBarChart(
|
|
|
|
|
|
"service-chart",
|
2026-06-16 20:33:06 +08:00
|
|
|
|
"暂无服务版本分布",
|
2026-06-13 14:49:58 +08:00
|
|
|
|
payload.by_service_version,
|
|
|
|
|
|
"#059669",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateRangeButtons() {
|
|
|
|
|
|
const isThirty = state.range === "30d";
|
2026-06-16 15:02:01 +08:00
|
|
|
|
document.getElementById("range-7d").className = isThirty
|
2026-06-16 20:33:06 +08:00
|
|
|
|
? "range-btn secondary"
|
|
|
|
|
|
: "range-btn active-range";
|
2026-06-16 15:02:01 +08:00
|
|
|
|
document.getElementById("range-30d").className = isThirty
|
2026-06-16 20:33:06 +08:00
|
|
|
|
? "range-btn active-range"
|
|
|
|
|
|
: "range-btn secondary";
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchDashboard(token) {
|
|
|
|
|
|
const resp = await fetch(`${resolveApiBase()}/api/stats/dashboard`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!resp.ok) {
|
2026-06-16 15:02:01 +08:00
|
|
|
|
throw new Error(`数据加载失败:HTTP ${resp.status}`);
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
return resp.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 10:42:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
function renderDashboard(payload) {
|
|
|
|
|
|
state.payload = payload;
|
2026-06-16 20:33:06 +08:00
|
|
|
|
clearCardEmptyStates();
|
2026-06-13 14:49:58 +08:00
|
|
|
|
renderSummary(payload.summary || {});
|
|
|
|
|
|
renderTrend(payload.trends || {});
|
|
|
|
|
|
renderDistributions(payload);
|
|
|
|
|
|
updateRangeButtons();
|
2026-06-16 20:33:06 +08:00
|
|
|
|
const generatedAt = payload.generated_at || "刚刚";
|
|
|
|
|
|
document.getElementById("generated-at-hint")?.remove();
|
|
|
|
|
|
setStatus(
|
|
|
|
|
|
"online",
|
|
|
|
|
|
"已连接 · 数据已加载",
|
|
|
|
|
|
`更新时间:${generatedAt} · 数据范围:${state.range === "30d" ? "30 天" : "7 天"}`,
|
|
|
|
|
|
);
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadDashboard({ loginFirst = false } = {}) {
|
2026-06-16 20:33:06 +08:00
|
|
|
|
if (state.loading) return;
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
setMessage(loginFirst ? "登录中..." : "加载中...", "loading");
|
|
|
|
|
|
setStatus(
|
|
|
|
|
|
"loading",
|
|
|
|
|
|
"正在连接...",
|
|
|
|
|
|
loginFirst ? "正在登录并拉取数据" : "正在拉取最新数据",
|
|
|
|
|
|
);
|
|
|
|
|
|
markCardsLoading();
|
2026-06-13 14:49:58 +08:00
|
|
|
|
|
2026-06-16 20:33:06 +08:00
|
|
|
|
try {
|
2026-06-13 14:49:58 +08:00
|
|
|
|
let token = getToken();
|
|
|
|
|
|
if (!token && loginFirst) {
|
|
|
|
|
|
token = await loginAndStoreToken();
|
2026-06-16 20:33:06 +08:00
|
|
|
|
setMessage("登录成功,开始加载仪表盘...", "success");
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
|
throw new Error("缺少 JWT,请先登录或粘贴 Bearer Token。");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const payload = await fetchDashboard(token);
|
|
|
|
|
|
renderDashboard(payload);
|
2026-06-16 20:33:06 +08:00
|
|
|
|
setMessage("仪表盘加载成功", "success");
|
2026-06-13 14:49:58 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setError(error.message || String(error));
|
2026-06-16 20:33:06 +08:00
|
|
|
|
setMessage("加载失败,请检查认证或服务状态。", "error");
|
|
|
|
|
|
setStatus("error", "连接失败", error.message || "请检查登录状态或刷新");
|
|
|
|
|
|
markCardsEmpty();
|
|
|
|
|
|
setChartEmpty("trend-chart", "登录后展示订单与收入趋势");
|
|
|
|
|
|
setChartEmpty("status-chart", "暂无订单状态分布");
|
|
|
|
|
|
setChartEmpty("source-chart", "暂无来源分布");
|
|
|
|
|
|
setChartEmpty("service-chart", "暂无服务版本分布");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindRangeButton(id, range) {
|
|
|
|
|
|
document.getElementById(id).addEventListener("click", () => {
|
|
|
|
|
|
state.range = range;
|
|
|
|
|
|
updateRangeButtons();
|
|
|
|
|
|
if (state.payload) {
|
|
|
|
|
|
renderTrend(state.payload.trends || {});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 20:33:06 +08:00
|
|
|
|
function logout() {
|
|
|
|
|
|
setToken("");
|
|
|
|
|
|
state.payload = null;
|
|
|
|
|
|
Object.keys(state.charts).forEach((id) => setChartEmpty(id, "等待加载数据"));
|
|
|
|
|
|
markCardsEmpty();
|
|
|
|
|
|
setStatus("loading", "尚未连接", "登录后查看核心经营指标");
|
|
|
|
|
|
setMessage("已退出登录,请重新登录。", "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
function bootstrap() {
|
|
|
|
|
|
const cachedToken = window.sessionStorage.getItem(TOKEN_KEY);
|
|
|
|
|
|
if (cachedToken && !getToken()) {
|
|
|
|
|
|
setToken(cachedToken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 10:42:14 +08:00
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
if (params.get("seed-tools") === "1") {
|
|
|
|
|
|
setDevSeedPanelVisible(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
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 });
|
|
|
|
|
|
});
|
2026-06-16 20:33:06 +08:00
|
|
|
|
document.getElementById("quick-refresh-btn").addEventListener("click", () => {
|
|
|
|
|
|
loadDashboard({ loginFirst: false });
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("quick-logout-btn").addEventListener("click", logout);
|
2026-06-13 14:49:58 +08:00
|
|
|
|
bindRangeButton("range-7d", "7d");
|
|
|
|
|
|
bindRangeButton("range-30d", "30d");
|
2026-06-17 10:42:14 +08:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
updateRangeButtons();
|
2026-06-16 20:33:06 +08:00
|
|
|
|
setStatus("loading", "尚未连接", "登录后查看核心经营指标");
|
2026-06-13 14:49:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("DOMContentLoaded", bootstrap);
|