<template>
|
<div class="dashboard-page">
|
<section class="panel overview-panel" v-loading="overviewLoading">
|
<div class="section-head">
|
<div class="section-title">数据总览</div>
|
<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 class="summary-grid">
|
<article
|
v-for="item in summaryCards"
|
:key="item.title"
|
class="summary-card"
|
:class="item.theme"
|
>
|
<div class="summary-content">
|
<div class="summary-value">{{ item.value }}</div>
|
<div class="summary-title">{{ item.title }}</div>
|
</div>
|
<div class="summary-illustration">
|
<div class="glow"></div>
|
<div class="badge">{{ item.tag }}</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-select v-model="deptFilter" class="filter-select" placeholder="全部" @change="loadAppointmentStats">
|
<el-option
|
v-for="item in deptOptions"
|
:key="item.value"
|
:label="item.label"
|
:value="item.value"
|
/>
|
</el-select>
|
<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 class="line-grid">
|
<span v-for="line in 6" :key="line" class="grid-line"></span>
|
</div>
|
<div class="y-axis">
|
<span v-for="label in yAxisLabels" :key="label">{{ label }}</span>
|
</div>
|
<svg viewBox="0 0 760 280" preserveAspectRatio="none" class="chart-svg">
|
<polyline
|
v-for="line in chartLines"
|
:key="line.name"
|
:points="line.points"
|
fill="none"
|
:stroke="line.color"
|
stroke-width="3"
|
stroke-linecap="round"
|
stroke-linejoin="round"
|
/>
|
</svg>
|
<div class="x-axis" :style="{ gridTemplateColumns: `repeat(${Math.max(xAxisLabels.length, 1)}, minmax(0, 1fr))` }">
|
<span v-for="label in xAxisLabels" :key="label">{{ label }}</span>
|
</div>
|
<div class="legend">
|
<span v-for="line in chartLines" :key="line.name" class="legend-item">
|
<i :style="{ backgroundColor: line.color }"></i>
|
{{ line.name }}
|
</span>
|
</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)"
|
>
|
<div class="quick-icon" :class="entry.theme">
|
<el-icon><component :is="entry.icon" /></el-icon>
|
</div>
|
<span>{{ entry.title }}</span>
|
</button>
|
</div>
|
</section>
|
</div>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { ElMessage } from "element-plus";
|
import { computed, onMounted, ref } from "vue";
|
import { useRouter } from "vue-router";
|
import {
|
DocumentCopy,
|
FolderOpened,
|
LocationInformation,
|
OfficeBuilding,
|
Operation,
|
Setting,
|
Tickets,
|
User,
|
} from "@element-plus/icons-vue";
|
import http from "../api/http";
|
|
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 DeptOption = {
|
id?: number;
|
deptName: string;
|
};
|
|
const deptOptions = ref<Array<{ label: string; value: number | undefined }>>([
|
{ label: "全部", value: undefined },
|
]);
|
const deptFilter = ref<number | undefined>(undefined);
|
const overviewLoading = ref(false);
|
const chartLoading = ref(false);
|
|
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), tag: "日", theme: "orange" },
|
{ title: "累计预约数", value: formatNumber(overview.value.totalAppointmentCount), tag: "总", theme: "blue" },
|
{ title: "注册用户", value: formatNumber(overview.value.registeredUserCount), tag: "户", theme: "cyan" },
|
{ title: "员工数", value: formatNumber(overview.value.employeeCount), tag: "人", theme: "green" },
|
]);
|
|
const gaugeCards = computed(() => [
|
{ label: "预约数", value: formatNumber(appointmentStats.value.appointmentCount), color: "#45c36c" },
|
{ label: "审核中", value: formatNumber(appointmentStats.value.inProgressCount), color: "#8fb8ff" },
|
{ label: "审核未通过", value: formatNumber(appointmentStats.value.rejectedCount), color: "#ff5f42" },
|
{ label: "访客取消", value: formatNumber(appointmentStats.value.cancelledCount), color: "#ffbb3d" },
|
]);
|
|
const xAxisLabels = computed(() => cappedTrend.value.map((item) => item.label));
|
|
const axisMax = computed(() => {
|
const values = cappedTrend.value.flatMap((item) => [
|
Number(item.appointmentCount) || 0,
|
Number(item.inProgressCount) || 0,
|
Number(item.rejectedCount) || 0,
|
Number(item.cancelledCount) || 0,
|
]);
|
const maxValue = values.length ? Math.max(...values) : 0;
|
const step = Math.max(1, Math.ceil(Math.max(maxValue, 1) / 5));
|
return step * 5;
|
});
|
|
const yAxisLabels = computed(() => {
|
const labels: string[] = [];
|
const step = axisMax.value / 5;
|
for (let i = 5; i >= 0; i -= 1) {
|
labels.push(String(Math.round(step * i)));
|
}
|
return labels;
|
});
|
|
const chartLines = computed(() => [
|
{
|
name: "预约数",
|
color: "#45c36c",
|
points: buildLinePoints(cappedTrend.value.map((item) => Number(item.appointmentCount) || 0)),
|
},
|
{
|
name: "审核中",
|
color: "#8fb8ff",
|
points: buildLinePoints(cappedTrend.value.map((item) => Number(item.inProgressCount) || 0)),
|
},
|
{
|
name: "审核未通过",
|
color: "#ff6a4d",
|
points: buildLinePoints(cappedTrend.value.map((item) => Number(item.rejectedCount) || 0)),
|
},
|
{
|
name: "访客取消",
|
color: "#ffc24b",
|
points: buildLinePoints(cappedTrend.value.map((item) => Number(item.cancelledCount) || 0)),
|
},
|
]);
|
|
const quickEntries = [
|
{ title: "部门管理", path: "/staff/depts", icon: OfficeBuilding, theme: "indigo" },
|
{ title: "账号管理", path: "/users", icon: User, theme: "purple" },
|
{ title: "人员分组", path: "/staff/groups", icon: FolderOpened, theme: "blue" },
|
{ title: "预约记录", path: "/appointments", icon: Tickets, theme: "green" },
|
{ title: "区域管理", path: "/region/pass-areas", icon: LocationInformation, theme: "orange" },
|
{ title: "用户管理", path: "/staff/employees", icon: DocumentCopy, theme: "sky" },
|
{ title: "审核配置", path: "/system/approval-settings", icon: Operation, theme: "red" },
|
{ title: "预约配置", path: "/system/booking-settings", icon: Setting, theme: "azure" },
|
];
|
|
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 buildLinePoints(values: number[]): string {
|
if (!values.length) {
|
return "";
|
}
|
const left = 20;
|
const right = 720;
|
const top = 2;
|
const bottom = 278;
|
if (values.length === 1) {
|
return `${(left + right) / 2},${calcY(values[0], top, bottom)}`;
|
}
|
return values
|
.map((value, index) => {
|
const x = left + ((right - left) * index) / (values.length - 1);
|
return `${x},${calcY(value, top, bottom)}`;
|
})
|
.join(" ");
|
}
|
|
function calcY(value: number, top: number, bottom: number): number {
|
const max = axisMax.value || 1;
|
return bottom - (Math.max(value, 0) / max) * (bottom - top);
|
}
|
|
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,
|
},
|
});
|
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/depts");
|
const rows = Array.isArray(data.result) ? (data.result as DeptOption[]) : [];
|
deptOptions.value = [{ label: "全部", value: undefined }, ...rows.map((item) => ({
|
label: item.deptName,
|
value: item.id,
|
}))];
|
} catch (error: unknown) {
|
ElMessage.error(error instanceof Error ? error.message : "加载部门失败");
|
}
|
}
|
|
onMounted(async () => {
|
await Promise.all([loadDeptOptions(), loadOverview(), loadAppointmentStats()]);
|
});
|
</script>
|
|
<style scoped>
|
.dashboard-page {
|
min-height: 100%;
|
padding: 18px;
|
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;
|
}
|
|
.section-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
margin-bottom: 14px;
|
}
|
|
.section-title {
|
position: relative;
|
padding-left: 12px;
|
color: #2e4d92;
|
font-size: 18px;
|
font-weight: 700;
|
}
|
|
.section-title::before {
|
content: "";
|
position: absolute;
|
left: 0;
|
top: 3px;
|
bottom: 3px;
|
width: 4px;
|
border-radius: 999px;
|
background: linear-gradient(180deg, #4b82ff, #67d2ff);
|
}
|
|
.range-select,
|
.range-picker,
|
.filter-select {
|
width: 240px;
|
}
|
|
.filters {
|
display: flex;
|
gap: 10px;
|
}
|
|
.summary-grid {
|
display: grid;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 16px;
|
}
|
|
.summary-card {
|
position: relative;
|
overflow: hidden;
|
min-height: 92px;
|
padding: 18px 18px 16px;
|
border-radius: 16px;
|
color: #fff;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.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-card.orange {
|
background: linear-gradient(135deg, #ff9f32, #ffbe21);
|
}
|
|
.summary-card.blue {
|
background: linear-gradient(135deg, #4a67ff, #47a8ff);
|
}
|
|
.summary-card.cyan {
|
background: linear-gradient(135deg, #35a8f4, #4fd3d7);
|
}
|
|
.summary-card.green {
|
background: linear-gradient(135deg, #3fd6a4, #71dc68);
|
}
|
|
.summary-content,
|
.summary-illustration {
|
position: relative;
|
z-index: 1;
|
}
|
|
.summary-value {
|
font-size: 24px;
|
font-weight: 700;
|
line-height: 1;
|
margin-bottom: 10px;
|
}
|
|
.summary-title {
|
font-size: 14px;
|
opacity: 0.96;
|
}
|
|
.summary-illustration {
|
width: 68px;
|
height: 56px;
|
}
|
|
.glow {
|
position: absolute;
|
right: -8px;
|
top: -4px;
|
width: 56px;
|
height: 56px;
|
border-radius: 18px;
|
background: rgba(255, 255, 255, 0.2);
|
filter: blur(1px);
|
}
|
|
.badge {
|
position: absolute;
|
right: 0;
|
bottom: 2px;
|
width: 42px;
|
height: 42px;
|
border-radius: 14px;
|
background: rgba(255, 255, 255, 0.24);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 18px;
|
font-weight: 700;
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
}
|
|
.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: 18px 10px;
|
border: 1px solid #e8eefc;
|
border-radius: 14px;
|
background: linear-gradient(180deg, #ffffff, #f8fbff);
|
}
|
|
.gauge-item {
|
display: flex;
|
justify-content: center;
|
}
|
|
.gauge-ring {
|
width: 86px;
|
height: 86px;
|
padding: 6px;
|
border-radius: 50%;
|
background:
|
conic-gradient(var(--ring-color) 0 280deg, #edf2ff 280deg 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: 18px 16px 32px 42px;
|
border-top: 1px solid #edf2fd;
|
}
|
|
.line-grid {
|
position: absolute;
|
inset: 18px 16px 32px 42px;
|
display: flex;
|
flex-direction: column;
|
justify-content: space-between;
|
}
|
|
.grid-line {
|
border-top: 1px dashed #dbe5fb;
|
}
|
|
.y-axis {
|
position: absolute;
|
left: 0;
|
top: 12px;
|
bottom: 32px;
|
width: 34px;
|
display: flex;
|
flex-direction: column;
|
justify-content: space-between;
|
color: #8d9aba;
|
font-size: 12px;
|
text-align: right;
|
}
|
|
.chart-svg {
|
position: absolute;
|
inset: 18px 16px 32px 42px;
|
width: calc(100% - 58px);
|
height: calc(100% - 50px);
|
}
|
|
.x-axis {
|
position: absolute;
|
left: 42px;
|
right: 16px;
|
bottom: 8px;
|
display: grid;
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
color: #8d9aba;
|
font-size: 12px;
|
}
|
|
.legend {
|
position: absolute;
|
top: 18px;
|
right: 18px;
|
display: flex;
|
flex-wrap: wrap;
|
justify-content: flex-end;
|
gap: 10px 14px;
|
}
|
|
.legend-item {
|
display: inline-flex;
|
align-items: center;
|
gap: 6px;
|
color: #6d7ea8;
|
font-size: 12px;
|
}
|
|
.legend-item i {
|
width: 10px;
|
height: 10px;
|
border-radius: 50%;
|
}
|
|
.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 {
|
width: 56px;
|
height: 56px;
|
border-radius: 18px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
color: #fff;
|
font-size: 28px;
|
box-shadow: 0 12px 22px rgba(79, 121, 216, 0.18);
|
}
|
|
.quick-icon.indigo {
|
background: linear-gradient(135deg, #5d79ff, #6d9fff);
|
}
|
|
.quick-icon.purple {
|
background: linear-gradient(135deg, #7e63ff, #ab5ef7);
|
}
|
|
.quick-icon.blue {
|
background: linear-gradient(135deg, #30a5ff, #4e84ff);
|
}
|
|
.quick-icon.green {
|
background: linear-gradient(135deg, #57ca4a, #88d54e);
|
}
|
|
.quick-icon.orange {
|
background: linear-gradient(135deg, #ff7a33, #ff5835);
|
}
|
|
.quick-icon.sky {
|
background: linear-gradient(135deg, #5f83ff, #679cff);
|
}
|
|
.quick-icon.red {
|
background: linear-gradient(135deg, #ff6b45, #f2452d);
|
}
|
|
.quick-icon.azure {
|
background: linear-gradient(135deg, #3aa0ff, #3b82f6);
|
}
|
|
@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: 12px;
|
}
|
|
.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;
|
padding-left: 36px;
|
}
|
|
.line-grid,
|
.chart-svg,
|
.x-axis {
|
left: 36px;
|
}
|
|
.legend {
|
position: static;
|
margin-bottom: 8px;
|
justify-content: flex-start;
|
}
|
}
|
</style>
|