mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-26 22:31:21 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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%" })
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user