| | |
| | | <template> |
| | | <div class="dashboard-page"> |
| | | <section class="panel overview-panel"> |
| | | <section class="panel overview-panel" v-loading="overviewLoading"> |
| | | <div class="section-head"> |
| | | <div class="section-title">数据总览</div> |
| | | <el-select v-model="overviewRange" class="range-select" placeholder="选择时间"> |
| | | <el-option |
| | | v-for="item in rangeOptions" |
| | | :key="item.value" |
| | | :label="item.label" |
| | | :value="item.value" |
| | | /> |
| | | </el-select> |
| | | <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"> |
| | |
| | | </section> |
| | | |
| | | <div class="content-grid"> |
| | | <section class="panel chart-panel"> |
| | | <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="全部"> |
| | | <el-select v-model="deptFilter" class="filter-select" placeholder="全部" @change="loadAppointmentStats"> |
| | | <el-option |
| | | v-for="item in deptOptions" |
| | | :key="item.value" |
| | |
| | | :value="item.value" |
| | | /> |
| | | </el-select> |
| | | <el-select v-model="chartRange" class="range-select" placeholder="选择时间"> |
| | | <el-option |
| | | v-for="item in rangeOptions" |
| | | :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="line-chart"> |
| | | <div class="line-grid"> |
| | | <span v-for="line in 5" :key="line" class="grid-line"></span> |
| | | <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> |
| | |
| | | stroke-linejoin="round" |
| | | /> |
| | | </svg> |
| | | <div class="x-axis"> |
| | | <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"> |
| | |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { ref } from "vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import { computed, onMounted, ref } from "vue"; |
| | | import { useRouter } from "vue-router"; |
| | | import { |
| | | DocumentCopy, |
| | |
| | | Tickets, |
| | | User, |
| | | } from "@element-plus/icons-vue"; |
| | | import http from "../api/http"; |
| | | |
| | | const router = useRouter(); |
| | | |
| | | const rangeOptions = [ |
| | | { label: "2024.09.13 - 2024.09.19", value: "2024.09.13 - 2024.09.19" }, |
| | | { label: "2024.09.06 - 2024.09.12", value: "2024.09.06 - 2024.09.12" }, |
| | | ]; |
| | | type DashboardOverview = { |
| | | startDate: string; |
| | | endDate: string; |
| | | dailyDate: string; |
| | | dailyAppointmentCount: number; |
| | | totalAppointmentCount: number; |
| | | registeredUserCount: number; |
| | | employeeCount: number; |
| | | }; |
| | | |
| | | const deptOptions = [ |
| | | { label: "全部", value: "all" }, |
| | | { label: "综合管理部", value: "综合管理部" }, |
| | | { label: "信息技术部", value: "信息技术部" }, |
| | | ]; |
| | | type DashboardTrendPoint = { |
| | | statDate: string; |
| | | label: string; |
| | | appointmentCount: number; |
| | | inProgressCount: number; |
| | | rejectedCount: number; |
| | | cancelledCount: number; |
| | | }; |
| | | |
| | | const overviewRange = ref(rangeOptions[0].value); |
| | | const chartRange = ref(rangeOptions[0].value); |
| | | const deptFilter = ref(deptOptions[0].value); |
| | | type DashboardAppointmentStats = { |
| | | startDate: string; |
| | | endDate: string; |
| | | visitedDeptId?: number; |
| | | appointmentCount: number; |
| | | inProgressCount: number; |
| | | rejectedCount: number; |
| | | cancelledCount: number; |
| | | trend: DashboardTrendPoint[]; |
| | | }; |
| | | |
| | | const summaryCards = [ |
| | | { title: "单日预约数", value: "3", tag: "日", theme: "orange" }, |
| | | { title: "累计预约数", value: "3,154", tag: "总", theme: "blue" }, |
| | | { title: "注册用户", value: "43", tag: "户", theme: "cyan" }, |
| | | { title: "员工数", value: "433", tag: "人", theme: "green" }, |
| | | ]; |
| | | type DeptOption = { |
| | | id?: number; |
| | | deptName: string; |
| | | }; |
| | | |
| | | const gaugeCards = [ |
| | | { label: "预约数", value: "656", color: "#45c36c" }, |
| | | { label: "审批中", value: "656", color: "#5878ff" }, |
| | | { label: "审核未通过", value: "6", color: "#ff5f42" }, |
| | | { label: "访客取消", value: "6", color: "#ffbb3d" }, |
| | | ]; |
| | | 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 yAxisLabels = ["250", "200", "150", "100", "50", "0"]; |
| | | const xAxisLabels = ["2-12", "2-13", "2-14", "2-15", "2-16", "2-17", "2-18", "2-19", "2-20", "2-21", "2-22", "2-23"]; |
| | | const overviewDateRange = ref<[string, string]>(getCurrentWeekRange()); |
| | | const chartDateRange = ref<[string, string]>(getCurrentWeekRange()); |
| | | |
| | | const chartLines = [ |
| | | 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: "#6a84ff", |
| | | points: "20,132 82,116 144,68 206,104 268,86 330,46 392,102 454,82 516,68 578,118 640,60 720,88", |
| | | color: "#45c36c", |
| | | points: buildLinePoints(cappedTrend.value.map((item) => Number(item.appointmentCount) || 0)), |
| | | }, |
| | | { |
| | | name: "审批中", |
| | | color: "#7acb79", |
| | | points: "20,156 82,124 144,40 206,132 268,92 330,104 392,62 454,96 516,100 578,150 640,112 720,168", |
| | | name: "审核中", |
| | | color: "#8fb8ff", |
| | | points: buildLinePoints(cappedTrend.value.map((item) => Number(item.inProgressCount) || 0)), |
| | | }, |
| | | { |
| | | name: "审核未通过", |
| | | color: "#ff6a4d", |
| | | points: "20,220 82,176 144,238 206,188 268,210 330,170 392,238 454,226 516,234 578,182 640,160 720,202", |
| | | points: buildLinePoints(cappedTrend.value.map((item) => Number(item.rejectedCount) || 0)), |
| | | }, |
| | | { |
| | | name: "访客取消", |
| | | color: "#ffc24b", |
| | | points: "20,144 82,170 144,166 206,126 268,118 330,202 392,132 454,136 516,138 578,192 640,134 720,124", |
| | | points: buildLinePoints(cappedTrend.value.map((item) => Number(item.cancelledCount) || 0)), |
| | | }, |
| | | ]; |
| | | ]); |
| | | |
| | | const quickEntries = [ |
| | | { title: "部门管理", path: "/staff/depts", icon: OfficeBuilding, theme: "indigo" }, |
| | |
| | | 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> |
| | |
| | | } |
| | | |
| | | .range-select, |
| | | .range-picker, |
| | | .filter-select { |
| | | width: 220px; |
| | | width: 240px; |
| | | } |
| | | |
| | | .filters { |
| | |
| | | } |
| | | |
| | | .range-select, |
| | | .range-picker, |
| | | .filter-select { |
| | | width: 100%; |
| | | } |