<template>
|
<div class="dashboard-page">
|
<section class="panel overview-panel" v-loading="overviewLoading">
|
<div class="section-head">
|
<div class="section-title">数据总览</div>
|
<div class="filters">
|
<el-date-picker
|
v-model="overviewDateRange"
|
class="range-picker"
|
type="daterange"
|
unlink-panels
|
range-separator="至"
|
start-placeholder="开始日期"
|
end-placeholder="结束日期"
|
value-format="YYYY-MM-DD"
|
:clearable="false"
|
@change="loadOverview"
|
/>
|
</div>
|
</div>
|
|
<div class="summary-grid">
|
<article
|
v-for="item in summaryCards"
|
:key="item.title"
|
class="summary-card"
|
:style="{ backgroundImage: `url(${item.bg})` }"
|
>
|
<div class="summary-content">
|
<div class="summary-value">{{ item.value }}</div>
|
<div class="summary-title">{{ item.title }}</div>
|
</div>
|
</article>
|
</div>
|
</section>
|
|
<div class="content-grid">
|
<section class="panel chart-panel" v-loading="chartLoading">
|
<div class="section-head">
|
<div class="section-title">预约数据</div>
|
<div class="filters">
|
<el-tree-select
|
v-model="deptFilter"
|
class="filter-select"
|
:data="deptOptions"
|
:props="{ value: 'value', label: 'label', children: 'children' }"
|
check-strictly
|
default-expand-all
|
:render-after-expand="false"
|
@change="loadAppointmentStats"
|
/>
|
<el-date-picker
|
v-model="chartDateRange"
|
class="range-picker"
|
type="daterange"
|
unlink-panels
|
range-separator="至"
|
start-placeholder="开始日期"
|
end-placeholder="结束日期"
|
value-format="YYYY-MM-DD"
|
:clearable="false"
|
@change="loadAppointmentStats"
|
/>
|
</div>
|
</div>
|
|
<div class="gauge-board">
|
<div v-for="item in gaugeCards" :key="item.label" class="gauge-item">
|
<div class="gauge-ring" :style="{ '--ring-color': item.color }">
|
<div class="gauge-inner">
|
<div class="gauge-value">{{ item.value }}</div>
|
<div class="gauge-label">{{ item.label }}</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<div class="line-chart">
|
<div ref="lineChartRef" class="echarts-line"></div>
|
</div>
|
</section>
|
|
<section class="panel quick-panel">
|
<div class="section-head">
|
<div class="section-title">便捷入口</div>
|
</div>
|
<div class="quick-grid">
|
<button
|
v-for="entry in quickEntries"
|
:key="entry.title"
|
type="button"
|
class="quick-item"
|
@click="go(entry.path)"
|
>
|
<img :src="entry.icon" :alt="entry.title" class="quick-icon-image" />
|
<span>{{ entry.title }}</span>
|
</button>
|
</div>
|
</section>
|
</div>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { ElMessage } from "element-plus";
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { useRouter } from "vue-router";
|
import * as echarts from "echarts/core";
|
import type { ECharts, EChartsOption } from "echarts";
|
import { LineChart } from "echarts/charts";
|
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
import { CanvasRenderer } from "echarts/renderers";
|
import http from "../api/http";
|
import dailyAppointmentCardBg from "../static/img/单日预约数.png";
|
import totalAppointmentCardBg from "../static/img/累计预约数.png";
|
import registeredUserCardBg from "../static/img/注册用户.png";
|
import employeeCountCardBg from "../static/img/员工数.png";
|
import deptManageIcon from "../static/img/部门管理.png";
|
import userManageIcon from "../static/img/账号管理.png";
|
import staffGroupIcon from "../static/img/人员分组.png";
|
import appointmentRecordIcon from "../static/img/预约记录.png";
|
import regionManageIcon from "../static/img/区域管理.png";
|
import employeeManageIcon from "../static/img/用户管理.png";
|
import approvalConfigIcon from "../static/img/审核配置.png";
|
import bookingConfigIcon from "../static/img/预约配置.png";
|
|
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
|
|
const router = useRouter();
|
|
type DashboardOverview = {
|
startDate: string;
|
endDate: string;
|
dailyDate: string;
|
dailyAppointmentCount: number;
|
totalAppointmentCount: number;
|
registeredUserCount: number;
|
employeeCount: number;
|
};
|
|
type DashboardTrendPoint = {
|
statDate: string;
|
label: string;
|
appointmentCount: number;
|
inProgressCount: number;
|
rejectedCount: number;
|
cancelledCount: number;
|
};
|
|
type DashboardAppointmentStats = {
|
startDate: string;
|
endDate: string;
|
visitedDeptId?: number;
|
appointmentCount: number;
|
inProgressCount: number;
|
rejectedCount: number;
|
cancelledCount: number;
|
trend: DashboardTrendPoint[];
|
};
|
|
type DeptTreeNode = {
|
id: number;
|
deptName: string;
|
status?: number;
|
children?: DeptTreeNode[];
|
};
|
|
type DeptTreeOption = {
|
value: number | "ALL";
|
label: string;
|
children?: DeptTreeOption[];
|
};
|
|
const ALL_DEPT_VALUE = "ALL" as const;
|
|
const deptOptions = ref<DeptTreeOption[]>([]);
|
const deptFilter = ref<number | typeof ALL_DEPT_VALUE>(ALL_DEPT_VALUE);
|
const overviewLoading = ref(false);
|
const chartLoading = ref(false);
|
const lineChartRef = ref<HTMLElement | null>(null);
|
let lineChartInstance: ECharts | null = null;
|
|
const overviewDateRange = ref<[string, string]>(getCurrentWeekRange());
|
const chartDateRange = ref<[string, string]>(getCurrentWeekRange());
|
|
const overview = ref<DashboardOverview>({
|
startDate: overviewDateRange.value[0],
|
endDate: overviewDateRange.value[1],
|
dailyDate: overviewDateRange.value[1],
|
dailyAppointmentCount: 0,
|
totalAppointmentCount: 0,
|
registeredUserCount: 0,
|
employeeCount: 0,
|
});
|
|
const appointmentStats = ref<DashboardAppointmentStats>({
|
startDate: chartDateRange.value[0],
|
endDate: chartDateRange.value[1],
|
appointmentCount: 0,
|
inProgressCount: 0,
|
rejectedCount: 0,
|
cancelledCount: 0,
|
trend: [],
|
});
|
|
const cappedTrend = computed(() => appointmentStats.value.trend.slice(0, 31));
|
|
const summaryCards = computed(() => [
|
{ title: "单日预约数", value: formatNumber(overview.value.dailyAppointmentCount), theme: "orange", bg: dailyAppointmentCardBg },
|
{ title: "累计预约数", value: formatNumber(overview.value.totalAppointmentCount), theme: "blue", bg: totalAppointmentCardBg },
|
{ title: "注册用户", value: formatNumber(overview.value.registeredUserCount), theme: "cyan", bg: registeredUserCardBg },
|
{ title: "员工数", value: formatNumber(overview.value.employeeCount), theme: "green", bg: employeeCountCardBg },
|
]);
|
|
const gaugeCards = computed(() => [
|
{ label: "预约数", value: formatNumber(appointmentStats.value.appointmentCount), color: "#45c36c" },
|
{ label: "审核中", value: formatNumber(appointmentStats.value.inProgressCount), color: "#5269FF" },
|
{ label: "审核驳回", value: formatNumber(appointmentStats.value.rejectedCount), color: "#ff5f42" },
|
{ label: "访客取消", value: formatNumber(appointmentStats.value.cancelledCount), color: "#ffbb3d" },
|
]);
|
|
const quickEntries = [
|
{ title: "部门管理", path: "/staff/depts", icon: deptManageIcon },
|
{ title: "账号管理", path: "/users", icon: userManageIcon },
|
{ title: "人员分组", path: "/staff/groups", icon: staffGroupIcon },
|
{ title: "预约记录", path: "/appointments", icon: appointmentRecordIcon },
|
{ title: "区域管理", path: "/region/pass-areas", icon: regionManageIcon },
|
{ title: "用户管理", path: "/visitor-info", icon: employeeManageIcon },
|
{ title: "审核配置", path: "/system/approval-settings", icon: approvalConfigIcon },
|
{ title: "预约配置", path: "/system/booking-settings", icon: bookingConfigIcon },
|
];
|
|
function go(path: string) {
|
router.push(path);
|
}
|
|
function getCurrentWeekRange(): [string, string] {
|
const now = new Date();
|
const currentDay = now.getDay() || 7;
|
const start = new Date(now);
|
start.setDate(now.getDate() - currentDay + 1);
|
const end = new Date(start);
|
end.setDate(start.getDate() + 6);
|
return [formatDate(start), formatDate(end)];
|
}
|
|
function formatDate(date: Date): string {
|
const year = date.getFullYear();
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const day = String(date.getDate()).padStart(2, "0");
|
return `${year}-${month}-${day}`;
|
}
|
|
function formatNumber(value: number | undefined): string {
|
return new Intl.NumberFormat("zh-CN").format(Number(value) || 0);
|
}
|
|
function getLineChartOption(): EChartsOption {
|
const rows = cappedTrend.value;
|
const today = formatDate(new Date());
|
const inProgressSeries = rows.map((item) => {
|
const value = Number(item.inProgressCount) || 0;
|
return item.statDate > today ? 0 : value;
|
});
|
return {
|
color: ["#45c36c", "#5269FF", "#ff6a4d", "#ffc24b"],
|
tooltip: {
|
trigger: "axis",
|
axisPointer: { type: "line" },
|
},
|
legend: {
|
top: 4,
|
right: 8,
|
itemWidth: 10,
|
itemHeight: 10,
|
textStyle: {
|
color: "#6d7ea8",
|
fontSize: 12,
|
},
|
data: ["预约数", "审核中", "审核驳回", "访客取消"],
|
},
|
grid: {
|
left: 46,
|
right: 22,
|
top: 44,
|
bottom: 34,
|
},
|
xAxis: {
|
type: "category",
|
boundaryGap: false,
|
data: rows.map(item => item.label),
|
axisLine: { lineStyle: { color: "#dbe5fb" } },
|
axisTick: { show: false },
|
axisLabel: { color: "#8d9aba", fontSize: 12 },
|
},
|
yAxis: {
|
type: "value",
|
minInterval: 1,
|
axisLine: { show: false },
|
axisTick: { show: false },
|
axisLabel: { color: "#8d9aba", fontSize: 12 },
|
splitLine: { lineStyle: { type: "dashed", color: "#dbe5fb" } },
|
},
|
series: [
|
{
|
name: "预约数",
|
type: "line",
|
smooth: true,
|
showSymbol: true,
|
symbolSize: 6,
|
data: rows.map(item => Number(item.appointmentCount) || 0),
|
label: { show: true, position: "top", fontSize: 11, fontWeight: 600, color: "#45c36c" },
|
},
|
{
|
name: "审核中",
|
type: "line",
|
smooth: true,
|
showSymbol: true,
|
symbolSize: 6,
|
data: inProgressSeries,
|
label: { show: true, position: "top", fontSize: 11, fontWeight: 600, color: "#5269FF" },
|
},
|
{
|
name: "审核驳回",
|
type: "line",
|
smooth: true,
|
showSymbol: true,
|
symbolSize: 6,
|
data: rows.map(item => Number(item.rejectedCount) || 0),
|
label: { show: true, position: "top", fontSize: 11, fontWeight: 600, color: "#ff6a4d" },
|
},
|
{
|
name: "访客取消",
|
type: "line",
|
smooth: true,
|
showSymbol: true,
|
symbolSize: 6,
|
data: rows.map(item => Number(item.cancelledCount) || 0),
|
label: { show: true, position: "top", fontSize: 11, fontWeight: 600, color: "#ffc24b" },
|
},
|
],
|
};
|
}
|
|
function renderLineChart() {
|
const el = lineChartRef.value;
|
if (!el) {
|
return;
|
}
|
if (!lineChartInstance) {
|
lineChartInstance = echarts.init(el);
|
}
|
lineChartInstance.setOption(getLineChartOption(), true);
|
}
|
|
function handleResize() {
|
lineChartInstance?.resize();
|
}
|
|
async function loadOverview() {
|
overviewLoading.value = true;
|
try {
|
const { data } = await http.get("/dashboard/overview", {
|
params: {
|
startDate: overviewDateRange.value?.[0],
|
endDate: overviewDateRange.value?.[1],
|
},
|
});
|
overview.value = {
|
...overview.value,
|
...(data.result || {}),
|
} as DashboardOverview;
|
} catch (error: unknown) {
|
ElMessage.error(error instanceof Error ? error.message : "加载数据总览失败");
|
} finally {
|
overviewLoading.value = false;
|
}
|
}
|
|
async function loadAppointmentStats() {
|
chartLoading.value = true;
|
try {
|
const { data } = await http.get("/dashboard/appointment-stats", {
|
params: {
|
startDate: chartDateRange.value?.[0],
|
endDate: chartDateRange.value?.[1],
|
visitedDeptId: deptFilter.value === ALL_DEPT_VALUE ? undefined : deptFilter.value,
|
},
|
});
|
appointmentStats.value = {
|
...appointmentStats.value,
|
...(data.result || {}),
|
trend: Array.isArray(data.result?.trend) ? data.result.trend : [],
|
} as DashboardAppointmentStats;
|
} catch (error: unknown) {
|
ElMessage.error(error instanceof Error ? error.message : "加载预约统计失败");
|
} finally {
|
chartLoading.value = false;
|
}
|
}
|
|
async function loadDeptOptions() {
|
try {
|
const { data } = await http.get("/sys/dept-manage/tree");
|
const rows = Array.isArray(data.result) ? (data.result as DeptTreeNode[]) : [];
|
deptOptions.value = [{
|
value: ALL_DEPT_VALUE,
|
label: "全部",
|
children: mapDeptTreeOptions(rows),
|
}];
|
} catch (error: unknown) {
|
ElMessage.error(error instanceof Error ? error.message : "加载部门失败");
|
}
|
}
|
|
function mapDeptTreeOptions(nodes: DeptTreeNode[]): DeptTreeOption[] {
|
return nodes.map((item) => ({
|
value: item.id,
|
label: item.status === 0 ? `${item.deptName}(禁用)` : item.deptName,
|
children: item.children?.length ? mapDeptTreeOptions(item.children) : undefined,
|
}));
|
}
|
|
onMounted(async () => {
|
await Promise.all([loadDeptOptions(), loadOverview(), loadAppointmentStats()]);
|
await nextTick();
|
renderLineChart();
|
window.addEventListener("resize", handleResize);
|
});
|
|
watch(cappedTrend, async () => {
|
await nextTick();
|
renderLineChart();
|
}, { deep: true });
|
|
onBeforeUnmount(() => {
|
window.removeEventListener("resize", handleResize);
|
lineChartInstance?.dispose();
|
lineChartInstance = null;
|
});
|
</script>
|
|
<style scoped>
|
.dashboard-page {
|
min-height: 100%;
|
padding: 0;
|
background:
|
linear-gradient(180deg, #f7faff 0%, #f2f6ff 100%);
|
}
|
|
.panel {
|
background: #ffffff;
|
border: 1px solid #e5ecfb;
|
border-radius: 16px;
|
box-shadow: 0 10px 30px rgba(47, 99, 212, 0.06);
|
}
|
|
.overview-panel,
|
.chart-panel,
|
.quick-panel {
|
padding: 16px;
|
}
|
|
.overview-panel {
|
width: 82.22vw;
|
height: 217px;
|
padding-left: 0.9vw;
|
padding-right: 0.9vw;
|
padding-top: 0;
|
padding-bottom: 0;
|
box-sizing: border-box;
|
}
|
|
.section-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
margin-bottom: 14px;
|
}
|
|
.overview-panel .section-head {
|
height: 58px;
|
margin-bottom: 0;
|
border-bottom: 1px solid #e5ecfb;
|
}
|
|
.section-title {
|
position: relative;
|
padding-left: 12px;
|
color: #2e4d92;
|
font-size: 18px;
|
font-weight: 700;
|
}
|
|
.range-select,
|
.range-picker,
|
.filter-select {
|
width: 15.56vw;
|
}
|
|
.filters {
|
display: flex;
|
gap: 10px;
|
}
|
|
.summary-grid {
|
display: grid;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 16px;
|
height: 158px;
|
align-items: center;
|
}
|
|
.summary-card {
|
position: relative;
|
overflow: hidden;
|
width: 272px;
|
height: 110px;
|
padding: 18px 18px 16px;
|
border-radius: 16px;
|
color: #fff;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
background-position: center;
|
background-repeat: no-repeat;
|
background-size: 100% 100%;
|
box-sizing: border-box;
|
}
|
|
.summary-card::before {
|
content: "";
|
position: absolute;
|
inset: 0;
|
background:
|
linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 42%),
|
linear-gradient(225deg, rgba(255, 255, 255, 0.14), transparent 46%);
|
}
|
|
.summary-content {
|
position: relative;
|
z-index: 1;
|
}
|
|
.summary-value {
|
font-size: 40px;
|
font-weight: 900;
|
line-height: 1;
|
margin-bottom: 10px;
|
}
|
|
.summary-title {
|
font-size: 16px;
|
font-weight: 500;
|
opacity: 0.96;
|
}
|
|
.content-grid {
|
display: grid;
|
grid-template-columns: minmax(0, 1.8fr) minmax(260px, 1fr);
|
gap: 16px;
|
margin-top: 16px;
|
}
|
|
.gauge-board {
|
display: grid;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 12px;
|
padding: 9px 10px;
|
border: 1px solid #e8eefc;
|
border-radius: 14px;
|
background: linear-gradient(180deg, #ffffff, #f8fbff);
|
}
|
|
.gauge-item {
|
display: flex;
|
justify-content: center;
|
}
|
|
.gauge-ring {
|
width: 76px;
|
height: 76px;
|
padding: 6px;
|
border-radius: 50%;
|
background:
|
conic-gradient(var(--ring-color) 0 130deg, #edf2ff 130deg 360deg);
|
}
|
|
.gauge-inner {
|
width: 100%;
|
height: 100%;
|
border-radius: 50%;
|
background: #fff;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
color: #4a5f89;
|
}
|
|
.gauge-value {
|
font-size: 21px;
|
font-weight: 700;
|
line-height: 1;
|
}
|
|
.gauge-label {
|
margin-top: 4px;
|
font-size: 12px;
|
}
|
|
.line-chart {
|
position: relative;
|
height: 330px;
|
margin-top: 16px;
|
padding: 0;
|
border-top: 1px solid #edf2fd;
|
}
|
.echarts-line {
|
width: 100%;
|
height: 100%;
|
}
|
|
.quick-grid {
|
display: grid;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
gap: 22px 12px;
|
padding: 10px 6px 8px;
|
}
|
|
.quick-item {
|
border: 0;
|
background: transparent;
|
cursor: pointer;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
gap: 10px;
|
color: #445882;
|
font-size: 14px;
|
transition: transform 0.18s ease, color 0.18s ease;
|
}
|
|
.quick-item:hover {
|
color: #2f63d4;
|
transform: translateY(-2px);
|
}
|
|
.quick-icon-image {
|
display: block;
|
width: 64px;
|
height: 64px;
|
object-fit: contain;
|
}
|
|
@media (max-width: 1280px) {
|
.summary-grid {
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
|
.content-grid {
|
grid-template-columns: 1fr;
|
}
|
}
|
|
@media (max-width: 768px) {
|
.dashboard-page {
|
padding: 0px;
|
}
|
|
.section-head {
|
flex-direction: column;
|
align-items: stretch;
|
}
|
|
.filters {
|
flex-direction: column;
|
}
|
|
.range-select,
|
.range-picker,
|
.filter-select {
|
width: 100%;
|
}
|
|
.summary-grid,
|
.gauge-board,
|
.quick-grid {
|
grid-template-columns: 1fr;
|
}
|
|
.line-chart {
|
height: 280px;
|
}
|
}
|
</style>
|