瀏覽代碼

first commit

gcz 1 月之前
當前提交
28e3473ed2
共有 11 個文件被更改,包括 832 次插入0 次删除
  1. 28 0
      .gitignore
  2. 13 0
      index.html
  3. 22 0
      package.json
  4. 3 0
      public/robots.txt
  5. 8 0
      public/vite.svg
  6. 27 0
      src/App.vue
  7. 10 0
      src/api/statistics.js
  8. 9 0
      src/main.js
  9. 44 0
      src/utils/request.js
  10. 652 0
      src/views/Statistics.vue
  11. 16 0
      vite.config.js

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+.DS_Store
+node_modules/
+dist/
+dist-prod/
+dist-staging/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+**/*.log
+.history/
+
+.cursor/
+
+tests/**/coverage/
+tests/e2e/reports
+selenium-debug.log
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.local
+
+package-lock.json
+yarn.lock

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>凤凰山景区票务统计</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html> 

+ 22 - 0
package.json

@@ -0,0 +1,22 @@
+{
+  "name": "fhs_statistic",
+  "private": true,
+  "version": "0.0.1",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^0.27.2",
+    "dayjs": "^1.11.7",
+    "echarts": "^5.4.0",
+    "element-plus": "^2.2.0",
+    "vue": "^3.2.47"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^4.0.0",
+    "vite": "^4.0.0"
+  }
+}

+ 3 - 0
public/robots.txt

@@ -0,0 +1,3 @@
+User-agent: *
+Disallow: /api/
+Allow: / 

+ 8 - 0
public/vite.svg

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>凤凰山</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <path d="M16,2 L30,28 L2,28 L16,2 Z" fill="#409EFF"/>
+        <path d="M16,8 L24,24 L8,24 L16,8 Z" fill="#FFFFFF"/>
+    </g>
+</svg> 

+ 27 - 0
src/App.vue

@@ -0,0 +1,27 @@
+<template>
+  <div class="app-container">
+    <Statistics />
+  </div>
+</template>
+
+<script setup>
+import Statistics from './views/Statistics.vue'
+</script>
+
+<style>
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  background-color: #f5f7fa;
+}
+
+.app-container {
+  min-height: 100vh;
+  /* padding: 20px; */
+}
+</style> 

+ 10 - 0
src/api/statistics.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 获取统计数据
+export function getStatisticsData(date) {
+  return request({
+    url: '/statistics',
+    method: 'get',
+    params: { date }
+  })
+} 

+ 9 - 0
src/main.js

@@ -0,0 +1,9 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import locale from "element-plus/es/locale/lang/zh-cn";
+import App from './App.vue'
+
+const app = createApp(App)
+.use(ElementPlus, { size: "default", locale: locale })
+app.mount('#app') 

+ 44 - 0
src/utils/request.js

