This commit is contained in:
dzc committed 2025-10-17 13:01:55 +08:00
commit 5485c3b85a
94 files changed
+10131

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# 开发环境配置
ENV = 'development'
# 开发环境
VITE_BASE_API = '/api'
# 本地
VITE_PROXY_PATH = 'http://127.0.0.1:9999'
# 测试
# VITE_PROXY_PATH = 'http://36585.asset-cloud.com.cn:36588'
+6
View File
@@ -0,0 +1,6 @@
# 生产环境配置
ENV = 'production'
# 生产环境
VITE_BASE_API = '/api'
VITE_PROXY_PATH = 'http://127.0.0.1:9999'
+36
View File
@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env.local
.env.*.local
# Testing
/cypress/videos/
/cypress/screenshots/
# Temporary folders
tmp/
temp/
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<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>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "biz-pc-cockpit",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"build:prod": "vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.12.2",
"dayjs": "^1.11.18",
"echarts": "^6.0.0",
"element-plus": "^2.11.4",
"js-cookie": "^3.0.5",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"less": "^4.4.1",
"vite": "^6.1.0"
}
}
+1370
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="main-container">
<router-view />
</div>
</template>
<style scoped lang="less">
.main-container {
width: 100vw;
height: 100vh;
background-size: 100% 100%;
background-repeat: no-repeat;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
import { service } from "./share/request"
/**
* 用餐数据统计(根据时间段和日期分类统计)
*/
export const groupByTimeAndHourType = (data) => {
return service({
url: 'hard/panel-canteen/group/by-time-and-hour-type',
method: 'post',
data,
})
}
/**
* 各个食堂的用餐人数
*/
export const mchPayPersonCount = (data) => {
return service({
url: 'hard/panel-canteen/mch-pay-person-count',
method: 'post',
data,
})
}
/**
* 各个食堂的用餐人数(趋势)
*/
export const payPersonCountTrend = (data) => {
return service({
url: 'hard/panel-canteen/pay-person-count-trend',
method: 'post',
data,
})
}
/**
* 用餐数据统计(根据自定义时间段和日期分类统计)
*/
export const groupByTimeAndCustomHour = (data) => {
return service({
url: "hard/panel-canteen/group/by-time-and-custom-hour",
method: 'post',
data,
})
}
/**
* 排行榜
*/
export const mchPayCountTop = (data) => {
return service({
url: 'hard/panel-canteen/mch-pay-count-top',
method: 'post',
data,
})
}
/**
* 食堂报表
*/
export const canteenReport = (data) => {
return service({
url: 'hard/panel-canteen/report',
method: 'post',
data,
})
}
/**
* 最新的吃饭数据
*/
export const newPage = (data) => {
return service({
url: 'hard/panel-canteen/new-page',
method: 'post',
data,
})
}
/**
* 最近吃饭数据
*/
export const recentlyCanteen = (data) => {
return service({
url: 'hard/panel-canteen/recently',
method: 'post',
data,
})
}
+8
View File
@@ -0,0 +1,8 @@
export * as canteenApi from "./canteen"
export * as shopApi from "./shop"
export * as nutritionApi from "./nutrition"
export * as userApi from "./user"
+68
View File
@@ -0,0 +1,68 @@
import { service } from "./share/request"
/**
* 食堂用餐分类统计
*/
export const classCount = (data) => {
return service({
url: 'hard/panel-nutrition/class-count',
method: 'post',
data,
})
}
/**
* 食堂用餐分类统计
*/
export const cuisineTop = (data) => {
return service({
url: 'hard/panel-nutrition/cuisine-top',
method: 'post',
data,
})
}
/**
* 营养分析(按照购买时间统计)
*/
export const nutritionByTime = (data) => {
return service({
url: 'hard/panel-nutrition/nutrition/by-time',
method: 'post',
data,
})
}
/**
* 营养分析(总数统计)
*/
export const nutritionTotal = (data) => {
return service({
url: 'hard/panel-nutrition/nutrition/total',
method: 'post',
data,
})
}
/**
* 营养分析(趋势)
*/
export const nutritionTrend = (data) => {
return service({
url: 'hard/panel-nutrition/nutrition/trend',
method: 'post',
data,
})
}
/**
* 复购排行榜(top5)
*/
export const repurchase = (data) => {
return service({
url: 'hard/panel-nutrition/repurchase',
method: 'post',
data,
})
}
+76
View File
@@ -0,0 +1,76 @@
import axios from "axios";
import Cookies from 'js-cookie';
import router from '@/router';
export const service = axios.create({
baseURL: import.meta.env.VITE_BASE_API,
// 请求超时时间
timeout: 20000,
headers: {
"Content-Type": "application/json",
},
withCredentials: false,
});
// 请求拦截
service.interceptors.request.use(
(config) => {
// get请求映射params参数
if (config.method === "get" && config.params) {
let url = config.url + "?" + tansParams(config.params);
url = url.slice(0, -1);
config.params = {};
config.url = url;
}
// 加token
const token = Cookies.get("panel_token") ?? ""
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截
service.interceptors.response.use(
(res) => {
return res.data;
},
(error) => {
console.log(error);
// 登录失效
if (error.status == 424) {
// 清除token
// Cookies.remove("panel_token")
// Cookies.remove("panel_refresh_token")
// router.replace("/no-permission")
}
return Promise.reject(error);
}
);
function tansParams(params) {
let result = "";
for (const propName of Object.keys(params)) {
const value = params[propName];
var part = encodeURIComponent(propName) + "=";
if (value !== null && value !== "" && typeof value !== "undefined") {
if (typeof value === "object") {
for (const key of Object.keys(value)) {
if (value[key] !== null && value[key] !== "" && typeof value[key] !== "undefined") {
let params = propName + "[" + key + "]";
var subPart = encodeURIComponent(params) + "=";
result += subPart + encodeURIComponent(value[key]) + "&";
}
}
} else {
result += part + encodeURIComponent(value) + "&";
}
}
}
return result;
}
+67
View File
@@ -0,0 +1,67 @@
import { service } from "./share/request"
/**
* 各个超市的购买数量统计
*/
export const mchPayPersonCount = (data) => {
return service({
url: 'hard/panel-shop/mch-pay-count',
method: 'post',
data,
})
}
/**
* 各个自营超市的统计数据
*/
export const mchPayCountTrend = (data) => {
return service({
url: 'hard/panel-shop/mch-pay-count-trend',
method: 'post',
data,
})
}
/**
* 超市报表
*/
export const canteenReport = (data) => {
return service({
url: 'hard/panel-shop/report',
method: 'post',
data,
})
}
/**
* 最新的超市购买数据
*/
export const newPage = (data) => {
return service({
url: 'hard/panel-shop/new-page',
method: 'post',
data,
})
}
/**
* 最近的超市购买数据
*/
export const recentlyShop = (data) => {
return service({
url: 'hard/panel-shop/recently',
method: 'post',
data,
})
}
/**
* 排行榜
*/
export const mchPayCountTop = (data) => {
return service({
url: 'hard/panel-shop/mch-pay-count-top',
method: 'post',
data,
})
}
+45
View File
@@ -0,0 +1,45 @@
import { service } from "./share/request"
/**
* 获取个人信息
*/
export const getUserInfo = (params) => {
return service({
url: '/admin/user/info',
method: 'get',
params,
})
}
/**
* 刷新token
*/
export const refreshToken = (refresh_token) => {
const basicAuth = 'Basic ' + window.btoa('pig:pig');
const grant_type = 'refresh_token';
const scope = 'server';
return service({
url: '/auth/oauth2/token',
method: 'post',
headers: {
skipToken: true,
Authorization: basicAuth,
'Content-Type': "application/x-www-form-urlencoded",
},
params: { refresh_token, grant_type, scope },
})
}
export const checkToken = (token) => {
const basicAuth = 'Basic ' + window.btoa('pig:pig');
return service({
url: '/auth/token/check_token',
method: 'get',
headers: {
skipToken: true,
Authorization: basicAuth,
'Content-Type': 'application/x-www-form-urlencoded',
},
params: { token },
})
}
+183
View File
@@ -0,0 +1,183 @@
<template>
<div class="main-container">
<el-header class="header">
<div class="left">
<div class="title">智 慧 后 勤</div>
<div class="sub-title">Smart logistics platform</div>
</div>
<div class="nav">
<router-link class="nav-item" v-for="(tab, index) in navs" :key="index" :to="tab.path">
<span class="nav-name">{{ tab.name }}</span>
</router-link>
</div>
<div class="right">
<span class="date">{{ date }}</span>
<span class="time">{{ time }}</span>
</div>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"
import Cookies from 'js-cookie';
import { getDate, getTime } from "@/utils/date";
import { getUserInfo } from "@/utils/auth"
const { replace } = useRouter()
const { query } = useRoute()
const navs = reactive([
{ name: "智慧食堂", path: "/canteen" },
{ name: "智慧超市", path: "/shop" },
// { name: "智慧服务", path: "/service" },
// { name: "智慧通行", path: "/through" },
{ name: "营养分析", path: "/nutrition" },
]);
const date = ref(getDate());
const time = ref(getTime());
onMounted(async () => {
await getNavs()
setInterval(() => {
time.value = getTime();
date.value = getDate();
}, 1000);
});
// 获取菜单
async function getNavs() {
try {
if (query.token && query.refresh_token) {
Cookies.set("panel_token", query.token)
Cookies.set("panel_refresh_token", query.refresh_token)
}
await getUserInfo()
} catch (error) {
console.log("|获取菜单|错误:", error);
}
}
</script>
<style scoped lang="less">
.main-container {
position: relative;
width: 100vw;
height: 100vh;
.background("@/assets/images/bg-image.png");
.header {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 10vh;
.background();
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)), url("@/assets/images/header-bg.png");
background-position: 0 0, bottom;
display: flex;
justify-content: space-between;
.left {
height: 100%;
display: flex;
flex-direction: column;
font-style: italic;
.title {
position: relative;
font-size: 1.6vw;
font-weight: bold;
color: @text-color;
text-align: left;
text-shadow: 0 0 0.5vw #ffffff;
letter-spacing: 0.3vw;
}
.sub-title {
font-size: 1vw;
letter-spacing: 0.1vw;
text-shadow: 0 0 0.5vw #ffffff;
}
}
.nav {
display: flex;
align-items: center;
gap: 20px;
height: 5vh;
font-size: 1vw;
.nav-item {
flex: 1;
text-align: center;
text-decoration: none;
.nav-name {
color: @text-color;
white-space: nowrap;
padding: 1vh 4vh;
font-weight: bold;
font-style: italic;
}
&.router-link-active {
.nav-name {
color: @text-color;
.background(@url: "@/assets/images/nav-active-button-bg.png", @size: 100% 90%);
}
}
}
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
width: 20vw;
height: 5vh;
font-size: 1vw;
.weather-icon {
width: 1.5vw;
}
.weather {
margin-left: 0.5vw;
font-size: 0.8vw;
color: @text-color;
}
.date {
margin-left: 1vw;
font-size: 0.8vw;
color: @text-color;
}
.time {
margin-left: 0.6vw;
color: @text-color;
font-weight: bold;
font-size: 1.2vw;
}
}
}
.main-content {
position: absolute;
top: 10vh;
left: 0;
width: 100%;
height: calc(100vh - 10vh);
overflow: hidden;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="alarm-list">
<div class="segmented">
<el-segmented v-model="activeSegment" :options="props.segments" :props="{ label: 'label', value: 'value' }"
size="small" @change="dateTypeChange" />
</div>
<div class="list scrollbar-hide">
<div v-for="(row, idx) in curList" :key="idx" class="item" @click="doCar(row)">
<div class="type" :class="props.type == 'canteen' ? 'type-1' : 'type-2'">
<span v-if="row.tag" class="type-text">{{ row.tag }}</span>
<span v-else class="type-text">未知</span>
</div>
<div class="end">
<span>{{ row.name || "未知" }}</span>
<span>{{ row.time }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
const emit = defineEmits(["changeDateType"]);
const props = defineProps({
// "canteen"
type: {
type: String,
default: () => "canteen",
},
data: {
type: Object,
default: () => [{ alarmList: [], name: null, time: null }],
},
segments: {
type: Array,
default: () => [
{
label: "昨日",
value: "YESTERDAY",
},
{
label: "今日",
value: "TODAY",
},
{
label: "本月",
value: "THIS_MONTH",
},
{
label: "近半年",
value: "HALF_YEAR",
},
],
},
});
const activeSegment = ref(props.segments[0].value || "YESTERDAY");
if (Array.isArray(props.segments) && props.segments.length > 0) {
activeSegment.value = props.segments[0].value;
}
const curList = computed(() => {
if (!props.data || !props.data.length) return [];
const list = props.data;
return Array.isArray(list) && list.length ? list : [];
});
const doCar = (row) => {
if (props.type == "person") {
const _params = {
// 抓拍时间
swingTimeList: DateTool.getFormattedDateTimeRange(activeSegment.value),
// 用户名
personName: row.name ?? "",
};
console.log(`|点击跳转|-|异常事件实时统计|-|${row.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_alarm", _params);
}
if (props.type == "car") {
const _params = {
// 抓拍时间
capTimeList: DateTool.getFormattedDateTimeRange(activeSegment.value),
// 车牌号
carNum: row.name ?? "",
};
console.log(`|点击跳转|-|车辆异常出入实时统计|-|${row.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("car_alarm", _params);
}
};
const dateTypeChange = (e) => {
emit("changeDateType", e);
};
</script>
<style scoped lang="less">
.alarm-list {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding-top: 4vh;
.segmented {
position: absolute;
right: 0;
top: 0;
z-index: 2;
.el-segmented {
--el-segmented-item-selected-color: var(--el-text-color-primary);
--el-segmented-item-selected-bg-color: #095d82;
--el-border-radius-base: 8px;
}
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.2vh;
padding-bottom: 4vh;
.item {
display: flex;
align-items: center;
justify-content: space-evenly;
padding: 0.8vh 0.5vw;
cursor: pointer;
background-image: url("@/assets/images/car-alarm-list-item-bg.png");
background-repeat: no-repeat;
background-size: 100% 100%;
.type {
width: 7vw;
height: 3.6vh;
line-height: 3.6vh;
text-align: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: center;
.type-text {
display: inline-block;
font-size: 0.65vw;
color: #c9e9ff;
width: 80%;
margin: 0 0.5vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.type-1 {
background-image: url("@/assets/images/car-alarm-type-blue.png");
}
.type-2 {
background-image: url("@/assets/images/car-alarm-type-green.png");
}
.end {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
span {
display: inline-block;
width: 5vw;
font-size: 0.6vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span:nth-child(1) {
text-align: center;
color: #d6e3ee;
flex: 1;
margin-right: 1vw;
}
span:nth-child(2) {
color: #d6e3ee;
width: 6.5vw;
}
}
}
}
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>
@@ -0,0 +1,253 @@
<template>
<div class="container">
<div class="segmented">
<el-segmented v-model="activeSegment" :options="props.segments" :props="{ label: 'label', value: 'value' }"
size="small" @change="dateTypeChange" />
</div>
<div class="chart" ref="chartRef"></div>
<div class="area-info">
<div class="area-info-item" v-for="(item, index) in props.data" :key="item.name" @click="doItem(item)">
<span
:style="{ width: '1vw', height: '1vw', background: colors[index], borderRadius: '50%', display: 'inline-block' }"></span>
<span class="name">{{ item.title }}</span>
<span :style="{ marginLeft: '0.2vw', color: colors[index] }">{{ item.total }}次</span>
<span :style="{ marginLeft: '0.1vw', color: colors[index], fontSize: '0.6vw' }"> ({{ percent(item.total) }})
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
const emit = defineEmits(["changeDateType"]);
const props = defineProps({
title: { type: String, default: "告警总数" },
type: {
type: String,
default: () => "car",
},
data: {
type: Array,
default: () => [
{
title: "未知",
total: 0,
},
],
},
segments: {
type: Array,
default: () => [
{
label: "昨日",
value: "YESTERDAY",
},
{
label: "今日",
value: "TODAY",
},
{
label: "本月",
value: "THIS_MONTH",
},
{
label: "近半年",
value: "HALF_YEAR",
},
],
},
});
const colors = ["#31D3A4", "#1AD0F9", "#5470C6"];
const activeSegment = ref(props.segments[0].value || "TODAY");
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
if (Array.isArray(props.segments) && props.segments.length > 0) {
activeSegment.value = props.segments[0].value;
}
const total = computed(() => {
if (!props.data || !props.data.length) return 0;
return props.data.reduce((sum, i) => sum + (Number(i.total) || 0), 0);
});
const seriesData = computed(() => {
if (!props.data || !props.data.length) return [];
return props.data.map((i, idx) => ({ name: i.title, value: i.total, itemStyle: { color: colors[idx] } }));
});
watch(
() => [props.data],
() => {
if (!chartInstance) return;
chartInstance.setOption({
series: [{ data: seriesData.value }],
graphic: [{ type: "text", left: "40%", top: "40%", style: { text: String(total.value) } }],
});
}
);
const percent = (v) => {
if (!total.value) return "0%";
return ((Number(v) / total.value) * 100).toFixed(1) + "%";
};
const dateTypeChange = (e) => {
emit("changeDateType", e);
};
const doItem = (item) => {
// if (props.type == "person") {
// const _params = {
// // 报警类型
// alarmType: item.type,
// // 抓拍时间
// swingTimeList: DateTool.getFormattedDateTimeRange(activeSegment.value),
// }
// console.log(`|点击跳转|-|异常事件类型统计|-|${item.title}|params:|{${JSON.stringify(_params)}}|`);
// jumpAdminPath("door_alarm", _params)
// }
// if (props.type == "car") {
// const _params = {
// // 报警类型
// alarmType: item.type,
// // 抓拍时间
// capTimeList: DateTool.getFormattedDateTimeRange(activeSegment.value)
// }
// console.log(`|点击跳转|-|车辆异常出入类型统计|-|${item.title}|params:|{${JSON.stringify(_params)}}|`);
// jumpAdminPath("car_alarm", _params)
// }
};
const options = {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (p) => `${p.name}:${p.value} (${p.percent}%)`,
confine: true,
},
series: [
{
type: "pie",
radius: ["60%", "78%"],
center: ["50%", "50%"],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
itemStyle: { borderWidth: 0 },
data: seriesData.value,
},
],
graphic: [
{
type: "text",
left: "40%",
top: "40%",
style: {
text: String(total.value),
fill: "#fff",
fontSize: 22,
fontWeight: 700,
textAlign: "center",
},
},
{
type: "text",
left: "39%",
top: "55%",
style: {
text: props.title,
fill: "#B8C7D9",
fontSize: 12,
textAlign: "center",
},
},
],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(options);
window.addEventListener("resize", resizeHandler);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped lang="less">
.container {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
.segmented {
position: absolute;
right: 0;
top: 0;
z-index: 2;
.el-segmented {
--el-segmented-item-selected-color: var(--el-text-color-primary);
--el-segmented-item-selected-bg-color: #095d82;
--el-border-radius-base: 8px;
}
}
.chart {
flex: 1;
height: 100%;
}
.area-info {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
.area-info-item {
display: flex;
align-items: center;
padding: 0.4vw 0;
font-size: 0.7vw;
border-bottom: 1px dashed #eeeeee99;
cursor: pointer;
.name {
width: 5vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 0.2vw;
color: #b8c7d9;
font-size: 0.7vw;
}
}
}
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="aside-block" :style="blockStyle">
<div class="aside-block-title" :style="titleStyle" v-bind="$attrs">
<slot name="header"></slot>
</div>
<div class="aside-block-content">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
// position: {
// type: String,
// default: "left",
// },
// bgImageShow: {
// type: Boolean,
// default: true,
// },
height: {
type: String,
default: "",
},
titleWidth: {
type: String,
default: "100%",
},
titleBgWidth: {
type: String,
default: "100%",
},
// mainStyle: {
// type: String,
// default: "",
// },
});
// const titleClass = computed(() => (props.position === "left" ? "aside-block-title-left" : "aside-block-title-right"));
// const contentClass = computed(() => {
// if (props.position === "left" && props.bgImageShow) return "bg-image-left";
// if (props.position === "right" && props.bgImageShow) return "bg-image-right";
// });
const blockStyle = computed(() =>
props.height ? { height: props.height } : props.height === "auto" ? { height: "auto" } : { height: "calc(100% / 3)" }
);
const titleStyle = computed(() => ({
width: props.titleWidth,
backgroundSize: `${props.titleBgWidth} 100%`,
}));
</script>
<style scoped lang="less">
.aside-block {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
}
.aside-block-title {
height: 4vh;
line-height: 4vh;
font-size: 0.78vw;
font-weight: bold;
text-indent: 2.8vw;
/* padding: 0 2.8vw; */
color: @text-color;
display: flex;
justify-content: space-between;
align-items: center;
.background("@/assets/images/box-title.png");
letter-spacing: 0.1vw;
}
.aside-block-content {
flex: 1;
box-sizing: border-box;
overflow: hidden;
padding: 0.5vw;
}
/*
.bg-image-left {
background: linear-gradient(to right, #1a3642, transparent);
background: url("@/assets/images/aside_block_bg.png") center center/contain no-repeat;
background-size: 100% 100%;
}
.bg-image-right {
background: linear-gradient(to left, #1a3642, transparent);
background: url("@/assets/images/aside_block_bg.png") center center/contain no-repeat;
background-size: 100% 100%;
} */
</style>
@@ -0,0 +1,105 @@
<template>
<div class="tab">
<span class="tab-item" v-for="(item, index) in tabList" :key="index" :class="{ active: activeIndex === index }"
@click="dateTypeChange(index)">
{{ item.label }}
</span>
</div>
<div class="content">
<keep-alive>
<component :is="tabList[activeIndex].component" :data="componentData" :labels="componentLabels" />
</keep-alive>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import YesterdayStatistics from "./yesterday-statistics.vue";
import WeekStatistics from "./week-statistics.vue";
import MonthStatistics from "./month-statistics.vue";
import YearStatistics from "./year-statistics.vue";
import { getMonthDays } from "@/utils/date";
const emit = defineEmits(["changeDateType"]);
const props = defineProps({
labels: {
type: Object,
default: () => [],
},
data: {
type: Object,
default: () => ({
YESTERDAY: { morning: 0, noon: 0, evening: 0 },
TODAY: { morning: 0, noon: 0, evening: 0 },
THIS_WEEK: [
{ text: "在岗", name: "morning", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "迟到", name: "noon", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "早退", name: "evening", values: [0, 0, 0, 0, 0, 0, 0, 0] },
],
THIS_MONTH: getMonthDays().map((day) => ({ day, type: -1 })),
THIS_YEAR: [
{ text: "在岗", name: "morning", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "迟到", name: "noon", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "早退", name: "evening", values: [0, 0, 0, 0, 0, 0, 0, 0] },
],
}),
},
});
const tabList = [
{ label: "昨日", value: "YESTERDAY", component: YesterdayStatistics },
{ label: "今日", value: "TODAY", component: YesterdayStatistics },
{ label: "本周", value: "THIS_WEEK", component: WeekStatistics },
{ label: "本月", value: "THIS_MONTH", component: MonthStatistics },
{ label: "本年", value: "THIS_YEAR", component: YearStatistics },
];
const activeIndex = ref(0);
const activeKey = computed(() => tabList[activeIndex.value].value);
const componentData = computed(() => {
if (!props.data) {
return activeIndex.value == 0 ? {} : [];
}
return props.data[activeKey.value];
});
const componentLabels = computed(() => {
if (!props.labels) return [];
return props.labels[activeKey.value] ?? [];
});
const dateTypeChange = (index) => {
activeIndex.value = index;
emit("changeDateType", tabList[index].value);
};
</script>
<style scoped lang="less">
.tab {
width: 70%;
height: 14%;
display: flex;
justify-content: space-around;
align-items: center;
.tab-item {
padding: 1.2vh 1vw;
cursor: pointer;
text-align: center;
font-size: 0.8vw;
white-space: nowrap;
.background("@/assets/images/date-dimension-tab.png", @size: 100% 100%, @position: center);
color: @text-color-primary;
transition: all 0.2s ease;
&.active {
background-image: url("@/assets/images/date-dimension-tab-select.png");
color: @text-color;
}
}
}
.content {
width: 100%;
height: 86%;
}
</style>
@@ -0,0 +1,203 @@
<template>
<div class="container">
<div class="legend">
<span class="legend-item" :style="{ backgroundColor: getBarColor(0) }"></span>
<span>正常</span>
<span class="legend-item" :style="{ backgroundColor: getBarColor(1) }"></span>
<span>休息</span>
<span class="legend-item" :style="{ backgroundColor: getBarColor(-1) }"></span>
<span>未知</span>
</div>
<div class="days">
<div class="item" v-for="(item, index) in displayData" :key="index" @click="doCar(item)">
<div class="day">{{ item.day }}</div>
<div class="bar" :style="{ backgroundColor: getBarColor(item.type) }"></div>
<div class="tooltip">
<div class="tooltip-content">
<span class="label">早餐:</span>
<span class="value">{{ item.morningCount ?? 0 }}人</span>
</div>
<div class="tooltip-content">
<span class="label">午餐:</span>
<span class="value">{{ item.noonCount ?? 0 }}人</span>
</div>
<div class="tooltip-content">
<span class="label">晚餐:</span>
<span class="value">{{ item.eveningCount ?? 0 }}人</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { getMonthDays } from "@/utils/date";
import { computed } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
const props = defineProps({
data: {
type: Array,
default: () => getMonthDays().map((day) => ({ day, type: -1 })),
},
});
const displayData = computed(() => {
const days = getMonthDays();
const data = props.data;
if (!Array.isArray(props.data)) return days.map((day) => ({ day, type: -1 }));
if (props.data.length !== days.length) {
for (let i = data.length; i < days.length; i++) {
data.push({
day: days[i],
type: -1,
});
}
return data.slice(0, days.length);
}
return data;
});
const getBarColor = (type) => {
if (type === 0) return "#40c9cb";
if (type === 1) return "#5470C6";
return "#7f7f7f77";
};
// 新增:类型文案
const getTypeText = (type) => {
if (type === 0) return "正常";
if (type === 1) return "休息";
return "未知";
};
const doCar = (item) => {
if (!item.day) return;
if (item.type == "-1") {
ElMessage.warning(`还未到${item.day}号,无法跳转`);
return;
}
const params = {
// 抓拍时间
swingTimeList: [
dayjs().date(item.day).startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs().date(item.day).endOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
};
console.log(`|点击跳转|-|打卡统计|-|月|params:|{${JSON.stringify(params)}}|`);
jumpAdminPath("door_reign_record", params);
};
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.legend {
width: 100%;
display: flex;
justify-content: end;
align-items: center;
font-size: 0.6vw;
gap: 0.4vw;
margin: 1vh 0;
.legend-item {
width: 1vw;
height: 0.5vw;
}
}
.days {
flex: 1;
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.2vw;
.item {
position: relative;
background-color: #122c40;
border: 1px solid #1b3346;
cursor: pointer;
.day {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
width: 100%;
text-align: center;
color: #cfe8f6;
font-size: 0.8vw;
}
.bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 0.3vh;
}
/* Hover 弹框样式 */
.tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: #0f1f2e;
border: 1px solid #1b3346;
color: #cfe8f6;
font-size: 0.7vw;
padding: 0.4vw 0.6vw;
border-radius: 0.3vw;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 10;
}
&:hover .tooltip {
opacity: 1;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: #0f1f2e transparent transparent transparent;
filter: drop-shadow(0 -1px 0 rgba(27, 51, 70, 0.9));
display: grid;
gap: 0.2vw;
}
.tooltip-content {
display: flex;
justify-content: center;
gap: 0.2vw;
align-items: center;
}
.tooltip-row .label {
color: #7fb5d6;
}
.tooltip-row .value {
color: #cfe8f6;
font-weight: 600;
}
}
}
}
</style>
@@ -0,0 +1,205 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
import dayjs from "dayjs";
const props = defineProps({
labels: {
type: Object,
default: () => [],
},
data: {
type: Object,
default: () => [
{ text: "在岗", name: "onJob", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "迟到", name: "beLate", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "早退", name: "leaveEarly", values: [0, 0, 0, 0, 0, 0, 0, 0] },
],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
watch(
() => [props.data, props.labels],
(val) => {
if (!chartInstance) return;
chartInstance.setOption({
xAxis: {
data: props.labels?.map((v) => v.split("|")[1]) ?? [],
},
series: props.data.map((item, i) => {
return {
type: "bar",
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
});
},
{ deep: true }
);
const colors = ["#287CE7", "#F4CF3D", "#30C6DC"];
const linearGradientColors = [
["#01A5FA", "#086BA5", "#0E385A"],
["#E0B60D", "#78752F", "#143750"],
["#15D1C4", "#117883", "#0F3654"],
];
const option = {
backgroundColor: "transparent",
grid: {
top: "14%",
left: "0%",
right: "0%",
bottom: "4%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
valueFormatter: (value) => `${value}人`,
},
xAxis: {
data: [],
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
axisLine: {
lineStyle: {
color: "#21303C",
},
},
},
yAxis: {
min: 0,
axisLine: {
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
legend: {
icon: "rect",
right: 0,
top: 0,
itemWidth: 10,
itemHeight: 8,
itemGap: 16,
textStyle: { color: "#ffffff", fontSize: 9 },
itemStyle: { borderWidth: 0, borderColor: "transparent" },
data: props.data.map((item, i) => {
return { name: item.text, itemStyle: { color: colors[i], borderWidth: 0, borderColor: "transparent" } };
}),
},
series: props.data.map((item, i) => {
return {
type: "bar",
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", resizeHandler);
chartInstance.on("click", function (params) {
const target = props.labels[params.dataIndex];
if (!target) return;
const [timeStr, _] = target.split("|");
const _params = {
swingTimeList: [
dayjs(timeStr).startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs(timeStr).endOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
};
console.log(`|点击跳转|-|打卡统计|-|本周|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_reign_record", _params);
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
@@ -0,0 +1,187 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
import dayjs from "dayjs";
const props = defineProps({
labels: {
type: Object,
default: () => [],
},
data: {
type: Object,
default: () => [
{ text: "早饭", name: "morning", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "中饭", name: "noon", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "晚饭", name: "evening", values: [0, 0, 0, 0, 0, 0, 0, 0] },
],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
const colors = ["#287CE7", "#F4CF3D", "#30C6DC"];
const linearGradientColors = [
["#01A5FA", "#086BA5", "#0E385A"],
["#E0B60D", "#78752F", "#143750"],
["#15D1C4", "#117883", "#0F3654"],
];
watch(
() => [props.labels, props.data],
(val) => {
if (!chartInstance) return;
chartInstance.setOption({
xAxis: {
data: props.labels,
},
legend: {
data: props.data.map((item, i) => {
return { name: item.text, itemStyle: { color: colors[i], borderWidth: 0, borderColor: "transparent" } };
}),
},
series: props.data.map((item, i) => {
return {
type: "bar",
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
});
},
{ deep: true }
);
const option = {
backgroundColor: "transparent",
grid: {
top: "14%",
left: "0%",
right: "0%",
bottom: "4%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
valueFormatter: (value) => `${value}人`,
},
xAxis: {
data: [],
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
axisLine: {
lineStyle: {
color: "#21303C",
},
},
},
yAxis: {
min: 0,
axisLine: {
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
legend: {
icon: "rect",
right: 0,
top: 0,
itemWidth: 10,
itemHeight: 8,
itemGap: 16,
textStyle: { color: "#ffffff", fontSize: 9 },
itemStyle: { borderWidth: 0, borderColor: "transparent" },
data: [],
},
series: [],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", resizeHandler);
chartInstance.on("click", function (params) {
if (!params.name) return;
const [year, month] = params.name.split("-");
const _params = {
// 抓拍时间
swingTimeList: [
dayjs()
.year(year)
.month(month - 1)
.startOf("month")
.format("YYYY-MM-DD HH:mm:ss"),
dayjs()
.year(year)
.month(month - 1)
.endOf("month")
.format("YYYY-MM-DD HH:mm:ss"),
],
};
console.log(`|点击跳转|-|打卡统计|-|年|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_reign_record", _params);
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
@@ -0,0 +1,93 @@
<template>
<div class="container">
<div class="item" :class="item.bgCls" v-for="(item, index) in displayData" :key="index" @click="handleClick(item)">
<span class="value">{{ item.value }}</span>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import dayjs from "dayjs";
const props = defineProps({
data: {
type: Object,
default: () => ({
morning: 0,
noon: 0,
evening: 0,
}),
},
});
const displayData = computed(() => {
const { morning, noon, evening } = props.data;
return [
{ name: "早饭", type: "11", value: morning, bgCls: "on-job-bg" },
{ name: "午饭", type: "100", value: noon, bgCls: "be-late-bg" },
{ name: "晚饭", type: "200", value: evening, bgCls: "leave-early-bg" },
];
});
const handleClick = (item) => {
const params = {
// 抓拍时间
swingTimeList: [
dayjs().subtract(1, "day").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs().startOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
// 类型
alarmTypeList: [],
personIdIsNotNull: true,
};
const alarmTypeItem = displayData.value.find((v) => v.name == item.name);
if (alarmTypeItem) {
params.alarmTypeList = [alarmTypeItem.type];
}
console.log(`|点击跳转|-|打卡统计|-|昨日|params:|{${JSON.stringify(params)}}|`);
jumpAdminPath("door_reign_record", params);
};
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: space-around;
align-items: center;
.item {
width: 24%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
text-align: center;
cursor: pointer;
position: relative;
.value {
position: absolute;
top: 46%;
left: 46%;
transform: translateX(-50%);
font-size: 1.2vw;
color: #fff;
}
}
.on-job-bg {
background-image: url(@/assets/images/on-job-bg.png) !important;
}
.be-late-bg {
background-image: url(@/assets/images/be-late-bg.png) !important;
}
.leave-early-bg {
background-image: url(@/assets/images/leave-early-bg.png) !important;
}
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="container">
<el-table :data="state.dataList" :size="TABLE.SIZE" :style="TABLE.STYLE" :cell-style="TABLE.CELL_STYLE"
:row-style="TABLE.ROW_STYLE" :header-cell-style="TABLE.HEADER_CELL_STYLE" border
:summary-method="getSummaries" show-summary>
<el-table-column align="center" type="index" label="#" width="50" />
<el-table-column align="center" label="商户" prop="merchantName" show-overflow-tooltip>
<template #default="{ row }">
{{ row.merchantName || "未知" }}
</template>
</el-table-column>
<el-table-column align="center" label="流水金额" prop="totalPayPrice">
<template #default="{ row }">
<span class="color-success">
{{ displayPrice(row.totalPayPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="流水次数" prop="totalPayCount">
<template #default="{ row }">
<span class="color-primary">
{{ row.totalPayCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="退款金额" prop="totalRefundPrice">
<template #default="{ row }">
<span class="color-danger">
{{ displayPrice(row.totalRefundPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="退款次数" prop="totalRefundCount">
<template #default="{ row }">
<span class="color-danger">
{{ row.totalRefundCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="消费净金额" prop="totalProfitPrice" min-width="120">
<template #default="{ row }">
<span class="color-success">
{{ displayPrice(row.totalProfitPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="消费净次数" prop="totalProfitCount" min-width="120">
<template #default="{ row }">
<span class="color-success">
{{ row.totalProfitCount || 0 }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, reactive } from "vue";
import { canteenApi } from "@/api/index"
import { displayPrice } from "@/utils/format-money"
import { TABLE } from "@/constant/table-constant"
const state = reactive({
dataList: [],
queryParams: {
createTimeList: []
}
})
onMounted(() => {
getDataList()
})
// 食堂报表
const getDataList = async () => {
try {
const { code, data } = await canteenApi.canteenReport({ ...state.queryParams })
console.log("食堂报表:", data);
state.dataList = data
} catch (error) {
console.error("|食堂报表|意外的错误:", error);
}
}
const refresh = async (createTimeList) => {
state.queryParams.createTimeList = createTimeList ?? null
await getDataList()
}
const getSummaries = (param) => {
const { columns, data } = param
const totalColumns = ["totalPayPrice", "totalPayCount", "totalRefundPrice", "totalRefundCount", "totalProfitPrice", "totalProfitCount"]
const totalRow = Array.from({ length: columns.length })
totalRow[0] = "合计"
columns.forEach((i, index) => {
if (totalColumns.includes(i.property)) {
const total = data
.filter((v) => v[i.property] != null)
.map((v) => v[i.property])
.reduce((pren, next) => pren + next, 0)
if (i.property.includes("Price")) {
totalRow[index] = displayPrice(total)
} else {
totalRow[index] = total
}
}
})
return totalRow
}
defineExpose({
refresh,
});
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
}
:deep(.el-table) {
tr {
background: #1d283477 !important;
}
.el-table__footer-wrapper {
.el-table__cell {
background: #1d283477 !important;
border-color: #121F33 !important;
}
}
}
</style>
@@ -0,0 +1,240 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
import { getRecentMonths } from "@/utils/date";
const props = defineProps({
data: {
type: Object,
default: () => ({
in: [],
out: [],
}),
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
onMounted(async () => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
window.removeEventListener("resize", handleResize);
});
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
watch(
() => props.data,
(val) => {
if (!chartInstance) return;
const { in: inData, out: outData } = props.data;
const inChart = inData.list?.map((v) => {
return v.carCount;
});
const outChart = outData.list?.map((v) => {
return v.carCount;
});
chartInstance.setOption({
series: [
{ name: outData.typeStr, data: outChart },
{ name: inData.typeStr, data: inChart },
],
xAxis: {
data: inData.list?.map((v) => {
return v.time;
}),
},
legend: {
data: [{ name: outData.typeStr }, { name: inData.typeStr }],
},
});
}
);
const option = {
backgroundColor: "transparent",
grid: {
top: "18%",
left: "0%",
right: "4%",
bottom: "10%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (params) => {
// return `${params[0].axisValue}<br/>${params[0].marker}${params[0].value}辆<br/>
// ${params[1].axisValue}<br/>${params[1].marker}${params[1].value}辆<br/>
// ${params[2].axisValue}<br/>${params[2].marker}${params[2].value}辆<br/>`;
// },
valueFormatter: (value) => value,
},
legend: {
icon: "rect",
right: 0,
top: 0,
itemWidth: 14,
itemHeight: 8,
itemGap: 16,
textStyle: { color: "#ffffff", fontSize: 12 },
itemStyle: { borderWidth: 0, borderColor: "transparent" },
data: [
{ name: "车出", itemStyle: { color: "#1DB996", borderWidth: 0, borderColor: "transparent" } },
{ name: "车入", itemStyle: { color: "#01C6EF", borderWidth: 0, borderColor: "transparent" } },
],
},
xAxis: {
type: "category",
data: [],
boundaryGap: false,
axisLine: {
lineStyle: {
color: "#21303C",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
// backgroundColor: "#2d5a3d",
// padding: [4, 4, 4, 4],
},
},
yAxis: {
type: "value",
name: "车辆数",
min: 0,
nameTextStyle: {
color: "#ffffff",
fontSize: 10,
padding: [0, 0, 0, 0],
// align: "right",
},
axisLine: {
show: false,
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
series: [
{
name: "车出",
type: "line",
// stack: "Total",
data: props.data?.out ?? [],
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#1DB996",
width: 2,
},
itemStyle: {
color: "#1DB996",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#1DB996",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#1DB996",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#1C7C67" },
{ offset: 1, color: "#0D1E25" },
],
},
},
},
{
name: "车入",
type: "line",
// stack: "Total",
data: props.data?.in ?? [],
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#01C6EF",
width: 2,
},
itemStyle: {
color: "#01C6EF",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#01C6EF",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#01C6EF",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#097186" },
{ offset: 1, color: "#0D1E25" },
],
},
},
},
],
};
</script>
+191
View File
@@ -0,0 +1,191 @@
<template>
<div class="container">
<div class="count-total">
<div class="total-item in-bg" @click="doTotalCar('enter')">
<div class="total-item-container">
<div class="text">在位军车数量</div>
<div class="count" style="color: #9ce7e8">{{ total?.inCarNum ?? 0 }} 辆</div>
</div>
</div>
<div class="total-item not-in-bg" @click="doTotalCar('exit')">
<div class="total-item-container">
<div class="text">不在位军车数量</div>
<div class="count" style="color: #f5d097">{{ total?.exitCarNum ?? 0 }} 辆</div>
</div>
</div>
</div>
<div class="out-statue">
<div class="item" v-for="(item, index) in data.items" :key="index" @click="doCar(item)">
<div class="name">
<span>{{ item.typeName }}</span>
</div>
<div class="number">
<div>
<span>出车次数</span>
</div>
<div>
<span>{{ item.outNumber }} 次</span>
</div>
</div>
<div class="duration" v-if="false">
<div>出车时长</div>
<div>{{ item.outDuration }} 时</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ElMessage } from "element-plus";
const props = defineProps({
total: {
type: Object,
default: () => ({}),
},
data: {
type: Object,
default: () => ({
items: [],
}),
},
});
const doCar = (item) => {
// if (item.outNumber == 0) {
// ElMessage.warning(`${item.typeName}过车次数为0,无法跳转`);
// return;
// }
// const _params = {
// // 抓拍时间
// capTimeList: DateTool.getFormattedDateTimeRange(item.type),
// // 军车
// isArmyCar: "1"
// }
// console.log(`|点击跳转|-|军车出车情况统计|-|${item.name}|params:|{${JSON.stringify(_params)}}|`);
// jumpAdminPath("car_record", _params)
};
const doTotalCar = (type) => {
// const _params = {
// inTimeList: DateTool.getFormattedDateTimeRange("TODAY"),
// isArmyCar: "1"
// }
// if (type == "enter") {
// _params.isEnter = '1'
// }
// if (type == "exit") {
// _params.isEnter = '0'
// }
// jumpAdminPath("car_park", _params)
};
</script>
<style scoped lang="less">
.container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 2vh;
.count-total {
width: 100%;
height: 26%;
display: flex;
align-items: center;
justify-content: space-between;
.total-item {
position: relative;
width: 50%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: 80% 100%;
padding-right: 2vw;
color: #c7cfd3;
cursor: pointer;
.total-item-container {
height: 100%;
position: absolute;
right: 1vw;
bottom: 0;
top: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: start;
.text {
font-size: 0.7vw;
}
.count {
font-size: 0.8vw;
}
}
}
.in-bg {
background-image: url("@/assets/images/car-count-in-bg.png");
}
.not-in-bg {
background-image: url("@/assets/images/car-count-not-in-bg.png");
}
}
.out-statue {
width: 100%;
height: 74%;
display: flex;
align-items: center;
justify-content: space-evenly;
.item {
height: 100%;
width: calc(100% / 5 - 1vw);
background-image: url("@/assets/images/car-out-statue-item-bg.png");
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
display: flex;
flex-direction: column;
align-items: center;
color: #d1eafd;
cursor: pointer;
.name {
display: flex;
justify-content: center;
align-items: flex-end;
height: calc(100% / 3);
font-size: 0.7vw;
}
.number {
height: calc(100% / 3);
font-size: 0.6vw;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
}
.duration {
height: calc(100% / 3);
font-size: 0.6vw;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
}
}
</style>
+213
View File
@@ -0,0 +1,213 @@
<template>
<div class="menu">
<span
class="menu-item"
v-for="(item, index) in menuList"
:key="index"
:class="{ active: activeIndex === index }"
@click="changeTab(index)"
>
{{ item.label }}
</span>
</div>
<div class="status-type">
<div v-if="false">按出车次数</div>
<div v-if="false" style="margin-left: 1vw">按出车时长</div>
</div>
<div ref="chartRef" style="width: 100%; height: 74%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, watch, useTemplateRef, ref, computed } from "vue";
import * as echarts from "echarts";
const emit = defineEmits(["changeDateType"]);
const props = defineProps({
data: {
type: Object,
default: () => [],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
const menuList = [
{ label: "昨日", value: "YESTERDAY" },
{ label: "今日", value: "TODAY" },
{ label: "本周", value: "THIS_WEEK" },
{ label: "本月", value: "THIS_MONTH" },
{ label: "本年", value: "THIS_YEAR" },
];
const activeIndex = ref(0);
const activeKey = computed(() => menuList[activeIndex.value].value);
watch(
() => activeKey.value,
() => {
if (!chartInstance) return;
chartInstance.setOption({ series: [{ data: props.data[activeKey.value] }] });
}
);
watch(
() => [props.data],
() => {
if (!chartInstance) return;
const labels = props.data.map((item) => item.carNum) ?? [];
const seriesData = props.data.map((item) => item.timeCount) ?? [];
console.log(props.data);
chartInstance.setOption({
yAxis: { data: labels.reverse() },
series: [{ data: seriesData.reverse() }],
});
}
);
const changeTab = (index) => {
activeIndex.value = index;
emit("changeDateType", menuList[index].value);
};
const options = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
axisPointer: { type: "shadow" },
// formatter: (params) => {
// const p = Array.isArray(params) ? params[0] : params;
// return `${p.name}:${p.value}`;
// },
confine: true,
},
backgroundColor: "transparent",
grid: {
top: "0%",
left: "0%",
right: "4%",
bottom: "2%",
containLabel: true,
},
xAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: "rgba(255,255,255,0.6)" },
splitLine: { show: true, lineStyle: { color: "rgba(255,255,255,0.08)" } },
},
yAxis: {
type: "category",
data: [],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: "#fff" },
},
series: [
{
type: "bar",
data: [],
barWidth: 14,
showBackground: true,
backgroundStyle: { color: "rgba(255,255,255,0.06)" },
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: (params) => {
const palette = [
["#112537", "#3081DC"],
["#112537", "#3081DC"],
["#112537", "#D0F43D"],
["#112537", "#3081DC"],
["#112537", "#3081DC"],
];
const [c1, c2] = palette[params.dataIndex % palette.length];
return new echarts.graphic.LinearGradient(1, 0, 0, 0, [
{ offset: 0, color: c2 },
{ offset: 1, color: c1 },
]);
},
},
},
],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(options);
window.addEventListener("resize", resizeHandler);
// chartInstance.on('click', (params) => {
// console.log("params:", params);
// if (!params.name) return
// const _params = {
// // 抓拍时间
// capTimeList: DateTool.getFormattedDateTimeRange(activeKey.value),
// // 车牌号
// carNum: params.name
// }
// console.log(`|点击跳转|-|军车出车情况排行|-|${params.name}|params:|{${JSON.stringify(_params)}}|`);
// jumpAdminPath("car_record", _params)
// })
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped lang="less">
.menu {
width: 70%;
height: 14%;
display: flex;
justify-content: space-around;
align-items: center;
.menu-item {
padding: 1.2vh 1vw;
cursor: pointer;
text-align: center;
font-size: 0.8vw;
white-space: nowrap;
background-image: url("@/assets/images/date-dimension-tab.png");
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: center;
color: #419ce3;
transition: all 0.2s ease;
&.active {
background-image: url("@/assets/images/date-dimension-tab-select.png");
color: #ffffff;
}
}
}
.status-type {
display: flex;
height: 10%;
padding: 1vh 0;
font-size: 0.8vw;
cursor: pointer;
&.status-type-active {
color: #419ce3;
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="container">
<div class="car-status-img">
<img class="img" src="@/assets/images/car-status.png" />
</div>
<div class="car-status-info">
<!-- start 在册私家车 start -->
<div class="info-item" @click="doCar('carManage')">
<div class="info-title">
<el-icon style="color: #0494b9; opacity: 0.5">
<ArrowRightBold />
</el-icon>
<el-icon style="color: #0494b9; margin-left: -6px">
<ArrowRightBold />
</el-icon>
<div>当前营区在册私家车数量</div>
</div>
<div class="info-content">
<span class="count">{{ data?.carManage?.selfCarTotal ?? 0 }}</span>
<span class="unit">辆</span>
</div>
</div>
<!-- end 在册私家车 end -->
<!-- start 私家车情况 start -->
<div class="info-item" @click="doCar('selfCar')">
<div class="info-title">
<el-icon style="color: #0494b9; opacity: 0.5">
<ArrowRightBold />
</el-icon>
<el-icon style="color: #0494b9; margin-left: -6px">
<ArrowRightBold />
</el-icon>
<div>当前营区在册私家车进</div>
</div>
<div class="info-content">
<span class="count">{{ data?.selfCar?.enterCarNum ?? 0 }}</span>
<span class="unit">辆</span>,
</div>
<div class="info-title">出</div>
<div class="info-content">
<span class="count">{{ data?.selfCar?.exitCarNum ?? 0 }}</span>
<span class="unit">辆</span>
</div>
</div>
<!-- end 私家车情况 end -->
<!-- start 外部车情况 start -->
<div class="info-item" @click="doCar('foreignCar')">
<div class="info-title">
<el-icon style="color: #0494b9; opacity: 0.5">
<ArrowRightBold />
</el-icon>
<el-icon style="color: #0494b9; margin-left: -6px">
<ArrowRightBold />
</el-icon>
<div>当前营区外来车进</div>
</div>
<div class="info-content">
<span class="count">{{ data?.foreignCar?.enterCarNum ?? 0 }}</span>
<span class="unit">辆</span>,
</div>
<div class="info-title">出</div>
<div class="info-content">
<span class="count">{{ data?.foreignCar?.exitCarNum ?? 0 }}</span>
<span class="unit">辆</span>
</div>
</div>
<!-- end 外部车情况 end -->
</div>
</div>
</template>
<script setup>
import { ArrowRightBold } from "@element-plus/icons-vue";
const props = defineProps({
data: {
type: Array,
default: () => {},
},
});
const doCar = (type) => {
// let actionType = ""
// const _params = {}
// if (type == "carManage") {
// actionType = "car_manage"
// _params.isArmyCar = '0'
// }
// if (type == "selfCar") {
// actionType = "car_park"
// _params.inTimeList = DateTool.getFormattedDateTimeRange("TODAY")
// _params.isInner = '0'
// _params.isArmyCar = '0'
// }
// if (type == "foreignCar") {
// actionType = "car_park"
// _params.inTimeList = DateTool.getFormattedDateTimeRange("TODAY")
// _params.isInner = '1'
// _params.isArmyCar = '0'
// }
// if (!actionType) return
// jumpAdminPath(actionType, _params)
};
</script>
<style scoped lang="less">
.container {
height: 100%;
width: 100%;
display: flex;
align-items: center;
.car-status-img {
width: calc(100% / 3);
height: 100%;
display: flex;
align-items: center;
.img {
width: 100%;
}
}
.car-status-info {
width: calc(100% / 3 * 2 + 5vw);
height: 60%;
background-image: url(@/assets/images/car-status-info-bg.png);
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
display: flex;
flex-direction: column;
justify-content: space-around;
padding-left: 12%;
font-size: 0.6vw;
.info-item {
display: flex;
align-items: center;
cursor: pointer;
.info-title {
display: flex;
align-items: center;
}
.info-content {
display: flex;
align-items: center;
.count {
font-size: 0.7vw;
color: #00d8ff;
}
.unit {
margin-left: 0vw;
}
}
}
}
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
data: {
type: Array,
default: () => [],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", resizeHandler);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
watch(
() => [props.data, props.labels],
() => {
if (!chartInstance) return;
chartInstance.setOption({
series: [{ data: props.data }],
xAxis: {
data: props.labels,
},
});
},
{ deep: true }
);
const option = {
backgroundColor: "transparent",
grid: {
top: "18%",
left: "0%",
right: "2%",
bottom: "10%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (params) => {
// return `${params[0].axisValue}<br/>${params[0].marker}${params[0].value}辆<br/>
// ${params[1].axisValue}<br/>${params[1].marker}${params[1].value}辆<br/>
// ${params[2].axisValue}<br/>${params[2].marker}${params[2].value}辆<br/>`;
// },
valueFormatter: (value) => value + "次",
},
xAxis: {
type: "category",
data: [],
boundaryGap: false,
axisLine: {
lineStyle: {
color: "#21303C",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
// backgroundColor: "#2d5a3d",
// padding: [4, 4, 4, 4],
},
},
yAxis: {
type: "value",
name: "告警率(次)",
min: 0,
nameTextStyle: {
color: "#ffffff",
fontSize: 10,
padding: [0, 0, 0, 60]
// align: "right",
},
axisLine: {
show: false,
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
series: [
{
name: "设备告警率",
type: "line",
stack: "Total",
data: props.data,
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#1DB996",
width: 2,
},
itemStyle: {
color: "#1DB996",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#1DB996",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#1DB996",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#1C7C67" },
{ offset: 1, color: "#0D1E25" },
],
},
},
},
],
};
</script>
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="alarm-list">
<div class="totals">
<div class="total-card" @click="doTotalCar('today')">
<div class="total-title">今日告警数</div>
<div class="total-value" style="color: #ef3121">{{ data.todayTotal }}</div>
</div>
<div class="total-card" @click="doTotalCar('all')">
<div class="total-title">告警总数</div>
<div class="total-value" style="color: #ece415">{{ data.allTotal }}</div>
</div>
</div>
<div class="table">
<div class="thead">
<div class="th">序号</div>
<div class="th" style="width: 6vw">告警设备</div>
<div class="th">告警区域</div>
<div class="th" style="color: rgba(255, 255, 255, 0.7); width: 8vw">告警时间</div>
</div>
<div class="tbody scrollbar-hide">
<div v-for="(row, idx) in data.list" :key="idx" class="tr" @click="doCar(row)">
<div class="td">
<span class="index-badge">{{ idx + 1 }}</span>
</div>
<div class="td" style="width: 6vw">
<span class="device-dot"></span>
<span class="text-overflow">{{ row.deviceName }}</span>
</div>
<div class="td text-overflow" style="width: 5vw">{{ row.areaName }}</div>
<div class="td" style="width: 8vw">{{ row.time }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
data: {
type: Object,
default: () => ({
list: [],
todayTotal: 0,
allTotal: 0,
}),
},
});
const doTotalCar = (type) => {
// const _params = {}
// if (type == "today") {
// _params.alarmDateList = DateTool.getFormattedDateTimeRange("TODAY")
// }
// console.log(`|点击跳转|-|设备告警实时统计统计|params:|{${JSON.stringify(_params)}}|`);
// jumpAdminPath("device_alarm", _params)
};
const doCar = (row) => {
console.log("row:", row);
const _params = {
alarmCode: row.uniqueCode,
};
console.log(`|点击跳转|-|设备告警实时统计列表|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("device_alarm", _params);
};
</script>
<style scoped lang="less">
.text-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alarm-list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.totals {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1vw;
.total-card {
height: 7vh;
background-repeat: no-repeat;
background-image: url("@/assets/images/device-alarm-list-total-bg.png");
background-size: 100% 100%;
display: flex;
align-items: center;
padding: 0 1vw;
gap: 1vw;
white-space: nowrap;
cursor: pointer;
}
.total-title {
color: #fff;
font-size: 0.9vw;
margin-left: 20%;
}
.total-value {
margin-left: auto;
font-weight: 700;
font-size: 1vw;
}
}
.table {
margin-top: 1vh;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
.thead {
display: grid;
grid-template-columns: 0.6fr 1fr 1fr 1.6fr;
gap: 1vh;
padding: 1vh;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
.th {
color: rgba(255, 255, 255, 0.7);
font-size: 0.8vw;
padding: 6px 0;
text-align: center;
}
}
.tbody {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1vh;
padding-top: 1vh;
min-height: 0;
.tr {
display: grid;
grid-template-columns: 0.6fr 1fr 1fr 1.6fr;
gap: 1vh;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #0c1d31;
border-radius: 8px;
padding: 1vh 0;
cursor: pointer;
.td {
color: rgba(255, 255, 255, 0.9);
font-size: 0.7vw;
display: flex;
align-items: center;
justify-content: flex-start;
.index-badge {
width: 1.6vw;
height: 1vw;
line-height: 1vw;
text-align: center;
color: #cfe8ff;
font-weight: 600;
background-repeat: no-repeat;
background-image: url("@/assets/images/device-alarm-list-index-bg.png");
background-size: 100% 100%;
display: inline-block;
}
.device-dot {
width: 0.6vw;
height: 0.6vw;
border-radius: 50%;
background: #3bd2a6;
margin-right: 8px;
}
}
}
}
}
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>
@@ -0,0 +1,197 @@
<template>
<div class="container">
<div class="chart" ref="chartRef"></div>
<div class="area-info">
<div class="area-info-item" v-for="(item, i) in props.data" :key="item.name" @click="doCar(item)">
<span
:style="{ width: '1vw', height: '1vw', background: colors[i], borderRadius: '50%', display: 'inline-block' }"></span>
<span class="name">{{ item.name }}</span>
<span :style="{ marginLeft: '0.2vw', color: colors[i] }">{{ item.total }}台</span>
<span :style="{ marginLeft: '0.1vw', color: colors[i], fontSize: '0.6vw' }">({{ percent(item.total) }})</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
import { ElMessage } from "element-plus";
import * as echarts from "echarts";
const props = defineProps({
title: { type: String, default: "监控总数" },
data: {
type: Array,
default: () => [],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
const colors = ["#5470C6", "#91CC75", "#FFC83D", "#FF6B6B", "#9B59B6"];
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(options);
window.addEventListener("resize", resizeHandler);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
watch(
() => [props.data, props.title],
() => {
if (!chartInstance) return;
console.log(props.data);
chartInstance.setOption({
graphic: [{ style: { text: total.value } }],
series: [
{
data: props.data.map((v, i) => {
return {
name: v.name,
value: Number(v.total),
itemStyle: { color: colors[i] },
};
}),
},
],
});
},
{ deep: true }
);
const total = computed(() => {
if (!props.data || !props.data.length) return [];
return props.data.reduce((sum, i) => sum + (Number(i.total) || 0), 0);
});
const doCar = (item) => {
if (!item.name) return;
if (item.total == 0) {
ElMessage.warning(`当前${item.name}设备总数为0,无法跳转`);
return;
}
const _params = {
// 空间名
areaName: item.name,
};
console.log(`|点击跳转|-|监控区域统计|-|${item.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("area_house", _params);
};
const options = {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (p) => `${p.name}:${p.value} (${p.percent}%)`,
confine: true,
},
series: [
{
type: "pie",
radius: ["60%", "78%"],
center: ["50%", "50%"],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
itemStyle: { borderWidth: 0 },
data: [],
},
],
graphic: [
{
type: "text",
left: "40%",
top: "40%",
style: {
text: String(total.value),
fill: "#fff",
fontSize: 22,
fontWeight: 700,
textAlign: "center",
},
},
{
type: "text",
left: "39%",
top: "55%",
style: {
text: props.title,
fill: "#B8C7D9",
fontSize: 12,
textAlign: "center",
},
},
],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
const percent = (v) => {
if (!total.value) return "0%";
return ((Number(v) / Number(total.value)) * 100).toFixed(1) + "%";
};
</script>
<style scoped lang="less">
.container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
.chart {
flex: 1;
height: 100%;
}
.area-info {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
.area-info-item {
display: flex;
align-items: center;
padding: 0.4vw 0;
font-size: 0.7vw;
cursor: pointer;
.name {
width: 5vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 0.2vw;
color: #b8c7d9;
font-size: 0.7vw;
}
&:not(:last-child) {
border-bottom: 1px dashed #eeeeee99;
}
}
}
}
</style>
@@ -0,0 +1,139 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, watch, useTemplateRef } from "vue";
import { ElMessage } from "element-plus";
import * as echarts from "echarts";
const props = defineProps({
data: { type: Array, default: () => [] },
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(options);
window.addEventListener("resize", resizeHandler);
chartInstance.on("click", (params) => {
if (!params.name) return;
if (params.data == 0) {
ElMessage.warning(`当前${params.name}设备总数为0,无法跳转`);
return;
}
const _params = {
// 空间名
areaName: params.name,
};
console.log(`|点击跳转|-|监控数量排行|-|${params.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("area_house", _params);
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
watch(
() => props.data,
() => {
if (!chartInstance) return;
console.log(props.data,);
chartInstance.setOption({
yAxis: {
data: props.data?.labels?.map((v) => {
return v;
}),
},
series: [
{
data: props.data?.values?.map((v) => {
return v;
}),
},
],
});
},
{ deep: true }
);
const options = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
axisPointer: { type: "shadow" },
// formatter: (params) => {
// const p = Array.isArray(params) ? params[0] : params;
// return `${p.name}:${p.value}`;
// },
confine: true,
},
backgroundColor: "transparent",
grid: {
top: "0%",
left: "0%",
right: "4%",
bottom: "0%",
containLabel: true,
},
xAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: "rgba(255,255,255,0.6)" },
splitLine: { show: true, lineStyle: { color: "rgba(255,255,255,0.08)" } },
},
yAxis: {
type: "category",
data: props.labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: "#fff" },
},
series: [
{
type: "bar",
data: props.data,
barWidth: 14,
showBackground: true,
backgroundStyle: { color: "rgba(255,255,255,0.06)" },
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: (params) => {
const palette = [
["#1F4F68", "#2EE4D9"],
["#1F4F68", "#2EE4D9"],
["#1F4F68", "#F57F3C"],
["#1F4F68", "#2EE4D9"],
["#1F4F68", "#2EE4D9"],
];
const [c1, c2] = palette[params.dataIndex % palette.length];
return new echarts.graphic.LinearGradient(1, 0, 0, 0, [
{ offset: 0, color: c2 },
{ offset: 1, color: c1 },
]);
},
},
},
],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
</script>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
import { getRecentDays } from "@/utils/date";
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
data: {
type: Array,
default: () => [],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
window.removeEventListener("resize", handleResize);
});
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
watch(
() => [props.data, props.labels],
(val) => {
if (!chartInstance) return;
chartInstance.setOption({
xAxis: {
data: props.labels,
},
series: [{ data: props.data }],
});
}
);
const option = {
backgroundColor: "transparent",
grid: {
top: "18%",
left: "0%",
right: "4%",
bottom: "10%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (params) => {
// return `${params[0].axisValue}<br/>${params[0].marker}${params[0].value}辆<br/>
// ${params[1].axisValue}<br/>${params[1].marker}${params[1].value}辆<br/>
// ${params[2].axisValue}<br/>${params[2].marker}${params[2].value}辆<br/>`;
// },
valueFormatter: (value) => value + "%",
},
xAxis: {
type: "category",
data: [],
boundaryGap: false,
axisLine: {
lineStyle: {
color: "#5598B3",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
// backgroundColor: "#2d5a3d",
// padding: [4, 4, 4, 4],
},
},
yAxis: {
type: "value",
name: "购买次数",
min: 0,
nameTextStyle: {
color: "#ffffff",
fontSize: 10,
padding: [0, 0, 0, 60]
},
axisLine: {
show: true,
lineStyle: {
color: "#374D59",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
// formatter: "{value}%",
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
series: [
{
name: "设备在线率",
type: "line",
stack: "Total",
data: props.data,
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#1FE9D5",
width: 2,
},
itemStyle: {
color: "#1FE9D5",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#1FE9D5",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#1FE9D5",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#129F97" },
{ offset: 1, color: "#102837" },
],
},
},
},
],
};
</script>
+167
View File
@@ -0,0 +1,167 @@
<template>
<div class="container">
<div class="device" v-for="(item, index) in displayData" :key="index" @click="doCar(item)">
<img :src="item.img" />
<div class="info">
<div class="title">{{ item.name }}</div>
<div class="number">
<span>总数</span>
<span :style="{ marginLeft: '0.3vw', color: item.color }">{{ item.total }}</span>
<span style="margin-left: 12%">在线</span>
<span :style="{ marginLeft: '0.3vw', color: item.color }">{{ item.online }}</span>
<span style="margin-left: 12%">在线率</span>
<span :style="{ marginLeft: '0.3vw', color: item.color }">{{ item.onlineRate.toFixed(2) }}%</span>
</div>
<div class="percent" :class="item.bgClass" :style="{ backgroundSize: item.bgSize }" />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { ElMessage } from "element-plus";
import entranceGuardImg from "@/assets/images/entrance-guard.png";
import barrierGateImg from "@/assets/images/barrier-gate.png";
import monitoringImg from "@/assets/images/monitoring.png";
const props = defineProps({
data: {
type: Array,
default: () => [
{
name: "门禁设备",
total: 0,
online: 0,
onlineRate: 0,
},
{
name: "闸道设备",
total: 0,
online: 0,
onlineRate: 0,
},
{
name: "监控设备",
total: 0,
online: 0,
onlineRate: 0,
},
],
},
});
const styleConfig = [
{
img: entranceGuardImg,
color: "#39f291",
bgClass: "guard",
},
{
img: barrierGateImg,
color: "#00ebfb",
bgClass: "gate",
},
{
img: monitoringImg,
color: "#f7bb03",
bgClass: "monitoring",
},
];
const doCar = (item) => {
if (item.total == 0) {
ElMessage.warning(`当前${item.name}设备总数为0,无法跳转`);
return;
}
const _params = {
// 设备大类
deviceCategory: item.type,
};
console.log(`|点击跳转|-|设备状态统计|-|${item.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("device_manage", _params);
};
const displayData = computed(() => {
const data = Array.isArray(props.data) ? props.data : [];
return styleConfig.map((style, index) => {
const item = data[index] || { name: "", total: 0, online: 0 };
const total = Number(item.total) || 0;
const online = Number(item.online) || 0;
const rate = total > 0 ? (online / total) * 100 : 0;
return {
name: item.name || "",
total,
online,
type: item.type,
onlineRate: rate,
img: style.img,
color: style.color,
bgClass: style.bgClass,
bgSize: `${rate}% 100%`,
};
});
});
</script>
<style scoped lang="less">
.container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
.device {
width: 94%;
height: calc(90% / 3);
display: flex;
cursor: pointer;
.info {
background-image: url("@/assets/images/device-status-bg.png");
background-size: 100% 100%;
width: 100%;
padding: 0 6px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
.title {
font-size: 0.7vw;
font-weight: bold;
text-align: left;
background-image: linear-gradient(to bottom, #ffffff, #367fab);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.number {
font-size: 0.6vw;
color: #ecf7ff;
}
.percent {
height: 0.6vh;
background-color: #002f51;
background-repeat: no-repeat;
}
.guard {
background-image: url("@/assets/images/entrance-guard-percent.png");
}
.gate {
background-image: url("@/assets/images/barrier-gate-percent.png");
}
.monitoring {
background-image: url("@/assets/images/monitoring-percent.png");
}
}
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="container">
<div class="video-list">
<div class="video-item" :class="{ 'video-item-active': index === curIndex }" @click="curIndex = index"
v-for="(item, index) in 4" :key="index"></div>
</div>
<div class="video-operate">
<el-button type="primary" plain size="small">自适应</el-button>
<el-button type="primary" plain size="small">单屏</el-button>
<el-button type="primary" plain size="small">四分屏</el-button>
<el-button type="primary" plain size="small" style="margin-right: 2vw">全屏</el-button>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const curIndex = ref(0);
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.video-list {
width: 100%;
height: 88%;
display: flex;
flex-wrap: wrap;
.video-item {
width: calc(50% - 0.2vh * 2);
height: calc(50% - 0.2vh * 2);
background-color: #1d283477;
border: 0.2vh solid #000;
}
.video-item-active {
border: 0.2vh solid #0b4f82 !important;
}
}
.video-operate {
width: 100%;
height: 12%;
background-color: #054861;
display: flex;
justify-content: end;
align-items: center;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import { h, render } from "vue";
import NotificationProvider from "./notificationProvider.vue";
let vnode;
const useNotification = () => {
if (!vnode) {
const container = document.createElement("div");
document.body.appendChild(container);
vnode = h(NotificationProvider, {});
render(vnode, container);
}
const { add: notificationAdd, remove: notificationRemove, clear: notificationClear } = (vnode.component && vnode.component.exposed) || {};
return {
notificationAdd,
notificationRemove,
notificationClear,
};
};
export default useNotification;
@@ -0,0 +1,172 @@
<template>
<div class="notification" @mouseenter="clearTimer" @mouseleave="createTimer">
<div class="top">
<span class="title">
<ElIcon>
<BellFilled />
</ElIcon>
<span>{{ title }}</span>
</span>
<a class="btn" @click="handleClose">{{ btnText }}</a>
</div>
<span class="content">
<div v-if="content.tag" class="tag">{{ content.tag }}</div>
<img v-if="content.pic" class="pic" :src="content.pic" />
<div class="info">
<div class="row">
<ElIcon style="color: #1ac1ba">
<ShoppingBag />
</ElIcon>
<span>{{ content.channelName }}</span>
</div>
<div class="row">
<ElIcon style="color: #c1951a">
<Timer />
</ElIcon>
<span>{{ content.createTime }}</span>
</div>
</div>
</span>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import { ElIcon } from "element-plus";
import { ShoppingBag, Timer, BellFilled } from "@element-plus/icons-vue";
const props = defineProps({
title: {
type: String,
default: "标题",
},
content: {
type: Object,
default: {
tag: "",
pic: "",
channelName: "",
createTime: "",
},
},
btnText: {
type: String,
default: "关闭",
},
duration: {
type: Number,
default: 5 * 1000,
},
});
const emit = defineEmits(["onClosed"]);
const timer = ref(0);
// 点击关闭按钮
const handleClose = (e) => {
emit("onClosed");
};
const createTimer = () => {
if (props.duration) {
timer.value = setTimeout(() => {
emit("onClosed");
}, props.duration);
}
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = 0;
}
};
onMounted(() => {
createTimer();
});
onBeforeUnmount(() => {
clearTimer();
});
</script>
<style scoped>
.notification {
display: flex;
flex-wrap: wrap;
align-items: center;
flex-direction: column;
background-color: transparent;
color: #e0e2e2;
min-width: 18vw;
box-shadow: 0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12);
transition: all 0.3s;
border: 1px solid #0c3245;
margin-bottom: 10px;
}
.top {
width: 100%;
display: flex;
align-items: center;
background-color: #0d4c71;
padding: 4px 0;
font-size: 0.8vw;
}
.title {
margin-left: 20px;
display: flex;
gap: 4px;
align-items: center;
}
.btn {
color: #e0e2e2;
padding-right: 20px;
margin-left: auto;
cursor: pointer;
}
.content {
width: 100%;
padding: 10px 0;
background-color: #132227;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8vw;
}
.pic {
width: 50px;
height: 50px;
background-color: #7f7f7f;
object-fit: cover;
}
.tag {
display: flex;
justify-content: flex-start;
align-items: center;
padding-left: 10px;
width: 100px;
height: 50px;
}
.info {
flex: 1;
margin-left: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: 4px;
}
</style>
@@ -0,0 +1,42 @@
<template>
<div class="notification-provider">
<transition-group name="fade">
<notification v-for="item in notificationList" :key="item.id" :title="item.title" :content="item.content"
:btnText="item.btnText" :duration="item.duration" @onClosed="remove(item.id)" />
</transition-group>
</div>
</template>
<script setup>
import notification from "./notification.vue";
import useList from "./useList";
const { notificationList, add, remove, clear } = useList();
defineExpose({
add,
remove,
clear,
});
</script>
<style scoped>
.notification-provider {
position: fixed;
top: 40px;
right: 1vw;
z-index: 9999;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s;
transform: translateX(0);
}
.fade-enter,
.fade-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { ref } from "vue";
let count = 0;
const useList = () => {
const notificationList = ref([]);
const add = (optionProps) => {
notificationList.value.push({ id: count++, ...optionProps });
};
const remove = (id) => {
notificationList.value = notificationList.value.filter((item) => item.id !== id);
};
const clear = () => {
notificationList.value = [];
}
return { notificationList, add, remove, clear };
};
export default useList;
@@ -0,0 +1,98 @@
<template>
<div class="tab">
<span class="tab-item" v-for="(item, index) in tabList" :key="index" :class="{ active: activeIndex === index }"
@click="dateTypeChange(index)">
{{ item.label }}
</span>
</div>
<div class="content">
<keep-alive>
<component :is="tabList[activeIndex].component" :data="componentData" :labels="componentLabels" />
</keep-alive>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { getMonthDays } from "@/utils/date";
import YesterdayStatistics from "./yesterday-statistics.vue";
import WeekStatistics from "./week-statistics.vue";
import MonthStatistics from "./month-statistics.vue";
import YearStatistics from "./year-statistics.vue";
const emit = defineEmits(["changeDateType"]);
const props = defineProps({
labels: {
type: Object,
default: () => [],
},
data: {
type: Object,
default: () => ({
YESTERDAY: {},
TODAY: {},
THIS_WEEK: [],
THIS_MONTH: [],
THIS_YEAR: [],
}),
},
});
const tabList = [
{ label: "昨日", value: "YESTERDAY", component: YesterdayStatistics },
{ label: "今日", value: "TODAY", component: YesterdayStatistics },
{ label: "本周", value: "THIS_WEEK", component: WeekStatistics },
{ label: "本月", value: "THIS_MONTH", component: MonthStatistics },
{ label: "本年", value: "THIS_YEAR", component: YearStatistics },
];
const activeIndex = ref(0);
const activeKey = computed(() => tabList[activeIndex.value].value);
const componentData = computed(() => {
if (!props.data) {
return activeIndex.value == 0 ? {} : [];
}
return props.data[activeKey.value];
});
const componentLabels = computed(() => {
if (!props.labels) return [];
return props.labels[activeKey.value] ?? [];
});
const dateTypeChange = (index) => {
activeIndex.value = index;
emit("changeDateType", tabList[index].value);
};
</script>
<style scoped lang="less">
.tab {
width: 70%;
height: 14%;
display: flex;
justify-content: space-around;
align-items: center;
.tab-item {
padding: 1.2vh 1vw;
cursor: pointer;
text-align: center;
font-size: 0.8vw;
white-space: nowrap;
.background("@/assets/images/date-dimension-tab.png", @size: 100% 100%, @position: center);
color: @text-color-primary;
transition: all 0.2s ease;
&.active {
background-image: url("@/assets/images/date-dimension-tab-select.png");
color: @text-color;
}
}
}
.content {
width: 100%;
height: 86%;
}
</style>
@@ -0,0 +1,241 @@
<template>
<div class="container">
<div class="legend">
<span class="legend-item" :style="{ backgroundColor: getBarColorByTypeStr('hcCount') }"></span>
<span>荤菜</span>
<span class="legend-item" :style="{ backgroundColor: getBarColorByTypeStr('scCount') }"></span>
<span>素菜</span>
<span class="legend-item" :style="{ backgroundColor: getBarColorByTypeStr('zsCount') }"></span>
<span>主食</span>
</div>
<div class="days">
<div class="item" v-for="(item, index) in displayData" :key="index" @click="doCar(item)">
<div class="day">{{ item.day }}</div>
<div class="bar">
<div class="progress-container">
<div v-for="(typeItem, typeIndex) in typeSettings" :key="typeIndex" class="progress-segment"
:style="{
backgroundColor: getBarColorByTypeStr(typeItem.typeStr),
width: getWidth(typeItem, item),
}"></div>
</div>
</div>
<div class="tooltip">
<div class="tooltip-content">
<span class="label">荤菜:</span>
<span class="value">{{ item.hcCount ?? 0 }}份</span>
</div>
<div class="tooltip-content">
<span class="label">素菜:</span>
<span class="value">{{ item.scCount ?? 0 }}份</span>
</div>
<div class="tooltip-content">
<span class="label">主食:</span>
<span class="value">{{ item.zsCount ?? 0 }}份</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { getMonthDays } from "@/utils/date";
import { computed } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
const props = defineProps({
data: {
type: Array,
default: () => getMonthDays().map((day) => ({ day, type: -1 })),
},
});
const typeSettings = [
{
type: "荤菜",
typeStr: "hcCount",
color: "#287CE7",
},
{
type: "素菜",
typeStr: "scCount",
color: "#F4CF3D",
},
{
type: "主食",
typeStr: "zsCount",
color: "#30C6DC",
},
]
const displayData = computed(() => {
const days = getMonthDays();
const data = props.data;
if (!Array.isArray(props.data)) return days.map((day) => ({ day, type: -1 }));
if (props.data.length !== days.length) {
for (let i = data.length; i < days.length; i++) {
data.push({
day: days[i],
type: -1,
});
}
return data.slice(0, days.length);
}
return data;
});
const getBarColorByTypeStr = (type) => {
const target = typeSettings.find((v) => v.typeStr === type)
if (!target) return
return target.color
}
const getWidth = (type, item) => {
const { typeStr } = type;
const total = item.hcCount + item.scCount + item.zsCount;
return `${(item[typeStr] / total * 100).toFixed(2)}%`;
}
const doCar = (item) => {
if (!item.day) return;
if (item.type == "-1") {
ElMessage.warning(`还未到${item.day}号,无法跳转`);
return;
}
const params = {
// 抓拍时间
swingTimeList: [
dayjs().date(item.day).startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs().date(item.day).endOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
};
console.log(`|点击跳转|-|打卡统计|-|月|params:|{${JSON.stringify(params)}}|`);
jumpAdminPath("door_reign_record", params);
};
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.legend {
width: 100%;
display: flex;
justify-content: end;
align-items: center;
font-size: 0.6vw;
gap: 0.4vw;
margin: 1vh 0;
.legend-item {
width: 1vw;
height: 0.5vw;
}
}
.days {
flex: 1;
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.2vw;
.item {
position: relative;
background-color: #122c40;
border: 1px solid #1b3346;
cursor: pointer;
.day {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
width: 100%;
text-align: center;
color: #cfe8f6;
font-size: 0.8vw;
}
.bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 0.3vh;
.progress-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.progress-segment {
width: 33%;
height: 100%;
}
}
}
/* Hover 弹框样式 */
.tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: #0f1f2e;
border: 1px solid #1b3346;
color: #cfe8f6;
font-size: 0.7vw;
padding: 0.4vw 0.6vw;
border-radius: 0.3vw;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 10;
}
&:hover .tooltip {
opacity: 1;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: #0f1f2e transparent transparent transparent;
filter: drop-shadow(0 -1px 0 rgba(27, 51, 70, 0.9));
display: grid;
gap: 0.2vw;
}
.tooltip-content {
display: flex;
justify-content: center;
gap: 0.2vw;
align-items: center;
}
.tooltip-row .label {
color: #7fb5d6;
}
.tooltip-row .value {
color: #cfe8f6;
font-weight: 600;
}
}
}
}
</style>
@@ -0,0 +1,208 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
import dayjs from "dayjs";
const props = defineProps({
labels: {
type: Object,
default: () => [],
},
data: {
type: Object,
default: () => [
{ text: "荤菜", name: "hc", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "素菜", name: "sc", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "主食", name: "zs", values: [0, 0, 0, 0, 0, 0, 0, 0] },
],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
watch(
() => [props.data, props.labels],
(val) => {
if (!chartInstance) return;
chartInstance.setOption({
xAxis: {
data: props.labels?.map((v) => v.split("|")[1]) ?? [],
},
series: props.data.map((item, i) => {
return {
type: "bar",
stack: 'total',
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
});
},
{ deep: true }
);
const colors = ["#287CE7", "#F4CF3D", "#30C6DC"];
const linearGradientColors = [
["#01A5FA", "#086BA5", "#0E385A"],
["#E0B60D", "#78752F", "#143750"],
["#15D1C4", "#117883", "#0F3654"],
];
const option = {
backgroundColor: "transparent",
grid: {
top: "14%",
left: "0%",
right: "0%",
bottom: "4%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
valueFormatter: (value) => `${value}份`,
},
xAxis: {
type: 'category',
data: [],
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
axisLine: {
lineStyle: {
color: "#21303C",
},
},
},
yAxis: {
min: 0,
axisLine: {
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
legend: {
icon: "rect",
right: 0,
top: 0,
itemWidth: 10,
itemHeight: 8,
itemGap: 16,
textStyle: { color: "#ffffff", fontSize: 9 },
itemStyle: { borderWidth: 0, borderColor: "transparent" },
data: props.data.map((item, i) => {
return { name: item.text, itemStyle: { color: colors[i], borderWidth: 0, borderColor: "transparent" } };
}),
},
series: props.data.map((item, i) => {
return {
type: "bar",
stack: 'total',
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", resizeHandler);
chartInstance.on("click", function (params) {
const target = props.labels[params.dataIndex];
if (!target) return;
const [timeStr, _] = target.split("|");
const _params = {
swingTimeList: [
dayjs(timeStr).startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs(timeStr).endOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
};
console.log(`|点击跳转|-|打卡统计|-|本周|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_reign_record", _params);
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
@@ -0,0 +1,208 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
import dayjs from "dayjs";
const props = defineProps({
labels: {
type: Object,
default: () => [],
},
data: {
type: Object,
default: () => [
{ text: "荤菜", name: "hc", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "素菜", name: "sc", values: [0, 0, 0, 0, 0, 0, 0, 0] },
{ text: "主食", name: "zs", values: [0, 0, 0, 0, 0, 0, 0, 0] },
],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
watch(
() => [props.data, props.labels],
(val) => {
if (!chartInstance) return;
chartInstance.setOption({
xAxis: {
data: props.labels?.map((v) => v) ?? [],
},
series: props.data.map((item, i) => {
return {
type: "bar",
stack: 'total',
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
});
},
{ deep: true }
);
const colors = ["#287CE7", "#F4CF3D", "#30C6DC"];
const linearGradientColors = [
["#01A5FA", "#086BA5", "#0E385A"],
["#E0B60D", "#78752F", "#143750"],
["#15D1C4", "#117883", "#0F3654"],
];
const option = {
backgroundColor: "transparent",
grid: {
top: "14%",
left: "0%",
right: "0%",
bottom: "4%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
valueFormatter: (value) => `${value}份`,
},
xAxis: {
type: 'category',
data: [],
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
axisLine: {
lineStyle: {
color: "#21303C",
},
},
},
yAxis: {
min: 0,
axisLine: {
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
legend: {
icon: "rect",
right: 0,
top: 0,
itemWidth: 10,
itemHeight: 8,
itemGap: 16,
textStyle: { color: "#ffffff", fontSize: 9 },
itemStyle: { borderWidth: 0, borderColor: "transparent" },
data: props.data.map((item, i) => {
return { name: item.text, itemStyle: { color: colors[i], borderWidth: 0, borderColor: "transparent" } };
}),
},
series: props.data.map((item, i) => {
return {
type: "bar",
stack: 'total',
name: item.text,
showBackground: false,
barGap: "50%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][0] },
{ offset: 0.7, color: linearGradientColors[i][1] },
{ offset: 1, color: linearGradientColors[i][0] },
]),
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: linearGradientColors[i][2] },
{ offset: 0.5, color: linearGradientColors[i][0] },
{ offset: 1, color: linearGradientColors[i][1] },
]),
},
},
data: item.values,
};
}),
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", resizeHandler);
chartInstance.on("click", function (params) {
const target = props.labels[params.dataIndex];
if (!target) return;
const [timeStr, _] = target.split("|");
const _params = {
swingTimeList: [
dayjs(timeStr).startOf("day").format("YYYY-MM-DD HH:mm:ss"),
dayjs(timeStr).endOf("day").format("YYYY-MM-DD HH:mm:ss"),
],
};
console.log(`|点击跳转|-|打卡统计|-|本周|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_reign_record", _params);
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
@@ -0,0 +1,173 @@
<template>
<div class="container">
<div class="chart" ref="chartRef"></div>
<div class="area-info">
<div class="area-info-item" v-for="(item, index) in props.data" :key="item.name" @click="doItem(item)">
<span
:style="{ width: '1vw', height: '1vw', background: colors[index], borderRadius: '50%', display: 'inline-block' }"></span>
<span class="name">{{ item.title }}</span>
<span :style="{ marginLeft: '0.2vw', color: colors[index] }">
{{ item.total }}份
</span>
<span :style="{ marginLeft: '0.1vw', color: colors[index], fontSize: '0.6vw' }">
({{ percent(item.total) }})
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
const props = defineProps({
data: {
type: Array,
default: () => [],
},
});
const colors = ["#31D3A4", "#1AD0F9", "#5470C6"];
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
const total = computed(() => {
if (!props.data || !props.data.length) return 0;
return props.data.reduce((sum, i) => sum + (Number(i.total) || 0), 0);
});
const seriesData = computed(() => {
if (!props.data || !props.data.length) return [];
return props.data.map((i, idx) => ({ name: i.title, value: i.total, itemStyle: { color: colors[idx] } }));
});
watch(
() => [props.data],
() => {
if (!chartInstance) return;
chartInstance.setOption({
series: [{ data: seriesData.value }],
graphic: [{ type: "text", left: "40%", top: "40%", style: { text: String(total.value) } }],
});
}
);
const percent = (v) => {
if (!total.value) return "0%";
return ((Number(v) / total.value) * 100).toFixed(1) + "%";
};
const options = {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (p) => `${p.name}:${p.value} (${p.percent}%)`,
confine: true,
},
series: [
{
type: "pie",
radius: ["60%", "78%"],
center: ["50%", "50%"],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
itemStyle: { borderWidth: 0 },
data: seriesData.value,
},
],
graphic: [
{
type: "text",
left: "40%",
top: "40%",
style: {
text: String(total.value),
fill: "#fff",
fontSize: 22,
fontWeight: 700,
textAlign: "center",
},
},
{
type: "text",
left: "39%",
top: "55%",
style: {
text: "购买数量",
fill: "#B8C7D9",
fontSize: 12,
textAlign: "center",
},
},
],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(options);
window.addEventListener("resize", resizeHandler);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped lang="less">
.container {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
.chart {
flex: 1;
height: 100%;
}
.area-info {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
.area-info-item {
display: flex;
align-items: center;
padding: 0.4vw 0;
font-size: 0.7vw;
border-bottom: 1px dashed #eeeeee99;
cursor: pointer;
.name {
width: 5vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 0.2vw;
color: #b8c7d9;
font-size: 0.7vw;
}
}
}
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="container">
<el-table :data="state.dataList" :size="TABLE.SIZE" :style="TABLE.STYLE" :cell-style="TABLE.CELL_STYLE"
:row-style="TABLE.ROW_STYLE" :header-cell-style="TABLE.HEADER_CELL_STYLE" border
:summary-method="getSummaries" show-summary>
<el-table-column align="center" type="index" label="#" width="50" />
<el-table-column align="center" label="商户" prop="merchantName" show-overflow-tooltip>
<template #default="{ row }">
{{ row.merchantName || "未知" }}
</template>
</el-table-column>
<el-table-column align="center" label="流水金额" prop="totalPayPrice">
<template #default="{ row }">
<span class="color-success">
{{ displayPrice(row.totalPayPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="流水次数" prop="totalPayCount">
<template #default="{ row }">
<span class="color-primary">
{{ row.totalPayCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="退款金额" prop="totalRefundPrice">
<template #default="{ row }">
<span class="color-danger">
{{ displayPrice(row.totalRefundPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="退款次数" prop="totalRefundCount">
<template #default="{ row }">
<span class="color-danger">
{{ row.totalRefundCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="消费净金额" prop="totalProfitPrice" min-width="120">
<template #default="{ row }">
<span class="color-success">
{{ displayPrice(row.totalProfitPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="消费净次数" prop="totalProfitCount" min-width="120">
<template #default="{ row }">
<span class="color-success">
{{ row.totalProfitCount || 0 }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, reactive } from "vue";
import { canteenApi } from "@/api/index"
import { displayPrice } from "@/utils/format-money"
import { TABLE } from "@/constant/table-constant"
const state = reactive({
dataList: [],
queryParams: {
createTimeList: []
}
})
onMounted(() => {
getDataList()
})
// 食堂报表
const getDataList = async () => {
try {
const { code, data } = await canteenApi.canteenReport({ ...state.queryParams })
console.log("食堂报表:", data);
state.dataList = data
} catch (error) {
console.error("|食堂报表|意外的错误:", error);
}
}
const refresh = async (createTimeList) => {
state.queryParams.createTimeList = createTimeList ?? null
await getDataList()
}
const getSummaries = (param) => {
const { columns, data } = param
const totalColumns = ["totalPayPrice", "totalPayCount", "totalRefundPrice", "totalRefundCount", "totalProfitPrice", "totalProfitCount"]
const totalRow = Array.from({ length: columns.length })
totalRow[0] = "合计"
columns.forEach((i, index) => {
if (totalColumns.includes(i.property)) {
const total = data
.filter((v) => v[i.property] != null)
.map((v) => v[i.property])
.reduce((pren, next) => pren + next, 0)
if (i.property.includes("Price")) {
totalRow[index] = displayPrice(total)
} else {
totalRow[index] = total
}
}
})
return totalRow
}
defineExpose({
refresh,
});
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
}
:deep(.el-table) {
tr {
background: #1d283477 !important;
}
.el-table__footer-wrapper {
.el-table__cell {
background: #1d283477 !important;
border-color: #121F33 !important;
}
}
}
</style>
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="container">
<div class="segmented">
<el-segmented v-model="activeSegment" :options="props.segments" :props="{ label: 'label', value: 'value' }"
size="small" @change="dateTypeChange" />
</div>
<div class="list scrollbar-hide">
<div v-for="(row, idx) in curList" :key="idx" class="item" @click="doCar(row)">
<div class="type" :class="props.type == 'canteen' ? 'type-1' : 'type-2'">
<span class="type-text">{{ idx + 1 }}</span>
</div>
<div class="end">
<span>{{ row.title || "未知" }}</span>
<span>{{ row.total }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
const emit = defineEmits(["changeDateType"]);
const props = defineProps({
// "canteen"
type: {
type: String,
default: () => "canteen",
},
data: {
type: Object,
default: () => [{ alarmList: [], name: null, time: null }],
},
segments: {
type: Array,
default: () => [
{
label: "昨日",
value: "YESTERDAY",
},
{
label: "今日",
value: "TODAY",
},
{
label: "本月",
value: "THIS_MONTH",
},
{
label: "近半年",
value: "HALF_YEAR",
},
],
},
});
const activeSegment = ref(props.segments[0].value || "YESTERDAY");
const curList = computed(() => {
if (!props.data || !props.data.length) return [];
const list = props.data;
return Array.isArray(list) && list.length ? list : [];
});
const dateTypeChange = (e) => {
emit("changeDateType", e);
};
</script>
<style scoped lang="less">
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding-top: 4vh;
.segmented {
position: absolute;
right: 0;
top: 0;
z-index: 2;
.el-segmented {
--el-segmented-item-selected-color: var(--el-text-color-primary);
--el-segmented-item-selected-bg-color: #095d82;
--el-border-radius-base: 8px;
}
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.2vh;
padding-bottom: 4vh;
.item {
display: flex;
align-items: center;
justify-content: space-evenly;
padding: 0.8vh 0.5vw;
cursor: pointer;
background-image: url("@/assets/images/car-alarm-list-item-bg.png");
background-repeat: no-repeat;
background-size: 100% 100%;
.type {
width: 7vw;
height: 3.6vh;
line-height: 3.6vh;
text-align: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: center;
.type-text {
display: inline-block;
font-size: 0.65vw;
color: #c9e9ff;
width: 80%;
margin: 0 0.5vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.type-1 {
background-image: url("@/assets/images/car-alarm-type-blue.png");
}
.type-2 {
background-image: url("@/assets/images/car-alarm-type-green.png");
}
.end {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
span {
display: inline-block;
width: 5vw;
font-size: 0.6vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span:nth-child(1) {
text-align: center;
color: #d6e3ee;
flex: 1;
margin-right: 1vw;
}
span:nth-child(2) {
color: #d6e3ee;
width: 6.5vw;
}
}
}
}
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>
@@ -0,0 +1,333 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, watch } from "vue";
import * as echarts from "echarts";
const props = defineProps({
// 图表类型 "canteen" | "nutrition" | "shop"
type: {
type: String,
default: "canteen",
},
yTitle: {
type: String,
default: "",
},
data: {
type: Array,
default: () => [
[0, 1, 2, 3, 4, 5, 6, 7, 8],
[0, 1, 2, 3, 4, 5, 6, 7, 8],
[0, 1, 2, 3, 4, 5, 6, 7, 8],
],
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
onMounted(async () => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
window.removeEventListener("resize", handleResize);
});
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
watch(
() => props.data,
(val) => {
if (!chartInstance) return;
// console.log(props.type, "type");
// console.log(props.data, "data");
if (props.type == "nutrition") {
const { hc, sc, zs } = props.data;
chartInstance.setOption({
yAxis: {
name: props.yTitle,
},
xAxis: {
data: hc?.list?.map((v) => {
return v.time;
}),
},
legend: {
data: [{ name: hc.typeStr }, { name: sc.typeStr }, { name: zs.typeStr }],
},
series: [
{ name: hc.typeStr, data: hc?.list?.map((v) => v.personCount) },
{ name: sc.typeStr, data: sc?.list?.map((v) => v.personCount) },
{ name: zs.typeStr, data: zs?.list?.map((v) => v.personCount) },
],
});
}
if (props.type == "canteen") {
const { morning, noon, evening } = props.data;
chartInstance.setOption({
yAxis: {
name: props.yTitle,
},
xAxis: {
data: morning?.list?.map((v) => {
return v.time;
}),
},
legend: {
data: [{ name: morning.typeStr }, { name: noon.typeStr }, { name: evening.typeStr }],
},
series: [
{ name: morning.typeStr, data: morning?.list?.map((v) => v.personCount) },
{ name: noon.typeStr, data: noon?.list?.map((v) => v.personCount) },
{ name: evening.typeStr, data: evening?.list?.map((v) => v.personCount) },
],
});
}
if (props.type == "shop") {
chartInstance.setOption({
yAxis: {
name: props.yTitle,
},
xAxis: {
data: props.data[0]?.list?.map((v) => {
return v.time;
}),
},
series: props.data?.map((v) => {
return {
name: v.typeStr,
data: v?.list?.map((v) => v.buyProductCount ?? 0),
}
})
});
}
}
);
const option = {
backgroundColor: "transparent",
grid: {
top: "18%",
left: "0%",
right: "4%",
bottom: "10%",
containLabel: true,
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
// formatter: (params) => {
// return `${params[0].axisValue}<br/>${params[0].marker}${params[0].value}辆<br/>
// ${params[1].axisValue}<br/>${params[1].marker}${params[1].value}辆<br/>
// ${params[2].axisValue}<br/>${params[2].marker}${params[2].value}辆<br/>`;
// },
valueFormatter: (value) => value,
},
legend: {
icon: "rect",
right: 0,
top: 0,
itemWidth: 14,
itemHeight: 8,
itemGap: 16,
textStyle: { color: "#ffffff", fontSize: 12 },
itemStyle: { borderWidth: 0, borderColor: "transparent" },
data: [
{ itemStyle: { color: "#1DB996", borderWidth: 0, borderColor: "transparent" } },
{ itemStyle: { color: "#01C6EF", borderWidth: 0, borderColor: "transparent" } },
{ itemStyle: { color: "#F7CC08", borderWidth: 0, borderColor: "transparent" } },
],
},
xAxis: {
type: "category",
data: [],
boundaryGap: false,
axisLine: {
lineStyle: {
color: "#21303C",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
// backgroundColor: "#2d5a3d",
// padding: [4, 4, 4, 4],
},
},
yAxis: {
type: "value",
name: "",
min: 0,
nameTextStyle: {
color: "#ffffff",
fontSize: 10,
padding: [0, 0, 0, 40]
// align: "right",
},
axisLine: {
show: false,
lineStyle: {
color: "#00ffff",
},
},
axisLabel: {
color: "#ffffff",
fontSize: 10,
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.2)",
},
},
},
series: [
{
name: "",
type: "line",
// stack: "Total",
data: [],
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#1DB996",
width: 2,
},
itemStyle: {
color: "#1DB996",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#1DB996",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#1DB996",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#1C7C67" },
{ offset: 1, color: "#0D1E25" },
],
},
},
},
{
name: "",
type: "line",
// stack: "Total",
data: [],
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#01C6EF",
width: 2,
},
itemStyle: {
color: "#01C6EF",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#01C6EF",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#01C6EF",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#097186" },
{ offset: 1, color: "#0D1E25" },
],
},
},
},
{
name: "",
type: "line",
// stack: "Total",
data: [],
smooth: true,
symbol: "circle",
symbolSize: 0,
yAxisIndex: 0,
lineStyle: {
color: "#F7CC08",
width: 2,
},
itemStyle: {
color: "#F7CC08",
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
formatter: "{c}",
color: "#F7CC08",
fontWeight: "bold",
fontSize: 12,
},
areaStyle: {
color: "#F7CC08",
opacity: 0.8,
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
// { offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 0, color: "#6B6119" },
{ offset: 1, color: "#0D1E25" },
],
},
},
},
],
};
</script>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, watch, useTemplateRef } from "vue";
import * as echarts from "echarts";
import dayjs from "dayjs";
const props = defineProps({
data: {
type: Object,
default: () => ({
labels: [],
values: [],
}),
},
});
const chartRef = useTemplateRef("chartRef");
let chartInstance = null;
onMounted(() => {
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(options);
window.addEventListener("resize", resizeHandler);
chartInstance.on("click", (params) => {
if (!params.name) return;
const _params = {
swingTimeList: [dayjs().startOf("day").format("YYYY-MM-DD HH:mm:ss"), dayjs().endOf("day").format("YYYY-MM-DD HH:mm:ss")],
personName: params.name,
isMainFloor: "1",
};
console.log(`|点击跳转|-|今日出入机关楼次数排行|-|${params.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_record", _params);
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeHandler);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
watch(
() => props.data,
() => {
if (!chartInstance) return;
chartInstance.setOption({
yAxis: { data: props.data?.labels ?? [] },
series: [{ data: props.data?.values ?? [] }],
});
},
{ deep: true }
);
const options = {
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
axisPointer: { type: "shadow" },
// formatter: (params) => {
// return `${params[0].value}次`;
// },
confine: true,
},
backgroundColor: "transparent",
grid: {
top: "0%",
left: "0%",
right: "4%",
bottom: "0%",
containLabel: true,
},
xAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: "rgba(255,255,255,0.6)" },
splitLine: { show: true, lineStyle: { color: "rgba(255,255,255,0.08)" } },
},
yAxis: {
type: "category",
data: [],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: "#fff" },
},
series: [
{
type: "bar",
data: [],
barWidth: 14,
showBackground: true,
backgroundStyle: { color: "rgba(255,255,255,0.06)" },
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: (params) => {
const palette = [
["#1F4F68", "#2FC6DC"],
["#1F4F68", "#2FC6DC"],
["#F47E3D", "#F4C93D"],
["#1F4F68", "#2FC6DC"],
["#1F4F68", "#2FC6DC"],
];
const [c1, c2] = palette[params.dataIndex % palette.length];
return new echarts.graphic.LinearGradient(1, 0, 0, 0, [
{ offset: 0, color: c2 },
{ offset: 1, color: c1 },
]);
},
},
},
],
};
const resizeHandler = () => {
if (chartInstance) chartInstance.resize();
};
</script>
@@ -0,0 +1,235 @@
<template>
<div ref="chartRef" style="width: 100%; height: 100%"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from "vue";
import * as echarts from "echarts";
import dayjs from "dayjs";
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
data: {
type: Array,
default: () => [],
},
});
const chartRef = ref(null);
let chartInstance = null;
watch(
() => [props.data, props.labels],
() => {
if (!chartInstance) return;
const dataLength = props.data.length
chartInstance.setOption({
xAxis: {
data: props.labels || [],
},
series: [
{
data:
props.data.map((item) => {
if (item >= 50)
return {
value: item,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#0D3149" },
{ offset: 0.5, color: "#039A5D" },
{ offset: 1, color: "#00EF7E" },
]),
},
};
return item;
}) || [],
},
{
data: new Array(dataLength).fill(0)
},
{
data: new Array(dataLength).fill(0)
},
{
data: new Array(dataLength).fill(0)
}
],
});
}
);
const option = {
backgroundColor: "transparent",
grid: {
left: "0%",
right: "0%",
top: "14%",
bottom: "8%",
containLabel: true,
},
xAxis: {
type: "category",
data: [],
axisLine: {
lineStyle: {
color: "#313A42",
},
},
axisLabel: {
color: "rgba(255,255,255,0.6)",
fontSize: 10,
},
},
yAxis: {
type: "value",
name: "就餐人数",
nameTextStyle: {
color: "#ffffff",
fontSize: 10,
// align: "right",
},
axisLine: {
show: true,
lineStyle: {
color: "#313A42",
},
},
axisLabel: {
color: "rgba(255,255,255,0.6)",
fontSize: 12,
},
splitLine: {
lineStyle: {
color: "#313A42",
},
},
},
series: [
{
type: "bar",
data: [],
barWidth: 12,
showBackground: true,
backgroundStyle: { color: "rgba(255,255,255,0.06)" },
itemStyle: {
borderRadius: [0, 0, 6, 6],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#0D3149" },
{ offset: 0.5, color: "#0D84AC" },
{ offset: 1, color: "#01F6C4" },
]),
},
z: 3,
},
{
z: 4,
type: "pictorialBar",
symbol: "circle",
symbolOffset: ["0%", "0%"],
symbolSize: [36, 6],
toolltip: {
show: false,
},
data: [],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#0A2531" },
{ offset: 1, color: "#106DA7" },
]),
},
},
{
z: 5,
type: "pictorialBar",
symbol: "circle",
symbolOffset: ["0%", "-30%"],
symbolSize: [26, 10],
toolltip: {
show: false,
},
data: [],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#0A2531" },
{ offset: 1, color: "#106DA7" },
]),
},
},
{
z: 6,
type: "pictorialBar",
symbol: "circle",
symbolPosition: "end",
symbolOffset: ["0%", "-240%"],
symbolSize: [12, 6],
toolltip: {
show: false,
},
data: [],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#07BFB8" },
{ offset: 1, color: "#01F6C4" },
]),
},
},
],
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "#000000",
borderWidth: 1,
textStyle: {
color: "#ffffff",
fontSize: 12,
},
padding: 10,
formatter: (params) => {
const p = Array.isArray(params) ? params[0] : params;
return `${p.name}:${p.value}人`;
},
confine: true,
},
};
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
onMounted(() => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(option);
window.addEventListener("resize", handleResize);
chartInstance.on("click", (params) => {
const hourList = params.name.replace("点", "").split("~");
if (!hourList || hourList.length !== 2) return;
const startHour = hourList[0];
const endHour = hourList[1];
const start = dayjs().hour(startHour).minute(0).second(0).format("YYYY-MM-DD HH:mm:ss");
const end = dayjs().hour(endHour).minute(0).second(0).format("YYYY-MM-DD HH:mm:ss");
const _params = {
swingTimeList: [start, end],
// 进门
alarmTypeList: ["11"],
personIdIsNotNull: true,
};
console.log(`|点击跳转|-|今日人员打卡统计|-|${params.name}|params:|{${JSON.stringify(_params)}}|`);
jumpAdminPath("door_reign_record", _params);
});
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
}
window.removeEventListener("resize", handleResize);
});
</script>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="container">
<el-table :data="state.dataList" :size="TABLE.SIZE" :style="TABLE.STYLE" :cell-style="TABLE.CELL_STYLE"
:row-style="TABLE.ROW_STYLE" :header-cell-style="TABLE.HEADER_CELL_STYLE" border
:summary-method="getSummaries" show-summary>
<el-table-column align="center" type="index" label="#" width="50" />
<el-table-column align="center" label="商户" prop="merchantName" show-overflow-tooltip>
<template #default="{ row }">
{{ row.merchantName || "未知" }}
</template>
</el-table-column>
<el-table-column align="center" label="流水金额" prop="totalPayPrice">
<template #default="{ row }">
<span class="color-success">
{{ displayPrice(row.totalPayPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="流水次数" prop="totalPayCount">
<template #default="{ row }">
<span class="color-primary">
{{ row.totalPayCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="退款金额" prop="totalRefundPrice">
<template #default="{ row }">
<span class="color-danger">
{{ displayPrice(row.totalRefundPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="退款次数" prop="totalRefundCount">
<template #default="{ row }">
<span class="color-danger">
{{ row.totalRefundCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="消费净金额" prop="totalProfitPrice" min-width="120">
<template #default="{ row }">
<span class="color-success">
{{ displayPrice(row.totalProfitPrice) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="消费净次数" prop="totalProfitCount" min-width="120">
<template #default="{ row }">
<span class="color-success">
{{ row.totalProfitCount || 0 }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, useTemplateRef, reactive } from "vue";
import { shopApi } from "@/api/index"
import { displayPrice } from "@/utils/format-money"
import { TABLE } from "@/constant/table-constant"
const state = reactive({
dataList: [],
queryParams: {
createTimeList: []
}
})
onMounted(() => {
getDataList()
})
// 食堂报表
const getDataList = async () => {
try {
const { code, data } = await shopApi.canteenReport({ ...state.queryParams })
console.log("食堂报表:", data);
state.dataList = data
} catch (error) {
console.error("|食堂报表|意外的错误:", error);
}
}
const refresh = async (createTimeList) => {
state.queryParams.createTimeList = createTimeList ?? null
await getDataList()
}
const getSummaries = (param) => {
const { columns, data } = param
const totalColumns = ["totalPayPrice", "totalPayCount", "totalRefundPrice", "totalRefundCount", "totalProfitPrice", "totalProfitCount"]
const totalRow = Array.from({ length: columns.length })
totalRow[0] = "合计"
columns.forEach((i, index) => {
if (totalColumns.includes(i.property)) {
const total = data
.filter((v) => v[i.property] != null)
.map((v) => v[i.property])
.reduce((pren, next) => pren + next, 0)
if (i.property.includes("Price")) {
totalRow[index] = displayPrice(total)
} else {
totalRow[index] = total
}
}
})
return totalRow
}
defineExpose({
refresh,
});
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
}
:deep(.el-table) {
tr {
background: #1d283477 !important;
}
.el-table__footer-wrapper {
.el-table__cell {
background: #1d283477 !important;
border-color: #121F33 !important;
}
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
export const TABLE = {
SIZE: "small",
STYLE: {
width: "100%",
height: "100%",
},
CELL_STYLE: {
background: "#1d283477",
borderColor: "#121F33",
},
ROW_STYLE: {
background: "#1d283477",
borderColor: "#121F33",
},
HEADER_CELL_STYLE: {
background: "#1d283477",
borderColor: "#121F33",
}
}
+30
View File
@@ -0,0 +1,30 @@
@background-color: #222222;
@text-color: #ffffff;
@text-color-primary: #419ce3;
:root {
::selection {
background: #45b2fe;
}
::-moz-selection {
background: #45b2fe;
}
}
body,
html {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
background-color: @background-color;
color: @text-color;
}
.background(@url: "", @size: auto auto, @position: 0% 0%, @repeat: no-repeat) {
background-image: url(@url);
background-size: @size;
background-position: @position;
background-repeat: @repeat;
}
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import "element-plus/theme-chalk/dark/css-vars.css";
import "./global.less";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(ElementPlus, { locale: zhCn });
app.use(router);
app.mount("#app");
+73
View File
@@ -0,0 +1,73 @@
import { createWebHashHistory } from "vue-router";
import { createRouter } from "vue-router";
import Cookies from 'js-cookie';
import { getUserInfo, checkToken } from "@/utils/auth"
const routes = [
{
path: "/",
redirect: "/canteen",
component: () => import("@/app-panel.vue"),
children: [
{
path: "/canteen",
name: "canteen",
component: () => import("@/views/canteen.vue"),
},
{
path: "/shop",
name: "shop",
component: () => import("@/views/shop.vue"),
},
{
path: "/service",
name: "service",
component: () => import("@/views/service.vue"),
},
{
path: "/through",
name: "through",
component: () => import("@/views/through.vue"),
},
{
path: "/nutrition",
name: "nutrition",
component: () => import("@/views/nutrition.vue"),
},
],
},
{
path: "/no-permission",
name: "noPermission",
component: () => import("@/views/auth-unknown.vue"),
},
];
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes,
// 路由切换时滚动到顶部
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
} else {
return { top: 0 };
}
},
});
router.beforeEach(async (to, from, next) => {
const { query } = to
// 处理URL中的token参数
if (query.token && query.refresh_token) {
Cookies.set("panel_token", query.token)
Cookies.set("panel_refresh_token", query.refresh_token)
await getUserInfo()
await checkToken()
next()
}
next()
})
export default router;
+38
View File
@@ -0,0 +1,38 @@
import Cookies from 'js-cookie';
import { userApi } from "@/api/index"
// 获取用户信息
export async function getUserInfo() {
try {
const { code, data } = await userApi.getUserInfo()
if (code != 0) throw new Error("code!=0")
console.log("获取用户信息", data);
window.localStorage.setItem("panel_user_info", JSON.stringify(data))
} catch (error) {
console.log("获取用户信息:", error);
}
}
// 检查用户token
export async function checkToken() {
try {
const token = Cookies.get("panel_token") ?? ""
const refresh_token = Cookies.get("panel_refresh_token") ?? "";
if (!token || !refresh_token) return
// 检查token
const { code: checkCode, data: checkData } = await userApi.checkToken(token)
if (checkCode != 0) throw new Error("checkCode!=0")
const expire = Date.parse(checkData.expiresAt);
if (!expire) throw new Error("时间解析错误")
const expiredPeriod = expire - new Date().getTime();
if (expiredPeriod <= 30 * 60 * 1000) {
const res = await userApi.refreshToken(refresh_token)
if (!res) throw new Error("refreshCode!=0")
Cookies.set('panel_token', res.access_token);
Cookies.set('panel_refresh_token', res.refresh_token);
}
} catch (error) {
console.error("检查用户token:", error);
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* 获取当前日期
* @returns {string} 获取当前日期,如 '2024/1/15'
*/
export const getDate = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}/${month}/${day}`;
};
/**
* 获取当前时间
* @returns {string} 获取当前日期,如 '00:00:00'
*/
export const getTime = () => {
const now = new Date();
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
};
/**
* 获取最近8天的周
* @returns {Array} 获取最近8天的周
*/
export function getRecentWeeks() {
const weekArr = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const result = [];
const today = new Date();
for (let i = 7; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
result.push(weekArr[d.getDay()]);
}
return result;
}
/**
* 获取最近的前8天日期(包括今天)
* @param {number} days - 要获取的天数,默认为8
* @returns {string[]} - 格式为 'MM-DD' 的日期数组
*/
export function getRecentDays(days = 8) {
const today = new Date();
const dates = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(today.getDate() - i);
// 修改日期格式为MM-DD
const formattedDate = date.toISOString().split("T")[0].split("-").slice(1).join("-");
dates.push(formattedDate);
}
return dates;
}
/**
* 获取最近的前12个月日期(包含当月)
* @param {number} months - 要获取的月数,默认为12
* @returns {string[]} - 格式为 'YYYY-MM' 的日期数组
*/
export function getRecentMonths(months = 12) {
const today = new Date();
const dates = [];
for (let i = months - 1; i >= 0; i--) {
const date = new Date(today);
date.setMonth(today.getMonth() - i);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); // 月份从0开始
const formattedDate = `${year}-${month}`;
dates.push(formattedDate);
}
return dates;
}
/**
* 获取当前月份的所有日期
* @returns {string[]} - 格式为 'DD' 的当前月份全部日期数组
*/
export function getMonthDays() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
// 获取当月第一天
const firstDay = new Date(year, month, 1);
// 获取当月最后一天
const lastDay = new Date(year, month + 1, 0);
const dates = [];
let currentDate = firstDay;
while (currentDate <= lastDay) {
// const formattedDate = `${year}-${String(month + 1).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;
const formattedDate = `${String(currentDate.getDate()).padStart(2, "0")}`;
dates.push(formattedDate);
// 下一天
currentDate = new Date(year, month, currentDate.getDate() + 1);
}
return dates;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* 日期范围类型枚举
* @typedef {'YESTERDAY' | 'TODAY' | 'THIS_WEEK' | 'THIS_MONTH' | 'CURRENT_SEASON' | 'HALF_YEAR' | 'THIS_YEAR'} DateRangeTypeEnum
*/
import dayjs from 'dayjs';
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
// 注册插件
dayjs.extend(quarterOfYear);
dayjs.extend(isoWeek);
/**
* 日期工具类 - 转换日期类型为日期范围
*/
export const DateTool = {
/**
* 将日期类型转换为日期数组
* @param {DateRangeTypeEnum} dateType - 日期类型枚举
* @returns {Array<string>} - 日期数组 [开始日期, 结束日期],格式为 YYYY-MM-DD
*/
transitionDateTypeToDateList(dateType) {
const localDateTimes = this.transitionDateTypeToDateTimeList(dateType);
return [
localDateTimes[0].format('YYYY-MM-DD'),
localDateTimes[1].format('YYYY-MM-DD')
];
},
/**
* 将日期类型转换为日期时间数组
* @param {DateRangeTypeEnum} dateType - 日期类型枚举
* @returns {Array<dayjs.Dayjs>} - 日期时间数组 [开始日期时间, 结束日期时间]
*/
transitionDateTypeToDateTimeList(dateType) {
const now = dayjs();
switch (dateType) {
case 'YESTERDAY':
return [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day')
];
case 'TODAY':
return [
dayjs().startOf('day'),
dayjs().endOf('day')
];
case 'THIS_WEEK':
return [
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day')
];
case 'THIS_MONTH':
return [
dayjs().startOf('month'),
dayjs().endOf('month')
];
case 'CURRENT_SEASON':
return [
dayjs().startOf('quarter'),
dayjs().endOf('quarter')
];
case 'HALF_YEAR':
// 半年期间(从当前季度开始的6个月)
const halfYearStart = dayjs().startOf('quarter');
return [
halfYearStart,
halfYearStart.add(5, 'month').endOf('month')
];
case 'THIS_YEAR':
return [
dayjs().startOf('year'),
dayjs().endOf('year')
];
default:
return [null, null];
}
},
/**
* 将日期类型转换为格式化的日期时间数组
* @param {DateRangeTypeEnum} dateType - 日期类型枚举
* @param {string} format - 日期格式,默认为 'YYYY-MM-DD HH:mm:ss'
* @returns {Array<string>} - 格式化的日期时间数组 [开始日期时间, 结束日期时间]
*/
getFormattedDateTimeRange(dateType, format = 'YYYY-MM-DD HH:mm:ss') {
const dateTimeRange = this.transitionDateTypeToDateTimeList(dateType);
return [
dateTimeRange[0]?.format(format) || '',
dateTimeRange[1]?.format(format) || ''
];
}
};
+8
View File
@@ -0,0 +1,8 @@
/**
* 显示价格把分转换到元
*/
export function displayPrice(price, suffix = "元", fixed = 2) {
if (typeof price === "undefined") return "0.00" + suffix
const parsedNumber = typeof price === "string" ? parseFloat(price) : price
return (parsedNumber / 100.0).toFixed(fixed) + suffix
}
+236
View File
@@ -0,0 +1,236 @@
/**
* 轮询类 - 用于管理定时轮询任务
*/
class Poll {
/**
* 构造函数
* @param {Object} options 配置选项
* @param {Function} options.callback 轮询回调函数
* @param {number} options.interval 轮询间隔时间(毫秒),默认1000ms
* @param {boolean} options.immediate 是否立即执行一次,默认true
* @param {number} options.maxRetries 最大重试次数,默认3次
* @param {Function} options.onError 错误处理回调
*/
constructor(options = {}) {
this.callback = options.callback || (() => { });
this.interval = options.interval || 1000 + Math.random() * 1000;
this.immediate = options.immediate || true;
this.maxRetries = options.maxRetries || 3;
this.onError = options.onError || ((error) => console.error("轮询错误:", error));
// 内部状态
this.timerId = null;
this.isRunning = false;
this.isPaused = false;
this.retryCount = 0;
this.executionCount = 0;
// 绑定this上下文
this._execute = this._execute.bind(this);
}
/**
* 初始化轮询配置
* @param {Function} callback 轮询回调函数
* @param {number} interval 轮询间隔时间(毫秒)
* @param {Object} options 其他配置选项
*/
init(callback, interval, options = {}) {
// 如果正在运行,先停止
if (this.isRunning) {
this.stop();
}
this.callback = callback || this.callback;
this.interval = interval || this.interval;
this.immediate = options.immediate !== undefined ? options.immediate : this.immediate;
this.maxRetries = options.maxRetries !== undefined ? options.maxRetries : this.maxRetries;
this.onError = options.onError || this.onError;
// 重置状态
this.retryCount = 0;
this.executionCount = 0;
return this;
}
// 开始轮询
start() {
if (this.isRunning) {
return this;
}
if (!this.callback || typeof this.callback !== "function") {
throw new Error("轮询回调函数未定义或不是函数");
}
this.isRunning = true;
this.isPaused = false;
// 如果设置了立即执行,先执行一次
if (this.immediate) {
this._execute();
} else {
this._scheduleNext();
}
return this;
}
// 暂停轮询
pause() {
if (!this.isRunning) {
return this;
}
this.isPaused = true;
this._clearTimer();
return this;
}
// 恢复轮询
resume() {
if (!this.isRunning) {
return this;
}
if (!this.isPaused) {
return this;
}
this.isPaused = false;
this._scheduleNext();
return this;
}
// 停止轮询
stop() {
if (!this.isRunning) {
return this;
}
this.isRunning = false;
this.isPaused = false;
this._clearTimer();
return this;
}
// 销毁轮询实例
destroy() {
this.stop();
this.callback = null;
this.onError = null;
this.retryCount = 0;
this.executionCount = 0;
}
// 立即执行一次轮询(不影响定时器)
executeOnce() {
if (typeof this.callback === "function") {
this._execute(false); // false表示不调度下一次
}
return this;
}
/**
* 更新轮询间隔
* @param {number} newInterval 新的间隔时间(毫秒)
*/
updateInterval(newInterval) {
if (typeof newInterval !== "number" || newInterval <= 0) {
throw new Error("间隔时间必须是正数");
}
this.interval = newInterval;
// 如果正在运行且未暂停,重新调度
if (this.isRunning && !this.isPaused) {
this._clearTimer();
this._scheduleNext();
}
return this;
}
// 获取轮询状态
getStatus() {
return {
isRunning: this.isRunning,
isPaused: this.isPaused,
interval: this.interval,
executionCount: this.executionCount,
retryCount: this.retryCount,
};
}
/**
* 内部方法:执行轮询回调
* @param {boolean} scheduleNext 是否调度下一次执行
*/
async _execute(scheduleNext = true) {
if (!this.isRunning || this.isPaused) {
return;
}
try {
this.executionCount++;
// 执行回调函数
await this.callback();
// 重置重试计数
this.retryCount = 0;
// 调度下一次执行
if (scheduleNext && this.isRunning && !this.isPaused) {
this._scheduleNext();
}
} catch (error) {
this.retryCount++;
console.error(`轮询执行失败 (第${this.retryCount}次重试):`, error);
// 调用错误处理回调
if (typeof this.onError === "function") {
try {
await this.onError(error, this.retryCount, this.executionCount);
} catch (errorHandlerError) {
console.error("错误处理回调执行失败:", errorHandlerError);
}
}
// 检查是否需要重试
if (this.retryCount < this.maxRetries) {
if (scheduleNext && this.isRunning && !this.isPaused) {
this._scheduleNext();
}
} else {
console.error(`达到最大重试次数 (${this.maxRetries}),停止轮询`);
this.stop();
}
}
}
// 内部方法:调度下一次执行
_scheduleNext() {
this._clearTimer();
this.timerId = setTimeout(this._execute, this.interval);
}
// 内部方法:清除定时器
_clearTimer() {
if (this.timerId) {
clearTimeout(this.timerId);
this.timerId = null;
}
}
}
export default Poll;
// 也可以导出一个工厂函数,方便快速创建轮询实例
export function createPoll(callback, interval, options = {}) {
return new Poll({
callback,
interval,
...options,
});
}
// 导出一个简化的轮询函数,用于简单场景
export function simplePoll(callback, interval = 1000) {
const poll = new Poll({ callback, interval });
poll.start();
return poll;
}
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="no-permission">
<div class="container">
<div class="icon">
<el-icon>
<WarningFilled />
</el-icon>
</div>
<h1>无权限访问</h1>
<p>抱歉,您没有权限访问该页面</p>
<div class="actions">
<el-button type="primary" @click="quit">退出</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { WarningFilled } from '@element-plus/icons-vue';
const quit = () => {
// 尝试关闭窗口,如果是脚本打开的窗口才能成功关闭
try {
window.close();
} catch (e) {
console.error('无法关闭窗口:', e);
}
};
</script>
<style scoped>
.no-permission {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #0a1218;
background-image: url("@/assets/images/bg-image.png");
background-size: cover;
background-position: center;
}
.container {
text-align: center;
padding: 40px;
background-color: rgba(26, 54, 66, 0.7);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 100%;
}
.icon {
margin-bottom: 20px;
}
.icon .el-icon {
font-size: 64px;
color: #e6a23c;
}
h1 {
font-size: 24px;
color: #f2f3f6;
margin-bottom: 16px;
}
p {
font-size: 16px;
color: #a3a6ad;
margin-bottom: 30px;
}
.actions {
display: flex;
justify-content: center;
gap: 16px;
}
</style>
+495
View File
@@ -0,0 +1,495 @@
<template>
<div class="container">
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>就餐人数统计</span>
</template>
<AttendanceStatistic :data="timerObj.byHourType.data.data" :labels="timerObj.byHourType.data.labels"
@changeDateType="byHourTypeChangeDateType" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>今日就餐人数统计</span>
</template>
<TodayClockStatus :data="timerObj.haveDinnerTotal.data.data" :labels="timerObj.haveDinnerTotal.data.labels" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>今日消费次数排行(top5)</span>
</template>
<PersonThroughRank :data="timerObj.mchPayCount.data" />
</AsideBlock>
</el-aside>
<div class="main">
<AsideBlock height="calc(100% * 2 / 3)" titleBgWidth="50%">
<template #header>
<span>食堂报表</span>
<div class="report-search">
<el-date-picker style="width: 20vw;" v-model="queryParams.createTimeList" type="datetimerange" size="small"
placeholder="请选择日期" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
@change="changeQueryDate" />
</div>
</template>
<CanteenReport ref="canteenReportRef" />
</AsideBlock>
<AsideBlock titleBgWidth="50%">
<template #header>
<span>食堂购买人数趋势统计</span>
</template>
<PersonAbnormalEventLineChart yTitle="购买人数" :data="timerObj.mchPayPersonCountTrend.data" />
</AsideBlock>
</div>
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>食堂用餐人数统计</span>
</template>
<AbnormalEventTypeStatus title="用餐人数" type="person" :data="timerObj.mchPayPersonTotal.data"
@changeDateType="alarmTotalByTimerChnageDateType" />
</AsideBlock>
<AsideBlock height="calc(100% * 2 / 3)">
<template #header>
<span>食堂用餐实时统计</span>
</template>
<AbnormalEventList type="person" :data="timerObj.canteenOrderPage.data"
@changeDateType="alarmPageDateTypeChange" />
</AsideBlock>
</el-aside>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, reactive, ref } from "vue";
import { canteenApi } from "@/api/index"
import useNotification from "@/components/notification/index";
import { simplePoll } from "@/utils/poll"
import { displayPrice } from "@/utils/format-money"
import AsideBlock from "@/components/aside-block.vue";
import AttendanceStatistic from "@/components/attendance-statistic/index.vue";
import TodayClockStatus from "@/components/person-today-clock-status.vue";
import PersonAbnormalEventLineChart from "@/components/person-abnormal-event-line-chart.vue";
import PersonThroughRank from "@/components/person-through-rank.vue";
import AbnormalEventTypeStatus from "@/components/abnormal-event-type-status.vue";
import AbnormalEventList from "@/components/abnormal-event-list.vue";
import CanteenReport from "@/components/canteen-report.vue"
const { notificationAdd, notificationClear } = useNotification();
const canteenReportRef = ref()
const queryParams = reactive({
createTimeList: null,
})
const timerObj = reactive({
// 食堂用餐人数统计
mchPayPersonTotal: {
initial: null,
interval: 10 * 1000,
queryParams: {
dateRangeType: "YESTERDAY",
},
data: null,
},
// 食堂用餐实时统计
canteenOrderPage: {
initial: null,
interval: 10 * 1000,
queryParams: {
dateRangeType: "YESTERDAY",
current: 1,
size: 30,
},
data: null,
},
// 食堂购买人数趋势统计
mchPayPersonCountTrend: {
initial: null,
interval: 10 * 1000,
queryParams: {},
data: null,
},
// 今日消费次数排行
mchPayCount: {
initial: null,
interval: 10 * 1000,
queryParams: {
dateRangeType: "TODAY"
},
data: null,
},
// 用餐时间段统计
byHourType: {
initial: null,
interval: 10 * 1000,
queryParams: {
dateRangeType: "YESTERDAY",
},
data: {
data: null,
labels: null,
},
},
// 今日就餐人数统计
haveDinnerTotal: {
initial: null,
interval: 10 * 1000,
queryParams: {
dateRangeType: "TODAY",
},
data: {
data: null,
labels: null,
},
},
// 最近异常事件
recentlyAlarm: {
initial: null,
interval: 5 * 1000,
queryParams: {},
data: null,
},
});
onMounted(() => {
getAttendanceStatisticData();
getTodayClockStatusData();
getPersonThroughRankData();
getAbnormalEventType();
getAbnormalEventTypeStatusData();
getCanteenOrderList();
getNotification();
});
onBeforeUnmount(() => {
notificationClear();
for (let key of Object.keys(timerObj)) {
const value = timerObj[key];
if (value && value.initial) {
value.initial.destroy();
}
}
});
const changeQueryDate = (e) => {
canteenReportRef.value?.refresh(e)
}
// 用餐时间段统计
const getAttendanceStatisticData = () => {
timerObj.byHourType.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.groupByTimeAndHourType({ ...timerObj.byHourType.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("用餐时间段统计:", data);
if (!data) {
return;
}
// 昨天
const { dateRangeType } = timerObj.byHourType.queryParams;
if (dateRangeType === "YESTERDAY") {
timerObj.byHourType.data.data = {
YESTERDAY: {
morning: data.morning.list[0].personCount,
noon: data.noon.list[0].personCount,
evening: data.evening.list[0].personCount,
},
};
}
// 今日
if (dateRangeType === "TODAY") {
timerObj.byHourType.data.data = {
TODAY: {
morning: data.morning.list[0].personCount,
noon: data.noon.list[0].personCount,
evening: data.evening.list[0].personCount,
},
};
}
// 本周
if (dateRangeType === "THIS_WEEK") {
timerObj.byHourType.data.data = { THIS_WEEK: disposeBeLateTotal(data) };
timerObj.byHourType.data.labels = {
THIS_WEEK: data.morning?.list?.map((v) => {
return v.time + "|" + v.weekStr;
}),
};
}
// 本月
if (dateRangeType === "THIS_MONTH") {
timerObj.byHourType.data.data = {
THIS_MONTH: data.morning?.list?.map((v) => {
const morningCount = data.morning?.list.find((item) => item.time === item.time)?.personCount ?? 0
const noonCount = data.noon?.list.find((item) => item.time === item.time)?.personCount ?? 0
const eveningCount = data.evening?.list.find((item) => item.time === item.time)?.personCount ?? 0
let type = -1;
// 如果time大于等于当天
const targetDate = new Date(v.time);
const today = new Date().setHours(23, 59, 59, 0);
if (targetDate <= today) {
type = 0;
// 用餐人数小于等于0则为休息
if (v.personCount <= 0) {
type = 1;
}
}
return {
day: v.time.slice(-2),
type: type,
morningCount,
noonCount,
eveningCount,
};
}),
};
timerObj.byHourType.data.labels = {
THIS_MONTH: [],
};
}
// 本年
if (dateRangeType === "THIS_YEAR") {
timerObj.byHourType.data.data = {
THIS_YEAR: disposeBeLateTotal(data),
};
timerObj.byHourType.data.labels = {
THIS_YEAR: data.morning?.list?.map((v) => {
return v.time.slice(0, -3);
}),
};
}
} catch (error) {
console.error("|用餐时间段统计|意外的错误:", error);
}
}, timerObj.byHourType.interval);
};
// 处理打卡统计的数据
const disposeBeLateTotal = (obj) => {
if (!obj) return;
return [
{
text: "早饭",
name: "morning",
values:
obj?.morning?.list?.map((v) => {
return Number(v.personCount);
}) ?? [],
},
{
text: "中饭",
name: "noon",
values:
obj?.noon?.list?.map((v) => {
return Number(v.personCount);
}) ?? [],
},
{
text: "晚饭",
name: "evening",
values:
obj?.evening?.list?.map((v) => {
return Number(v.personCount);
}) ?? [],
},
];
};
const byHourTypeChangeDateType = (type) => {
timerObj.byHourType.queryParams.dateRangeType = type;
timerObj.byHourType.initial.executeOnce();
};
// 今日就餐人数统计
const getTodayClockStatusData = () => {
timerObj.haveDinnerTotal.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.groupByTimeAndCustomHour({ ...timerObj.haveDinnerTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("今日就餐人数统计:", data);
if (!data) {
return;
}
timerObj.haveDinnerTotal.data.data = data.map((v) => {
return Number(v.personCount);
});
timerObj.haveDinnerTotal.data.labels = data.map((v) => {
return v.hourType;
});
} catch (error) {
console.error("|今日就餐人数统计|意外的错误:", error);
}
}, timerObj.haveDinnerTotal.interval);
};
// 今日消费次数排行
const getPersonThroughRankData = () => {
timerObj.mchPayCount.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.mchPayCountTop({ ...timerObj.mchPayCount.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("今日消费次数排行:", data);
if (!data) {
return;
}
timerObj.mchPayCount.data = {
labels:
data
?.map((v) => {
return v.mchName;
})
.reverse() ?? [],
values:
data
?.map((v) => {
return v.buyCount;
})
.reverse() ?? [],
};
} catch (error) {
console.error("|今日消费次数排行|意外的错误:", error);
}
}, timerObj.mchPayCount.interval);
};
// 食堂购买人数趋势统计
const getAbnormalEventType = async () => {
timerObj.mchPayPersonCountTrend.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.payPersonCountTrend({ ...timerObj.mchPayPersonCountTrend.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("食堂购买人数趋势统计:", data);
if (!data) {
return;
}
timerObj.mchPayPersonCountTrend.data = data;
} catch (error) {
console.error("|食堂购买人数趋势统计|意外的错误:", error);
}
}, timerObj.mchPayPersonCountTrend.interval);
};
// 食堂用餐人数统计
const getAbnormalEventTypeStatusData = () => {
timerObj.mchPayPersonTotal.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.mchPayPersonCount({ ...timerObj.mchPayPersonTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("食堂用餐人数统计:", data);
if (!data) {
return;
}
timerObj.mchPayPersonTotal.data =
data.map((v) => {
return {
title: v.mchName,
type: v.mchId,
total: v.personCount,
};
}) ?? [];
} catch (error) {
console.error("|食堂用餐人数统计|意外的错误:", error);
}
}, timerObj.mchPayPersonTotal.interval);
};
const alarmTotalByTimerChnageDateType = (type) => {
timerObj.mchPayPersonTotal.queryParams.dateRangeType = type;
timerObj.mchPayPersonTotal.initial.executeOnce();
};
// 食堂用餐实时统计
const getCanteenOrderList = () => {
timerObj.canteenOrderPage.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.newPage({ ...timerObj.canteenOrderPage.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("食堂用餐实时统计:", data);
if (!data || !data.records || !data.records.length) {
timerObj.canteenOrderPage.data = [];
return;
}
timerObj.canteenOrderPage.data = data.records.map((v) => {
return {
tag: v.merchantName,
name: `${v.productDesc},共${v.payPrice}元`,
time: v.createTime,
};
});
} catch (error) {
console.error("|食堂用餐实时统计|意外的错误:", error);
}
}, timerObj.canteenOrderPage.interval);
};
const alarmPageDateTypeChange = (type) => {
timerObj.canteenOrderPage.queryParams.dateRangeType = type;
timerObj.canteenOrderPage.initial.executeOnce();
};
// 最近用餐数据
const getNotification = () => {
timerObj.recentlyAlarm.initial = simplePoll(async () => {
try {
const { code, data } = await canteenApi.recentlyCanteen({ ...timerObj.recentlyAlarm.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("最近用餐数据:", data);
if (!data) {
return;
}
notificationAdd({
title: "最近用餐数据",
content: {
tag: data.merchantName,
channelName: `${data.productDesc},${displayPrice(data.payPrice)}`,
createTime: `${data.createTime}`,
},
});
} catch (error) {
console.error("|最近报警|意外的错误:", error);
}
}, timerObj.recentlyAlarm.interval);
};
</script>
<style scoped lang="less">
.report-search {
display: flex;
align-items: center;
justify-content: flex-end;
}
.container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
color: @text-color;
.aside {
height: 100%;
width: 24%;
z-index: 10;
box-sizing: border-box;
overflow: hidden;
}
.main {
height: 100%;
flex: 1;
box-sizing: border-box;
overflow: hidden;
margin: 0 1vw;
}
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<template>
<div class="container">
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>营养分析</span>
</template>
<NutritionAnalyse :data="timerObj.nutritionAnalyse.data.data"
:labels="timerObj.nutritionAnalyse.data.labels" @changeDateType="nutritionAnalyseChangeDateType" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>今日就餐人数统计</span>
</template>
</AsideBlock>
<AsideBlock>
<template #header>
<span>今日复购排行榜(top5)</span>
</template>
<PersonThroughRank :data="timerObj.repurchase.data" />
</AsideBlock>
</el-aside>
<div class="main">
<AsideBlock height="calc(100% * 2 / 3)" titleBgWidth="50%">
<template #header>
<span>营养分析</span>
<div class="report-search">
<el-date-picker style="width: 20vw;" v-model="queryParams.createTimeList" type="datetimerange"
size="small" placeholder="请选择日期" format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss" @change="changeQueryDate" />
</div>
</template>
<NutritionReport ref="nutritionReportRef" />
</AsideBlock>
<AsideBlock titleBgWidth="50%">
<template #header>
<span>营养分析趋势</span>
</template>
<PersonAbnormalEventLineChart type="nutrition" yTitle="购买次数"
:data="timerObj.nutritionAnalyseTrend.data" />
</AsideBlock>
</div>
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>菜品分类购买数量</span>
</template>
<AbnormalEventTypeStatus title="购买数量" type="shop" :data="timerObj.classBuyCount.data"
@changeDateType="alarmTotalByTimerChnageDateType" />
</AsideBlock>
<AsideBlock height="calc(100% * 2 / 3)">
<template #header>
<span>菜品喜好排行</span>
</template>
<NutritionTop :data="timerObj.cuisineTop.data" @change-date-type="changeCuisineTopDateType" />
</AsideBlock>
</el-aside>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, reactive, ref } from "vue";
import { nutritionApi } from "@/api/index"
import useNotification from "@/components/notification/index";
import { simplePoll } from "@/utils/poll"
import { displayPrice } from "@/utils/format-money"
import AsideBlock from "@/components/aside-block.vue";
import NutritionReport from "@/components/nutrition-report.vue";
import AbnormalEventTypeStatus from "@/components/abnormal-event-type-status.vue";
import NutritionAnalyse from "@/components/nutrition-analyse/index.vue"
import PersonThroughRank from "@/components/person-through-rank.vue";
import PersonAbnormalEventLineChart from "@/components/person-abnormal-event-line-chart.vue";
import NutritionTop from "@/components/nutrition-top.vue"
const { notificationAdd, notificationClear } = useNotification();
const nutritionReportRef = ref()
const queryParams = reactive({
createTimeList: null,
})
const timerObj = reactive({
// 复购排行榜
repurchase: {
initial: null,
interval: 10 * 1000,
data: null,
queryParams: {
dateRangeType: "YESTERDAY"
},
},
// 营养分析
nutritionAnalyse: {
initial: null,
interval: 10 * 1000,
data: {
data: null,
labels: null,
},
queryParams: {
dateRangeType: "YESTERDAY"
},
},
// 营养分析趋势
nutritionAnalyseTrend: {
initial: null,
interval: 10 * 1000,
data: {
data: null,
labels: null,
},
queryParams: {},
},
// 菜品分类购买数量
classBuyCount: {
initial: null,
interval: 10 * 1000,
data: null,
queryParams: {
dateRangeType: "YESTERDAY"
},
},
// 菜品喜好排行
cuisineTop: {
initial: null,
interval: 10 * 1000,
data: null,
queryParams: {
dateRangeType: "YESTERDAY"
},
}
})
onMounted(() => {
getRepurchase()
getNutritionAnalyse()
getClassBuyCount()
getCuisineTop()
getNutritionAnalyseTrend()
})
onBeforeUnmount(() => {
notificationClear();
for (let key of Object.keys(timerObj)) {
const value = timerObj[key];
if (value && value.initial) {
value.initial.destroy();
}
}
})
// 复购排行榜
const getRepurchase = () => {
timerObj.repurchase.initial = simplePoll(async () => {
try {
const { code, data } = await nutritionApi.repurchase({ ...timerObj.repurchase.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("复购排行榜:", data);
if (!data) return;
timerObj.repurchase.data = data
} catch (error) {
console.error("|复购排行榜|意外的错误:", error);
}
}, timerObj.repurchase.interval)
}
// 营养分析趋势
const getNutritionAnalyseTrend = () => {
timerObj.nutritionAnalyseTrend.initial = simplePoll(async () => {
try {
const { code, data } = await nutritionApi.nutritionTrend({ ...timerObj.nutritionAnalyseTrend.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("营养分析趋势:", data);
if (!data) return;
timerObj.nutritionAnalyseTrend.data = data;
} catch (error) {
console.error("|营养分析趋势|意外的错误:", error);
}
}, timerObj.nutritionAnalyseTrend.interval)
}
// 营养分析
const getNutritionAnalyse = () => {
timerObj.nutritionAnalyse.initial = simplePoll(async () => {
try {
const { code, data } = await nutritionApi.nutritionByTime({ ...timerObj.nutritionAnalyse.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("营养分析:", data);
if (!data) return;
const dataKeys = Object.keys(data)
const { dateRangeType } = timerObj.nutritionAnalyse.queryParams;
// 昨天
if (dateRangeType === "YESTERDAY") {
timerObj.nutritionAnalyse.data.data = {
YESTERDAY: Object.keys(data).map(v => {
const _entity = data[v]
return {
title: _entity.typeStr,
total: _entity?.list[0]?.buyProductCount ?? 0
}
}),
}
}
// 今天
if (dateRangeType === "TODAY") {
timerObj.nutritionAnalyse.data.data = {
// 今日
TODAY: Object.keys(data).map(v => {
const _entity = data[v]
return {
title: _entity.typeStr,
total: _entity?.list[0]?.buyProductCount ?? 0
}
}),
}
}
// 本周
if (dateRangeType === "THIS_WEEK") {
timerObj.nutritionAnalyse.data.data = { THIS_WEEK: disposeBeLateTotal(data) };
timerObj.nutritionAnalyse.data.labels = {
THIS_WEEK: data[dataKeys[0]]?.list?.map((v) => {
return v.time + "|" + v.weekStr;
}),
};
}
// 本月
if (dateRangeType === "THIS_MONTH") {
timerObj.nutritionAnalyse.data.data = {
THIS_MONTH: data[dataKeys[0]]?.list?.map((v) => {
const hcCount = data.hc?.list.find((item) => item.time === item.time)?.buyProductCount ?? 0
const scCount = data.sc?.list.find((item) => item.time === item.time)?.buyProductCount ?? 0
const zsCount = data.zs?.list.find((item) => item.time === item.time)?.buyProductCount ?? 0
return {
day: v.time.slice(-2),
type: 0,
hcCount: Number(hcCount),
scCount: Number(scCount),
zsCount: Number(zsCount),
};
}),
}
timerObj.nutritionAnalyse.data.labels = {
THIS_MONTH: [],
};
}
// 本年
if (dateRangeType === "THIS_YEAR") {
timerObj.nutritionAnalyse.data.data = {
THIS_YEAR: disposeBeLateTotal(data),
};
timerObj.nutritionAnalyse.data.labels = {
THIS_YEAR: data[dataKeys[0]]?.list?.map((v) => {
return v.time.slice(0, -3);
}),
};
}
} catch (error) {
console.error("|营养分析|意外的错误:", error);
}
}, timerObj.nutritionAnalyse.interval);
}
const disposeBeLateTotal = (obj) => {
if (!obj) return;
return Object.keys(obj).map(v => {
const _entity = obj[v]
return {
text: _entity.typeStr,
name: _entity.type,
values:
_entity?.list?.map((v) => {
return Number(v.buyProductCount);
}) ?? [],
}
})
}
const nutritionAnalyseChangeDateType = (type) => {
timerObj.nutritionAnalyse.queryParams.dateRangeType = type;
timerObj.nutritionAnalyse.initial.executeOnce(type);
}
// 菜品分类购买数量
const getClassBuyCount = () => {
timerObj.classBuyCount.initial = simplePoll(async () => {
try {
const { code, data } = await nutritionApi.classCount({ ...timerObj.classBuyCount.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("菜品分类购买数量:", data);
if (!data) {
return;
}
timerObj.classBuyCount.data =
data.map((v) => {
return {
title: v.classifyName,
type: v.classifyId,
total: v.buyCount,
};
}) ?? [];
console.log("timerObj.classBuyCount.data:", timerObj.classBuyCount.data);
} catch (error) {
console.error("|菜品分类购买数量|意外的错误:", error);
}
}, timerObj.classBuyCount.interval);
};
const alarmTotalByTimerChnageDateType = async (type) => {
timerObj.classBuyCount.queryParams.dateRangeType = type;
timerObj.classBuyCount.initial.executeOnce();
}
// 菜品喜好排行
const getCuisineTop = () => {
timerObj.cuisineTop.initial = simplePoll(async () => {
try {
const { code, data } = await nutritionApi.cuisineTop({ ...timerObj.cuisineTop.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("菜品喜好排行:", data);
if (!data) {
return;
}
timerObj.cuisineTop.data =
data.map((v) => {
return {
title: v.productName,
type: v.productId,
total: v.buyProductCount,
};
}) ?? [];
} catch (error) {
console.error("|菜品分类购买数量|意外的错误:", error);
}
}, timerObj.cuisineTop.interval);
}
const changeCuisineTopDateType = (type) => {
timerObj.cuisineTop.queryParams.dateRangeType = type;
timerObj.cuisineTop.initial.executeOnce();
}
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
color: @text-color;
.aside {
height: 100%;
width: 24%;
z-index: 10;
box-sizing: border-box;
overflow: hidden;
}
.main {
height: 100%;
flex: 1;
box-sizing: border-box;
overflow: hidden;
margin: 0 1vw;
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="container"></div>
</template>
<script setup></script>
<style scoped>
.container {
height: 100%;
width: 100%;
overflow: hidden;
}
</style>
+384
View File
@@ -0,0 +1,384 @@
<template>
<div class="container">
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>设备状态统计</span>
</template>
<DeviceStatus :data="timerObj.deviceTotal.data" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>各商户购买次数趋势统计(近7日)</span>
</template>
<DeviceOnlineLineChart :data="timerObj.deviceOfflineRate.data.list"
:labels="timerObj.deviceOfflineRate.data.labels" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>今日消费次数排行(top5)</span>
</template>
<DeviceMonitoringQuantity :data="timerObj.topTotal.data" />
</AsideBlock>
</el-aside>
<div class="main">
<AsideBlock height="calc(100% * 2 / 3)" titleBgWidth="50%">
<template #header>
<span>实时视频</span>
<div class="report-search">
<el-date-picker style="width: 20vw;" v-model="queryParams.createTimeList" type="datetimerange" size="small"
placeholder="请选择日期" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
@change="changeQueryDate" />
</div>
</template>
<ShopReport ref="shopReportRef" />
</AsideBlock>
<AsideBlock titleBgWidth="50%">
<template #header>
<span>各商户购买次数趋势统计</span>
</template>
<PersonAbnormalEventLineChart type="shop" yTitle="购买次数" :data="timerObj.deviceAlarmTrend.data" />
</AsideBlock>
</div>
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>超市消费人数统计</span>
</template>
<AbnormalEventTypeStatus title="购买人数" type="shop" :data="timerObj.areaCameraTotal.data"
@changeDateType="alarmTotalByTimerChnageDateType" />
</AsideBlock>
<AsideBlock height="calc(100% * 2 / 3)">
<template #header>
<span>超市购买实时统计</span>
</template>
<AbnormalEventList type="shop" :data="timerObj.shopOrder.data" @changeDateType="alarmPageDateTypeChange" />
</AsideBlock>
</el-aside>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, reactive, ref } from "vue";
import { shopApi } from "@/api/index"
import { simplePoll } from "@/utils/poll"
import { displayPrice } from "@/utils/format-money"
import AsideBlock from "@/components/aside-block.vue";
import DeviceStatus from "@/components/device-status.vue";
import DeviceOnlineLineChart from "@/components/device-online-line-chart.vue";
import PersonAbnormalEventLineChart from "@/components/person-abnormal-event-line-chart.vue";
import DeviceMonitoringQuantity from "@/components/device-monitoring-quantity.vue";
import AbnormalEventTypeStatus from "@/components/abnormal-event-type-status.vue";
import AbnormalEventList from "@/components/abnormal-event-list.vue";
import ShopReport from "@/components/shop-report.vue";
import useNotification from "@/components/notification/index";
const { notificationAdd, notificationClear } = useNotification();
const shopReportRef = ref()
const queryParams = reactive({
createTimeList: null,
})
const timerObj = reactive({
// 设备状态统计
deviceTotal: {
initial: null,
interval: 10 * 1000,
data: null,
queryParams: {},
},
// 超市消费人数统计
areaCameraTotal: {
initial: null,
interval: 10 * 1000,
data: null,
queryParams: {
dateRangeType: "YESTERDAY"
},
},
// 超市购买实时统计
shopOrder: {
initial: null,
interval: 10 * 1000,
data: {},
queryParams: {
current: 1,
size: 30,
dateRangeType: "YESTERDAY",
},
},
// 设备在线率趋势
deviceOfflineRate: {
initial: null,
interval: 10 * 1000,
data: {
list: [],
labels: [],
},
queryParams: {
},
},
// 各商户购买次数趋势统计
deviceAlarmTrend: {
initial: null,
interval: 10 * 1000,
data: {
list: [],
labels: [],
},
queryParams: {},
},
// 今日消费次数排行(top5)
topTotal: {
initial: null,
interval: 10 * 1000,
data: null,
queryParams: {},
},
// 最近异常事件
recentlyAlarm: {
initial: null,
interval: 5 * 1000,
queryParams: {},
data: null,
},
});
onMounted(() => {
// getDeviceStatusData();
// getDeviceOnlineData();
getAreaCameraTotalData();
getAlarmData();
getAlarmListData();
getTopTotalData();
getNotification();
});
onBeforeUnmount(() => {
notificationClear();
for (let key of Object.keys(timerObj)) {
const value = timerObj[key];
if (value && value.initial) {
value.initial.destroy();
}
}
});
// 设备状态统计
const getDeviceStatusData = () => {
timerObj.deviceTotal.initial = simplePoll(async () => {
try {
const { code, data } = await deviceApi.totalDevice({ ...timerObj.deviceTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("设备状态统计:", data);
if (!data) {
return;
}
const _array = Array(3).fill({});
const _keyList = ["door", "gate", "camera"];
for (let index = 0; index < _keyList.length; index++) {
const target = data[_keyList[index]];
if (!target) continue;
_array[index] = {
name: target.deviceName,
total: target.total,
type: target.deviceCategory,
online: target.online,
onlineRate: target.onlineRate,
};
}
timerObj.deviceTotal.data = _array;
} catch (error) {
console.error("|设备状态统计|意外的错误:", error);
}
}, timerObj.deviceTotal.interval);
};
// 设备在线率趋势 (近7日)
const getDeviceOnlineData = async () => {
timerObj.deviceOfflineRate.initial = simplePoll(async () => {
try {
const { code, data } = await deviceApi.deviceOfflineRate({ ...timerObj.deviceOfflineRate.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("设备在线率趋势:", data);
if (!data) {
return;
}
timerObj.deviceOfflineRate.data.list = data.map((v) => {
return v.onlineDeviceTotalRate;
});
timerObj.deviceOfflineRate.data.labels = data.map((v) => {
return v.recordTime;
});
} catch (error) {
console.error("|设备在线率趋势|意外的错误:", error);
}
}, timerObj.deviceOfflineRate.interval);
};
// 今日消费次数排行(top5)
const getTopTotalData = () => {
timerObj.topTotal.initial = simplePoll(async () => {
try {
const { code, data } = await shopApi.mchPayCountTop({ ...timerObj.topTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("今日消费次数排行:", data);
if (!data) {
return;
}
timerObj.topTotal.data = {
labels:
data
?.map((v) => {
return v.mchName;
})
.reverse() ?? [],
values:
data
?.map((v) => {
return v.buyCount;
})
.reverse() ?? [],
};
} catch (error) {
console.error("|今日消费次数排行|意外的错误:", error);
}
}, timerObj.topTotal.interval);
};
// 超市消费人数统计
const getAreaCameraTotalData = () => {
timerObj.areaCameraTotal.initial = simplePoll(async () => {
try {
const { code, data } = await shopApi.mchPayPersonCount({ ...timerObj.areaCameraTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("超市消费人数统计:", data);
if (!data || !data.length) {
return;
}
timerObj.areaCameraTotal.data =
data.map((v) => {
return {
title: v.mchName,
type: v.mchId,
total: v.personCount,
};
}) ?? [];
} catch (error) {
console.error("|超市消费人数统计|意外的错误:", error);
}
}, timerObj.areaCameraTotal.interval);
};
const alarmTotalByTimerChnageDateType = async (type) => {
timerObj.areaCameraTotal.queryParams.dateRangeType = type;
timerObj.areaCameraTotal.initial.executeOnce();
}
// 各商户购买次数趋势统计
const getAlarmData = async () => {
timerObj.deviceAlarmTrend.initial = simplePoll(async () => {
try {
const { code, data } = await shopApi.mchPayCountTrend({ ...timerObj.deviceAlarmTrend.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("各商户购买次数趋势统计:", data);
if (!data || !data.length) {
return;
}
timerObj.deviceAlarmTrend.data = data;
} catch (error) {
console.error("|各商户购买次数趋势统计|意外的错误:", error);
}
}, timerObj.deviceAlarmTrend.interval);
};
// 超市购买实时统计
const getAlarmListData = () => {
timerObj.shopOrder.initial = simplePoll(async () => {
try {
const { code, data } = await shopApi.newPage({ ...timerObj.shopOrder.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("超市购买实时统计:", data);
if (!data || !data.records || !data.records.length) {
timerObj.shopOrder.data = [];
return;
}
timerObj.shopOrder.data = data.records.map((v) => {
return {
tag: v.merchantName,
name: `${v.productDesc},共${v.payPrice}元`,
time: v.createTime,
};
});
} catch (error) {
console.error("|超市购买实时统计|意外的错误:", error);
}
}, timerObj.shopOrder.interval);
};
const alarmPageDateTypeChange = (type) => {
timerObj.shopOrder.queryParams.dateRangeType = type;
timerObj.shopOrder.initial.executeOnce();
};
// 最近用超市购买数据
const getNotification = () => {
timerObj.recentlyAlarm.initial = simplePoll(async () => {
try {
const { code, data } = await shopApi.recentlyShop({ ...timerObj.recentlyAlarm.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("最近用超市购买数据:", data);
if (!data) {
return;
}
notificationAdd({
title: "最近用超市购买数据",
content: {
tag: data.merchantName,
channelName: `${data.productDesc},${displayPrice(data.payPrice)}`,
createTime: `${data.createTime}`,
},
});
} catch (error) {
console.error("|最近用超市购买数据|意外的错误:", error);
}
}, timerObj.recentlyAlarm.interval);
};
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
color: #fff;
.aside {
height: 100%;
width: 24%;
z-index: 10;
box-sizing: border-box;
overflow: hidden;
}
.main {
height: 100%;
flex: 1;
box-sizing: border-box;
overflow: hidden;
margin: 0 1vw;
}
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<template>
<div class="container">
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>私家车数据统计</span>
</template>
<CarStatus :data="timerObj.carTotal.data" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>军车出车情况统计</span>
</template>
<CarGoOutStatus :total="timerObj.carTotal.data?.armyCar" :data="timerObj.armyCarCountByDateType.data" />
</AsideBlock>
<AsideBlock>
<template #header>
<span>军车出车情况排行</span>
</template>
<CarOutQuantity :data="timerObj.armyCarCountByCarNum.data" @changeDateType="armyCarCountByCarNumChnageDateType" />
</AsideBlock>
</el-aside>
<div class="main">
<AsideBlock height="calc(100% * 2 / 3)" titleBgWidth="50%">
<template #header>
<span>实时视频</span>
</template>
<DeviceVideoV2 ref="deviceVideoV2Ref" />
</AsideBlock>
<AsideBlock titleBgWidth="50%">
<template #header>
<span>车辆异常出入趋势统计</span>
</template>
<CarAbnormalThroughLineChart :data="timerObj.enterOrExitTotal.data" />
</AsideBlock>
</div>
<el-aside class="aside">
<AsideBlock>
<template #header>
<span>车辆异常出入类型统计</span>
</template>
<AbnormalEventTypeStatus type="car" :data="timerObj.carAlarmTotal.data" @changeDateType="carAlarmChnageDateType" />
</AsideBlock>
<AsideBlock height="calc(100% * 2 / 3)">
<template #header>
<span>车辆异常出入实时统计</span>
</template>
<AbnormalEventList type="car" :data="timerObj.carAlarmPage.data" @changeDateType="carAlarmPageChnageDateType" />
</AsideBlock>
</el-aside>
</div>
</template>
<script setup>
import AsideBlock from "@/components/aside-block.vue";
import CarStatus from "@/components/car-status.vue";
import CarGoOutStatus from "@/components/car-go-out-status.vue";
import CarAbnormalThroughLineChart from "@/components/car-abnormal-through-line-chart.vue";
import DeviceVideoV2 from "@/components/device-video.vue";
import CarOutQuantity from "@/components/car-out-quantity.vue";
import AbnormalEventTypeStatus from "@/components/abnormal-event-type-status.vue";
import AbnormalEventList from "@/components/abnormal-event-list.vue";
import { onMounted, onBeforeUnmount, reactive, ref } from "vue";
import useNotification from "@/components/notification/index";
const { notificationAdd, notificationClear } = useNotification();
const deviceVideoV2Ref = ref(null);
const state = reactive({
groupId: null,
carStatusData: [0, 0, 0],
carOutQuantityData: {},
});
const timerObj = reactive({
// 车辆异常出入类型统计
carAlarmTotal: {
initial: null,
interval: 10 * 1000,
queryParams: {
type: "YESTERDAY",
},
data: null,
},
// 车辆异常出入实时统计
carAlarmPage: {
initial: null,
interval: 10 * 1000,
queryParams: {
type: "YESTERDAY",
current: 1,
size: 30,
},
data: null,
},
// 车辆异常出入趋势统计
enterOrExitTotal: {
initial: null,
interval: 10 * 1000,
queryParams: {},
data: null,
},
// 军车出车情况排行
armyCarCountByCarNum: {
initial: null,
interval: 10 * 1000,
queryParams: {
type: "YESTERDAY",
},
data: null,
},
// 军车出车情况统计
armyCarCountByDateType: {
initial: null,
interval: 10 * 1000,
queryParams: {},
data: {
reignTotal: 0,
unReignTotal: 0,
items: [],
},
},
// 最近报警
recentlyAlarm: {
initial: null,
interval: 5 * 1000,
queryParams: {},
data: null,
},
// 车辆统计
carTotal: {
initial: null,
interval: 5 * 1000,
queryParams: {
inType: "TODAY",
},
data: {
carManage: {},
selfCar: {},
foreignCar: {},
armyCar: {},
},
},
});
onMounted(() => {
// getCarStatusData();
// getOutStatusData();
// getCarOutQuantityData();
// getCarAbnormalThroughData();
// getAbnormalEventTypeStatusData();
// getAbnormalList();
// getNotification();
});
onBeforeUnmount(() => {
notificationClear();
for (let key of Object.keys(timerObj)) {
const value = timerObj[key];
if (value && value.initial) {
value.initial.destroy();
}
}
});
const groupChange = (e) => {
if (!e) return;
state.groupId = e;
deviceVideoV2Ref.value.open({ groupId: state.groupId });
};
// 车辆统计
const getCarStatusData = () => {
timerObj.carTotal.initial = simplePoll(async () => {
try {
const { code, data } = await carApi.carTotal({ ...timerObj.carTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("|车辆统计|:", data);
timerObj.carTotal.data = data;
} catch (error) {
console.log("|车辆统计|意外的错误:", error);
}
}, timerObj.carTotal.interval);
};
// 军车出车情况统计
const getOutStatusData = () => {
timerObj.armyCarCountByDateType.initial = simplePoll(async () => {
let reignTotal = 0;
let unReignTotal = 0;
let items = [];
try {
// 军车出车情况排行数组
const { code: listCode, data: listData } = await carApi.armyCarCountByDateType({ ...timerObj.armyCarCountByDateType.queryParams });
if (listCode != 0) throw new Error("code!=0");
console.log("军车出车情况排行:", listData);
if (!listData || !listData.length) {
items = [];
} else {
items = listData.map((item) => ({
type: item.dateType,
typeName: item.dateTypeStr,
outNumber: item.timeCount,
outDuration: 0,
}));
}
timerObj.armyCarCountByDateType.data = {
reignTotal: reignTotal,
unReignTotal: unReignTotal,
items: items,
};
} catch (error) {
console.log("军车出车情况统计意外的错误:", error);
}
}, timerObj.armyCarCountByDateType.interval);
};
// 军车出车情况排行
const getCarOutQuantityData = () => {
timerObj.armyCarCountByCarNum.initial = simplePoll(async () => {
try {
const { code, data } = await carApi.armyCarCountByCarNum({ ...timerObj.armyCarCountByCarNum.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("军车出车情况排行:", data);
if (!data || !data.length) {
timerObj.armyCarCountByCarNum.data = [];
return;
}
timerObj.armyCarCountByCarNum.data = data;
} catch (error) {
console.log("军车出车情况排行意外的错误:", error);
}
}, timerObj.armyCarCountByCarNum.interval);
};
const armyCarCountByCarNumChnageDateType = (type) => {
timerObj.armyCarCountByCarNum.queryParams.type = type;
timerObj.armyCarCountByCarNum.initial.executeOnce();
};
// 车辆异常出入趋势统计
const getCarAbnormalThroughData = async () => {
timerObj.enterOrExitTotal.initial = simplePoll(async () => {
try {
const { code, data } = await carApi.enterOrExitTotal({ ...timerObj.enterOrExitTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("车辆异常出入趋势统计:", data);
if (!data) {
return;
}
timerObj.enterOrExitTotal.data = {
in: data.enterDetail,
out: data.exitDetail,
};
} catch (error) {
console.log("车辆异常出入趋势统计意外的错误:", error);
}
}, timerObj.enterOrExitTotal.interval);
};
// 车辆异常出入类型统计
const getAbnormalEventTypeStatusData = () => {
timerObj.carAlarmTotal.initial = simplePoll(async () => {
try {
const { code, data } = await carApi.carAlarmTotal({ ...timerObj.carAlarmTotal.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("车辆异常出入类型统计:", data);
if (!data || !data.length) {
return;
}
timerObj.carAlarmTotal.data = data.map((v) => {
return {
title: v.armyName,
type: v.alarmType,
total: v.carCount,
};
});
} catch (error) {
console.log("车辆异常出入类型统计意外的错误:", error);
}
}, timerObj.carAlarmTotal.interval);
};
const carAlarmChnageDateType = (e) => {
timerObj.carAlarmTotal.queryParams.type = e;
timerObj.carAlarmTotal.initial.executeOnce();
};
// 车辆异常出入列表
const getAbnormalList = () => {
timerObj.carAlarmPage.initial = simplePoll(async () => {
try {
const { code, data } = await carApi.carAlarmPage({ ...timerObj.carAlarmPage.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("车辆异常出入列表:", data);
if (!data || !data.records || !data.records.length) {
timerObj.carAlarmPage.data = [];
return;
}
timerObj.carAlarmPage.data = data.records.map((v) => {
return {
alarmList: v.alarmList,
name: v.carNum,
time: v.capTime,
};
});
} catch (error) {
console.error("|车辆异常出入列表|意外的错误:", error);
}
}, timerObj.carAlarmPage.interval);
};
const carAlarmPageChnageDateType = (e) => {
timerObj.carAlarmPage.queryParams.current = 1;
timerObj.carAlarmPage.queryParams.type = e;
timerObj.carAlarmPage.initial.executeOnce();
};
// 最近报警
const getNotification = () => {
timerObj.recentlyAlarm.initial = simplePoll(async () => {
try {
const { code, data } = await carApi.recentlyAlarm({ ...timerObj.recentlyAlarm.queryParams });
if (code != 0) throw new Error("code!=0");
console.log("最近报警:", data);
if (!data) {
return;
}
notificationAdd({
title: "报警信息",
content: {
pic: data.parseCarImgUrl,
channelName: data.devChnName,
createTime: `${data.carNum}-${data.capTime}`,
},
});
} catch (error) {
console.error("|最近报警|意外的错误:", error);
}
}, timerObj.recentlyAlarm.interval);
};
</script>
<style scoped lang="less">
.container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
color: #fff;
.aside {
height: 100%;
width: 24%;
z-index: 10;
box-sizing: border-box;
overflow: hidden;
}
.main {
height: 100%;
flex: 1;
box-sizing: border-box;
overflow: hidden;
margin: 0 1vw;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import { fileURLToPath, URL } from "node:url";
import { loadEnv, defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vite.dev/config/
export default defineConfig((mode) => {
const env = loadEnv(mode.mode, process.cwd());
return {
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
[env.VITE_BASE_API]: {
target: env.VITE_PROXY_PATH,
ws: false,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp("^" + env.VITE_BASE_API), ""),
},
},
},
css: {
preprocessorOptions: {
less: {
additionalData: '@import "@/global.less";',
},
},
},
};
});