refactor: 移除数据大屏页面及相关组件,更新滚动容器逻辑与配置

1. 移除dashboard数据大屏页面及全部依赖的图表、统计组件
2. 重构主滚动容器选择逻辑,统一使用getMainScrollEl获取滚动元素
3. 更新配置项,添加TRUSTED_PROXY_HOPS与IP2REGION相关配置
4. 替换IP归属地查询为离线本地解析,移除相关HTTP请求配置
5. 简化OAuth与微信小程序登录的background_tasks参数传递
6. 修复系统参数描述与前端提示文案,更新alembic版本清理逻辑
7. 简化页面滚动重置逻辑,过滤隐藏标签页
This commit is contained in:
zhangtao
2026-09-12 01:16:03 +08:00
parent 8b63042ecf
commit 8d7efb6c58
56 changed files with 224 additions and 4640 deletions
@@ -19,7 +19,7 @@
</template>
<script setup lang="ts">
import { useCommon } from "@/hooks/core/useCommon";
import { getMainScrollEl, useCommon } from "@/hooks/core/useCommon";
defineOptions({ name: "FaBackToTop" });
@@ -29,8 +29,7 @@ const showButton = ref(false);
const scrollThreshold = 300;
onMounted(() => {
const scrollContainer =
document.getElementById("app-scroll-main") ?? document.getElementById("app-main");
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
const { y } = useScroll(scrollContainer);
watch(y, (newY: number) => {
+13 -4
View File
@@ -5,6 +5,19 @@
import { computed } from "vue";
import { useMenuStore, useSettingsStore } from "@stores";
/**
* 主内容区滚动容器。
*
* 真正的滚动元素是 `#app-content`(`_layouts.scss` 里带 `overflow: auto`,
* 靠 `flex: 1` + `min-height: 0` 被限制在视口剩余高度内)。
* 注意 `#app-scroll-main`(`.layout-content`)只是限宽居中的包装层,
* 自身没有 `overflow`,对它设 `scrollTop` 是空操作;
* 这里不能改用 `.el-scrollbar__wrap` 之类的第三方类名定位——
* 文档里第一个命中的是左侧菜单的滚动条。
*/
export const getMainScrollEl = (): HTMLElement | null =>
document.getElementById("app-content") ?? document.getElementById("app-main");
export function useCommon() {
const menuStore = useMenuStore();
const settingStore = useSettingsStore();
@@ -16,10 +29,6 @@ export function useCommon() {
settingStore.reload();
};
/** 主内容区滚动容器(顶栏、标签页固定,仅此处纵向滚动) */
const getMainScrollEl = (): HTMLElement | null =>
document.getElementById("app-scroll-main") ?? document.getElementById("app-main");
const scrollToTop = () => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
@@ -40,7 +40,7 @@
</Transition>
</RouterView>
<!-- 返回顶部:宽屏 #app-scroll-main;窄屏文档滚动 target 置空 -->
<!-- 返回顶部:宽屏滚动容器是 #app-content;窄屏改为文档滚动,target 置空 -->
<ElBacktop
:key="backtopTargetKey"
:target="backtopScrollTarget"
@@ -147,7 +147,7 @@ const containerStyle = computed(
})
);
/** 常规布局下由 `.layout-content` 承担纵向滚动,路由视图填满剩余高度 */
/** 纵向滚动由外层 `#app-content` 承担,`.layout-content` 仅做限宽居中,路由视图填满剩余高度 */
const contentStyle = computed(
(): CSSProperties => ({ flex: "1", minHeight: "0", minWidth: 0, width: "100%" })
);
+4 -1
View File
@@ -18,6 +18,7 @@ import { NProgress } from "@utils/ui";
import { Auth } from "@utils/auth";
import { isHttpError, ApiStatus } from "@utils/http";
import { refreshState } from "./refresh";
import { getMainScrollEl } from "@/hooks/core/useCommon";
/** 全局 loading 状态(用于路由切换时显示加载遮罩) */
const globalLoading = ref(false);
@@ -319,7 +320,9 @@ export function setupAfterEachGuard(router: Router): void {
router.afterEach((to) => {
setWorktab(to);
setPageTitle(to);
document.querySelector(".el-scrollbar__wrap")?.scrollTo(0, 0);
// 只重置主内容区(#app-content):不能用 .el-scrollbar__wrap 这类第三方类名定位,
// 文档里第一个命中的是左侧菜单的滚动条(#app-sidebar 在 #app-main 之前)。
getMainScrollEl()?.scrollTo(0, 0);
window.scrollTo(0, 0);
NProgress.done();
if (globalLoading.value) globalLoading.value = false;
+8 -15
View File
@@ -17,7 +17,6 @@ import { useWorktabStore } from "@stores";
import LayoutComponent from "@/layouts/index.vue";
import DashboardWorkplace from "@views/dashboard/workplace/index.vue";
import DashboardAnalysis from "@views/dashboard/analysis/index.vue";
import DashboardScreen from "@views/dashboard/screen/index.vue";
import RedirectView from "@views/redirect/index.vue";
import LoginView from "@views/module_system/auth/login/index.vue";
import Exception401 from "@views/exception/401/index.vue";
@@ -131,12 +130,6 @@ export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
keepAlive: false,
},
},
{
path: "screen",
name: "DashboardScreen",
component: DashboardScreen,
meta: { title: "数据大屏", icon: "ri:tv-line", keepAlive: false, hidden: false },
},
];
// ──────── 路由常量 ────────
@@ -224,10 +217,10 @@ export const IframeView = defineComponent({
* 2、静态路由不管是否登录都可以访问
*/
export const staticRoutes: AppRouteRecordRaw[] = [
// 重定向中转页
// 重定向中转页:仅用于强制重新进入目标路由,本身不应出现在菜单和工作标签栏
{
path: "/redirect",
meta: { hidden: true },
meta: { hidden: true, isHideTab: true },
component: LayoutComponent,
children: [
{
@@ -243,29 +236,29 @@ export const staticRoutes: AppRouteRecordRaw[] = [
meta: { hidden: true, isHideTab: true, title: "menus.login.title" },
component: LoginView,
},
// 异常页
// 异常页:不在侧栏菜单显示(hidden),也不进入工作标签栏(isHideTab)
{
path: "/401",
name: "401",
meta: { hidden: true, title: "401" },
meta: { hidden: true, isHideTab: true, title: "401" },
component: Exception401,
},
{
path: "/403",
name: "403",
component: Exception403,
meta: { hidden: true, title: "403" },
meta: { hidden: true, isHideTab: true, title: "403" },
},
{
path: "/404",
name: "404",
meta: { hidden: true, title: "404" },
meta: { hidden: true, isHideTab: true, title: "404" },
component: Exception404,
},
{
path: "/500",
name: "500",
meta: { hidden: true, title: "500" },
meta: { hidden: true, isHideTab: true, title: "500" },
component: Exception500,
},
// 根 Layout:存放壳层路由(home/dashboard/fastlink)
@@ -374,6 +367,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
path: "/:pathMatch(.*)*",
name: "CatchAll404",
component: Exception404,
meta: { hidden: true, title: "404" },
meta: { hidden: true, isHideTab: true, title: "404" },
},
];
@@ -44,7 +44,6 @@ import type { LocationQueryRaw, Router } from "vue-router";
import type { WorkTab } from "@/types/store";
import { useCommon } from "@/hooks/core/useCommon";
import { ROUTE_PATH_LOGIN_ALT } from "@/router/routes";
interface WorktabState {
current: Partial<WorkTab>;
@@ -485,13 +484,27 @@ export const useWorktabStore = defineStore(
}
};
/** 登录页不应出现在工作台标签(历史持久化可能残留) */
const isLoginWorktab = (tab: Partial<WorkTab>): boolean =>
tab.name === "Login" || tab.path === "/login" || tab.path === ROUTE_PATH_LOGIN_ALT;
/**
* 路由是否声明了 `meta.isHideTab`(登录页、异常页、静态外链页等)。
* 这类页面不该出现在工作台标签,历史持久化可能残留,启动时按 meta 统一清理。
*/
const isHideTabWorktab = (tab: Partial<WorkTab>): boolean => {
try {
if (!tab.path) return false;
return (
routerInstance.resolve({
path: tab.path,
query: (tab.query as LocationQueryRaw) || undefined,
}).meta.isHideTab === true
);
} catch {
return false;
}
};
// 过滤出有效的标签页
const validTabs = opened.value.filter(
(tab) => isTabRouteValid(tab) && !isLoginWorktab(tab)
(tab) => isTabRouteValid(tab) && !isHideTabWorktab(tab)
);
if (validTabs.length !== opened.value.length) {
@@ -506,9 +519,9 @@ export const useWorktabStore = defineStore(
opened.value = validTabs;
}
// 验证当前激活标签的有效性(登录页不应作为当前工作台标签)
// 验证当前激活标签的有效性(isHideTab 页面不应作为当前工作台标签)
const isCurrentValid =
current.value && isTabRouteValid(current.value) && !isLoginWorktab(current.value);
current.value && isTabRouteValid(current.value) && !isHideTabWorktab(current.value);
if (!isCurrentValid && validTabs.length > 0) {
if (import.meta.env.DEV) console.warn("当前激活标签无效,已自动切换");
@@ -1,756 +0,0 @@
<template>
<div ref="containerRef" class="screen-container">
<canvas ref="canvasRef" class="particle-canvas" />
<div class="scan-line scan-1" />
<div class="scan-line scan-2" />
<ScreenHeader />
<!-- 顶部核心指标卡 x6 -->
<div class="stat-row">
<div v-for="s in stats" :key="s.label" class="stat-card">
<div class="stat-val" :class="'stat-' + s.color">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
<div class="stat-sub">
<span :class="s.up ? 'up' : 'down'">{{ s.up ? "▲" : "▼" }} {{ s.change }}</span>
<span class="stat-vs">较昨日</span>
</div>
</div>
</div>
<!-- 主体布局 -->
<div class="screen-grid">
<!-- 左列 -->
<ServiceLevel class="gp-r1 gp-c1-3" />
<TargetReality class="gp-r2 gp-c1-3" />
<!-- 中间左 -->
<LiveMetrics class="gp-r1 gp-c3-5" />
<FunnelChart class="gp-r2 gp-c3-5" />
<!-- 中国地图 -->
<div class="panel p1 map-panel gp-r1 gp-c5-9" style="grid-row: 1 / 3">
<FaMapChart :dynamic="true" />
</div>
<!-- 中间右 -->
<ChannelDonut class="gp-r1 gp-c9-11" />
<UserProfile class="gp-r2 gp-c9-11" />
<!-- 右列 -->
<ProductBar class="gp-r1 gp-c11-13" />
<RealtimeMessages class="gp-r2 gp-c11-13" />
</div>
<!-- 大卡片行 x6 统计图 -->
<div class="card-row">
<MiniRevenue />
<MiniOrders />
<MiniTraffic />
<MiniRefund />
<MiniInventory />
<MiniRating />
</div>
<!-- 底部状态栏 -->
<div class="bottom-bar">
<div class="bb-item"><span class="bb-dot pulse" />系统运行正常</div>
<div class="bb-item">数据更新时间:{{ updateTime }}</div>
<div class="bb-ticker">
<span class="ticker-track">
<span v-for="t in tickerItems" :key="t" class="ticker-item">{{ t }}</span>
</span>
</div>
<div class="bb-items-right">
<div v-for="s in structItems" :key="s.label" class="bb-meta">
<span class="bb-meta-dot" :class="'sr-' + s.cls" />
{{ s.label }}: {{ s.value }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
defineAsyncComponent,
onMounted,
onUnmounted,
provide,
reactive,
ref,
shallowRef,
} from "vue";
import ScreenHeader from "./modules/ScreenHeader.vue";
import ServiceLevel from "./modules/ServiceLevel.vue";
import TargetReality from "./modules/TargetReality.vue";
import LiveMetrics from "./modules/LiveMetrics.vue";
import FunnelChart from "./modules/FunnelChart.vue";
import ChannelDonut from "./modules/ChannelDonut.vue";
import UserProfile from "./modules/UserProfile.vue";
import ProductBar from "./modules/ProductBar.vue";
import RealtimeMessages from "./modules/RealtimeMessages.vue";
import MiniRevenue from "./modules/MiniRevenue.vue";
import MiniOrders from "./modules/MiniOrders.vue";
import MiniTraffic from "./modules/MiniTraffic.vue";
import MiniRefund from "./modules/MiniRefund.vue";
import MiniInventory from "./modules/MiniInventory.vue";
import MiniRating from "./modules/MiniRating.vue";
defineOptions({ name: "DashboardScreen" });
const FaMapChart = defineAsyncComponent(() => import("@/components/charts/fa-map-chart/index.vue"));
const containerRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
const isFullscreen = ref(false);
const updateTime = ref("");
/** 窗口 resize 处理器,保存引用以便在 onUnmounted 中移除 */
function onResize() {
resizeCanvas();
spawnParticles();
}
const tickerItems = ref([
"交易引擎: 23%",
"消息队列: 58%",
"缓存命中: 94.1%",
"网关吞吐: 128Mbps",
"QPS: 3.2K",
"P99 延迟: 12ms",
]);
const animFrame = shallowRef(0);
let statsTimer = 0;
let timeTickInterval = 0;
const stats = reactive([
{ label: "总交易额", value: "¥128.6万", change: "12.5%", up: true, color: "cyan" as const },
{ label: "订单总量", value: "8,350", change: "8.2%", up: true, color: "purple" as const },
{ label: "活跃商户", value: "1,286", change: "3.1%", up: true, color: "green" as const },
{ label: "售后工单", value: "356", change: "5.7%", up: false, color: "warn" as const },
{ label: "平均客单价", value: "¥468", change: "2.3%", up: true, color: "teal" as const },
{ label: "支付转化率", value: "38.2%", change: "1.8%", up: true, color: "rose" as const },
]);
const structItems = reactive([
{ label: "交易节点", value: "128", cls: "cyan" },
{ label: "主数据库", value: "运行中", cls: "green" },
{ label: "缓存集群", value: "94.1%", cls: "purple" },
{ label: "带宽使用", value: "1.2G", cls: "warn" },
{ label: "存储容量", value: "2.4T", cls: "cyan" },
{ label: "工作节点", value: "32", cls: "green" },
{ label: "API 网关", value: "99.9%", cls: "purple" },
{ label: "日志服务", value: "运行中", cls: "cyan" },
]);
function fmt(n: number): string {
return n >= 10000 ? (n / 10000).toFixed(1) + "万" : n.toLocaleString();
}
function randFloat(base: number, pct: number): number {
return base * (1 + (Math.random() - 0.5) * pct * 2);
}
function randPct(): string {
return (Math.random() * 15 - 3).toFixed(1) + "%";
}
function updateStats() {
const t = randFloat(1286000, 0.05);
const o = randFloat(8350, 0.04);
const m = randFloat(1286, 0.03);
const w = randFloat(356, 0.08);
stats[0]!.value = "¥" + fmt(Math.round(t));
stats[1]!.value = fmt(Math.round(o));
stats[2]!.value = fmt(Math.round(m));
stats[3]!.value = fmt(Math.round(w));
stats[4]!.value = "¥" + Math.round(randFloat(468, 0.03));
stats[5]!.value = randFloat(38.2, 0.06).toFixed(1) + "%";
stats[0]!.change = randPct();
stats[0]!.up = Math.random() > 0.3;
stats[1]!.change = randPct();
stats[1]!.up = Math.random() > 0.4;
stats[2]!.change = randPct();
stats[2]!.up = Math.random() > 0.5;
stats[3]!.change = randPct();
stats[3]!.up = Math.random() > 0.6;
stats[4]!.change = randPct();
stats[4]!.up = Math.random() > 0.3;
stats[5]!.change = randPct();
stats[5]!.up = Math.random() > 0.4;
}
async function toggleFullscreen() {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await containerRef.value?.requestFullscreen();
}
} catch {
/* ignored */
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement;
}
// fullscreenchange 是 document 级别事件,必须在 onUnmounted 中清理,
// 否则 keep-alive / 重复进入会累积监听器
onMounted(() => {
document.addEventListener("fullscreenchange", onFullscreenChange);
});
onUnmounted(() => {
document.removeEventListener("fullscreenchange", onFullscreenChange);
});
provide("toggleFullscreen", toggleFullscreen);
provide("isFullscreen", isFullscreen);
/* ========== 粒子背景 ========== */
let ctx: CanvasRenderingContext2D | null = null;
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
}
let particles: Particle[] = [];
function initParticles() {
const c = canvasRef.value;
if (!c) return;
ctx = c.getContext("2d");
resizeCanvas();
spawnParticles();
tick();
}
function resizeCanvas() {
const c = canvasRef.value;
if (!c || !containerRef.value) return;
c.width = containerRef.value.clientWidth;
c.height = containerRef.value.clientHeight;
}
function spawnParticles() {
const c = canvasRef.value;
if (!c) return;
const count = Math.floor((c.width * c.height) / 18000);
particles = Array.from({ length: count }, () => ({
x: Math.random() * c.width,
y: Math.random() * c.height,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
r: Math.random() * 1.5 + 0.5,
}));
}
function tick() {
if (!ctx || !canvasRef.value) return;
const c = canvasRef.value;
const w = c.width;
const h = c.height;
ctx.clearRect(0, 0, w, h);
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
}
for (let i = 0; i < particles.length; i++) {
const a = particles[i]!;
ctx.beginPath();
ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,212,255,0.25)";
ctx.fill();
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j]!;
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(0,212,255,${0.06 * (1 - dist / 120)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
animFrame.value = requestAnimationFrame(tick);
}
onMounted(() => {
const timeTick = () => {
updateTime.value = new Date().toLocaleTimeString("zh-CN", { hour12: false });
};
timeTick();
timeTickInterval = window.setInterval(timeTick, 1000);
initParticles();
statsTimer = window.setInterval(updateStats, 3000);
updateStats();
window.addEventListener("resize", onResize);
});
onUnmounted(() => {
cancelAnimationFrame(animFrame.value);
clearInterval(statsTimer);
clearInterval(timeTickInterval);
window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onFullscreenChange);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
});
</script>
<style scoped>
.screen-container {
--bg: #060b24;
--accent: #00d4ff;
--text: #b8c6e0;
--border: rgb(0 180 255 / 12%);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
font-family: "PingFang SC", "Microsoft YaHei", monospace;
color: var(--text);
background: var(--bg);
}
.particle-canvas {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.scan-line {
position: absolute;
left: 0;
z-index: 1;
width: 100%;
height: 1px;
pointer-events: none;
opacity: 0.08;
will-change: transform;
}
.scan-1 {
top: 0;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
animation: scanDown 6s linear infinite;
}
.scan-2 {
top: 100%;
background: linear-gradient(90deg, transparent, #7c3aed, transparent);
animation: scanDown 6s linear 3s infinite;
}
@keyframes scanDown {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-100vh);
}
}
/* ===== 背景网格 ===== */
.screen-container::after {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
content: "";
background-image:
linear-gradient(rgb(0 212 255 / 3%) 1px, transparent 1px),
linear-gradient(90deg, rgb(0 212 255 / 3%) 1px, transparent 1px);
background-size: 60px 60px;
}
/* ===== 顶部指标卡 ===== */
.stat-row {
position: relative;
z-index: 2;
display: flex;
flex-shrink: 0;
gap: 16px;
padding: 0 16px 16px;
}
.stat-card {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
padding: 18px 24px;
overflow: hidden;
background: linear-gradient(135deg, rgb(0 20 60 / 60%) 0%, rgb(0 10 40 / 50%) 100%);
border: 1px solid var(--border);
border-radius: 10px;
}
.stat-card::before {
position: absolute;
top: -1px;
right: 14px;
left: 14px;
height: 1px;
content: "";
background: linear-gradient(90deg, transparent, rgb(0 212 255 / 40%), transparent);
}
.stat-card::after {
position: absolute;
top: 4px;
left: 4px;
width: 8px;
height: 8px;
content: "";
border-top: 1px solid rgb(0 212 255 / 50%);
border-left: 1px solid rgb(0 212 255 / 50%);
}
.stat-val {
margin-bottom: 6px;
font-size: 28px;
font-weight: 800;
line-height: 1;
}
.stat-cyan {
color: #00d4ff;
}
.stat-purple {
color: #7c3aed;
}
.stat-green {
color: #10b981;
}
.stat-warn {
color: #f59e0b;
}
.stat-teal {
color: #14b8a6;
}
.stat-rose {
color: #f43f5e;
}
.stat-label {
margin-bottom: 6px;
font-size: 11px;
opacity: 0.4;
}
.stat-sub {
display: flex;
gap: 6px;
align-items: center;
font-size: 10px;
}
.stat-sub .up {
color: #10b981;
}
.stat-sub .down {
color: #f59e0b;
}
.stat-vs {
opacity: 0.3;
}
/* ===== Grid 主体 ===== */
.screen-grid {
position: relative;
z-index: 2;
display: grid;
flex: 1;
grid-template-rows: repeat(2, 1fr);
grid-template-columns: repeat(12, 1fr);
gap: 16px;
min-height: 0;
padding: 0 16px 16px;
}
.gp-r1 {
grid-row: 1;
}
.gp-r2 {
grid-row: 2;
}
.gp-c1-3 {
grid-column: 1 / 3;
}
.gp-c1-4 {
grid-column: 1 / 4;
}
.gp-c3-5 {
grid-column: 3 / 5;
}
.gp-c4-10 {
grid-column: 4 / 10;
}
.gp-c5-9 {
grid-column: 5 / 9;
}
.gp-c9-11 {
grid-column: 9 / 11;
}
.gp-c10-13 {
grid-column: 10 / 13;
}
.gp-c11-13 {
grid-column: 11 / 13;
}
/* ===== 共享面板 ===== */
:deep(.panel) {
position: relative;
display: flex;
flex-direction: column;
padding: 18px 16px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border);
border-radius: 14px;
}
:deep(.panel)::before {
position: absolute;
top: 0;
right: 20px;
left: 20px;
z-index: 1;
height: 1px;
pointer-events: none;
content: "";
background: linear-gradient(90deg, transparent, rgb(0 212 255 / 30%), transparent);
}
:deep(.panel)::after {
position: absolute;
inset: -1px;
z-index: 0;
pointer-events: none;
content: "";
background:
linear-gradient(to right, rgb(0 212 255 / 30%) 1px, transparent 1px) 0 0 / 12px 1px no-repeat,
linear-gradient(to bottom, rgb(0 212 255 / 30%) 1px, transparent 1px) 0 0 / 1px 12px no-repeat,
linear-gradient(to left, rgb(0 212 255 / 30%) 1px, transparent 1px) 100% 0 / 12px 1px no-repeat,
linear-gradient(to bottom, rgb(0 212 255 / 30%) 1px, transparent 1px) 100% 0 / 1px 12px
no-repeat,
linear-gradient(to right, rgb(0 212 255 / 30%) 1px, transparent 1px) 0 100% / 12px 1px no-repeat,
linear-gradient(to top, rgb(0 212 255 / 30%) 1px, transparent 1px) 0 100% / 1px 12px no-repeat,
linear-gradient(to left, rgb(0 212 255 / 30%) 1px, transparent 1px) 100% 100% / 12px 1px
no-repeat,
linear-gradient(to top, rgb(0 212 255 / 30%) 1px, transparent 1px) 100% 100% / 1px 12px
no-repeat;
border-radius: 14px;
}
:deep(.map-panel) {
padding: 0;
overflow: hidden;
}
:deep(.map-panel)::after {
background: none;
}
:deep(.map-panel > div) {
height: 100% !important;
padding: 12px;
}
:deep(#china-map) {
border-radius: 12px;
}
:deep(.panel-hd) {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
padding-bottom: 12px;
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
letter-spacing: 1px;
border-bottom: 1px solid rgb(0 212 255 / 8%);
}
:deep(.dot) {
display: inline-block;
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 6px currentcolor;
}
:deep(.dot.accent) {
color: var(--accent);
background: var(--accent);
}
:deep(.dot.green) {
color: #10b981;
background: #10b981;
}
:deep(.dot.warn) {
color: #f59e0b;
background: #f59e0b;
}
:deep(.dot.purple) {
color: #7c3aed;
background: #7c3aed;
}
/* ===== 大卡片行 x6 ===== */
.card-row {
position: relative;
z-index: 2;
display: flex;
flex-shrink: 0;
gap: 16px;
height: 180px;
padding: 0 16px 16px;
}
.card-row > * {
flex: 1;
}
/* ===== 底部状态栏 ===== */
.bottom-bar {
position: relative;
z-index: 2;
display: flex;
flex-shrink: 0;
gap: 24px;
align-items: center;
padding: 10px 24px;
margin: 0 16px 16px;
font-size: 11px;
background: linear-gradient(90deg, rgb(0 20 60 / 50%) 0%, rgb(0 20 60 / 30%) 100%);
border: 1px solid rgb(0 180 255 / 10%);
border-radius: 8px;
opacity: 0.7;
}
.bb-item {
display: flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
}
.bb-dot {
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
box-shadow: 0 0 6px #10b981;
}
.bb-ticker {
flex: 1;
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
}
.ticker-track {
display: flex;
gap: 32px;
white-space: nowrap;
animation: tickerScroll 20s linear infinite;
}
.ticker-item {
flex-shrink: 0;
}
@keyframes tickerScroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
.bb-items-right {
display: flex;
flex-shrink: 0;
gap: 14px;
align-items: center;
}
.bb-meta {
display: flex;
gap: 4px;
align-items: center;
font-size: 10px;
opacity: 0.6;
}
.bb-meta-dot {
flex-shrink: 0;
width: 5px;
height: 5px;
border-radius: 50%;
}
.sr-cyan {
background: #00d4ff;
box-shadow: 0 0 5px #00d4ff;
}
.sr-green {
background: #10b981;
box-shadow: 0 0 5px #10b981;
}
.sr-purple {
background: #7c3aed;
box-shadow: 0 0 5px #7c3aed;
}
.sr-warn {
background: #f59e0b;
box-shadow: 0 0 5px #f59e0b;
}
</style>
@@ -1,100 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />交易渠道占比</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "ChannelDonut" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
tooltip: {
trigger: "item" as const,
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff", fontSize: 10 },
formatter: "{b}: {c}万 ({d}%)",
},
legend: {
bottom: 0,
textStyle: { color: "#94a3b8", fontSize: 9 },
itemWidth: 8,
itemHeight: 8,
},
series: [
{
type: "pie",
radius: ["50%", "70%"],
center: ["50%", "45%"],
label: { show: false },
emphasis: {
scaleSize: 6,
label: { show: true, fontSize: 10, color: "#94a3b8", formatter: "{b}\n{d}%" },
},
itemStyle: { borderColor: "#060b24", borderWidth: 2 },
data: [
{ value: 48.5, name: "微信支付", itemStyle: { color: "#00d4ff" } },
{ value: 28.3, name: "支付宝", itemStyle: { color: "#7c3aed" } },
{ value: 15.2, name: "银联云闪付", itemStyle: { color: "#10b981" } },
{ value: 8.0, name: "其他", itemStyle: { color: "#f59e0b" } },
],
},
],
});
}
function handleResize() {
if (chart && !chart.isDisposed()) chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const r1 = +(40 + Math.random() * 15).toFixed(1);
const r2 = +(22 + Math.random() * 12).toFixed(1);
const r3 = +(10 + Math.random() * 10).toFixed(1);
const r4 = +(100 - +r1 - +r2 - +r3).toFixed(1);
chart.setOption({
series: [{ data: [{ value: r1 }, { value: r2 }, { value: r3 }, { value: r4 }] }],
});
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,77 +0,0 @@
<template>
<div class="panel p1 col-3">
<div class="panel-hd"><span class="dot accent" />数据概览</div>
<div class="stats-row">
<div v-for="c in overview" :key="c.label" class="stat-card" :style="{ borderColor: c.color }">
<div class="stat-icon" :style="{ background: c.color + '18', color: c.color }">
{{ c.icon }}
</div>
<div>
<span class="stat-val" :style="{ color: c.color }">{{ c.value }}</span>
<span class="stat-label">{{ c.label }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "DataOverview" });
const overview = ref([
{ label: "总用户数", value: "12,542", icon: "👤", color: "#00d4ff" },
{ label: "今日活跃", value: "387", icon: "⚡", color: "#10b981" },
{ label: "API 调用", value: "89.2K", icon: "🔗", color: "#7c3aed" },
{ label: "告警数", value: "2", icon: "⚠️", color: "#ef4444" },
]);
</script>
<style scoped>
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stat-card {
display: flex;
gap: 10px;
align-items: center;
padding: 12px;
background: rgb(12 18 48 / 60%);
border: 1px solid;
border-radius: 8px;
transition:
transform 0.2s,
box-shadow 0.3s;
}
.stat-card:hover {
transform: translateY(-1px);
}
.stat-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
font-size: 18px;
border-radius: 8px;
}
.stat-val {
display: block;
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 12px;
opacity: 0.5;
}
</style>
@@ -1,121 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot purple" />用户转化漏斗</div>
<div class="funnel-list">
<div v-for="(item, i) in data" :key="item.label" class="fn-item">
<div class="fn-row">
<span class="fn-idx" :style="{ background: colors[i] }">{{ i + 1 }}</span>
<span class="fn-label">{{ item.label }}</span>
<span class="fn-val">{{ item.value }}</span>
<span class="fn-rate" :style="{ color: colors[i] }">转化率 {{ item.rate }}%</span>
</div>
<div class="fn-bar-wrap">
<div class="fn-bar" :style="{ width: item.pct + '%', background: colors[i] }" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from "vue";
defineOptions({ name: "FunnelChart" });
const colors = ["#00d4ff", "#7c3aed", "#10b981", "#f59e0b"];
const data = reactive([
{ label: "曝光", value: "12,850", rate: 100, pct: 100 },
{ label: "点击", value: "4,320", rate: 34, pct: 34 },
{ label: "下单", value: "1,845", rate: 14, pct: 14 },
{ label: "支付", value: "963", rate: 8, pct: 8 },
]);
let timer = 0;
function tick() {
const bases = [12850, 4320, 1845, 963];
const vals = bases.map((b, i) => Math.round(b + (Math.random() - 0.5) * b * 0.08 * (4 - i)));
const max = vals[0]!;
data.forEach((item, i) => {
item.value = vals[i]!.toLocaleString();
item.rate = Math.round((vals[i]! / max) * 100);
item.pct = (vals[i]! / max) * 100;
});
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 5000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.funnel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
justify-content: center;
}
.fn-item {
padding: 2px 0;
}
.fn-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 3px;
font-size: 10px;
}
.fn-idx {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 8px;
font-weight: 700;
color: #fff;
border-radius: 3px;
}
.fn-label {
flex-shrink: 0;
width: 28px;
opacity: 0.5;
}
.fn-val {
flex: 1;
font-variant-numeric: tabular-nums;
text-align: right;
opacity: 0.8;
}
.fn-rate {
flex-shrink: 0;
width: 54px;
font-size: 9px;
font-weight: 600;
text-align: right;
}
.fn-bar-wrap {
height: 3px;
overflow: hidden;
background: rgb(26 40 80 / 40%);
border-radius: 2px;
}
.fn-bar {
height: 100%;
border-radius: 2px;
transition: width 0.8s;
}
</style>
@@ -1,163 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd">
<span class="dot warn" />热门排行
<span class="rank-badge">实时</span>
</div>
<div class="rank-list">
<div v-for="(item, i) in list" :key="item.name" class="rank-row">
<span class="rank-idx" :class="'top' + (i + 1)">{{ i + 1 }}</span>
<span class="rank-name">{{ item.name }}</span>
<span class="rank-bar-wrap">
<span
class="rank-bar"
:style="{ width: item.ratio * 100 + '%', background: item.color }"
/>
</span>
<span class="rank-val">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from "vue";
defineOptions({ name: "HotRanking" });
const colors = [
"#00d4ff",
"#7c3aed",
"#10b981",
"#f59e0b",
"#ef4444",
"#ec4899",
"#06b6d4",
"#f97316",
];
const list = reactive([
{ name: "iPhone 15 Pro", value: "892单", ratio: 0, color: colors[0] },
{ name: "MacBook Air M3", value: "651单", ratio: 0, color: colors[1] },
{ name: "AirPods Pro 2", value: "534单", ratio: 0, color: colors[2] },
{ name: "iPad Mini 7", value: "412单", ratio: 0, color: colors[3] },
{ name: "Apple Watch S9", value: "298单", ratio: 0, color: colors[4] },
{ name: "Samsung S24 Ultra", value: "187单", ratio: 0, color: colors[5] },
{ name: "华为 Mate 60 Pro", value: "165单", ratio: 0, color: colors[6] },
{ name: "Sony WH-1000XM5", value: "142单", ratio: 0, color: colors[7] },
]);
let timer = 0;
function tick() {
const vals = list.map(() => Math.round(100 + Math.random() * 800));
const max = Math.max(...vals);
list.forEach((item, i) => {
item.value = vals[i]! + "单";
item.ratio = vals[i]! / max;
});
list.sort((a, b) => parseInt(b.value) - parseInt(a.value));
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 5000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.rank-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
overflow: auto;
}
.rank-row {
display: flex;
gap: 8px;
align-items: center;
font-size: 11px;
}
.rank-idx {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 10px;
font-weight: 700;
background: rgb(26 40 80 / 60%);
border-radius: 4px;
}
.rank-idx.top1 {
color: #00d4ff;
background: rgb(0 212 255 / 20%);
}
.rank-idx.top2 {
color: #7c3aed;
background: rgb(124 58 237 / 20%);
}
.rank-idx.top3 {
color: #10b981;
background: rgb(16 185 129 / 20%);
}
.rank-name {
flex-shrink: 0;
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-bar-wrap {
flex: 1;
height: 5px;
overflow: hidden;
background: rgb(26 40 80 / 40%);
border-radius: 3px;
}
.rank-bar {
display: block;
height: 100%;
border-radius: 3px;
transition: width 0.6s;
}
.rank-val {
flex-shrink: 0;
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.rank-badge {
padding: 2px 8px;
margin-left: auto;
font-size: 10px;
color: #f59e0b;
background: rgb(245 158 11 / 15%);
border-radius: 10px;
opacity: 0.5;
animation: blink 2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
</style>
@@ -1,99 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot warn" />服务健康度</div>
<div class="metric-list">
<div v-for="m in metrics" :key="m.label" class="metric-row">
<div class="mr-label">{{ m.label }}</div>
<div class="mr-value" :style="{ color: m.color }">
{{ m.value }}<span class="mr-unit">{{ m.unit }}</span>
</div>
<div class="mr-bar-wrap">
<div ref="barRefs" class="mr-bar" :style="{ width: m.pct + '%', background: m.color }" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from "vue";
defineOptions({ name: "LiveMetrics" });
const metrics = reactive([
{ label: "QPS(每秒请求)", value: "3.2", unit: "K", color: "#00d4ff", pct: 0, base: 3200 },
{ label: "P99 耗时", value: "12", unit: "ms", color: "#7c3aed", pct: 0, base: 12 },
{ label: "错误率", value: "0.03", unit: "%", color: "#ef4444", pct: 0, base: 0.03 },
{ label: "服务可用率", value: "99.99", unit: "%", color: "#10b981", pct: 0, base: 99.99 },
]);
let timer = 0;
function tick() {
metrics[0]!.base = Math.round(2800 + Math.random() * 800);
metrics[1]!.base = Math.round(8 + Math.random() * 20);
metrics[2]!.base = +(0.01 + Math.random() * 0.08).toFixed(2);
metrics[3]!.base = +(99.95 + Math.random() * 0.05).toFixed(2);
metrics[0]!.value = (metrics[0]!.base / 1000).toFixed(1);
metrics[1]!.value = String(metrics[1]!.base);
metrics[2]!.value = String(metrics[2]!.base);
metrics[3]!.value = String(metrics[3]!.base);
metrics[0]!.pct = (metrics[0]!.base / 4000) * 100;
metrics[1]!.pct = (metrics[1]!.base / 30) * 100;
metrics[2]!.pct = (metrics[2]!.base / 0.1) * 100;
metrics[3]!.pct = (+(metrics[3]!.base - 99.9) / 0.1) * 100;
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 3000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.metric-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
justify-content: center;
}
.metric-row {
padding: 2px 0;
}
.mr-label {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mr-value {
margin-bottom: 3px;
font-size: 18px;
font-weight: 700;
line-height: 1;
}
.mr-unit {
margin-left: 2px;
font-size: 10px;
font-weight: 400;
opacity: 0.4;
}
.mr-bar-wrap {
height: 2px;
overflow: hidden;
background: rgb(26 40 80 / 40%);
border-radius: 1px;
}
.mr-bar {
height: 100%;
border-radius: 1px;
transition: width 0.8s;
}
</style>
@@ -1,125 +0,0 @@
<template>
<div class="mini-chart-panel">
<div class="mc-hd">库存周转天数</div>
<div class="mc-value" style="color: #14b8a6">{{ val }}<span class="mc-unit">天</span></div>
<div class="progress-wrap">
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: pct + '%', background: 'linear-gradient(90deg, #14b8a6, #06b6d4)' }"
/>
</div>
<div class="progress-markers">
<span>0</span><span>5</span><span>10</span><span>15</span><span>20</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
defineOptions({ name: "MiniInventory" });
const val = ref(8.5);
const pct = ref(42.5);
let timer = 0;
function tick() {
const n = +(6 + Math.random() * 6).toFixed(1);
val.value = n;
pct.value = (n / 20) * 100;
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 4000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.mini-chart-panel {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 12px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border, rgb(0 180 255 / 12%));
border-radius: 10px;
}
.mini-chart-panel::after {
position: absolute;
top: 4px;
left: 4px;
width: 6px;
height: 6px;
content: "";
border-top: 1px solid rgb(20 184 166 / 40%);
border-left: 1px solid rgb(20 184 166 / 40%);
}
.mc-hd {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mc-value {
margin-bottom: 8px;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.mc-unit {
margin-left: 2px;
font-size: 9px;
font-weight: 400;
opacity: 0.4;
}
.progress-wrap {
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-end;
}
.progress-track {
position: relative;
height: 6px;
overflow: hidden;
background: rgb(26 40 80 / 40%);
border-radius: 3px;
}
.progress-fill {
position: relative;
height: 100%;
border-radius: 3px;
transition: width 0.8s;
}
.progress-fill::after {
position: absolute;
top: -2px;
right: 0;
width: 10px;
height: 10px;
content: "";
background: #14b8a6;
border-radius: 50%;
box-shadow: 0 0 8px #14b8a6;
}
.progress-markers {
display: flex;
justify-content: space-between;
margin-top: 3px;
font-size: 8px;
opacity: 0.25;
}
</style>
@@ -1,118 +0,0 @@
<template>
<div class="mini-chart-panel">
<div class="mc-hd">已完成订单</div>
<div class="mc-value" style="color: #7c3aed">{{ val }}</div>
<div ref="chartRef" class="mc-chart" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniOrders" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const val = ref(3856);
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
tooltip: { show: false },
series: [
{
type: "pie",
radius: ["55%", "78%"],
center: ["50%", "58%"],
label: { show: false },
itemStyle: { borderColor: "#060b24", borderWidth: 1 },
data: [
{ value: 3856, name: "已完成", itemStyle: { color: "#7c3aed" } },
{ value: 843, name: "处理中", itemStyle: { color: "#a78bfa" } },
{ value: 213, name: "待付款", itemStyle: { color: "#1a2050" } },
],
},
],
});
}
function tick() {
if (!chart || chart.isDisposed()) return;
const n = Math.round(3000 + Math.random() * 2000);
val.value = n;
chart.setOption({
series: [
{
data: [
{ value: n },
{ value: Math.round(500 + Math.random() * 600) },
{ value: Math.round(100 + Math.random() * 200) },
],
},
],
});
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
});
onUnmounted(() => {
clearInterval(timer);
chart?.dispose();
});
</script>
<style scoped>
.mini-chart-panel {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 12px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border, rgb(0 180 255 / 12%));
border-radius: 10px;
}
.mini-chart-panel::after {
position: absolute;
top: 4px;
left: 4px;
width: 6px;
height: 6px;
content: "";
border-top: 1px solid rgb(124 58 237 / 40%);
border-left: 1px solid rgb(124 58 237 / 40%);
}
.mc-hd {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mc-value {
margin-bottom: 0;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.mc-chart {
flex: 1;
min-height: 0;
}
</style>
@@ -1,127 +0,0 @@
<template>
<div class="mini-chart-panel">
<div class="mc-hd">用户综合评分</div>
<div class="mc-value" style="color: #f43f5e">{{ val }}<span class="mc-unit">分</span></div>
<div ref="chartRef" class="mc-chart" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniRating" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const val = ref(92);
const indicators = [
{ name: "商品质量", max: 100 },
{ name: "服务态度", max: 100 },
{ name: "物流速度", max: 100 },
{ name: "性价比", max: 100 },
{ name: "包装完好", max: 100 },
];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
radar: {
center: ["50%", "56%"],
radius: "60%",
indicator: indicators,
axisName: { color: "#94a3b8", fontSize: 7 },
splitArea: { areaStyle: { color: ["rgba(244,63,94,0.02)", "rgba(244,63,94,0.02)"] } },
splitLine: { lineStyle: { color: "#1a2050" } },
axisLine: { lineStyle: { color: "#1a2050" } },
},
series: [
{
type: "radar",
symbol: "none",
lineStyle: { color: "#f43f5e", width: 1.5 },
areaStyle: { color: "rgba(244,63,94,0.1)" },
data: [{ value: [96, 91, 88, 85, 94] }],
},
],
});
}
function tick() {
if (!chart || chart.isDisposed()) return;
const v = indicators.map(() => Math.round(80 + Math.random() * 20));
const avg = Math.round(v.reduce((a, b) => a + b, 0) / v.length);
val.value = avg;
chart.setOption({ series: [{ data: [{ value: v }] }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
});
onUnmounted(() => {
clearInterval(timer);
chart?.dispose();
});
</script>
<style scoped>
.mini-chart-panel {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 12px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border, rgb(0 180 255 / 12%));
border-radius: 10px;
}
.mini-chart-panel::after {
position: absolute;
top: 4px;
left: 4px;
width: 6px;
height: 6px;
content: "";
border-top: 1px solid rgb(244 63 94 / 40%);
border-left: 1px solid rgb(244 63 94 / 40%);
}
.mc-hd {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mc-value {
margin-bottom: 0;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.mc-unit {
margin-left: 2px;
font-size: 9px;
font-weight: 400;
opacity: 0.4;
}
.mc-chart {
flex: 1;
min-height: 0;
}
</style>
@@ -1,123 +0,0 @@
<template>
<div class="mini-chart-panel">
<div class="mc-hd">售后退款率</div>
<div class="mc-value" style="color: #f59e0b">{{ val }}<span class="mc-unit">%</span></div>
<div ref="chartRef" class="mc-chart" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniRefund" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const val = ref(2.1);
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 8, right: 4, bottom: 2, left: 2 },
xAxis: {
type: "category",
show: false,
data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
},
yAxis: { type: "value", show: false, min: 0, max: 4 },
series: [
{
type: "bar",
barWidth: 5,
barGap: "30%",
itemStyle: {
borderRadius: [2, 2, 0, 0],
color: (p: any) => (p.dataIndex < 5 ? "#f59e0b" : "#ef4444"),
},
data: [2.4, 2.2, 2.5, 2.1, 2.3, 2.0, 2.1],
},
],
});
}
function tick() {
if (!chart || chart.isDisposed()) return;
const n = +(1.5 + Math.random() * 1.5).toFixed(1);
val.value = n;
const d = chart.getOption() as { series: [{ data: number[] }] };
const arr = d.series[0].data;
arr.push(n);
arr.shift();
chart.setOption({ series: [{ data: arr }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
});
onUnmounted(() => {
clearInterval(timer);
chart?.dispose();
});
</script>
<style scoped>
.mini-chart-panel {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 12px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border, rgb(0 180 255 / 12%));
border-radius: 10px;
}
.mini-chart-panel::after {
position: absolute;
top: 4px;
left: 4px;
width: 6px;
height: 6px;
content: "";
border-top: 1px solid rgb(245 158 11 / 40%);
border-left: 1px solid rgb(245 158 11 / 40%);
}
.mc-hd {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mc-value {
margin-bottom: 0;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.mc-unit {
margin-left: 2px;
font-size: 9px;
font-weight: 400;
opacity: 0.4;
}
.mc-chart {
flex: 1;
min-height: 0;
}
</style>
@@ -1,134 +0,0 @@
<template>
<div class="mini-chart-panel">
<div class="mc-hd">当日交易额</div>
<div class="mc-value" style="color: #00d4ff">¥{{ val }}<span class="mc-unit">万</span></div>
<div ref="chartRef" class="mc-chart" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniRevenue" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const val = ref(128.6);
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
series: [
{
type: "gauge",
startAngle: 210,
endAngle: -30,
center: ["50%", "58%"],
radius: "78%",
min: 0,
max: 200,
axisLine: {
lineStyle: {
width: 6,
color: [
[0.5, "#00d4ff"],
[0.8, "#7c3aed"],
[1, "#ef4444"],
],
},
},
pointer: { length: "55%", width: 3, itemStyle: { color: "#00d4ff" } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
detail: {
valueAnimation: true,
fontSize: 14,
fontWeight: 700,
color: "#00d4ff",
offsetCenter: [0, "42%"],
formatter: "",
},
data: [{ value: 128.6 }],
},
],
});
}
function tick() {
if (!chart || chart.isDisposed()) return;
const n = +(105 + Math.random() * 50).toFixed(1);
val.value = n;
chart.setOption({ series: [{ data: [{ value: n }] }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
});
onUnmounted(() => {
clearInterval(timer);
chart?.dispose();
});
</script>
<style scoped>
.mini-chart-panel {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 12px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border, rgb(0 180 255 / 12%));
border-radius: 10px;
}
.mini-chart-panel::after {
position: absolute;
top: 4px;
left: 4px;
width: 6px;
height: 6px;
content: "";
border-top: 1px solid rgb(0 212 255 / 40%);
border-left: 1px solid rgb(0 212 255 / 40%);
}
.mc-hd {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mc-value {
margin-bottom: 0;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.mc-unit {
margin-left: 2px;
font-size: 9px;
font-weight: 400;
opacity: 0.4;
}
.mc-chart {
flex: 1;
min-height: 0;
}
</style>
@@ -1,117 +0,0 @@
<template>
<div class="mini-chart-panel">
<div class="mc-hd">页面访问量(PV)</div>
<div class="mc-value" style="color: #10b981">{{ val }}</div>
<div ref="chartRef" class="mc-chart" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "MiniTraffic" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const val = ref(5210);
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 8, right: 4, bottom: 2, left: 2 },
xAxis: { type: "category", show: false, data: ["", "", "", "", "", ""] },
yAxis: { type: "value", show: false },
series: [
{
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 4,
lineStyle: { color: "#10b981", width: 2 },
itemStyle: { color: "#10b981" },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(16,185,129,0.25)" },
{ offset: 1, color: "rgba(16,185,129,0)" },
]),
},
data: [3800, 4200, 4500, 4800, 5100, 5210],
},
],
});
}
function tick() {
if (!chart || chart.isDisposed()) return;
const n = Math.round(4000 + Math.random() * 2500);
val.value = n;
const d = chart.getOption() as { series: [{ data: number[] }] };
const arr = d.series[0].data;
arr.push(n);
arr.shift();
chart.setOption({ series: [{ data: arr }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 4000);
}
});
observer.observe(el);
});
onUnmounted(() => {
clearInterval(timer);
chart?.dispose();
});
</script>
<style scoped>
.mini-chart-panel {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 12px;
overflow: hidden;
background: linear-gradient(180deg, rgb(0 30 80 / 55%) 0%, rgb(6 11 36 / 70%) 100%);
border: 1px solid var(--border, rgb(0 180 255 / 12%));
border-radius: 10px;
}
.mini-chart-panel::after {
position: absolute;
top: 4px;
left: 4px;
width: 6px;
height: 6px;
content: "";
border-top: 1px solid rgb(16 185 129 / 40%);
border-left: 1px solid rgb(16 185 129 / 40%);
}
.mc-hd {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.mc-value {
margin-bottom: 0;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.mc-chart {
flex: 1;
min-height: 0;
}
</style>
@@ -1,105 +0,0 @@
<template>
<div class="panel p1 col-3 row-2">
<div class="panel-hd"><span class="dot warn" />模块热度 TOP10</div>
<div class="rank-list">
<div v-for="(r, i) in ranks" :key="r.name" class="rank-row">
<span class="rank-no" :class="'rk-' + (i + 1)">{{ i + 1 }}</span>
<span class="rank-name">{{ r.name }}</span>
<span class="rank-bar-wrap"
><span class="rank-bar" :style="{ width: r.pct + '%', background: rankColor(i) }"
/></span>
<span class="rank-val">{{ r.val }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "ModuleRanking" });
const ranks = ref([
{ name: "用户管理", val: 1542, pct: 100 },
{ name: "操作日志", val: 892, pct: 78 },
{ name: "菜单管理", val: 197, pct: 55 },
{ name: "AI 助手", val: 156, pct: 42 },
{ name: "代码生成", val: 38, pct: 35 },
{ name: "角色管理", val: 45, pct: 28 },
{ name: "字典管理", val: 56, pct: 22 },
{ name: "文件管理", val: 128, pct: 18 },
{ name: "部门管理", val: 12, pct: 12 },
{ name: "定时任务", val: 24, pct: 8 },
]);
const RANK_COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#00d4ff", "#7c3aed", "#10b981"];
function rankColor(i: number) {
return RANK_COLORS[i % RANK_COLORS.length];
}
</script>
<style scoped>
.rank-list {
display: flex;
flex-direction: column;
gap: 5px;
overflow: auto;
}
.rank-row {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
}
.rank-no {
width: 18px;
height: 18px;
font-size: 10px;
font-weight: 700;
background: rgb(26 32 80 / 60%);
border-radius: 4px;
}
.rk-1 {
color: #fff;
background: #ef4444 !important;
}
.rk-2 {
color: #fff;
background: #f59e0b !important;
}
.rk-3 {
color: #fff;
background: #3b82f6 !important;
}
.rank-name {
flex-shrink: 0;
width: 60px;
}
.rank-bar-wrap {
flex: 1;
height: 6px;
overflow: hidden;
background: rgb(26 32 80 / 60%);
border-radius: 3px;
}
.rank-bar {
height: 100%;
border-radius: 3px;
transition: width 0.6s;
}
.rank-val {
width: 30px;
font-variant-numeric: tabular-nums;
text-align: right;
}
</style>
@@ -1,67 +0,0 @@
<template>
<div class="panel p1 col-2">
<div class="panel-hd"><span class="dot accent" />在线用户</div>
<div class="online-users">
<div class="online-count">{{ onlineUsers }}</div>
<div class="online-label">当前在线</div>
<div class="online-trend">
<span :class="onlineTrend >= 0 ? 'positive' : 'negative'"
>{{ onlineTrend >= 0 ? "+" : "" }}{{ onlineTrend }}%</span
>
<span class="trend-label">较昨日</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "OnlineUserCard" });
const onlineUsers = ref(387);
const onlineTrend = ref(15.6);
</script>
<style scoped>
.online-users {
padding: 20px 0;
text-align: center;
}
.online-count {
font-size: 48px;
font-weight: 700;
color: #00d4ff;
}
.online-label {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.5;
}
.online-trend {
margin-top: 10px;
}
.online-trend span:first-child {
font-size: 16px;
font-weight: 600;
}
.online-trend .positive {
color: #10b981;
}
.online-trend .negative {
color: #ef4444;
}
.trend-label {
margin-left: 4px;
font-size: 12px;
opacity: 0.5;
}
</style>
@@ -1,106 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />订单状态</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "OrderStatusChart" });
let chart: echarts.ECharts | null = null;
let timer = 0;
const textStyle = { color: "#94a3b8" };
const categories = ["待付款", "待发货", "已发货", "已完成", "已取消"];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 20, right: 20, bottom: 30, left: 60 },
xAxis: {
type: "category",
data: categories,
axisLine: { lineStyle: { color: "#1a2050" } },
axisLabel: textStyle,
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: "#1a2050", type: "dashed" } },
axisLabel: textStyle,
},
tooltip: {
trigger: "axis" as const,
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
},
series: [
{
type: "bar",
data: [45, 128, 256, 892, 32],
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#7c3aed" },
{ offset: 1, color: "rgba(124,58,237,0.3)" },
]),
},
barWidth: "50%",
},
],
});
}
function handleResize() {
if (!chart || chart.isDisposed()) return;
chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const data = [
Math.round(20 + Math.random() * 60),
Math.round(60 + Math.random() * 150),
Math.round(100 + Math.random() * 300),
Math.round(500 + Math.random() * 600),
Math.round(10 + Math.random() * 50),
];
chart.setOption({ series: [{ data }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 3500);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,95 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot green" />热销品类排行 TOP6</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "ProductBar" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const products = ["手机数码", "电脑办公", "家用电器", "美妆护肤", "服饰鞋包", "食品生鲜"];
const textStyle = { color: "#94a3b8", fontSize: 10 };
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 5, right: 40, bottom: 10, left: 10, outerBounds: true },
xAxis: { type: "value", splitLine: { lineStyle: { color: "#1a2050" } }, axisLabel: textStyle },
yAxis: {
type: "category",
data: products,
inverse: true,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { ...textStyle, width: 64, overflow: "truncate" },
},
series: [
{
type: "bar",
barWidth: 10,
itemStyle: {
borderRadius: [0, 3, 3, 0],
color: (p: any) =>
["#00d4ff", "#7c3aed", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][p.dataIndex % 6],
},
label: {
show: true,
position: "right",
color: "#94a3b8",
fontSize: 10,
formatter: "{c}单",
},
data: [892, 651, 534, 412, 298, 187],
},
],
});
}
function handleResize() {
if (chart && !chart.isDisposed()) chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const data = products.map(() => Math.round(120 + Math.random() * 800));
chart.setOption({ series: [{ data }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 4000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,136 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd">
<span class="dot accent" />实时操作日志
<span class="msg-count">NEW</span>
</div>
<div class="msg-scroll">
<div class="msg-inner">
<div v-for="(m, i) in messagesDuplicated" :key="i" class="msg-row">
<span class="msg-time">{{ m.time }}</span>
<span class="msg-tag" :class="m.tag">{{ m.tagText }}</span>
<span>{{ m.text }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "RealtimeMessages" });
const messages = [
{
time: "22:30",
tag: "order",
tagText: "交易",
text: "商户「星辰科技」完成大额订单支付 ¥86,400",
},
{ time: "22:15", tag: "system", tagText: "系统", text: "数据备份定时任务执行完成,耗时 12.3s" },
{ time: "21:52", tag: "audit", tagText: "审计", text: "运营主管 zhangsan 导出本月交易报表" },
{ time: "21:30", tag: "deploy", tagText: "部署", text: "v3.2.1 版本灰度发布至华东区节点" },
{ time: "21:05", tag: "notice", tagText: "公告", text: "系统公告: 6月1日进行数据库扩容维护" },
{
time: "20:40",
tag: "alarm",
tagText: "告警",
text: "[已恢复] 华南区 API 网关延迟突增至 320ms",
},
{ time: "20:15", tag: "order", tagText: "交易", text: "退款工单 #TK-2024-08921 已自动处理完成" },
{
time: "19:52",
tag: "system",
tagText: "系统",
text: "缓存集群节点 node-07 内存使用率降至 62%",
},
];
const messagesDuplicated = [...messages, ...messages];
</script>
<style scoped>
.msg-scroll {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
}
.msg-inner {
width: 100%;
animation: scrollUp 28s linear infinite;
}
.msg-row {
display: flex;
gap: 6px;
align-items: center;
padding: 5px 0;
font-size: 11px;
border-bottom: 1px solid rgb(26 40 80 / 30%);
opacity: 0.75;
}
.msg-time {
flex-shrink: 0;
width: 38px;
font-variant-numeric: tabular-nums;
opacity: 0.4;
}
.msg-tag {
flex-shrink: 0;
padding: 1px 4px;
font-size: 9px;
border-radius: 3px;
}
.msg-tag.order {
color: #00d4ff;
background: rgb(0 212 255 / 15%);
}
.msg-tag.system {
color: #7c3aed;
background: rgb(124 58 237 / 15%);
}
.msg-tag.audit {
color: #10b981;
background: rgb(16 185 129 / 15%);
}
.msg-tag.deploy {
color: #f59e0b;
background: rgb(245 158 11 / 15%);
}
.msg-tag.notice {
color: #00d4ff;
background: rgb(0 212 255 / 15%);
}
.msg-tag.alarm {
color: #ef4444;
background: rgb(239 68 68 / 15%);
}
.msg-count {
padding: 2px 6px;
margin-left: auto;
font-size: 10px;
color: #00d4ff;
background: rgb(0 212 255 / 15%);
border-radius: 10px;
opacity: 0.6;
}
@keyframes scrollUp {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
</style>
@@ -1,127 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />实时销售分时</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "RealtimeSales" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const textStyle = { color: "#94a3b8" };
const hours = [
"08:00",
"09:00",
"10:00",
"11:00",
"12:00",
"13:00",
"14:00",
"15:00",
"16:00",
"17:00",
"18:00",
"19:00",
"20:00",
];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 20, right: 30, bottom: 30, left: 55 },
xAxis: {
type: "category",
data: hours,
axisLine: { lineStyle: { color: "#1a2050" } },
axisLabel: { ...textStyle, rotate: 30 },
},
yAxis: {
type: "value",
name: "万元",
nameTextStyle: { color: "#94a3b8", fontSize: 11 },
splitLine: { lineStyle: { color: "#1a2050", type: "dashed" } },
axisLabel: textStyle,
},
tooltip: {
trigger: "axis" as const,
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
},
legend: { textStyle, top: 0 },
series: [
{
name: "销售额",
type: "bar",
barWidth: "60%",
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#00d4ff" },
{ offset: 1, color: "rgba(0,212,255,0.25)" },
]),
},
data: [2.1, 3.5, 4.2, 5.8, 3.2, 2.8, 4.5, 5.1, 6.3, 8.2, 4.8, 3.2, 2.5],
},
{
name: "订单数",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 5,
lineStyle: { color: "#f59e0b", width: 2 },
itemStyle: { color: "#f59e0b" },
data: [18, 32, 45, 52, 35, 28, 42, 48, 55, 72, 48, 35, 22],
},
],
});
}
function handleResize() {
if (chart && !chart.isDisposed()) chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const sales = hours.map(() => +(1.5 + Math.random() * 7).toFixed(1));
const orders = hours.map(() => Math.round(15 + Math.random() * 60));
chart.setOption({ series: [{ data: sales }, { data: orders }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 3500);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,108 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot green" />实时流量</div>
<div class="traffic-grid">
<div v-for="item in data" :key="item.label" class="tr-card">
<div class="tr-label">{{ item.label }}</div>
<div class="tr-value" :style="{ color: item.color }">
{{ item.value }}<span class="tr-unit">{{ item.unit }}</span>
</div>
<div class="tr-bar-wrap">
<div class="tr-bar" :style="{ width: item.pct + '%', background: item.color }" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from "vue";
defineOptions({ name: "RealtimeTraffic" });
const data = reactive([
{ label: "PV", value: "8,350", unit: "", color: "#00d4ff", pct: 0, base: 8350 },
{ label: "UV", value: "3,216", unit: "", color: "#7c3aed", pct: 0, base: 3216 },
{ label: "IP", value: "2,048", unit: "", color: "#10b981", pct: 0, base: 2048 },
{ label: "跳出率", value: "32.5", unit: "%", color: "#f59e0b", pct: 0, base: 32.5 },
]);
let timer = 0;
function tick() {
data[0]!.base += Math.round((Math.random() - 0.5) * 120);
data[1]!.base += Math.round((Math.random() - 0.5) * 60);
data[2]!.base += Math.round((Math.random() - 0.5) * 40);
data[3]!.base = +(30 + Math.random() * 8).toFixed(1);
data[0]!.value = data[0]!.base.toLocaleString();
data[1]!.value = data[1]!.base.toLocaleString();
data[2]!.value = data[2]!.base.toLocaleString();
data[3]!.value = data[3]!.base.toFixed(1);
const max = Math.max(data[0]!.base, data[1]!.base, data[2]!.base);
data[0]!.pct = (data[0]!.base / max) * 100;
data[1]!.pct = (data[1]!.base / max) * 100;
data[2]!.pct = (data[2]!.base / max) * 100;
data[3]!.pct = +data[3]!.value;
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 3000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.traffic-grid {
display: grid;
flex: 1;
grid-template-rows: 1fr 1fr;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.tr-card {
display: flex;
flex-direction: column;
justify-content: center;
padding: 10px 12px;
background: linear-gradient(135deg, rgb(0 212 255 / 4%) 0%, rgb(0 20 60 / 30%) 100%);
border: 1px solid rgb(0 180 255 / 6%);
border-radius: 8px;
}
.tr-label {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.tr-value {
display: flex;
gap: 2px;
align-items: baseline;
margin-bottom: 4px;
font-size: 18px;
font-weight: 700;
line-height: 1;
}
.tr-unit {
font-size: 10px;
font-weight: 400;
opacity: 0.4;
}
.tr-bar-wrap {
height: 2px;
overflow: hidden;
background: rgb(26 40 80 / 40%);
border-radius: 1px;
}
.tr-bar {
height: 100%;
border-radius: 1px;
transition: width 0.8s;
}
</style>
@@ -1,90 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />地域分布</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "RegionDistributionChart" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
const textStyle = { color: "#94a3b8" };
function initChart() {
if (!chartRef.value) return;
chart = echarts.init(chartRef.value);
chart.setOption({
grid: { top: 20, right: 20, bottom: 30, left: 50 },
xAxis: {
type: "category",
data: ["北京", "上海", "广州", "深圳", "杭州", "成都", "武汉", "南京"],
axisLabel: { color: "#94a3b8", rotate: 30 },
axisLine: { lineStyle: { color: "#1a2050" } },
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: "#1a2050", type: "dashed" } },
axisLabel: textStyle,
},
tooltip: {
trigger: "axis" as const,
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
},
series: [
{
type: "bar",
data: [280, 220, 180, 150, 130, 95, 80, 65],
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#00d4ff" },
{ offset: 1, color: "rgba(0,212,255,0.3)" },
]),
},
barWidth: 22,
},
],
});
}
function handleResize() {
if (!chart || chart.isDisposed()) return;
chart.resize();
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart();
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,80 +0,0 @@
<template>
<div class="panel p1 col-3">
<div class="panel-hd"><span class="dot green" />销售统计</div>
<div class="sales-cards">
<div class="sales-card">
<span class="sales-label">今日销售额</span>
<span class="sales-value">¥{{ formatNumber(todaySales) }}</span>
<span class="sales-change positive">+12.5%</span>
</div>
<div class="sales-card">
<span class="sales-label">订单数量</span>
<span class="sales-value">{{ todayOrders }}</span>
<span class="sales-change positive">+8.3%</span>
</div>
<div class="sales-card">
<span class="sales-label">转化率</span>
<span class="sales-value">{{ conversionRate }}%</span>
<span class="sales-change negative">-2.1%</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "SalesStatCards" });
const todaySales = ref(125860);
const todayOrders = ref(328);
const conversionRate = ref(3.2);
function formatNumber(num: number): string {
return num.toLocaleString();
}
</script>
<style scoped>
.sales-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.sales-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: rgb(12 18 48 / 60%);
border-radius: 8px;
}
.sales-label {
font-size: 12px;
opacity: 0.6;
}
.sales-value {
font-size: 18px;
font-weight: 600;
color: #e0e6ff;
}
.sales-change {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
}
.sales-change.positive {
color: #10b981;
background: rgb(16 185 129 / 20%);
}
.sales-change.negative {
color: #ef4444;
background: rgb(239 68 68 / 20%);
}
</style>
@@ -1,113 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />销售趋势分析</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "SalesTrendChart" });
let chart: echarts.ECharts | null = null;
let timer = 0;
const textStyle = { color: "#94a3b8" };
const tooltip = {
trigger: "axis" as const,
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
};
const xData = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 20, right: 30, bottom: 30, left: 50 },
xAxis: {
type: "category",
data: xData,
axisLine: { lineStyle: { color: "#1a2050" } },
axisLabel: textStyle,
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: "#1a2050", type: "dashed" } },
axisLabel: textStyle,
},
tooltip,
legend: { textStyle, top: 0 },
series: [
{
name: "订单金额",
type: "bar",
stack: "total",
data: [120, 132, 101, 134, 190, 230, 210],
itemStyle: { color: "#00d4ff" },
},
{
name: "退款金额",
type: "bar",
stack: "total",
data: [20, 32, 11, 34, 90, 30, 10],
itemStyle: { color: "#ef4444" },
},
{
name: "实际收入",
type: "bar",
stack: "total",
data: [100, 100, 90, 100, 100, 200, 200],
itemStyle: { color: "#10b981" },
},
],
});
}
function handleResize() {
if (!chart || chart.isDisposed()) return;
chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const d1 = xData.map(() => Math.round(80 + Math.random() * 180));
const d2 = d1.map((v) => Math.round(v * (0.05 + Math.random() * 0.25)));
const d3 = d1.map((v, i) => Math.round(v - d2[i]!));
chart.setOption({ series: [{ data: d1 }, { data: d2 }, { data: d3 }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 4000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,38 +0,0 @@
<template>
<footer class="screen-footer">
<span>CPU {{ cpu }}%</span><span class="sep">│</span> <span>内存 {{ mem }}%</span
><span class="sep">│</span> <span>磁盘 {{ disk }}%</span><span class="sep">│</span>
<span>网络延迟 {{ networkLatency }}ms</span><span class="sep">│</span>
<span>{{ uptime }}</span>
</footer>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "ScreenFooter" });
const cpu = ref(42);
const mem = ref(61);
const disk = ref(35);
const uptime = ref("7d 14h 32m");
const networkLatency = ref(12);
</script>
<style scoped>
.screen-footer {
position: relative;
z-index: 10;
display: flex;
gap: 12px;
justify-content: center;
padding: 10px 0;
font-size: 12px;
border-top: 1px solid rgb(26 40 80 / 60%);
opacity: 0.4;
}
.sep {
opacity: 0.3;
}
</style>
@@ -1,160 +0,0 @@
<template>
<header class="screen-header">
<div class="header-left">
<span class="dot" :class="{ pulse: online }" />
<span class="text-xs">SYSTEM ONLINE</span>
</div>
<h1 class="screen-title">
<span class="deco-line" />
FastapiAdmin · 智能运营数据监控平台
<span class="deco-line" />
</h1>
<div class="header-right">
<ElTooltip :content="isFs ? '退出全屏' : '进入全屏'" placement="bottom">
<button class="fullscreen-btn" @click="handleToggle">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="fs-icon"
>
<path
v-if="isFs"
d="M4 14h2v2h2v2H4v-4zm12 0h2v2h2v2h-4v-4zM4 4h4v2H6v2H4V4zm14 0h4v4h-2V6h-2V4z"
/>
<path
v-else
d="M4 4h4v2H6v2H4V4zm14 0h4v4h-2V6h-2V4zM4 20h4v-2H6v-2H4v4zm14 0h4v-4h-2v2h-2v2z"
/>
</svg>
</button>
</ElTooltip>
<span class="header-time font-mono text-sm">{{ currentTime }}</span>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, inject, onMounted, onUnmounted } from "vue";
defineOptions({ name: "ScreenHeader" });
const toggleFullscreen = inject<() => void>("toggleFullscreen", () => {});
const isFs = inject("isFullscreen", ref(false));
const online = ref(true);
const currentTime = ref("");
let timer = 0;
onMounted(() => {
const tick = () => {
currentTime.value = new Date().toLocaleString("zh-CN", { hour12: false });
};
tick();
timer = window.setInterval(tick, 1000);
});
onUnmounted(() => clearInterval(timer));
function handleToggle() {
toggleFullscreen();
}
</script>
<style scoped>
.screen-header {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 32px 14px;
margin-bottom: 12px;
border-bottom: 1px solid rgb(26 40 80 / 60%);
}
.header-left {
display: flex;
gap: 8px;
align-items: center;
width: 140px;
font-size: 12px;
opacity: 0.7;
}
.header-right {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
width: 200px;
}
.header-time {
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.dot {
display: inline-block;
flex-shrink: 0;
width: 8px;
height: 8px;
background: #00d4ff;
border-radius: 50%;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 8px #00d4ff;
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.screen-title {
flex: 1;
font-size: 20px;
font-weight: 700;
text-align: center;
letter-spacing: 4px;
text-shadow: 0 0 30px rgb(0 212 255 / 50%);
}
.deco-line {
display: inline-block;
width: 40px;
height: 1px;
margin: 0 12px;
vertical-align: middle;
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
}
.fullscreen-btn {
display: flex;
align-items: center;
padding: 4px 8px;
color: var(--accent, #00d4ff);
cursor: pointer;
background: rgb(0 212 255 / 10%);
border: 1px solid rgb(0 212 255 / 30%);
border-radius: 6px;
transition: background 0.2s;
}
.fullscreen-btn:hover {
background: rgb(0 212 255 / 20%);
}
.fs-icon {
width: 16px;
height: 16px;
}
</style>
@@ -1,78 +0,0 @@
<template>
<div class="panel p1 col-3">
<div class="panel-hd"><span class="dot green" />服务器状态</div>
<div class="server-status">
<div v-for="server in servers" :key="server.name" class="server-item">
<div class="server-header">
<span class="server-name">{{ server.name }}</span>
<span class="server-status-dot" :class="server.status" />
</div>
<div class="server-metrics">
<span>CPU: {{ server.cpu }}%</span>
<span>内存: {{ server.memory }}%</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "ServerStatusList" });
const servers = ref([
{ name: "API Server-01", status: "online", cpu: 45, memory: 62 },
{ name: "API Server-02", status: "online", cpu: 38, memory: 58 },
{ name: "DB Server-01", status: "online", cpu: 25, memory: 72 },
{ name: "Redis Server", status: "online", cpu: 15, memory: 45 },
]);
</script>
<style scoped>
.server-status {
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
}
.server-item {
padding: 10px;
background: rgb(12 18 48 / 60%);
border-radius: 8px;
}
.server-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.server-name {
font-size: 12px;
}
.server-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.server-status-dot.online {
background: #10b981;
box-shadow: 0 0 8px #10b981;
}
.server-status-dot.offline {
background: #ef4444;
}
.server-metrics {
display: flex;
gap: 12px;
font-size: 11px;
opacity: 0.6;
}
</style>
@@ -1,129 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot green" />API 响应时长(均值)</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "ServiceLevel" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const textStyle = { color: "#94a3b8" };
const xData = [
"08:00",
"09:00",
"10:00",
"11:00",
"12:00",
"13:00",
"14:00",
"15:00",
"16:00",
"17:00",
];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 20, right: 25, bottom: 25, left: 45 },
xAxis: {
type: "category",
data: xData,
boundaryGap: false,
axisLine: { lineStyle: { color: "#1a2050" } },
axisLabel: { ...textStyle, rotate: 30 },
},
yAxis: {
type: "value",
name: "ms",
nameTextStyle: { color: "#94a3b8", fontSize: 10 },
min: 20,
max: 80,
interval: 15,
splitLine: { lineStyle: { color: "#1a2050", type: "dashed" } },
axisLabel: textStyle,
},
tooltip: {
trigger: "axis" as const,
valueFormatter: (v: any) => v + "ms",
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
},
series: [
{
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 4,
lineStyle: { color: "#10b981", width: 2 },
itemStyle: { color: "#10b981" },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(16,185,129,0.2)" },
{ offset: 1, color: "rgba(16,185,129,0)" },
]),
},
data: [52, 48, 55, 42, 65, 38, 45, 51, 58, 44],
},
{
type: "line",
smooth: true,
symbol: "diamond",
symbolSize: 3,
lineStyle: { color: "#f59e0b", width: 1.5, type: "dashed" },
itemStyle: { color: "#f59e0b" },
data: [60, 55, 62, 50, 70, 45, 52, 58, 65, 50],
},
],
});
}
function handleResize() {
if (chart && !chart.isDisposed()) chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const d1 = xData.map(() => Math.round(35 + Math.random() * 35));
const d2 = xData.map(() => Math.round(42 + Math.random() * 30));
chart.setOption({ series: [{ data: d1 }, { data: d2 }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 4000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,167 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot green" />系统负载</div>
<div class="gauge-row">
<div v-for="g in gauges" :key="g.label" class="gauge-item">
<div :ref="(el) => setGaugeRef(g.label, el)" class="chart-box-xs" />
<span class="gauge-label">{{ g.label }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "SystemGauges" });
const gaugeRefs: Record<string, HTMLDivElement> = {};
const charts: echarts.ECharts[] = [];
let timer = 0;
let isMounted = true;
const gauges = [
{ label: "CPU", value: 42 },
{ label: "内存", value: 61 },
{ label: "磁盘", value: 35 },
];
function setGaugeRef(label: string, el: unknown) {
if (el instanceof HTMLDivElement) gaugeRefs[label] = el;
}
function waitForSize(el: HTMLDivElement): Promise<void> {
return new Promise((resolve) => {
if (!isMounted) {
resolve();
return;
}
if (el.clientWidth > 0 && el.clientHeight > 0) {
resolve();
return;
}
const check = () => {
if (!isMounted) return;
if (el.clientWidth > 0 && el.clientHeight > 0) {
resolve();
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
});
}
async function initGauges() {
const entries = Object.entries(gaugeRefs);
await Promise.all(entries.map(([, el]) => waitForSize(el)));
entries.forEach(([, el]) => {
const chart = echarts.init(el);
charts.push(chart);
chart.setOption({
series: [
{
type: "gauge",
radius: "90%",
center: ["50%", "60%"],
startAngle: 210,
endAngle: -30,
min: 0,
max: 100,
detail: {
formatter: "{value}%",
fontSize: 12,
color: "#e0e6ff",
offsetCenter: [0, "65%"],
},
data: [{ value: 0 }],
axisLine: {
lineStyle: {
width: 6,
color: [
[0.3, "#10b981"],
[0.7, "#f59e0b"],
[1, "#ef4444"],
],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { length: "55%", width: 3, itemStyle: { color: "#e0e6ff" } },
},
],
});
});
}
function handleResize() {
charts.forEach((c) => {
if (!c.isDisposed()) c.resize();
});
}
function tick() {
const v = [
clamp(gauges[0]!.value + (Math.random() - 0.5) * 8, 10, 90),
clamp(gauges[1]!.value + (Math.random() - 0.5) * 6, 20, 95),
clamp(gauges[2]!.value + (Math.random() - 0.5) * 3, 15, 85),
];
gauges[0]!.value = Math.round(v[0]!);
gauges[1]!.value = Math.round(v[1]!);
gauges[2]!.value = Math.round(v[2]!);
charts.forEach((c, i) => {
if (!c.isDisposed()) c.setOption({ series: [{ data: [{ value: gauges[i]!.value }] }] });
});
}
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
onMounted(() => {
initGauges().then(() => {
timer = window.setInterval(tick, 2000);
});
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
isMounted = false;
clearInterval(timer);
window.removeEventListener("resize", handleResize);
charts.forEach((c) => c.dispose());
});
</script>
<style scoped>
.gauge-row {
display: flex;
flex: 1;
align-items: center;
justify-content: space-around;
}
.gauge-item {
display: flex;
flex: 1;
flex-direction: column;
text-align: center;
}
.gauge-label {
display: block;
flex-shrink: 0;
margin-top: -6px;
font-size: 12px;
opacity: 0.5;
}
.chart-box-xs {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,109 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot purple" />业绩目标达成率</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "TargetReality" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const categories = ["华东区", "华南区", "华北区", "西南区"];
const textStyle = { color: "#94a3b8", fontSize: 10 };
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
grid: { top: 5, right: 30, bottom: 10, left: 10, outerBounds: true },
xAxis: {
type: "value",
max: 150,
splitLine: { lineStyle: { color: "#1a2050" } },
axisLabel: textStyle,
},
yAxis: {
type: "category",
data: categories,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: textStyle,
},
legend: {
textStyle: { color: "#94a3b8", fontSize: 10 },
top: 0,
right: 0,
itemWidth: 10,
itemHeight: 8,
},
series: [
{
name: "目标",
type: "bar",
barWidth: 8,
itemStyle: {
borderRadius: [0, 2, 2, 0],
color: "rgba(0,212,255,0.35)",
borderColor: "#00d4ff",
borderWidth: 1,
},
data: [120, 100, 80, 60],
},
{
name: "已完成",
type: "bar",
barWidth: 8,
itemStyle: { borderRadius: [0, 2, 2, 0], color: "#7c3aed" },
data: [112, 88, 75, 52],
},
],
});
}
function handleResize() {
if (chart && !chart.isDisposed()) chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const targets = [120, 100, 80, 60];
const actual = targets.map((t) => Math.round(t * (0.7 + Math.random() * 0.25)));
chart.setOption({ series: [{}, { data: actual }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,149 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />今日概览</div>
<div class="overview-grid">
<div v-for="item in data" :key="item.label" class="ov-card">
<div class="ov-label">{{ item.label }}</div>
<div class="ov-value" :style="{ color: item.color }">
<span class="ov-count">{{ item.value }}</span>
<span class="ov-unit">{{ item.unit }}</span>
</div>
<div class="ov-change" :class="item.up ? 'up' : 'down'">
{{ item.up ? "▲" : "▼" }} {{ item.change }}
<span class="ov-compare">较昨日</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted } from "vue";
defineOptions({ name: "TodayOverview" });
const data = reactive([
{
label: "总销售额",
value: "99.9",
unit: "万",
color: "#00d4ff",
change: "12.5%",
up: true,
base: 999000,
},
{
label: "订单量",
value: "2,356",
unit: "",
color: "#7c3aed",
change: "8.2%",
up: true,
base: 2356,
},
{
label: "活跃用户",
value: "1,286",
unit: "",
color: "#10b981",
change: "3.1%",
up: true,
base: 1286,
},
{
label: "转化率",
value: "3.82",
unit: "%",
color: "#f59e0b",
change: "1.2%",
up: false,
base: 3.82,
},
]);
let timer = 0;
function tick() {
const orders = data[1]!.base + Math.round((Math.random() - 0.5) * 80);
const users = data[2]!.base + Math.round((Math.random() - 0.5) * 60);
data[0]!.base = orders * 420 + Math.round(Math.random() * 10000);
data[1]!.base = orders;
data[2]!.base = users;
data[3]!.base = +(3.5 + Math.random() * 2).toFixed(2);
data[0]!.value = (data[0]!.base / 10000).toFixed(1);
data[1]!.value = data[1]!.base.toLocaleString();
data[2]!.value = data[2]!.base.toLocaleString();
data[3]!.value = data[3]!.base.toFixed(2);
for (const d of data) {
d.change = (Math.random() * 15 - 3).toFixed(1) + "%";
d.up = Math.random() > 0.4;
}
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 4000);
});
onUnmounted(() => clearInterval(timer));
</script>
<style scoped>
.overview-grid {
display: grid;
flex: 1;
grid-template-rows: 1fr 1fr;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.ov-card {
display: flex;
flex-direction: column;
justify-content: center;
padding: 10px 12px;
background: linear-gradient(135deg, rgb(0 212 255 / 4%) 0%, rgb(0 20 60 / 30%) 100%);
border: 1px solid rgb(0 180 255 / 6%);
border-radius: 8px;
}
.ov-label {
margin-bottom: 2px;
font-size: 10px;
opacity: 0.35;
}
.ov-value {
display: flex;
gap: 3px;
align-items: baseline;
margin-bottom: 3px;
}
.ov-count {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.ov-unit {
font-size: 11px;
opacity: 0.5;
}
.ov-change {
font-size: 10px;
}
.ov-change.up {
color: #10b981;
}
.ov-change.down {
color: #f59e0b;
}
.ov-compare {
margin-left: 3px;
opacity: 0.4;
}
</style>
@@ -1,101 +0,0 @@
<template>
<div class="panel p1 col-3">
<div class="panel-hd"><span class="dot purple" />热销产品 TOP5</div>
<div class="product-list">
<div v-for="(p, i) in topProducts" :key="p.name" class="product-row">
<span class="product-rank flex-cc" :class="'rank-' + (i + 1)">{{ i + 1 }}</span>
<div class="product-info">
<span class="product-name">{{ p.name }}</span>
<span class="product-category">{{ p.category }}</span>
</div>
<span class="product-sales">¥{{ formatNumber(p.sales) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
defineOptions({ name: "TopProductList" });
const topProducts = ref([
{ name: "企业版 License", category: "软件服务", sales: 45200 },
{ name: "高级数据分析", category: "数据服务", sales: 32800 },
{ name: "API 接口调用", category: "接口服务", sales: 28600 },
{ name: "技术支持服务", category: "服务", sales: 15600 },
{ name: "定制开发", category: "定制", sales: 12400 },
]);
function formatNumber(num: number): string {
return num.toLocaleString();
}
</script>
<style scoped>
.product-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.product-row {
display: flex;
gap: 8px;
align-items: center;
padding: 10px;
background: rgb(12 18 48 / 60%);
border-radius: 8px;
}
.product-rank {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
font-size: 10px;
font-weight: 700;
background: rgb(26 32 80 / 60%);
border-radius: 50%;
}
.product-rank.rank-1 {
color: #fff;
background: #ef4444;
}
.product-rank.rank-2 {
color: #fff;
background: #f59e0b;
}
.product-rank.rank-3 {
color: #fff;
background: #3b82f6;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
display: block;
font-size: 13px;
font-weight: 500;
}
.product-category {
display: block;
font-size: 11px;
opacity: 0.5;
}
.product-sales {
font-size: 14px;
font-weight: 600;
color: #00d4ff;
}
</style>
@@ -1,106 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot purple" />用户分布</div>
<div ref="chartRef" class="chart-box-sm" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "UserDistributionChart" });
let chart: echarts.ECharts | null = null;
let timer = 0;
const textStyle = { color: "#94a3b8" };
const names = ["管理员", "普通用户", "访客", "其他"];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
series: [
{
type: "pie",
radius: ["55%", "80%"],
center: ["50%", "55%"],
label: { show: false },
data: [
{ value: 620, name: "管理员", itemStyle: { color: "#00d4ff" } },
{ value: 450, name: "普通用户", itemStyle: { color: "#7c3aed" } },
{ value: 180, name: "访客", itemStyle: { color: "#f59e0b" } },
{ value: 292, name: "其他", itemStyle: { color: "#10b981" } },
],
},
],
tooltip: {
trigger: "item",
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
},
legend: {
orient: "vertical",
right: 10,
top: "center",
textStyle,
itemWidth: 10,
itemHeight: 10,
},
});
}
function handleResize() {
if (!chart || chart.isDisposed()) return;
chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const data = names.map((name) => ({
value: Math.round(100 + Math.random() * 600),
name,
itemStyle: (
chart as unknown as {
getOption: () => { series: [{ data: { name: string; itemStyle?: unknown }[] }] };
}
)
.getOption()
.series[0].data?.find((d: { name: string }) => d.name === name)?.itemStyle,
}));
chart.setOption({ series: [{ data }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box-sm {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,107 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot green" />用户画像分析</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "UserProfile" });
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
let timer = 0;
const indicators = [
{ name: "消费力", max: 100 },
{ name: "活跃度", max: 100 },
{ name: "复购率", max: 100 },
{ name: "客单价", max: 100 },
{ name: "满意度", max: 100 },
];
function initChart(dom: HTMLDivElement) {
chart = echarts.init(dom);
chart.setOption({
legend: {
bottom: 0,
textStyle: { color: "#94a3b8", fontSize: 8 },
itemWidth: 8,
itemHeight: 8,
},
radar: {
center: ["50%", "48%"],
radius: "55%",
indicator: indicators,
axisName: { color: "#94a3b8", fontSize: 8 },
splitArea: { areaStyle: { color: ["rgba(0,212,255,0.02)", "rgba(0,212,255,0.02)"] } },
splitLine: { lineStyle: { color: "#1a2050" } },
axisLine: { lineStyle: { color: "#1a2050" } },
},
series: [
{
type: "radar",
symbol: "circle",
symbolSize: 3,
lineStyle: { color: "#00d4ff", width: 2 },
areaStyle: { color: "rgba(0,212,255,0.12)" },
itemStyle: { color: "#00d4ff" },
data: [{ value: [82, 78, 65, 58, 88], name: "高价值客群" }],
},
{
type: "radar",
symbol: "triangle",
symbolSize: 3,
lineStyle: { color: "#f59e0b", width: 1.5, type: "dashed" },
areaStyle: { color: "rgba(245,158,11,0.06)" },
itemStyle: { color: "#f59e0b" },
data: [{ value: [55, 72, 48, 42, 78], name: "潜力客群" }],
},
],
});
}
function handleResize() {
if (chart && !chart.isDisposed()) chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
const v1 = indicators.map(() => Math.round(53 + Math.random() * 42));
const v2 = indicators.map(() => Math.round(38 + Math.random() * 45));
chart.setOption({ series: [{ data: [{ value: v1 }] }, { data: [{ value: v2 }] }] });
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 5000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -1,143 +0,0 @@
<template>
<div class="panel p1">
<div class="panel-hd"><span class="dot accent" />访问趋势</div>
<div ref="chartRef" class="chart-box" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const chartRef = ref<HTMLDivElement>();
import { echarts } from "@/plugins/echarts";
defineOptions({ name: "VisitTrendChart" });
let chart: echarts.ECharts | null = null;
let timer = 0;
const textStyle = { color: "#94a3b8" };
const tooltip = {
trigger: "axis" as const,
backgroundColor: "#0f143c",
borderColor: "#1a2050",
textStyle: { color: "#e0e6ff" },
};
const xData = [
"00:00",
"02:00",
"04:00",
"06:00",
"08:00",
"10:00",
"12:00",
"14:00",
"16:00",
"18:00",
"20:00",
"22:00",
];
let pv = [120, 85, 60, 45, 200, 380, 450, 520, 480, 380, 300, 200];
let uv = [80, 55, 40, 30, 140, 260, 320, 380, 350, 270, 220, 150];
function initChart(dom: HTMLDivElement) {
if (!dom) return;
chart = echarts.init(dom);
chart.setOption({
grid: { top: 20, right: 30, bottom: 30, left: 50 },
xAxis: {
type: "category",
data: xData,
axisLine: { lineStyle: { color: "#1a2050" } },
axisLabel: textStyle,
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: "#1a2050", type: "dashed" } },
axisLabel: textStyle,
},
tooltip,
legend: { textStyle, top: 0 },
series: [
{
name: "PV",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
lineStyle: { color: "#00d4ff", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(0,212,255,0.3)" },
{ offset: 1, color: "rgba(0,212,255,0.02)" },
]),
},
itemStyle: { color: "#00d4ff" },
data: pv,
},
{
name: "UV",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
lineStyle: { color: "#7c3aed", width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(124,58,237,0.3)" },
{ offset: 1, color: "rgba(124,58,237,0.02)" },
]),
},
itemStyle: { color: "#7c3aed" },
data: uv,
},
],
});
}
function handleResize() {
if (!chart || chart.isDisposed()) return;
chart.resize();
}
function tick() {
if (!chart || chart.isDisposed()) return;
pv.push(Math.round(pv[pv.length - 1]! * (0.85 + Math.random() * 0.3)));
uv.push(Math.round(uv[uv.length - 1]! * (0.85 + Math.random() * 0.3)));
pv.shift();
uv.shift();
chart.setOption({
series: [{ data: pv }, { data: uv }],
});
}
onMounted(() => {
const el = chartRef.value;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
observer.disconnect();
initChart(el);
timer = window.setInterval(tick, 3000);
}
});
observer.observe(el);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener("resize", handleResize);
chart?.dispose();
});
</script>
<style scoped>
.chart-box {
flex: 1;
width: 100%;
min-height: 0;
}
</style>
@@ -340,7 +340,6 @@ onMounted(() => {
}
&.is-current {
background: linear-gradient(180deg, #fffaf0 0%, #fff 60%);
border: 2px solid #e6a23c;
}
@@ -372,7 +371,6 @@ onMounted(() => {
padding: 5px 0;
font-size: 12px;
font-weight: 600;
color: #fff;
text-align: center;
letter-spacing: 1px;
background: linear-gradient(135deg, var(--el-color-primary, #409eff), #79bbff);
@@ -382,7 +380,7 @@ onMounted(() => {
/* Primary gradient button */
.btn-primary-gradient {
color: #fff !important;
background: linear-gradient(135deg, var(--el-color-primary, #409eff), #79bbff) !important;
border: none !important;
transition:
@@ -100,8 +100,8 @@
<span class="text-xs text-(--el-text-color-secondary)">
{{
fieldsMap.access[0]!.localValue === "on"
? "登录时将查询客户端 IP 归属地,用于日志记录"
: "IP 归属地查询已关闭,登录日志不记录地理位置"
? "登录时基于内置离线库本地解析客户端 IP 归属地,用于日志记录"
: "IP 归属地查询已关闭,登录位置只记录内网 / 未解析"
}}
</span>
</div>