mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
feat: add dashboard with ECharts and SN/callback statistics
Added sn-stats and callback-stats endpoints. HomeView now shows stat cards, pending todos, recent activity, and ECharts pie charts for SN status distribution and callback status. Installed echarts dependency. Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+13
@@ -198,4 +198,17 @@ public class ReportService {
|
||||
PlatformCustomer c = customerMapper.selectById(customerId);
|
||||
return c != null ? c.getName() : null;
|
||||
}
|
||||
|
||||
public Map<String, Long> getSnStats() {
|
||||
Map<String, Long> stats = new java.util.LinkedHashMap<>();
|
||||
for (String status : List.of("REGISTERED", "ISSUED", "ACTIVATED", "SUSPENDED", "REVOKED")) {
|
||||
long count = licenseSnMapper.selectCount(
|
||||
Wrappers.lambdaQuery(PlatformLicenseSn.class)
|
||||
.eq(PlatformLicenseSn::getStatus, status));
|
||||
if (count > 0) stats.put(status, count);
|
||||
}
|
||||
long total = stats.values().stream().mapToLong(Long::longValue).sum();
|
||||
stats.put("TOTAL", total);
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
@@ -1184,6 +1185,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.13.6",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz",
|
||||
@@ -1722,6 +1733,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
@@ -1864,6 +1881,15 @@
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
@@ -1,97 +1,217 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<el-card class="block">
|
||||
<el-alert
|
||||
title="交付平台(I7):按角色展示入口;Callback 仅 OPS / SYS_ADMIN"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<p class="meta">用户:{{ auth.displayName }},角色:{{ auth.roles.join(", ") || "—" }}</p>
|
||||
<div class="quick-links" aria-label="模块导航">
|
||||
<router-link v-for="l in visibleModuleLinks" :key="l.to" class="ql" :to="l.to">
|
||||
{{ l.label }}
|
||||
</router-link>
|
||||
<div class="greeting">
|
||||
<div>
|
||||
<h2>{{ greeting }},{{ auth.displayName }}</h2>
|
||||
<p class="meta">角色:{{ auth.roles.join(", ") || "—" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-value">{{ stats.customers }}</div>
|
||||
<div class="stat-label">客户总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-value">{{ stats.contracts }}</div>
|
||||
<div class="stat-label">在履约合同</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-value" style="color: #D54941">{{ stats.pendingCallbacks }}</div>
|
||||
<div class="stat-label">待处理事件</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-value">{{ stats.devices }}</div>
|
||||
<div class="stat-label">设备数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>SN 状态分布</template>
|
||||
<div ref="snChartRef" style="height: 240px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>已发 vs 已激活</template>
|
||||
<div ref="activationChartRef" style="height: 240px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header>Callback 状态</template>
|
||||
<div ref="cbChartRef" style="height: 240px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>待办事项</span>
|
||||
<el-button link type="primary" @click="$router.push('/todos')">查看全部</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="pendingTodos.length === 0" class="empty">暂无待办事项</div>
|
||||
<div v-for="t in pendingTodos" :key="t.id" class="todo-item">
|
||||
<el-tag :type="todoTag(t.todoType)" size="small" class="todo-tag">{{ todoLabel(t.todoType) }}</el-tag>
|
||||
<span class="todo-title">{{ t.title }}</span>
|
||||
<span class="todo-time">{{ timeAgo(t.createdAt) }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="block">
|
||||
<template #header>调试</template>
|
||||
<el-button type="primary" :loading="pingLoading" @click="ping">Bearer 调用 /api/v1/ping</el-button>
|
||||
<pre v-if="pingBody">{{ pingBody }}</pre>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<template #header>最近动态</template>
|
||||
<div v-if="recentEvents.length === 0" class="empty">暂无操作记录</div>
|
||||
<div v-for="e in recentEvents" :key="e.id" class="event-item">
|
||||
<span class="event-action">{{ e.action }}</span>
|
||||
<span class="event-entity">{{ e.entityType }} #{{ e.entityId }}</span>
|
||||
<span class="event-user">{{ e.actorUserId }}</span>
|
||||
<span class="event-time">{{ timeAgo(e.createdAt) }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { listTodos } from "../api/platform";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const pingBody = ref("");
|
||||
const pingLoading = ref(false);
|
||||
|
||||
/** I7:与 MainLayout / 路由 meta 一致 */
|
||||
const allModuleLinks = [
|
||||
{ to: "/customers", label: "客户", roles: ["SYS_ADMIN", "SALES"] },
|
||||
{ to: "/projects", label: "项目", roles: ["SYS_ADMIN", "SALES"] },
|
||||
{ to: "/contracts", label: "合同", roles: ["SYS_ADMIN", "SALES"] },
|
||||
{ to: "/deliveries", label: "交付", roles: ["SYS_ADMIN", "SALES", "DELIVERY"] },
|
||||
{ to: "/licenses/sn", label: "许可 SN", roles: ["SYS_ADMIN", "SALES"] },
|
||||
{ to: "/callbacks", label: "Callback 收件箱", roles: ["SYS_ADMIN", "LICENSE_OPS"] },
|
||||
{ to: "/integration/environments", label: "集成环境", roles: ["SYS_ADMIN", "SALES", "LICENSE_OPS"] },
|
||||
{ to: "/integration/product-lines", label: "产品线", roles: ["SYS_ADMIN", "SALES", "LICENSE_OPS"] },
|
||||
{ to: "/devices", label: "设备管理", roles: ["SYS_ADMIN", "SALES", "DELIVERY"] },
|
||||
{ to: "/todos", label: "待办中心", roles: ["SYS_ADMIN", "SALES", "LICENSE_OPS"] },
|
||||
{ to: "/reports/contract-sn", label: "报表中心", roles: ["SYS_ADMIN"] },
|
||||
{ to: "/reports/subscriptions", label: "报表订阅", roles: ["SYS_ADMIN"] },
|
||||
];
|
||||
const stats = ref({ customers: "—", contracts: "—", pendingCallbacks: "—", devices: "—" });
|
||||
const pendingTodos = ref([]);
|
||||
const recentEvents = ref([]);
|
||||
const snChartRef = ref(null);
|
||||
const activationChartRef = ref(null);
|
||||
const cbChartRef = ref(null);
|
||||
let snChart = null, actChart = null, cbChart = null;
|
||||
|
||||
const visibleModuleLinks = computed(() => allModuleLinks.filter((l) => auth.hasAnyRole(l.roles)));
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 6 ? "夜深了" : hour < 12 ? "早上好" : hour < 18 ? "下午好" : "晚上好";
|
||||
|
||||
onMounted(() => auth.restoreAxiosAuth());
|
||||
|
||||
async function ping() {
|
||||
pingLoading.value = true;
|
||||
pingBody.value = "";
|
||||
try {
|
||||
const { data } = await axios.get("/api/v1/ping");
|
||||
pingBody.value = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
pingBody.value = e.response?.data ? JSON.stringify(e.response.data) : String(e.message);
|
||||
} finally {
|
||||
pingLoading.value = false;
|
||||
}
|
||||
function todoTag(type) {
|
||||
const map = { CALLBACK: "warning", SN_GRANT: "primary", ACTIVATION_OVERDUE: "danger", SWAP_APPROVAL: "info" };
|
||||
return map[type] || "info";
|
||||
}
|
||||
function todoLabel(type) {
|
||||
const map = { CALLBACK: "Callback", SN_GRANT: "SN发放", ACTIVATION_OVERDUE: "超期", SWAP_APPROVAL: "换机" };
|
||||
return map[type] || type;
|
||||
}
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "刚刚";
|
||||
if (min < 60) return min + "分钟前";
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return hr + "小时前";
|
||||
return Math.floor(hr / 24) + "天前";
|
||||
}
|
||||
|
||||
function renderChart(el, title, data, colors) {
|
||||
if (!el) return;
|
||||
const chart = echarts.init(el);
|
||||
chart.setOption({
|
||||
tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" },
|
||||
series: [{
|
||||
type: "pie", radius: ["35%", "60%"], avoidLabelOverlap: true,
|
||||
label: { show: true, formatter: "{b}\n{d}%", fontSize: 11 },
|
||||
data: Object.entries(data).filter(([k]) => k !== "TOTAL").map(([k, v], i) => ({
|
||||
name: k, value: v, itemStyle: { color: colors[i % colors.length] }
|
||||
}))
|
||||
}]
|
||||
});
|
||||
return chart;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
auth.restoreAxiosAuth();
|
||||
const colors = ["#2C3E6B", "#409EFF", "#67C23A", "#E6A23C", "#D54941", "#909399"];
|
||||
try {
|
||||
const [cRes, cbRes, dRes, tRes, aRes, snRes, cbStatsRes] = await Promise.allSettled([
|
||||
axios.get("/api/v1/customers", { params: { page: 0, size: 1 } }),
|
||||
axios.get("/api/v1/callback-inbox", { params: { status: "PENDING", size: 1 } }),
|
||||
axios.get("/api/v1/devices", { params: { page: 0, size: 1 } }),
|
||||
listTodos({ status: "PENDING", size: 5 }),
|
||||
axios.get("/api/v1/audit-events", { params: { page: 0, size: 8 } }),
|
||||
axios.get("/api/v1/reports/sn-stats"),
|
||||
axios.get("/api/v1/reports/callback-stats"),
|
||||
]);
|
||||
|
||||
if (cRes.status === "fulfilled") stats.value.customers = cRes.value.data?.totalElements ?? cRes.value.data?.length ?? 0;
|
||||
if (cbRes.status === "fulfilled") stats.value.pendingCallbacks = cbRes.value.data?.totalElements ?? 0;
|
||||
if (dRes.status === "fulfilled") stats.value.devices = dRes.value.data?.totalElements ?? dRes.value.data?.length ?? 0;
|
||||
|
||||
if (tRes.status === "fulfilled") {
|
||||
const data = tRes.value.data;
|
||||
pendingTodos.value = data.content || data || [];
|
||||
}
|
||||
|
||||
if (aRes.status === "fulfilled") {
|
||||
const data = aRes.value.data;
|
||||
recentEvents.value = (data.content || data || []).slice(0, 8);
|
||||
}
|
||||
|
||||
if (snRes.status === "fulfilled") {
|
||||
const d = snRes.value.data;
|
||||
if (d.TOTAL > 0) {
|
||||
nextTick(() => { snChart = renderChart(snChartRef.value, "SN", d, colors); });
|
||||
}
|
||||
}
|
||||
if (cbStatsRes.status === "fulfilled") {
|
||||
const d = cbStatsRes.value.data;
|
||||
if (d && d.total) {
|
||||
nextTick(() => {
|
||||
cbChart = renderChart(cbChartRef.value, "Callback", {
|
||||
PENDING: d.pending || 0, PROCESSED: d.processed || 0,
|
||||
FAILED: d.failed || 0, IGNORED: d.ignored || 0
|
||||
}, colors);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* dashboard data is non-critical */ }
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
snChart?.dispose(); actChart?.dispose(); cbChart?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.meta {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.quick-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ql {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.ql:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
pre {
|
||||
margin-top: 12px;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.home { display: flex; flex-direction: column; }
|
||||
.greeting { margin-bottom: 16px; }
|
||||
.greeting h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
.meta { margin: 4px 0 0; color: #909399; font-size: 13px; }
|
||||
.stat-card { text-align: center; padding: 8px 0; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: #2C3E6B; line-height: 1.2; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.todo-item, .event-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
||||
.todo-item:last-child, .event-item:last-child { border-bottom: none; }
|
||||
.todo-tag { flex-shrink: 0; }
|
||||
.todo-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.todo-time, .event-time { flex-shrink: 0; color: #C0C4CC; font-size: 12px; }
|
||||
.event-action { font-weight: 500; color: #2C3E6B; }
|
||||
.event-entity { color: #606266; }
|
||||
.event-user { color: #909399; font-size: 12px; margin-left: auto; }
|
||||
.empty { color: #C0C4CC; text-align: center; padding: 24px 0; font-size: 14px; }
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user