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);
|
PlatformCustomer c = customerMapper.selectById(customerId);
|
||||||
return c != null ? c.getName() : null;
|
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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -1184,6 +1185,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.13.6",
|
"version": "2.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz",
|
||||||
@@ -1722,6 +1733,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
@@ -1864,6 +1881,15 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.0"
|
"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": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
|||||||
@@ -1,97 +1,217 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<el-card class="block">
|
<div class="greeting">
|
||||||
<el-alert
|
<div>
|
||||||
title="交付平台(I7):按角色展示入口;Callback 仅 OPS / SYS_ADMIN"
|
<h2>{{ greeting }},{{ auth.displayName }}</h2>
|
||||||
type="info"
|
<p class="meta">角色:{{ auth.roles.join(", ") || "—" }}</p>
|
||||||
show-icon
|
</div>
|
||||||
:closable="false"
|
</div>
|
||||||
/>
|
|
||||||
<p class="meta">用户:{{ auth.displayName }},角色:{{ auth.roles.join(", ") || "—" }}</p>
|
<el-row :gutter="16">
|
||||||
<div class="quick-links" aria-label="模块导航">
|
<el-col :span="6">
|
||||||
<router-link v-for="l in visibleModuleLinks" :key="l.to" class="ql" :to="l.to">
|
<el-card shadow="never" class="stat-card">
|
||||||
{{ l.label }}
|
<div class="stat-value">{{ stats.customers }}</div>
|
||||||
</router-link>
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card class="block">
|
</el-col>
|
||||||
<template #header>调试</template>
|
</el-row>
|
||||||
<el-button type="primary" :loading="pingLoading" @click="ping">Bearer 调用 /api/v1/ping</el-button>
|
|
||||||
<pre v-if="pingBody">{{ pingBody }}</pre>
|
<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>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useAuthStore } from "../stores/auth";
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
import { listTodos } from "../api/platform";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const pingBody = ref("");
|
|
||||||
const pingLoading = ref(false);
|
|
||||||
|
|
||||||
/** I7:与 MainLayout / 路由 meta 一致 */
|
const stats = ref({ customers: "—", contracts: "—", pendingCallbacks: "—", devices: "—" });
|
||||||
const allModuleLinks = [
|
const pendingTodos = ref([]);
|
||||||
{ to: "/customers", label: "客户", roles: ["SYS_ADMIN", "SALES"] },
|
const recentEvents = ref([]);
|
||||||
{ to: "/projects", label: "项目", roles: ["SYS_ADMIN", "SALES"] },
|
const snChartRef = ref(null);
|
||||||
{ to: "/contracts", label: "合同", roles: ["SYS_ADMIN", "SALES"] },
|
const activationChartRef = ref(null);
|
||||||
{ to: "/deliveries", label: "交付", roles: ["SYS_ADMIN", "SALES", "DELIVERY"] },
|
const cbChartRef = ref(null);
|
||||||
{ to: "/licenses/sn", label: "许可 SN", roles: ["SYS_ADMIN", "SALES"] },
|
let snChart = null, actChart = null, cbChart = null;
|
||||||
{ 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 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());
|
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) + "天前";
|
||||||
|
}
|
||||||
|
|
||||||
async function ping() {
|
function renderChart(el, title, data, colors) {
|
||||||
pingLoading.value = true;
|
if (!el) return;
|
||||||
pingBody.value = "";
|
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 {
|
try {
|
||||||
const { data } = await axios.get("/api/v1/ping");
|
const [cRes, cbRes, dRes, tRes, aRes, snRes, cbStatsRes] = await Promise.allSettled([
|
||||||
pingBody.value = JSON.stringify(data, null, 2);
|
axios.get("/api/v1/customers", { params: { page: 0, size: 1 } }),
|
||||||
} catch (e) {
|
axios.get("/api/v1/callback-inbox", { params: { status: "PENDING", size: 1 } }),
|
||||||
pingBody.value = e.response?.data ? JSON.stringify(e.response.data) : String(e.message);
|
axios.get("/api/v1/devices", { params: { page: 0, size: 1 } }),
|
||||||
} finally {
|
listTodos({ status: "PENDING", size: 5 }),
|
||||||
pingLoading.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home {
|
.home { display: flex; flex-direction: column; }
|
||||||
display: flex;
|
.greeting { margin-bottom: 16px; }
|
||||||
flex-direction: column;
|
.greeting h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||||
gap: 16px;
|
.meta { margin: 4px 0 0; color: #909399; font-size: 13px; }
|
||||||
}
|
.stat-card { text-align: center; padding: 8px 0; }
|
||||||
.meta {
|
.stat-value { font-size: 32px; font-weight: 700; color: #2C3E6B; line-height: 1.2; }
|
||||||
margin: 12px 0;
|
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||||
}
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.quick-links {
|
.todo-item, .event-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
||||||
display: flex;
|
.todo-item:last-child, .event-item:last-child { border-bottom: none; }
|
||||||
flex-wrap: wrap;
|
.todo-tag { flex-shrink: 0; }
|
||||||
gap: 10px 14px;
|
.todo-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
margin-top: 8px;
|
.todo-time, .event-time { flex-shrink: 0; color: #C0C4CC; font-size: 12px; }
|
||||||
}
|
.event-action { font-weight: 500; color: #2C3E6B; }
|
||||||
.ql {
|
.event-entity { color: #606266; }
|
||||||
color: var(--el-color-primary);
|
.event-user { color: #909399; font-size: 12px; margin-left: auto; }
|
||||||
text-decoration: none;
|
.empty { color: #C0C4CC; text-align: center; padding: 24px 0; font-size: 14px; }
|
||||||
}
|
|
||||||
.ql:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
margin-top: 12px;
|
|
||||||
background: #1e1e1e;
|
|
||||||
color: #d4d4d4;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user