@@ -0,0 +1,44 @@
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+
+// 创建 axios 实例
+const service = axios.create({
+  baseURL: '/api', // 基础URL
+  timeout: 10000, // 请求超时时间
+  headers: {
+    'Content-Type': 'application/json'
+  }
+})
+
+// 请求拦截器
+service.interceptors.request.use(
+  config => {
+    // 这里可以添加token等认证信息
+    return config
+  },
+  error => {
+    console.log(error)
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器
+service.interceptors.response.use(
+  response => {
+    const res = response.data
+    // 这里可以根据后端返回的状态码进行判断
+    if (res.code === 200) {
+      return res.data
+    } else {
+      ElMessage.error(res.message || '请求失败')
+      return Promise.reject(new Error(res.message || '请求失败'))
+    }
+  },
+  error => {
+    console.log('err' + error)
+    ElMessage.error(error.message || '请求失败')
+    return Promise.reject(error)
+  }
+)
+
+export default service 

+ 652 - 0
src/views/Statistics.vue

@@ -0,0 +1,652 @@
+<template>
+  <div class="statistics-container">
+    <!-- 页面标题 -->
+    <h1 class="page-title">辽宁凤凰山景区票务统计</h1>
+    
+    <!-- 日期选择器 -->
+    <el-card class="date-selector-card">
+      <div class="date-selector-wrapper">
+        <span class="date-label">选择日期:</span>
+        <el-date-picker
+          v-model="selectedDate"
+          type="date"
+          placeholder="选择日期"
+          :shortcuts="dateShortcuts"
+          @change="handleDateChange"
+          class="date-picker"
+        />
+      </div>
+    </el-card>
+
+    <!-- 统计数据卡片网格 -->
+    <div class="statistics-grid">
+      <!-- 模块1:售票数据 -->
+      <el-card class="statistics-card sales-card" :body-style="{ padding: '0' }">
+        <template #header>
+          <div class="card-header">
+            <span class="card-title">当日售票数据</span>
+            <el-tag type="success" size="small">{{ dayjs(selectedDate).format('YYYY年MM月DD日') }}</el-tag>
+          </div>
+        </template>
+        <div class="card-content">
+          <!-- 线上售票数据 -->
+          <div class="data-section">
+            <h3 class="section-title"><i class="el-icon-mobile-phone"></i> 线上售票</h3>
+            <div class="data-list">
+              <div class="data-item" v-for="(item, index) in onlineData" :key="index">
+                <span class="label">{{ item.label }}</span>
+                <span class="value highlight-value">{{ item.value }}</span>
+              </div>
+              <div class="total-item">
+                <span class="label">线上总计</span>
+                <span class="value total-value">{{ getTotalOnline() }}</span>
+              </div>
+            </div>
+          </div>
+          
+          <!-- 线下售票数据 -->
+          <div class="data-section">
+            <h3 class="section-title"><i class="el-icon-office-building"></i> 线下售票</h3>
+            <div class="data-list">
+              <div class="data-item" v-for="(item, index) in offlineData" :key="index">
+                <span class="label">{{ item.label }}</span>
+                <span class="value highlight-value">{{ item.value }}</span>
+              </div>
+              <div class="total-item">
+                <span class="label">线下总计</span>
+                <span class="value total-value">{{ getTotalOffline() }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </el-card>
+
+      <!-- 模块2:检票数据 -->
+      <el-card class="statistics-card check-card" :body-style="{ padding: '0' }">
+        <template #header>
+          <div class="card-header">
+            <span class="card-title">当日检票数据</span>
+            <el-tag type="warning" size="small">{{ dayjs(selectedDate).format('YYYY年MM月DD日') }}</el-tag>
+          </div>
+        </template>
+        <div class="card-content">
+          <div class="check-data-container">
+            <!-- 检票数据图表 -->
+            <div class="check-chart">
+              <div class="chart-placeholder">
+                <!-- 这里可以放置一个饼图或柱状图 -->
+                <div class="chart-circle" v-for="(item, index) in checkData" :key="index"
+                  :style="{
+                    width: `${getPercentage(item.value, getTotalCheck())}%`,
+                    backgroundColor: getColorByIndex(index)
+                  }">
+                  {{ item.label }}
+                </div>
+              </div>
+            </div>
+            
+            <!-- 检票数据列表 -->
+            <div class="data-list">
+              <div class="data-item" v-for="(item, index) in checkData" :key="index">
+                <div class="label-with-color">
+                  <span class="color-dot" :style="{ backgroundColor: getColorByIndex(index) }"></span>
+                  <span class="label">{{ item.label }}</span>
+                </div>
+                <span class="value highlight-value">{{ item.value }}</span>
+              </div>
+              <div class="total-item">
+                <span class="label">检票总计</span>
+                <span class="value total-value">{{ getTotalCheck() }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </el-card>
+
+      <!-- 模块3:收入数据 -->
+      <el-card class="statistics-card income-card" :body-style="{ padding: '0' }">
+        <template #header>
+          <div class="card-header">
+            <span class="card-title">当日收入</span>
+            <el-tag type="primary" size="small">{{ dayjs(selectedDate).format('YYYY年MM月DD日') }}</el-tag>
+          </div>
+        </template>
+        <div class="card-content">
+          <!-- 总收入展示 -->
+          <div class="total-income">
+            <div class="income-label">总收入</div>
+            <div class="income-amount">
+              <span class="income-icon">¥</span>
+              {{ formatCurrency(incomeData.total) }}
+            </div>
+            
+          </div>
+          
+          <!-- 收入明细 -->
+          <div class="income-details">
+            <div class="data-item" v-for="(item, index) in incomeData.details" :key="index">
+              <span class="label">{{ item.label }}</span>
+              <span class="value income-value">¥{{ formatCurrency(item.value) }}</span>
+            </div>
+            
+            <!-- 收入占比条 -->
+            <div class="income-ratio-bar">
+              <div class="ratio-segment online-segment" 
+                :style="{ width: `${(incomeData.details[0].value / incomeData.total) * 100}%` }">
+                {{ Math.round((incomeData.details[0].value / incomeData.total) * 100) }}%
+              </div>
+              <div class="ratio-segment offline-segment" 
+                :style="{ width: `${(incomeData.details[1].value / incomeData.total) * 100}%` }">
+                {{ Math.round((incomeData.details[1].value / incomeData.total) * 100) }}%
+              </div>
+            </div>
+            <div class="ratio-legend">
+              <div class="legend-item">
+                <span class="color-dot online-dot"></span>
+                <span>线上收入</span>
+              </div>
+              <div class="legend-item">
+                <span class="color-dot offline-dot"></span>
+                <span>线下收入</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { getStatisticsData } from '@/api/statistics'
+import dayjs from 'dayjs'
+
+// 日期选择
+const selectedDate = ref(new Date())
+const dateShortcuts = [
+  {
+    text: '今天',
+    value: new Date(),
+  },
+  {
+    text: '昨天',
+    value: () => {
+      const date = new Date()
+      date.setTime(date.getTime() - 3600 * 1000 * 24)
+      return date
+    },
+  },
+  {
+    text: '上周同日',
+    value: () => {
+      const date = new Date()
+      date.setTime(date.getTime() - 3600 * 1000 * 24 * 7)
+      return date
+    },
+  },
+]
+
+// 模拟数据
+const onlineData = ref([
+  { label: '小程序', value: 0 },
+  { label: '抖音', value: 0 },
+  { label: '美团', value: 0 },
+  { label: '携程', value: 0 }
+])
+
+const offlineData = ref([
+  { label: '窗口1', value: 0 },
+  { label: '窗口2', value: 0 }
+])
+
+const checkData = ref([
+  { label: '年卡', value: 0 },
+  { label: '普通票', value: 0 },
+  { label: '优惠票', value: 0 }
+])
+
+const incomeData = ref({
+  total: 0,
+  details: [
+    { label: '线上收入', value: 0 },
+    { label: '线下收入', value: 0 }
+  ]
+})
+
+// 计算总数的方法
+const getTotalOnline = () => {
+  return onlineData.value.reduce((sum, item) => sum + item.value, 0)
+}
+
+const getTotalOffline = () => {
+  return offlineData.value.reduce((sum, item) => sum + item.value, 0)
+}
+
+const getTotalCheck = () => {
+  return checkData.value.reduce((sum, item) => sum + item.value, 0)
+}
+
+// 格式化货币
+const formatCurrency = (value) => {
+  return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
+}
+
+// 获取百分比
+const getPercentage = (value, total) => {
+  if (total === 0) return 0
+  return Math.max(10, Math.round((value / total) * 100))
+}
+
+// 根据索引获取颜色
+const getColorByIndex = (index) => {
+  const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
+  return colors[index % colors.length]
+}
+
+// 获取统计数据
+const fetchData = async () => {
+  try {
+    const date = dayjs(selectedDate.value).format('YYYY-MM-DD')
+    // 这里先使用模拟数据
+    const mockData = {
+      online: {
+        miniProgram: 150,
+        douyin: 200,
+        meituan: 180,
+        ctrip: 120
+      },
+      offline: {
+        window1: 300,
+        window2: 250
+      },
+      check: {
+        yearCard: 50,
+        normal: 800,
+        discount: 200
+      },
+      income: {
+        total: 50000,
+        online: 30000,
+        offline: 20000
+      }
+    }
+
+    // 更新数据
+    onlineData.value = [
+      { label: '小程序', value: mockData.online.miniProgram },
+      { label: '抖音', value: mockData.online.douyin },
+      { label: '美团', value: mockData.online.meituan },
+      { label: '携程', value: mockData.online.ctrip }
+    ]
+
+    offlineData.value = [
+      { label: '窗口1', value: mockData.offline.window1 },
+      { label: '窗口2', value: mockData.offline.window2 }
+    ]
+
+    checkData.value = [
+      { label: '年卡', value: mockData.check.yearCard },
+      { label: '普通票', value: mockData.check.normal },
+      { label: '优惠票', value: mockData.check.discount }
+    ]
+
+    incomeData.value = {
+      total: mockData.income.total,
+      details: [
+        { label: '线上收入', value: mockData.income.online },
+        { label: '线下收入', value: mockData.income.offline }
+      ]
+    }
+  } catch (error) {
+    console.error('获取数据失败:', error)
+  }
+}
+
+const handleDateChange = () => {
+  fetchData()
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<style scoped>
+/* 整体容器样式 */
+.statistics-container {
+  padding: 24px;
+  max-width: 1280px;
+  margin: 0 auto;
+  background-color: #f5f7fa;
+  min-height: 100vh;
+}
+
+/* 页面标题 */
+.page-title {
+  font-size: 28px;
+  color: #303133;
+  text-align: center;
+  margin-bottom: 24px;
+  font-weight: 600;
+  position: relative;
+  padding-bottom: 12px;
+}
+
+.page-title::after {
+  content: '';
+  position: absolute;
+  bottom: 0;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 80px;
+  height: 3px;
+  background: linear-gradient(90deg, #409EFF, #67C23A);
+  border-radius: 3px;
+}
+
+/* 日期选择器 */
+.date-selector-card {
+  margin-bottom: 24px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+}
+
+.date-selector-wrapper {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+}
+
+.date-label {
+  margin-right: 10px;
+  font-weight: 500;
+  color: #606266;
+  font-size: 14px;
+}
+
+.date-picker {
+  width: 220px;
+}
+
+/* 统计卡片网格 */
+.statistics-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
+  gap: 24px;
+}
+
+/* 统计卡片通用样式 */
+.statistics-card {
+  border-radius: 8px;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+  transition: transform 0.3s, box-shadow 0.3s;
+  overflow: hidden;
+}
+
+.statistics-card:hover {
+  transform: translateY(-5px);
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 20px;
+  /*border-bottom: 1px solid #EBEEF5;*/
+}
+
+.card-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.card-content {
+  padding: 20px;
+}
+
+/* 数据部分通用样式 */
+.data-section {
+  margin-bottom: 24px;
+}
+
+.data-section:last-child {
+  margin-bottom: 0;
+}
+
+.section-title {
+  font-size: 16px;
+  color: #606266;
+  margin-bottom: 16px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  /* gap: 8px; */
+}
+
+.data-list {
+  display: flex;
+  flex-direction: column;
+  /* gap: 12px; */
+}
+
+/* 只展示需要修改的部分 */
+.data-item {
+  display: flex;
+  justify-content: space-between;
+  padding: 14px 0;
+  /* border-bottom: 1px dashed #EBEEF5; */
+}
+
+.data-item + .data-item {
+  border-top: 1px dashed #EBEEF5;
+}
+
+/* 修改总计项样式,使其独立于 data-item */
+.total-item {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 8px;
+  padding-top: 12px;
+  padding-bottom: 8px;
+  border-top: 1px solid #EBEEF5;
+}
+
+.label {
+  color: #606266;
+  font-size: 14px;
+}
+
+.value {
+  font-weight: 500;
+  color: #303133;
+  font-size: 14px;
+}
+
+.highlight-value {
+  color: #409EFF;
+  font-weight: 600;
+}
+
+.total-value {
+  font-size: 18px;
+  font-weight: 700;
+  color: #303133;
+}
+
+/* 售票卡片特殊样式 */
+.sales-card {
+  border-top: 4px solid #67C23A;
+}
+
+/* 检票卡片特殊样式 */
+.check-card {
+  border-top: 4px solid #E6A23C;
+}
+
+.check-data-container {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.check-chart {
+  margin-bottom: 16px;
+}
+
+.chart-placeholder {
+  display: flex;
+  height: 40px;
+  border-radius: 20px;
+  overflow: hidden;
+  margin-bottom: 16px;
+}
+
+.chart-circle {
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+  font-size: 12px;
+  font-weight: 500;
+  transition: all 0.3s;
+}
+
+.label-with-color {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.color-dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  display: inline-block;
+}
+
+/* 收入卡片特殊样式 */
+.income-card {
+  border-top: 4px solid #409EFF;
+}
+
+.total-income {
+  text-align: center;
+  padding: 24px 0;
+  margin-bottom: 24px;
+  background: linear-gradient(135deg, #ecf5ff, #f0f9eb);
+  border-radius: 8px;
+  position: relative;
+}
+
+.income-icon {
+  font-size: 24px;
+  color: #409EFF;
+  margin-bottom: 8px;
+  font-weight: bold;
+}
+
+.income-amount {
+  font-size: 32px;
+  font-weight: 700;
+  color: #409EFF;
+  margin-bottom: 8px;
+}
+
+.income-label {
+  font-size: 14px;
+  color: #606266;
+}
+
+.income-details {
+  margin-top: 16px;
+}
+
+.income-value {
+  color: #67C23A;
+  font-weight: 600;
+}
+
+.income-ratio-bar {
+  height: 24px;
+  display: flex;
+  border-radius: 12px;
+  overflow: hidden;
+  margin: 16px 0;
+}
+
+.ratio-segment {
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+  font-size: 12px;
+  font-weight: 500;
+  transition: width 0.5s;
+}
+
+.online-segment {
+  background-color: #409EFF;
+}
+
+.offline-segment {
+  background-color: #67C23A;
+}
+
+.ratio-legend {
+  display: flex;
+  justify-content: center;
+  gap: 24px;
+  margin-top: 8px;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #606266;
+}
+
+.online-dot {
+  background-color: #409EFF;
+}
+
+.offline-dot {
+  background-color: #67C23A;
+}
+
+/* 响应式设计 */
+@media (max-width: 1200px) {
+  .statistics-grid {
+    grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+  }
+}
+
+@media (max-width: 768px) {
+  .statistics-container {
+    padding: 16px;
+  }
+  
+  .statistics-grid {
+    grid-template-columns: 1fr;
+    gap: 16px;
+  }
+  
+  .page-title {
+    font-size: 24px;
+  }
+  
+  .card-title {
+    font-size: 16px;
+  }
+  
+  .income-amount {
+    font-size: 28px;
+  }
+}
+</style>

+ 16 - 0
vite.config.js

@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'path'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src')
+    }
+  },
+  server: {
+    port: 3000,
+    open: true
+  }
+})