Files
vue3-element-admin/src/views/dashboard/index.vue

1147 lines
28 KiB
Vue

<template>
<div class="dash">
<section class="dash-header">
<div class="card dash-header__card">
<div class="dash-header__start">
<div class="dash-avatar">
<img v-if="userStore.userInfo.avatar" :src="userStore.userInfo.avatar" alt="" />
<el-icon v-else :size="22"><User /></el-icon>
</div>
<div class="dash-header__text">
<h1 class="dash-header__greeting">{{ greetings }}</h1>
<p class="dash-header__date">{{ currentDateStr }}</p>
</div>
</div>
<div class="dash-header__end">
<a
href="https://github.com/youlaitech/vue3-element-admin"
target="_blank"
title="GitHub"
class="quick-link"
>
<span class="i-svg:github" />
<span>GitHub</span>
</a>
<a
href="https://gitee.com/youlaiorg/vue3-element-admin"
target="_blank"
title="Gitee"
class="quick-link"
>
<span class="i-svg:gitee" />
<span>Gitee</span>
</a>
<a
href="https://gitcode.com/youlai/vue3-element-admin"
target="_blank"
title="GitCode"
class="quick-link"
>
<span class="i-svg:gitcode" />
<span>GitCode</span>
</a>
<a
href="https://juejin.cn/post/7228990409909108793"
target="_blank"
title="文档"
class="quick-link"
>
<el-icon><Document /></el-icon>
<span>文档</span>
</a>
<a
href="https://www.bilibili.com/video/BV1eFUuYyEFj"
target="_blank"
title="视频"
class="quick-link"
>
<el-icon><VideoPlay /></el-icon>
<span>视频</span>
</a>
</div>
</div>
</section>
<section class="dash-stats">
<div class="stat-card">
<div class="stat-card__icon stat-card__icon--online">
<el-icon :size="18"><Connection /></el-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__label">在线用户</span>
<span class="stat-card__num">{{ onlineUserCount }}</span>
</div>
<span
:class="[
'stat-card__badge',
isConnected ? 'stat-card__badge--on' : 'stat-card__badge--off',
]"
>
{{ isConnected ? "实时" : "离线" }}
</span>
</div>
<div class="stat-card">
<div class="stat-card__icon stat-card__icon--visitor">
<el-icon :size="18"><User /></el-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__label">今日访客</span>
<span class="stat-card__num">{{ displayTransitionUvCount }}</span>
</div>
<span
v-if="uvGrowthText !== '--'"
:class="['stat-card__trend', `stat-card__trend--${uvTrendTone}`]"
>
<el-icon :size="12">
<ArrowUp v-if="uvIsUp" />
<ArrowDown v-else />
</el-icon>
{{ uvGrowthText }}
</span>
</div>
<div class="stat-card">
<div class="stat-card__icon stat-card__icon--view">
<el-icon :size="18"><View /></el-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__label">今日浏览量</span>
<span class="stat-card__num">{{ displayTransitionPvCount }}</span>
</div>
<span
v-if="pvGrowthText !== '--'"
:class="['stat-card__trend', `stat-card__trend--${pvTrendTone}`]"
>
<el-icon :size="12">
<ArrowUp v-if="pvIsUp" />
<ArrowDown v-else />
</el-icon>
{{ pvGrowthText }}
</span>
</div>
<div class="stat-card">
<div class="stat-card__icon stat-card__icon--account">
<span class="stat-card__svg i-svg:group" />
</div>
<div class="stat-card__body">
<span class="stat-card__label">系统用户</span>
<span class="stat-card__num">6</span>
</div>
<span :class="['stat-card__trend', `stat-card__trend--${systemTrendTone}`]">
<el-icon :size="12"><ArrowUp /></el-icon>
12.5%
</span>
</div>
</section>
<section class="dash-chart">
<div class="card dash-chart__trend">
<div class="card__head">
<h3 class="card__title">访问趋势</h3>
<el-radio-group v-model="visitTrendDateRange" size="small">
<el-radio-button label="近7天" :value="7" />
<el-radio-button label="近30天" :value="30" />
</el-radio-group>
</div>
<div class="card__body card__body--chart">
<ECharts :options="visitTrendChartOptions" height="260px" />
</div>
</div>
<div class="card dash-chart__overview">
<div class="card__head">
<h3 class="card__title">待办概览</h3>
<el-tag type="primary" size="small" effect="plain">5 项待处理</el-tag>
</div>
<div class="card__body overview-card">
<div class="overview-summary">
<div v-for="item in todoSummaryItems" :key="item.label" class="overview-summary__item">
<span class="overview-summary__label">{{ item.label }}</span>
<strong class="overview-summary__value">{{ item.value }}</strong>
</div>
</div>
<div class="overview-bars">
<div
v-for="item in todoOverviewItems"
:key="item.label"
class="overview-bars__item"
:style="{
'--overview-percent': `${item.percent}%`,
}"
>
<div class="overview-bars__meta">
<span class="overview-bars__label">
<span class="overview-bars__dot" />
{{ item.label }}
</span>
<span class="overview-bars__value">{{ item.value }} </span>
</div>
<span class="overview-bars__track">
<span class="overview-bars__bar" />
</span>
</div>
</div>
</div>
</div>
</section>
<section class="dash-bottom">
<div class="card">
<div class="card__head">
<h3 class="card__title">待办事项</h3>
<el-tag size="small" round>5 </el-tag>
</div>
<div class="card__body">
<div
v-for="todo in todoItems"
:key="todo.id"
class="todo-row"
:class="{ 'todo-row--done': todo.done }"
>
<el-icon
:size="16"
:class="todo.done ? 'todo-row__icon--done' : 'todo-row__icon--pending'"
>
<CircleCheck v-if="todo.done" />
<Clock v-else />
</el-icon>
<span class="todo-row__title">{{ todo.title }}</span>
<el-tag
:type="todo.done ? 'success' : todo.tag === '工单' ? 'warning' : 'info'"
size="small"
effect="plain"
class="todo-row__tag"
>
{{ todo.tag }}
</el-tag>
<span class="todo-row__time">{{ todo.time }}</span>
</div>
</div>
</div>
<div class="card">
<div class="card__head">
<h3 class="card__title">系统动态</h3>
</div>
<div class="card__body card__body--scroll">
<div class="feed">
<div v-for="item in activities" :key="item.id" class="feed__item">
<span class="feed__dot" />
<span class="feed__text">{{ item.content }}</span>
<span class="feed__time">{{ item.time }}</span>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "Dashboard", inheritAttrs: false });
import { dayjs } from "element-plus";
import { ref } from "vue";
import LogAPI from "@/api/system/log";
import type { VisitOverviewDetail, VisitTrendDetail } from "@/api/system/log";
import { useUserStore } from "@/stores/user";
import { useSettingsStore } from "@/stores/settings";
import { formatGrowthRate } from "@/utils";
import { useTransition } from "@vueuse/core";
import {
User,
Connection,
View,
ArrowUp,
ArrowDown,
Clock,
CircleCheck,
Document,
VideoPlay,
} from "@element-plus/icons-vue";
import { useOnlineUsers } from "@/composables";
const userStore = useUserStore();
const settingsStore = useSettingsStore();
const { onlineUserCount, isConnected } = useOnlineUsers();
const hours = new Date().getHours();
const greetings = computed(() => {
const n = userStore.userInfo.nickname;
if (hours >= 6 && hours < 8) return `早安,${n}`;
if (hours >= 8 && hours < 12) return `上午好,${n}`;
if (hours >= 12 && hours < 18) return `下午好,${n}`;
if (hours >= 18 && hours < 24) return `晚上好,${n}`;
return `夜深了,${n}`;
});
const currentDateStr = computed(() => {
const d = new Date();
const w = ["日", "一", "二", "三", "四", "五", "六"];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${w[d.getDay()]}`;
});
interface TodoItem {
id: number;
title: string;
tag: string;
time: string;
done: boolean;
}
const todoItems: TodoItem[] = [
{
id: 1,
title: "审批:张三提交的请假申请",
tag: "审批",
time: "10分钟前",
done: false,
},
{
id: 2,
title: "审核:新用户注册信息核实",
tag: "审核",
time: "30分钟前",
done: false,
},
{
id: 3,
title: "发布:系统维护通知公告",
tag: "通知",
time: "1小时前",
done: false,
},
{
id: 4,
title: "处理:工单 #TSK-20240509",
tag: "工单",
time: "2小时前",
done: false,
},
{
id: 5,
title: "更新:用户角色权限配置",
tag: "配置",
time: "昨天 15:30",
done: true,
},
];
interface Activity {
id: number;
content: string;
time: string;
}
const activities: Activity[] = [
{ id: 1, content: "管理员 admin 登录系统", time: "3分钟前" },
{ id: 2, content: "新增用户李四,角色为普通用户", time: "25分钟前" },
{ id: 3, content: "系统配置项「登录策略」已更新", time: "1小时前" },
{ id: 4, content: "数据库自动备份任务执行完成", time: "3小时前" },
{ id: 5, content: "角色权限批量修改:运营组新增导出权限", time: "昨天 16:42" },
{ id: 6, content: "SSL 证书已自动续期", time: "昨天 09:15" },
];
const todoOverviewItems = [
{ label: "审批", value: "2", percent: 40, tone: "primary" },
{ label: "审核", value: "1", percent: 20, tone: "primary" },
{ label: "通知", value: "1", percent: 20, tone: "primary" },
{ label: "工单", value: "1", percent: 20, tone: "primary" },
];
const todoSummaryItems = [
{ label: "今日新增", value: "3", tone: "primary" },
{ label: "即将超时", value: "1", tone: "primary" },
{ label: "今日完成", value: "1", tone: "success" },
];
const visitOverviewData = ref<VisitOverviewDetail>({
todayUvCount: 0,
uvGrowthRate: 0,
totalUvCount: 0,
todayPvCount: 0,
pvGrowthRate: 0,
totalPvCount: 0,
});
const uvGrowthText = computed(() => {
const r = visitOverviewData.value.uvGrowthRate;
return r == null ? "--" : formatGrowthRate(r);
});
const pvGrowthText = computed(() => {
const r = visitOverviewData.value.pvGrowthRate;
return r == null ? "--" : formatGrowthRate(r);
});
const uvIsUp = computed(() => (visitOverviewData.value.uvGrowthRate || 0) > 0);
const pvIsUp = computed(() => (visitOverviewData.value.pvGrowthRate || 0) > 0);
const uvTrendTone = computed(() => (uvIsUp.value ? "success" : "danger"));
const pvTrendTone = computed(() => (pvIsUp.value ? "success" : "danger"));
const systemTrendTone = "success";
const tUv = useTransition(
computed(() => visitOverviewData.value.todayUvCount),
{
duration: 800,
transition: [0.25, 0.1, 0.25, 1.0],
}
);
const tPv = useTransition(
computed(() => visitOverviewData.value.todayPvCount),
{
duration: 800,
transition: [0.25, 0.1, 0.25, 1.0],
}
);
const displayTransitionUvCount = computed(() => Math.round(Number(tUv.value)));
const displayTransitionPvCount = computed(() => Math.round(Number(tPv.value)));
const visitTrendDateRange = ref(7);
const visitTrendData = ref<VisitTrendDetail>();
const visitTrendChartOptions = ref({});
function getCssVar(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
function colorWithAlpha(color: string, alpha: number) {
const value = color.trim();
if (value.startsWith("#")) {
const hex =
value.length === 4
? `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
: value;
const rgb = Number.parseInt(hex.slice(1), 16);
const r = (rgb >> 16) & 255;
const g = (rgb >> 8) & 255;
const b = rgb & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const parts = value.match(/\d+(\.\d+)?/g);
if (parts && parts.length >= 3) {
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
return value;
}
function fetchVisitOverviewData() {
LogAPI.getVisitOverview().then((d) => {
visitOverviewData.value = d;
});
}
function fetchVisitTrendData() {
const s = dayjs()
.subtract(visitTrendDateRange.value - 1, "day")
.toDate();
LogAPI.getVisitTrend({
startDate: dayjs(s).format("YYYY-MM-DD"),
endDate: dayjs(new Date()).format("YYYY-MM-DD"),
}).then((d) => {
visitTrendData.value = d;
updateVisitTrendChartOptions(d);
});
}
function updateVisitTrendChartOptions(d: VisitTrendDetail) {
const primary = getCssVar("--el-color-primary", "#409eff");
const success = getCssVar("--el-color-success", "#67c23a");
const textSecondary = getCssVar("--el-text-color-secondary", "#909399");
const borderLighter = getCssVar("--el-border-color-lighter", "#ebeef5");
const gridLine = colorWithAlpha(borderLighter, 0.72);
visitTrendChartOptions.value = {
tooltip: {
trigger: "axis",
borderWidth: 0,
padding: [8, 12],
extraCssText: "box-shadow: var(--el-box-shadow-light); border-radius: 6px;",
},
legend: {
data: ["浏览量", "访客量"],
bottom: 0,
textStyle: { fontSize: 12, color: textSecondary },
itemWidth: 10,
itemHeight: 8,
itemGap: 24,
},
grid: { left: "0%", right: "3%", bottom: "14%", top: "5%", containLabel: true },
xAxis: {
type: "category",
data: d.dates,
axisTick: { show: false },
axisLine: { lineStyle: { color: colorWithAlpha(borderLighter, 0.82) } },
axisLabel: { fontSize: 11, color: textSecondary },
splitLine: { show: false },
},
yAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { type: "dashed", color: gridLine, width: 1 } },
axisLabel: { fontSize: 11, color: textSecondary },
},
series: [
{
name: "浏览量",
type: "line",
data: d.pvList,
smooth: true,
symbol: "circle",
symbolSize: 5,
showSymbol: false,
lineStyle: { color: primary, width: 2.2 },
itemStyle: { color: primary },
areaStyle: {
opacity: 1,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: colorWithAlpha(primary, 0.18) },
{ offset: 0.52, color: colorWithAlpha(primary, 0.08) },
{ offset: 1, color: colorWithAlpha(primary, 0.01) },
],
},
},
},
{
name: "访客量",
type: "line",
data: d.uvList,
smooth: true,
symbol: "circle",
symbolSize: 5,
showSymbol: false,
lineStyle: { color: colorWithAlpha(success, 0.9), width: 1.8, opacity: 0.86 },
itemStyle: { color: success },
areaStyle: {
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: colorWithAlpha(success, 0.08) },
{ offset: 1, color: colorWithAlpha(success, 0) },
],
},
},
},
],
};
}
watch(
() => visitTrendDateRange.value,
() => fetchVisitTrendData(),
{ immediate: true }
);
watch(
() => [settingsStore.resolvedTheme, settingsStore.themeColors],
() => {
if (!visitTrendData.value) return;
requestAnimationFrame(() => {
if (visitTrendData.value) updateVisitTrendChartOptions(visitTrendData.value);
});
},
{ deep: true }
);
onMounted(() => {
fetchVisitOverviewData();
});
</script>
<style lang="scss" scoped>
// Tokens
$gap: 12px;
$pad: 10px;
%card {
overflow: hidden;
background: var(--el-bg-color-overlay);
border: 1px solid var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
}
// Page
.dash {
display: flex;
flex-direction: column;
gap: $gap;
padding: $pad;
background: var(--page-bg);
}
// Header
.dash-header {
&__card {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
justify-content: space-between;
min-height: 78px;
padding: 16px 18px;
}
&__start {
display: flex;
flex: 1;
gap: 12px;
align-items: center;
min-width: 260px;
}
&__text {
display: flex;
flex-direction: column;
gap: 3px;
}
&__greeting {
margin: 0;
font-size: 18px;
font-weight: 500;
line-height: 1.3;
color: var(--el-text-color-primary);
}
&__date {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__end {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
}
.dash-avatar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
overflow: hidden;
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 14%, var(--el-bg-color-overlay));
border: 1px solid color-mix(in srgb, var(--el-color-primary) 18%, transparent);
border-radius: 50%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Quick links
.quick-link {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-decoration: none;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
transition:
color 0.15s,
background-color 0.15s,
border-color 0.15s;
.el-icon,
[class^="i-svg:"] {
width: 15px;
height: 15px;
font-size: 15px;
color: currentcolor;
}
&:hover {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 7%, var(--el-bg-color-overlay));
border-color: color-mix(in srgb, var(--el-color-primary) 20%, var(--el-border-color-lighter));
}
}
// Stat cards
.dash-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $gap;
}
.stat-card {
display: flex;
gap: 14px;
align-items: center;
min-height: 84px;
padding: 18px;
@extend %card;
&__icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
&--online {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 10%, var(--el-bg-color-overlay));
}
&--visitor {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 8%, var(--el-bg-color-overlay));
}
&--view {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 8%, var(--el-bg-color-overlay));
}
&--account {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 8%, var(--el-bg-color-overlay));
}
}
&__svg {
width: 20px;
height: 20px;
font-size: 20px;
color: currentcolor;
}
&__body {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
&__num {
font-size: 24px;
font-weight: 600;
line-height: 1.15;
color: var(--el-text-color-primary);
}
&__label {
margin-bottom: 3px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
&__badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
&--on {
color: var(--el-color-success);
}
&--off {
color: var(--el-text-color-secondary);
}
}
&__trend {
display: inline-flex;
flex-shrink: 0;
gap: 3px;
align-items: center;
font-size: 12px;
font-weight: 700;
color: var(--el-text-color-secondary);
&--success {
color: var(--el-color-success);
}
&--danger {
color: var(--el-color-danger);
}
}
}
// Generic card
.card {
display: flex;
flex-direction: column;
@extend %card;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 13px 18px;
border-bottom: 1px solid var(--card-border);
}
&__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__body {
padding: 16px 18px 18px;
&--chart {
padding: 14px 18px 16px;
}
&--scroll {
flex: 1;
padding: 0;
overflow-y: auto;
}
}
}
.dash-header__card {
flex-direction: row;
}
// Chart & bottom grids
.dash-chart {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
gap: $gap;
}
.dash-chart__trend,
.dash-chart__overview {
min-width: 0;
}
.overview-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 20px;
min-height: 0;
}
.overview-bars {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
min-height: 142px;
padding: 0;
&__item {
display: flex;
flex-direction: column;
gap: 7px;
}
&__meta {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
&__label,
&__value {
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__value {
flex-shrink: 0;
text-align: right;
}
&__label {
display: inline-flex;
gap: 6px;
align-items: center;
min-width: 0;
}
&__dot {
width: 6px;
height: 6px;
background: var(--el-color-primary);
border-radius: 50%;
}
&__track {
height: 5px;
overflow: hidden;
background: color-mix(in srgb, var(--el-color-primary) 10%, var(--el-fill-color-light));
border-radius: 999px;
}
&__bar {
display: block;
width: var(--overview-percent);
height: 100%;
background: linear-gradient(90deg, var(--el-color-primary), var(--el-color-primary-light-3));
border-radius: inherit;
}
}
.overview-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
&__item {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
min-height: 64px;
padding: 12px;
overflow: hidden;
background: color-mix(in srgb, var(--el-color-primary) 4%, var(--el-bg-color-overlay));
border: 1px solid
color-mix(in srgb, var(--el-color-primary) 10%, var(--el-border-color-lighter));
border-radius: 6px;
&:nth-child(2) {
background: color-mix(in srgb, var(--el-color-primary) 6%, var(--el-bg-color-overlay));
border-color: color-mix(in srgb, var(--el-color-primary) 14%, var(--el-border-color-lighter));
&::before {
background: color-mix(in srgb, var(--el-color-primary) 48%, transparent);
}
.overview-summary__value {
color: var(--el-color-warning);
}
}
&:nth-child(3) {
background: color-mix(in srgb, var(--el-color-primary) 3%, var(--el-bg-color-overlay));
border-color: color-mix(in srgb, var(--el-color-primary) 8%, var(--el-border-color-lighter));
&::before {
background: color-mix(in srgb, var(--el-color-primary) 36%, transparent);
}
.overview-summary__value {
color: var(--el-color-success);
}
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
content: "";
background: color-mix(in srgb, var(--el-color-primary) 62%, transparent);
}
}
&__label {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
&__value {
flex-shrink: 0;
margin-top: 5px;
font-size: 18px;
font-weight: 600;
line-height: 1.1;
color: var(--el-color-primary);
}
}
.dash-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $gap;
}
// Todo rows
.todo-row {
display: flex;
gap: 10px;
align-items: center;
padding: 11px 0;
& + & {
border-top: 1px solid var(--el-border-color-lighter);
}
&--done {
.todo-row__title {
color: var(--el-text-color-placeholder);
text-decoration: line-through;
}
}
&__icon--pending {
flex-shrink: 0;
color: var(--el-color-primary);
}
&__icon--done {
flex-shrink: 0;
color: var(--el-color-success);
}
&__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
&__tag {
flex-shrink: 0;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
&.el-tag--warning {
color: color-mix(in srgb, var(--el-color-warning) 78%, var(--el-text-color-primary));
background: color-mix(in srgb, var(--el-color-warning) 9%, var(--el-bg-color-overlay));
}
&.el-tag--success {
color: var(--el-color-success);
background: color-mix(in srgb, var(--el-color-success) 8%, var(--el-bg-color-overlay));
}
}
&__time {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
// Activity feed
.feed {
display: flex;
flex-direction: column;
padding: 10px 20px 16px;
&__item {
position: relative;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
padding: 10px 0 10px 16px;
&::before {
position: absolute;
top: 22px;
bottom: -4px;
left: 3px;
width: 1px;
content: "";
background: var(--el-border-color-lighter);
}
&:last-child::before {
display: none;
}
}
&__dot {
position: absolute;
top: 12px;
left: 0;
width: 7px;
height: 7px;
background: var(--el-color-primary);
border: 2px solid var(--el-color-primary-light-8);
border-radius: 50%;
}
&__text {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
color: var(--el-text-color-regular);
}
&__time {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
// Responsive
@media (max-width: 1200px) {
.dash-stats {
grid-template-columns: repeat(2, 1fr);
}
.dash-header__card {
flex-direction: column;
align-items: flex-start;
}
.dash-header__end {
justify-content: flex-start;
}
}
@media (max-width: 992px) {
.dash-chart {
grid-template-columns: 1fr;
}
.dash-bottom {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dash {
gap: 10px;
padding: 10px;
}
.dash-stats {
grid-template-columns: 1fr;
}
}
</style>