longz
2026-04-27 25df88d0ab9d7444ed1d162b9efa1259ac2f6f66
frontend-admin/src/views/DashboardView.vue
@@ -1,16 +1,20 @@
<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">
@@ -33,11 +37,11 @@
    </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"
@@ -45,14 +49,18 @@
                :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>
@@ -69,7 +77,7 @@
        <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>
@@ -86,7 +94,7 @@
              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">
@@ -122,7 +130,8 @@
</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,
@@ -134,63 +143,136 @@
  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" },
@@ -206,6 +288,110 @@
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>
@@ -257,8 +443,9 @@
}
.range-select,
.range-picker,
.filter-select {
  width: 220px;
  width: 240px;
}
.filters {
@@ -583,6 +770,7 @@
  }
  .range-select,
  .range-picker,
  .filter-select {
    width: 100%;
  }