| @ -0,0 +1,346 @@ | |||
| <template> | |||
| <CardBox | |||
| class="card-page" | |||
| :showTitle="false" | |||
| > | |||
| <div class="page-container"> | |||
| <div class="table-card" ref="tableCardRef"> | |||
| <el-table | |||
| v-loading="loading" | |||
| :data="dataList" | |||
| class="card-table" | |||
| ref="tableRef" | |||
| border | |||
| stripe | |||
| :height="tableHeight" | |||
| > | |||
| <el-table-column | |||
| label="序号" | |||
| type="index" | |||
| width="60" | |||
| align="center" | |||
| :index="table_index" | |||
| /> | |||
| <el-table-column label="区域" align="center" prop="areaName" /> | |||
| <el-table-column label="点位" align="center" prop="pointName" /> | |||
| <el-table-column label="采集时间" align="center" prop="patrolTime" /> | |||
| <el-table-column label="缺陷照片" align="center"> | |||
| <template slot-scope="scope"> | |||
| <AsyncImage | |||
| v-if="scope.row.imgAnalyse" | |||
| class="table-img" | |||
| :src="scope.row.imgAnalyse" | |||
| @load="onImageLoad" | |||
| @click="lookImageDetail(scope.row.imgAnalyse)" | |||
| > | |||
| </AsyncImage> | |||
| <span v-else>--</span> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column label="缺陷结果" align="center" prop="desc" /> | |||
| </el-table> | |||
| <CardPagination | |||
| ref="paginationRef" | |||
| v-show="total > 0" | |||
| :total="total" | |||
| :page.sync="queryParams.pageNum" | |||
| :limit.sync="queryParams.pageSize" | |||
| @pagination="getList" | |||
| /> | |||
| </div> | |||
| </div> | |||
| <el-dialog | |||
| title="图片预览" | |||
| :visible.sync="showPreviewImages" | |||
| :close-on-click-modal="false" | |||
| :close-on-press-escape="false" | |||
| :destroy-on-close="true" | |||
| custom-class="card-dialog card-page" | |||
| width="70%" | |||
| append-to-body | |||
| > | |||
| <ImagePreview | |||
| v-if="showPreviewImages" | |||
| :imageUrls="showImgesUrls" | |||
| :selectImgPosition="previewImagePos" | |||
| :showTemperatureTool="false" | |||
| /> | |||
| </el-dialog> | |||
| </CardBox> | |||
| </template> | |||
| <script> | |||
| import CardBox from "../components/cardBox.vue"; | |||
| import CardPagination from "../components/CardPagination/index.vue"; | |||
| import { defectRecordList } from "@/api/videosMonitor/index"; | |||
| import ImagePreview from "@/views/cards/components/ImagePreview/index.vue"; | |||
| import AsyncImage from "@/components/AsyncImage/index.vue"; | |||
| export default { | |||
| name: "DefectRecord", | |||
| components: { CardBox, CardPagination, ImagePreview, AsyncImage }, | |||
| data() { | |||
| return { | |||
| queryParams: { pageNum: 1, pageSize: 20 }, | |||
| dataList: [], | |||
| loading: false, | |||
| total: 0, | |||
| tableHeight: null, | |||
| addShow: false, | |||
| dialogTitle: "新建日常维护", | |||
| defaultProps: { | |||
| children: "children", | |||
| label: "label", | |||
| }, | |||
| dateRange: [], | |||
| dateRangePickerOptions: { | |||
| disabledDate(selectDate) { | |||
| return new Date().getTime() < selectDate.getTime(); | |||
| }, | |||
| }, | |||
| // 图片加载计数器 | |||
| imageLoadCount: 0, | |||
| expectedImageCount: 0, | |||
| layoutRefreshTimer: null, | |||
| showPreviewImages: false, | |||
| showImgesUrls: [], | |||
| previewImagePos: 0, | |||
| }; | |||
| }, | |||
| created() { | |||
| this.getList(); | |||
| window.addEventListener("resize", this.setTableHeight); | |||
| }, | |||
| mounted() { | |||
| this.setTableHeight(); | |||
| }, | |||
| beforeDestroy() { | |||
| // 清理定时器 | |||
| if (this.layoutRefreshTimer) { | |||
| clearTimeout(this.layoutRefreshTimer); | |||
| } | |||
| window.removeEventListener("resize", this.setTableHeight); | |||
| }, | |||
| methods: { | |||
| setTableHeight() { | |||
| if (!this.$refs.tableCardRef) return; | |||
| const pagination = this.$refs.paginationRef; | |||
| const paginationHeight = | |||
| pagination && pagination.$el ? pagination.$el.offsetHeight : 0; | |||
| this.tableHeight = Math.max( | |||
| this.$refs.tableCardRef.clientHeight - paginationHeight, | |||
| 160 | |||
| ); | |||
| }, | |||
| // 序号计算 | |||
| table_index(index) { | |||
| const { pageNum, pageSize } = this.queryParams; | |||
| return (pageNum - 1) * pageSize + index + 1; | |||
| }, | |||
| handleQuery() { | |||
| this.queryParams.pageNum = 1; | |||
| this.getList(); | |||
| }, | |||
| linkUrl() { | |||
| this.addShow = true; | |||
| this.dialogTitle = "新建日常维护"; | |||
| }, | |||
| controlChange() {}, | |||
| editClick() { | |||
| this.addShow = true; | |||
| this.dialogTitle = "编辑日常维护"; | |||
| }, | |||
| delClick() {}, | |||
| // 图片加载完成回调 | |||
| onImageLoad() { | |||
| this.imageLoadCount++; | |||
| // 当所有图片加载完成后刷新表格布局 | |||
| if ( | |||
| this.imageLoadCount === this.expectedImageCount && | |||
| this.expectedImageCount > 0 | |||
| ) { | |||
| this.refreshTableLayout(); | |||
| } | |||
| }, | |||
| lookImageDetail(imgUrl) { | |||
| this.previewImagePos = 0; | |||
| this.showImgesUrls = [imgUrl]; | |||
| this.showPreviewImages = true; | |||
| }, | |||
| // 刷新表格布局 | |||
| refreshTableLayout() { | |||
| this.$nextTick(() => { | |||
| if (this.layoutRefreshTimer) { | |||
| clearTimeout(this.layoutRefreshTimer); | |||
| } | |||
| // 延迟执行,确保DOM完全更新 | |||
| this.layoutRefreshTimer = setTimeout(() => { | |||
| if (this.$refs.tableRef && this.$refs.tableRef.doLayout) { | |||
| this.$refs.tableRef.doLayout(); | |||
| } | |||
| // 重置计数器 | |||
| this.imageLoadCount = 0; | |||
| this.expectedImageCount = 0; | |||
| }, 50); | |||
| }); | |||
| }, | |||
| // 强制刷新表格(用于没有图片的情况) | |||
| forceRefreshTable() { | |||
| this.$nextTick(() => { | |||
| setTimeout(() => { | |||
| if (this.$refs.tableRef && this.$refs.tableRef.doLayout) { | |||
| this.$refs.tableRef.doLayout(); | |||
| } | |||
| }, 100); | |||
| }); | |||
| }, | |||
| getList() { | |||
| this.loading = true; | |||
| // 重置图片计数器 | |||
| this.imageLoadCount = 0; | |||
| this.expectedImageCount = 0; | |||
| const params = { ...this.queryParams }; | |||
| if (this.dateRange && this.dateRange.length === 2) { | |||
| params.beginTime = this.dateRange[0]; | |||
| params.endTime = this.dateRange[1]; | |||
| } | |||
| defectRecordList(params) | |||
| .then((res) => { | |||
| this.dataList = res.rows || []; | |||
| this.total = res.total || 0; | |||
| this.$nextTick(this.setTableHeight); | |||
| // 计算需要加载的图片数量 | |||
| this.expectedImageCount = this.dataList.filter( | |||
| (item) => item.imgAnalyse | |||
| ).length; | |||
| // 如果没有图片需要加载,直接刷新表格 | |||
| if (this.expectedImageCount === 0) { | |||
| this.forceRefreshTable(); | |||
| } | |||
| }) | |||
| .catch((error) => { | |||
| console.error("获取缺陷记录失败:", error); | |||
| this.dataList = []; | |||
| this.total = 0; | |||
| }) | |||
| .finally(() => { | |||
| this.loading = false; | |||
| }); | |||
| }, | |||
| onSubmit() { | |||
| this.addShow = false; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page-container { | |||
| height: 100%; | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| .card-page { | |||
| height: 100%; | |||
| min-height: 0; | |||
| overflow: hidden; | |||
| } | |||
| .fss { | |||
| font-size: 20px; | |||
| margin: 0 15px; | |||
| cursor: pointer; | |||
| } | |||
| .table-card { | |||
| flex: 1; | |||
| height: 0; | |||
| ::v-deep .el-card__body { | |||
| height: 100%; | |||
| } | |||
| } | |||
| .tree-container { | |||
| display: flex; | |||
| } | |||
| .tree-box { | |||
| width: 45%; | |||
| } | |||
| // 给表格图片设置固定尺寸,避免高度变化过大 | |||
| .table-img { | |||
| ::v-deep img { | |||
| width: 80px; | |||
| height: 60px; | |||
| object-fit: cover; | |||
| display: block; | |||
| margin: 0 auto; | |||
| } | |||
| } | |||
| .card-page ::v-deep .el-table { | |||
| width: 100% !important; | |||
| color: #17345f; | |||
| background: transparent !important; | |||
| border-color: #6f9ed8 !important; | |||
| } | |||
| .card-page ::v-deep .el-table::before, | |||
| .card-page ::v-deep .el-table::after, | |||
| .card-page ::v-deep .el-table--border::after, | |||
| .card-page ::v-deep .el-table--group::after { | |||
| background-color: #6f9ed8 !important; | |||
| } | |||
| .card-page ::v-deep .el-table th.el-table__cell { | |||
| color: #ffffff !important; | |||
| background: #0b3d89 !important; | |||
| border-color: #6f9ed8 !important; | |||
| } | |||
| .card-page ::v-deep .el-table th.el-table__cell .cell { | |||
| color: #ffffff !important; | |||
| } | |||
| .card-page ::v-deep .el-table__body-wrapper, | |||
| .card-page ::v-deep .el-table__empty-block, | |||
| .card-page ::v-deep .el-table__fixed, | |||
| .card-page ::v-deep .el-table__fixed-right { | |||
| background: #0f124a !important; | |||
| } | |||
| .card-page ::v-deep .el-table__empty-text { | |||
| color: #9fc4ee !important; | |||
| } | |||
| .card-page ::v-deep .el-table__body .el-table__row, | |||
| .card-page ::v-deep .el-table__body .el-table__row td.el-table__cell { | |||
| color: #17345f !important; | |||
| background: #edf4ff !important; | |||
| border-color: #a9c4e8 !important; | |||
| } | |||
| .card-page ::v-deep .el-table__body .el-table__row td.el-table__cell .cell, | |||
| .card-page ::v-deep .el-table__body .el-table__row td.el-table__cell .cell span { | |||
| color: #17345f !important; | |||
| } | |||
| .card-page ::v-deep .el-table__body .el-table__row--striped, | |||
| .card-page ::v-deep .el-table__body .el-table__row--striped td.el-table__cell { | |||
| background: #dce9fa !important; | |||
| } | |||
| .card-page ::v-deep .el-table__body tr:hover > td.el-table__cell { | |||
| background: #c8dcf5 !important; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,710 @@ | |||
| <template> | |||
| <div class="day-view-container"> | |||
| <div class="day-view-header"> | |||
| <div class="header-content"> | |||
| <div class="current-date">{{ formattedDate }}</div> | |||
| <div class="time-week-info"> | |||
| <!-- <div class="current-time">{{ formattedTime }}</div> --> | |||
| <div class="current-time">时间</div> | |||
| <div class="week-day">{{ dayOfWeek }}</div> | |||
| </div> | |||
| </div> | |||
| <el-date-picker | |||
| v-model="value" | |||
| type="date" | |||
| size="mini" | |||
| popper-class="card-date-editor" | |||
| placeholder="选择日期" | |||
| :picker-options="pickerOptions" | |||
| @change="handleDateChange" | |||
| style="width: 140px; float: right" | |||
| ></el-date-picker> | |||
| </div> | |||
| <div class="day-view-content"> | |||
| <!-- 时间轴 --> | |||
| <div class="time-axis"> | |||
| <!-- 24小时制时间轴,从1点到24点 --> | |||
| <div v-for="hour in 24" :key="hour" class="time-slot"> | |||
| <div class="time-label hour-label"> | |||
| {{ formatHour(hour - 1) }}:00 | |||
| </div> | |||
| <div class="time-label half-hour-label"> | |||
| {{ formatHour(hour - 1) }}:30 | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <!-- 任务区域 --> | |||
| <div class="tasks-container"> | |||
| <!-- 所有格子线 --> | |||
| <div class="grid-lines"> | |||
| <div v-for="i in 48" :key="i" class="grid-line"></div> | |||
| </div> | |||
| <!-- 加载状态 --> | |||
| <div v-if="loading" class="loading-overlay"> | |||
| <div class="loading-spinner"></div> | |||
| <div class="loading-text">加载中...</div> | |||
| </div> | |||
| <!-- 无数据提示 --> | |||
| <div v-if="!loading && tasks.length === 0" class="no-data"> | |||
| 暂无任务数据 | |||
| </div> | |||
| <!-- 任务列表 --> | |||
| <div | |||
| v-for="task in tasks" | |||
| :key="task.id" | |||
| class="task-item" | |||
| :class="`task-status-${task.status}`" | |||
| :style="getTaskStyle(task)" | |||
| > | |||
| <div | |||
| class="task-time" | |||
| :title="`${task.title} (${task.startTime} - ${task.endTime})`" | |||
| > | |||
| {{ task.startTime }} - {{ task.endTime }} | |||
| <span class="task-title-inline">{{ task.title }}</span> | |||
| </div> | |||
| <!-- <div class="task-title">{{ task.title }}</div> --> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { calenderDay } from "@/api/videosMonitor/index"; | |||
| import debounce from "lodash/debounce"; | |||
| export default { | |||
| data() { | |||
| return { | |||
| value: new Date(), | |||
| pickerOptions: { | |||
| disabledDate(time) { | |||
| return time.getTime() > Date.now(); | |||
| }, | |||
| }, | |||
| tasks: [], | |||
| // 新增:存储网格化后的任务位置 | |||
| taskGridPositions: {}, | |||
| loading: false, | |||
| currentRequestId: 0, | |||
| }; | |||
| }, | |||
| computed: { | |||
| formattedDate() { | |||
| const year = this.value.getFullYear(); | |||
| const month = String(this.value.getMonth() + 1).padStart(2, "0"); | |||
| const day = String(this.value.getDate()).padStart(2, "0"); | |||
| return `${year}-${month}-${day}`; | |||
| }, | |||
| dayOfWeek() { | |||
| const weekDays = [ | |||
| "星期日", | |||
| "星期一", | |||
| "星期二", | |||
| "星期三", | |||
| "星期四", | |||
| "星期五", | |||
| "星期六", | |||
| ]; | |||
| return weekDays[this.value.getDay()]; | |||
| }, | |||
| }, | |||
| mounted() { | |||
| this.getCalenderDay(); | |||
| this.updateCurrentTime(); | |||
| this.debouncedCalculateTaskPositions = debounce( | |||
| this.calculateTaskPositions, | |||
| 100 | |||
| ); | |||
| }, | |||
| beforeDestroy() { | |||
| if (this.timeInterval) { | |||
| clearInterval(this.timeInterval); | |||
| } | |||
| }, | |||
| methods: { | |||
| updateCurrentTime() { | |||
| this.timeInterval = setInterval(() => { | |||
| this.$forceUpdate(); | |||
| }, 60000); | |||
| }, | |||
| formatHour(hour) { | |||
| const normalizedHour = hour < 0 ? hour + 24 : hour; | |||
| return normalizedHour < 10 ? '0' + normalizedHour : normalizedHour; | |||
| }, | |||
| formatTimeToHHMM(timeString) { | |||
| if (!timeString) return ""; | |||
| const date = new Date(timeString); | |||
| const hours = String(date.getHours()).padStart(2, "0"); | |||
| const minutes = String(date.getMinutes()).padStart(2, "0"); | |||
| return `${hours}:${minutes}`; | |||
| }, | |||
| mapStatus(apiStatus) { | |||
| const statusMap = { | |||
| executed: "completed", | |||
| executing: "running", | |||
| pending: "pending", | |||
| }; | |||
| return statusMap[apiStatus] || "pending"; | |||
| }, | |||
| processApiData(apiData) { | |||
| if (!apiData || !Array.isArray(apiData)) return []; | |||
| return apiData.map((item, index) => ({ | |||
| id: `${index}_${item.startTime}_${Math.random() | |||
| .toString(36) | |||
| .substr(2, 9)}`, | |||
| title: item.name, | |||
| startTime: this.formatTimeToHHMM(item.startTime), | |||
| endTime: this.formatTimeToHHMM(item.endTime), | |||
| status: this.mapStatus(item.status), | |||
| rawData: item, | |||
| // 新增:计算分钟数 | |||
| startMinutes: this.timeToMinutes(this.formatTimeToHHMM(item.startTime)), | |||
| endMinutes: this.timeToMinutes(this.formatTimeToHHMM(item.endTime)), | |||
| })); | |||
| }, | |||
| timeToMinutes(time) { | |||
| if (!time) return 0; | |||
| const [hours, minutes] = time.split(":").map(Number); | |||
| return hours * 60 + minutes; | |||
| }, | |||
| // 重写:计算任务位置,确保时间和空间都不重叠 | |||
| calculateTaskPositions() { | |||
| try { | |||
| // 清空之前的记录 | |||
| this.taskGridPositions = {}; | |||
| if (this.tasks.length === 0) { | |||
| return; | |||
| } | |||
| // 按照开始时间排序 | |||
| const sortedTasks = [...this.tasks].sort((a, b) => { | |||
| if (a.startMinutes !== b.startMinutes) { | |||
| return a.startMinutes - b.startMinutes; | |||
| } | |||
| // 如果开始时间相同,按持续时间短的优先 | |||
| return ( | |||
| a.endMinutes - a.startMinutes - (b.endMinutes - b.startMinutes) | |||
| ); | |||
| }); | |||
| // 定义网格参数:每30分钟为一个时间单元,每个任务占据一个或多个单元 | |||
| const gridWidth = 24 * 2; // 24小时 * 2(半小时单元)= 48个时间单元 | |||
| const maxTracks = 10; // 最大轨道数,防止无限扩展 | |||
| // 初始化网格 | |||
| const grid = Array(maxTracks) | |||
| .fill() | |||
| .map(() => Array(gridWidth).fill(null)); | |||
| // 为每个任务分配位置 | |||
| sortedTasks.forEach((task) => { | |||
| const startSlot = Math.floor(task.startMinutes / 30); | |||
| const endSlot = Math.ceil(task.endMinutes / 30); | |||
| const durationSlots = Math.max(1, endSlot - startSlot); | |||
| // 确保slot在有效范围内 | |||
| const validStartSlot = Math.max( | |||
| 0, | |||
| Math.min(startSlot, gridWidth - 1) | |||
| ); | |||
| const validEndSlot = Math.max( | |||
| validStartSlot + 1, | |||
| Math.min(endSlot, gridWidth) | |||
| ); | |||
| const validDuration = Math.max(1, validEndSlot - validStartSlot); | |||
| // 寻找可用的轨道 | |||
| let assignedTrack = -1; | |||
| for (let track = 0; track < maxTracks; track++) { | |||
| let canPlace = true; | |||
| // 检查这个轨道上从validStartSlot开始的validDuration个格子是否都为空 | |||
| for ( | |||
| let slot = validStartSlot; | |||
| slot < validStartSlot + validDuration && slot < gridWidth; | |||
| slot++ | |||
| ) { | |||
| if (grid[track][slot] !== null) { | |||
| canPlace = false; | |||
| break; | |||
| } | |||
| } | |||
| if (canPlace) { | |||
| assignedTrack = track; | |||
| break; | |||
| } | |||
| } | |||
| // 如果找到了可用的轨道 | |||
| if (assignedTrack !== -1) { | |||
| // 占用网格位置 | |||
| for ( | |||
| let slot = validStartSlot; | |||
| slot < validStartSlot + validDuration && slot < gridWidth; | |||
| slot++ | |||
| ) { | |||
| grid[assignedTrack][slot] = task.id; | |||
| } | |||
| // 记录任务的位置信息 | |||
| this.taskGridPositions[task.id] = { | |||
| track: assignedTrack, | |||
| startSlot: validStartSlot, | |||
| durationSlots: validDuration, | |||
| // 计算实际显示用的位置 | |||
| widthPercentage: 100 / maxTracks, // 每个轨道占固定宽度 | |||
| leftPercentage: assignedTrack * (100 / maxTracks), | |||
| }; | |||
| console.log( | |||
| `任务 ${task.title} 放置在轨道 ${assignedTrack}, 从格子 ${validStartSlot} 开始, 持续 ${validDuration} 个格子` | |||
| ); | |||
| } else { | |||
| // console.warn(`无法为任务 ${task.title} 找到合适的位置,任务将被忽略`); | |||
| } | |||
| }); | |||
| console.log("任务网格位置计算完成"); | |||
| console.log( | |||
| "网格占用情况:", | |||
| grid.map( | |||
| (track, index) => | |||
| `${index}: [${track.map((cell) => (cell ? "X" : ".")).join("")}]` | |||
| ) | |||
| ); | |||
| } catch (error) { | |||
| console.error("计算任务网格位置时出错:", error); | |||
| this.taskGridPositions = {}; | |||
| } | |||
| }, | |||
| // 重写:获取任务样式,确保不重叠 | |||
| getTaskStyle(task) { | |||
| try { | |||
| const startMinutes = this.timeToMinutes(task.startTime); | |||
| const endMinutes = this.timeToMinutes(task.endTime); | |||
| // 计算任务垂直位置和高度(保持原来的计算方式) | |||
| const top = startMinutes * 1; // 去掉 -60 | |||
| const height = Math.max(20, (endMinutes - startMinutes) * 1); | |||
| // 获取任务的网格位置信息 | |||
| const positionInfo = this.taskGridPositions[task.id]; | |||
| let widthPercentage = 8; // 默认宽度(一个轨道的宽度) | |||
| let leftPercentage = 0; | |||
| if (positionInfo) { | |||
| widthPercentage = positionInfo.widthPercentage - 0.5; // 减去一点间距 | |||
| leftPercentage = positionInfo.leftPercentage + 0.25; // 加上一点边距 | |||
| } else { | |||
| // 如果没有计算位置,放在第一列 | |||
| widthPercentage = 8; | |||
| leftPercentage = 0.25; | |||
| } | |||
| return { | |||
| height: `${height}px`, | |||
| top: `${top}px`, | |||
| width: `${widthPercentage}%`, | |||
| left: `${leftPercentage}%`, | |||
| position: "absolute", | |||
| "z-index": positionInfo ? positionInfo.track + 1 : 1, | |||
| }; | |||
| } catch (error) { | |||
| console.error("计算任务样式时出错:", error, task); | |||
| return { | |||
| height: "60px", | |||
| top: "0px", | |||
| width: "8%", | |||
| left: "0.25%", | |||
| position: "absolute", | |||
| "z-index": 1, | |||
| }; | |||
| } | |||
| }, | |||
| handleDateChange() { | |||
| this.getCalenderDay(); | |||
| }, | |||
| async getCalenderDay() { | |||
| const requestId = ++this.currentRequestId; | |||
| this.loading = true; | |||
| const year = this.value.getFullYear(); | |||
| const month = this.value.getMonth() + 1; | |||
| const day = this.value.getDate(); | |||
| try { | |||
| // 清空旧数据 | |||
| this.tasks = []; | |||
| this.taskGridPositions = {}; | |||
| const res = await calenderDay({ | |||
| year: year, | |||
| month: month, | |||
| day: day, | |||
| }); | |||
| // 检查是否是最新的请求 | |||
| if (requestId !== this.currentRequestId) { | |||
| console.log("收到旧的响应,已忽略"); | |||
| return; | |||
| } | |||
| if (res && Array.isArray(res)) { | |||
| this.tasks = this.processApiData(res); | |||
| // 计算任务网格位置 | |||
| this.calculateTaskPositions(); | |||
| } else { | |||
| this.tasks = []; | |||
| console.warn("接口返回数据格式不正确"); | |||
| } | |||
| } catch (error) { | |||
| if (requestId !== this.currentRequestId) { | |||
| return; | |||
| } | |||
| console.error("获取日历数据失败:", error); | |||
| this.tasks = []; | |||
| this.$message.error("加载任务数据失败"); | |||
| } finally { | |||
| if (requestId === this.currentRequestId) { | |||
| this.loading = false; | |||
| } | |||
| } | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| .day-view-container { | |||
| width: 100%; | |||
| height: 100%; | |||
| border-radius: 8px; | |||
| overflow: hidden; | |||
| position: relative; | |||
| } | |||
| .day-view-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| padding: 10px 20px; | |||
| border-bottom: 1px solid #40486a; | |||
| color: #fff; | |||
| } | |||
| .header-content { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 8px; | |||
| } | |||
| .current-date { | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| } | |||
| .time-week-info { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| width: 200px; | |||
| font-size: 14px; | |||
| color: #7984b1; | |||
| } | |||
| .current-time { | |||
| flex: 1; | |||
| text-align: left; | |||
| } | |||
| .week-day { | |||
| flex: 1; | |||
| text-align: right; | |||
| } | |||
| .day-view-content { | |||
| display: flex; | |||
| height: calc(100% - 80px); | |||
| overflow-x: hidden; | |||
| overflow-y: auto; | |||
| position: relative; | |||
| scrollbar-width: none; | |||
| -ms-overflow-style: none; | |||
| } | |||
| .day-view-content::-webkit-scrollbar { | |||
| display: none; | |||
| width: 0; | |||
| height: 0; | |||
| } | |||
| .time-axis { | |||
| width: 70px; | |||
| border-right: 1px solid #40486a; | |||
| padding: 0; | |||
| flex-shrink: 0; | |||
| } | |||
| .time-slot { | |||
| height: 60px; | |||
| position: relative; | |||
| } | |||
| .time-label { | |||
| height: 30px; | |||
| line-height: 30px; | |||
| font-size: 16px; | |||
| color: #7984b1; | |||
| text-align: center; | |||
| position: relative; | |||
| border-right: 1px solid #40486a; | |||
| } | |||
| .hour-label { | |||
| font-weight: 500; | |||
| border-bottom: 1px dashed #40486a; | |||
| } | |||
| .half-hour-label { | |||
| border-bottom: 1px solid #40486a; | |||
| } | |||
| .tasks-container { | |||
| flex: 1; | |||
| position: relative; | |||
| margin-left: -1px; | |||
| min-height: 300px; | |||
| } | |||
| .grid-lines { | |||
| position: absolute; | |||
| top: 0; | |||
| left: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| pointer-events: none; | |||
| } | |||
| .grid-line { | |||
| height: 30px; | |||
| border-bottom: 1px dashed #40486a; | |||
| background: #202f3e; | |||
| } | |||
| .grid-line:nth-child(odd) { | |||
| border-bottom: 1px solid #40486a; | |||
| background: #172533; | |||
| } | |||
| .task-item { | |||
| position: absolute; | |||
| border-radius: 4px; | |||
| padding: 4px 8px; | |||
| font-size: 12px; | |||
| overflow: hidden; | |||
| cursor: pointer; | |||
| box-sizing: border-box; | |||
| white-space: nowrap; | |||
| text-overflow: ellipsis; | |||
| transition: all 0.3s ease; | |||
| border: 1px solid rgba(255, 255, 255, 0.1); | |||
| min-height: 20px; | |||
| } | |||
| .task-item:hover { | |||
| opacity: 0.9; | |||
| transform: translateY(-1px); | |||
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); | |||
| z-index: 100 !important; | |||
| } | |||
| .task-time { | |||
| font-size: 10px; | |||
| margin-bottom: 2px; | |||
| opacity: 0.9; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| width: 100%; | |||
| display: flex; | |||
| align-items: center; | |||
| } | |||
| .task-title-inline { | |||
| display: inline-block; | |||
| max-width: 60%; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| margin-left: 4px; | |||
| flex-shrink: 1; | |||
| } | |||
| .task-title { | |||
| font-size: 11px; | |||
| font-weight: 500; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| display: inline-block; | |||
| } | |||
| /* 加载状态样式 */ | |||
| .loading-overlay { | |||
| position: absolute; | |||
| top: 0; | |||
| left: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| background: rgba(23, 37, 51, 0.8); | |||
| display: flex; | |||
| flex-direction: column; | |||
| justify-content: center; | |||
| align-items: center; | |||
| z-index: 10; | |||
| } | |||
| .loading-spinner { | |||
| width: 40px; | |||
| height: 40px; | |||
| border: 3px solid #40486a; | |||
| border-top-color: #247382; | |||
| border-radius: 50%; | |||
| animation: spin 1s linear infinite; | |||
| margin-bottom: 10px; | |||
| } | |||
| .loading-text { | |||
| color: #7984b1; | |||
| font-size: 14px; | |||
| } | |||
| /* 无数据样式 */ | |||
| .no-data { | |||
| position: absolute; | |||
| top: 50%; | |||
| left: 50%; | |||
| transform: translate(-50%, -50%); | |||
| color: #7984b1; | |||
| font-size: 16px; | |||
| text-align: center; | |||
| } | |||
| @keyframes spin { | |||
| to { | |||
| transform: rotate(360deg); | |||
| } | |||
| } | |||
| .task-status-running { | |||
| background-color: rgba(36, 115, 130, 0.9) !important; | |||
| color: #fff; | |||
| /* border-left: 3px solid #1a9ba8; */ | |||
| } | |||
| .task-status-completed { | |||
| background-color: rgba(41, 106, 90, 0.9) !important; | |||
| color: #fff; | |||
| /* border-left: 3px solid #1f8b6e; */ | |||
| } | |||
| .task-status-pending { | |||
| background-color: rgba(109, 125, 146, 0.9) !important; | |||
| color: #fff; | |||
| /* border-left: 3px solid #8a9bb3; */ | |||
| } | |||
| /* 为不同轨道添加不同的背景色提示 */ | |||
| .task-item[style*="left: 0.25%"] { | |||
| background-color: rgba(36, 115, 130, 0.85); | |||
| } | |||
| .task-item[style*="left: 8.75%"] { | |||
| background-color: rgba(41, 106, 90, 0.85); | |||
| } | |||
| .task-item[style*="left: 17.25%"] { | |||
| background-color: rgba(106, 41, 82, 0.85); | |||
| } | |||
| .task-item[style*="left: 25.75%"] { | |||
| background-color: rgba(106, 82, 41, 0.85); | |||
| } | |||
| .task-item[style*="left: 34.25%"] { | |||
| background-color: rgba(41, 82, 106, 0.85); | |||
| } | |||
| /* 滚动条样式 */ | |||
| .tasks-container::-webkit-scrollbar, | |||
| .time-axis::-webkit-scrollbar { | |||
| width: 6px; | |||
| height: 6px; | |||
| } | |||
| .tasks-container::-webkit-scrollbar-track, | |||
| .time-axis::-webkit-scrollbar-track { | |||
| background: transparent; | |||
| } | |||
| .tasks-container::-webkit-scrollbar-thumb, | |||
| .time-axis::-webkit-scrollbar-thumb { | |||
| background: #40486a; | |||
| border-radius: 3px; | |||
| } | |||
| .tasks-container::-webkit-scrollbar-thumb:hover, | |||
| .time-axis::-webkit-scrollbar-thumb:hover { | |||
| background: #4d5779; | |||
| } | |||
| /* Element UI 日期选择器样式 */ | |||
| :deep(.card-date-editor) { | |||
| background-color: #172533; | |||
| border-color: #40486a; | |||
| color: #fff; | |||
| } | |||
| :deep(.el-date-picker__header) { | |||
| border-bottom-color: #40486a; | |||
| } | |||
| :deep(.el-date-picker__header-label) { | |||
| color: #fff; | |||
| } | |||
| :deep(.el-date-table th) { | |||
| color: #7984b1; | |||
| } | |||
| :deep(.el-date-table td) { | |||
| color: #fff; | |||
| } | |||
| :deep(.el-date-table td.current:not(.disabled) > div) { | |||
| background-color: #247382; | |||
| } | |||
| :deep(.el-date-table td.today:not(.current) > div) { | |||
| color: #247382; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,162 @@ | |||
| <template> | |||
| <CardBox | |||
| class="card-page" | |||
| :showTitle="false" | |||
| > | |||
| <div class="view-content"> | |||
| <YearView | |||
| v-if="currentView === 'year'" | |||
| :year="currentYear" | |||
| @year-changed="updateYear" | |||
| /> | |||
| <MonthView | |||
| v-if="currentView === 'month'" | |||
| :year="currentYear" | |||
| :month="currentMonth" | |||
| @update:year="updateYear" | |||
| @update:month="updateMonth" | |||
| /> | |||
| <DayView | |||
| v-if="currentView === 'day'" | |||
| :year="currentYear" | |||
| :month="currentMonth" | |||
| :day="currentDay" | |||
| /> | |||
| </div> | |||
| <!-- </div> --> | |||
| </CardBox> | |||
| </template> | |||
| <script> | |||
| import YearView from "./yearDisplay.vue"; | |||
| import MonthView from "./monthlyDisplay.vue"; | |||
| import DayView from "./dailyDisplay.vue"; | |||
| import CardBox from "../components/cardBox.vue"; | |||
| export default { | |||
| name: "PatrolSchedule", | |||
| components: { | |||
| CardBox, | |||
| YearView, | |||
| MonthView, | |||
| DayView, | |||
| }, | |||
| data() { | |||
| const today = new Date(); | |||
| const routeView = this.$route.query.view; | |||
| return { | |||
| currentView: ["year", "month", "day"].includes(routeView) | |||
| ? routeView | |||
| : "year", | |||
| currentYear: today.getFullYear(), | |||
| currentMonth: today.getMonth() + 1, | |||
| currentDay: today.getDate(), | |||
| }; | |||
| }, | |||
| watch: { | |||
| "$route.query.view"(view) { | |||
| if (["year", "month", "day"].includes(view)) { | |||
| this.currentView = view; | |||
| } | |||
| }, | |||
| }, | |||
| methods: { | |||
| // 添加更新年份和月份的方法 | |||
| updateYear(year) { | |||
| this.currentYear = year; | |||
| }, | |||
| updateMonth(month) { | |||
| this.currentMonth = month; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| /* .calendar-container { | |||
| width: 100%; | |||
| max-width: 1200px; | |||
| margin: 0 auto; | |||
| font-family: Arial, sans-serif; | |||
| color: #ffffff; | |||
| background-color: #1a1a2e; | |||
| } */ | |||
| .card-page .el-table td .cell, | |||
| .card-page .el-table td .cell div, | |||
| .card-page .el-table td .cell span { | |||
| color: #cbd5e7; | |||
| } | |||
| .card-page { | |||
| height: 100%; | |||
| min-height: 0; | |||
| overflow: hidden; | |||
| } | |||
| .calendar-header { | |||
| background-size: cover; | |||
| background-position: center; | |||
| padding: 20px; | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| border-radius: 8px 8px 0 0; | |||
| } | |||
| .date-title { | |||
| font-size: 24px; | |||
| font-weight: bold; | |||
| color: #ffffff; | |||
| } | |||
| .view-buttons { | |||
| display: flex; | |||
| gap: 10px; | |||
| } | |||
| .view-btn { | |||
| background-size: cover; | |||
| background-position: center; | |||
| border: none; | |||
| padding: 10px 20px; | |||
| color: #ffffff; | |||
| font-weight: bold; | |||
| cursor: pointer; | |||
| border-radius: 4px; | |||
| transition: all 0.3s ease; | |||
| } | |||
| .view-btn:hover { | |||
| opacity: 0.8; | |||
| } | |||
| .view-btn.active { | |||
| transform: scale(1.05); | |||
| box-shadow: 0 0 10px rgba(255, 255, 255, 0.5); | |||
| } | |||
| .view-content { | |||
| // padding: 20px; | |||
| // background-color: #16213e; | |||
| border-radius: 0 0 8px 8px; | |||
| height: 100%; | |||
| min-height: 0; | |||
| overflow: auto; | |||
| scrollbar-width: none; | |||
| -ms-overflow-style: none; | |||
| } | |||
| .view-content::-webkit-scrollbar, | |||
| .view-content ::v-deep .tasks-container::-webkit-scrollbar, | |||
| .view-content ::v-deep .time-axis::-webkit-scrollbar, | |||
| .view-content ::v-deep .tasks-list::-webkit-scrollbar { | |||
| display: none !important; | |||
| width: 0 !important; | |||
| height: 0 !important; | |||
| } | |||
| .view-content ::v-deep .tasks-container, | |||
| .view-content ::v-deep .time-axis, | |||
| .view-content ::v-deep .tasks-list { | |||
| scrollbar-width: none !important; | |||
| -ms-overflow-style: none; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,791 @@ | |||
| <template> | |||
| <div class="month-view"> | |||
| <div class="month-header"> | |||
| <h2>{{ displayYear }}年{{ displayMonth }}月</h2> | |||
| <div class="month-controls"> | |||
| <el-date-picker | |||
| v-model="selectedDate" | |||
| style="float: right; width: 120px; border: 0" | |||
| popper-class="card-date-editor" | |||
| type="month" | |||
| size="mini" | |||
| placeholder="选择月" | |||
| :picker-options="pickerOptions" | |||
| @change="onMonthChange" | |||
| > | |||
| </el-date-picker> | |||
| <div class="nav-buttons"> | |||
| <button class="nav-btn" @click="prevMonth"><</button> | |||
| <button class="nav-btn" @click="nextMonth">></button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="calendar-grid"> | |||
| <div class="weekday-header"> | |||
| <div class="weekday" v-for="day in weekdays" :key="day">{{ day }}</div> | |||
| </div> | |||
| <div class="days-grid"> | |||
| <!-- 上月剩余天数 --> | |||
| <div | |||
| class="day-cell empty" | |||
| v-for="empty in firstDayOffset" | |||
| :key="'empty-' + empty" | |||
| ></div> | |||
| <!-- 当月天数 --> | |||
| <div | |||
| class="day-cell" | |||
| v-for="day in daysInMonth" | |||
| :key="day" | |||
| :class="{ today: isToday(day) }" | |||
| > | |||
| <div class="day-header"> | |||
| <div class="day-number">{{ day }}</div> | |||
| </div> | |||
| <div class="tasks-list" v-if="hasTasks(day)"> | |||
| <div | |||
| class="task-item" | |||
| v-for="(task, index) in getTasksForDay(day)" | |||
| :key="index" | |||
| :class="`status-${task.status}`" | |||
| @mouseenter="showTooltip($event, task.name)" | |||
| @mouseleave="hideTooltip" | |||
| > | |||
| <span class="task-name">{{ task.name }}</span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <!-- 下月补充天数 --> | |||
| <div | |||
| class="day-cell empty" | |||
| v-for="empty in lastDayOffset" | |||
| :key="'empty-end-' + empty" | |||
| ></div> | |||
| </div> | |||
| </div> | |||
| <!-- 工具提示 --> | |||
| <div v-if="tooltipVisible" class="task-tooltip" :style="tooltipStyle"> | |||
| {{ tooltipText }} | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { calenderMonth } from "@/api/videosMonitor/index"; | |||
| export default { | |||
| name: "MonthView", | |||
| props: { | |||
| year: { | |||
| type: Number, | |||
| required: false, | |||
| default: null, | |||
| }, | |||
| month: { | |||
| type: Number, | |||
| required: false, | |||
| default: null, | |||
| }, | |||
| }, | |||
| data() { | |||
| const today = new Date(); | |||
| const currentYear = today.getFullYear(); | |||
| const currentMonth = today.getMonth() + 1; | |||
| // console.log('初始化当前日期:', currentYear, currentMonth); | |||
| return { | |||
| // 内部维护的当前显示年月(默认当前月) | |||
| displayYear: currentYear, | |||
| displayMonth: currentMonth, | |||
| selectedDate: new Date(currentYear, currentMonth - 1, 1), | |||
| pickerOptions: {}, | |||
| weekdays: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], | |||
| tasksData: [], | |||
| loading: false, | |||
| // 工具提示相关 | |||
| tooltipVisible: false, | |||
| tooltipText: "", | |||
| tooltipStyle: { | |||
| left: "0px", | |||
| top: "0px", | |||
| }, | |||
| currentUpdatePosition: null, | |||
| // 防抖标记 | |||
| fetchTimeout: null, | |||
| // 标记是否已经执行过首次数据加载 | |||
| initialDataLoaded: false, | |||
| }; | |||
| }, | |||
| computed: { | |||
| daysInMonth() { | |||
| return new Date(this.displayYear, this.displayMonth, 0).getDate(); | |||
| }, | |||
| firstDayOffset() { | |||
| return new Date(this.displayYear, this.displayMonth - 1, 1).getDay(); | |||
| }, | |||
| lastDayOffset() { | |||
| const totalCells = | |||
| Math.ceil((this.firstDayOffset + this.daysInMonth) / 7) * 7; | |||
| return totalCells - this.firstDayOffset - this.daysInMonth; | |||
| }, | |||
| }, | |||
| watch: { | |||
| displayYear: { | |||
| handler(newYear, oldYear) { | |||
| if (newYear && oldYear && newYear !== oldYear) { | |||
| this.updateSelectedDate(); | |||
| this.debouncedFetchData(); | |||
| // 同步到父组件 | |||
| this.$emit("update:year", newYear); | |||
| } | |||
| }, | |||
| immediate: false, | |||
| }, | |||
| displayMonth: { | |||
| handler(newMonth, oldMonth) { | |||
| if (newMonth && oldMonth && newMonth !== oldMonth) { | |||
| this.updateSelectedDate(); | |||
| this.debouncedFetchData(); | |||
| // 同步到父组件 | |||
| this.$emit("update:month", newMonth); | |||
| } | |||
| }, | |||
| immediate: false, | |||
| }, | |||
| // 监听父组件传入的props,只有当父组件想要控制时才同步到内部状态 | |||
| // 但我们需要一个标志来判断是否应该使用父组件的值 | |||
| year: { | |||
| handler(newYear) { | |||
| // 只有当父组件明确想要控制(即传入了非null/undefined的值)且 | |||
| // 组件还没有初始化完成时,才使用父组件的值 | |||
| // 或者我们可以让父组件通过一个prop来控制是否使用父组件的值 | |||
| // 注意:这里我们不自动更新displayYear,而是由父组件通过prop控制 | |||
| // 或者我们可以在created中一次性处理 | |||
| }, | |||
| immediate: false, | |||
| }, | |||
| month: { | |||
| handler(newMonth) { | |||
| // 同样,不自动更新 | |||
| }, | |||
| immediate: false, | |||
| }, | |||
| }, | |||
| created() { | |||
| const today = new Date(); | |||
| const currentYear = today.getFullYear(); | |||
| const currentMonth = today.getMonth() + 1; | |||
| // 关键修改:始终使用当前日期作为默认值 | |||
| // 只有当父组件明确传入了有效的年月,并且我们确定要使用父组件的值时,才使用父组件的值 | |||
| // 但根据您的需求,您想要显示当前月,所以我们应该忽略父组件的传入值 | |||
| // 方案1:完全忽略父组件传入的值,始终使用当前日期 | |||
| this.displayYear = currentYear; | |||
| this.displayMonth = currentMonth; | |||
| // 通知父组件当前月份(如果父组件需要知道) | |||
| this.$emit("update:year", this.displayYear); | |||
| this.$emit("update:month", this.displayMonth); | |||
| this.updateSelectedDate(); | |||
| }, | |||
| mounted() { | |||
| // 只在首次挂载时获取数据(确保当前月份数据加载) | |||
| if (!this.initialDataLoaded) { | |||
| this.getMonthData(); | |||
| this.initialDataLoaded = true; | |||
| } | |||
| // 监听窗口变化,重新计算工具提示位置 | |||
| window.addEventListener("resize", this.handleResize); | |||
| }, | |||
| beforeDestroy() { | |||
| // 清理事件监听 | |||
| window.removeEventListener("resize", this.handleResize); | |||
| // 清理防抖定时器 | |||
| if (this.fetchTimeout) { | |||
| clearTimeout(this.fetchTimeout); | |||
| } | |||
| // 清理鼠标移动监听 | |||
| if (this.currentUpdatePosition) { | |||
| document.removeEventListener("mousemove", this.currentUpdatePosition); | |||
| } | |||
| }, | |||
| methods: { | |||
| // 更新选中的日期 | |||
| updateSelectedDate() { | |||
| if (this.displayYear && this.displayMonth) { | |||
| this.selectedDate = new Date(this.displayYear, this.displayMonth - 1, 1); | |||
| } | |||
| }, | |||
| // 防抖获取数据 | |||
| debouncedFetchData() { | |||
| if (this.fetchTimeout) { | |||
| clearTimeout(this.fetchTimeout); | |||
| } | |||
| this.fetchTimeout = setTimeout(() => { | |||
| this.getMonthData(); | |||
| }, 300); | |||
| }, | |||
| onMonthChange() { | |||
| if (this.selectedDate) { | |||
| const year = this.selectedDate.getFullYear(); | |||
| const month = this.selectedDate.getMonth() + 1; | |||
| // 更新内部状态 | |||
| if (year !== this.displayYear || month !== this.displayMonth) { | |||
| this.displayYear = year; | |||
| this.displayMonth = month; | |||
| // 注意:不需要在这里调用getMonthData,watch会触发 | |||
| } | |||
| } | |||
| }, | |||
| prevMonth() { | |||
| const newDate = new Date(this.displayYear, this.displayMonth - 2, 1); | |||
| this.displayYear = newDate.getFullYear(); | |||
| this.displayMonth = newDate.getMonth() + 1; | |||
| }, | |||
| nextMonth() { | |||
| const newDate = new Date(this.displayYear, this.displayMonth, 1); | |||
| this.displayYear = newDate.getFullYear(); | |||
| this.displayMonth = newDate.getMonth() + 1; | |||
| }, | |||
| isToday(day) { | |||
| const today = new Date(); | |||
| return ( | |||
| today.getFullYear() === this.displayYear && | |||
| today.getMonth() + 1 === this.displayMonth && | |||
| today.getDate() === day | |||
| ); | |||
| }, | |||
| hasTasks(day) { | |||
| return this.tasksData.some((task) => { | |||
| if (!task.start) return false; | |||
| const taskDate = new Date(task.start); | |||
| return ( | |||
| taskDate.getDate() === day && | |||
| taskDate.getMonth() + 1 === this.displayMonth && | |||
| taskDate.getFullYear() === this.displayYear | |||
| ); | |||
| }); | |||
| }, | |||
| getTasksForDay(day) { | |||
| return this.tasksData | |||
| .filter((task) => { | |||
| if (!task.start) return false; | |||
| const taskDate = new Date(task.start); | |||
| return ( | |||
| taskDate.getDate() === day && | |||
| taskDate.getMonth() + 1 === this.displayMonth && | |||
| taskDate.getFullYear() === this.displayYear | |||
| ); | |||
| }) | |||
| .map((task) => ({ | |||
| name: task.name || "未命名任务", | |||
| status: this.mapTaskStatus(task.status), | |||
| })); | |||
| }, | |||
| getTaskStats(day) { | |||
| const dayTasks = this.getTasksForDay(day); | |||
| const stats = { | |||
| completed: 0, | |||
| executing: 0, | |||
| "not-executed": 0, | |||
| }; | |||
| dayTasks.forEach((task) => { | |||
| if (stats.hasOwnProperty(task.status)) { | |||
| stats[task.status]++; | |||
| } | |||
| }); | |||
| return { | |||
| stats, | |||
| total: dayTasks.length, | |||
| }; | |||
| }, | |||
| mapTaskStatus(apiStatus) { | |||
| const statusMap = { | |||
| executed: "completed", | |||
| executing: "executing", | |||
| pending: "not-executed", | |||
| }; | |||
| return statusMap[apiStatus] || "not-executed"; | |||
| }, | |||
| async getMonthData() { | |||
| // 避免重复请求 | |||
| if (this.loading) return; | |||
| try { | |||
| this.loading = true; | |||
| const response = await calenderMonth({ | |||
| year: this.displayYear, | |||
| month: this.displayMonth, | |||
| }); | |||
| // 处理不同的响应格式 | |||
| const data = response?.rows || response?.data || response || []; | |||
| this.tasksData = Array.isArray(data) ? data : []; | |||
| } catch (error) { | |||
| this.tasksData = []; | |||
| } finally { | |||
| this.loading = false; | |||
| } | |||
| }, | |||
| // 显示工具提示 | |||
| showTooltip(event, text) { | |||
| this.tooltipText = text; | |||
| this.tooltipVisible = true; | |||
| // 使用当前鼠标位置 | |||
| const x = event.clientX; | |||
| const y = event.clientY; | |||
| const offset = 10; | |||
| this.tooltipStyle = { | |||
| left: `${x + offset}px`, | |||
| top: `${y + offset}px`, | |||
| }; | |||
| // 监听鼠标移动来更新位置 | |||
| const updatePosition = (e) => { | |||
| if (this.tooltipVisible) { | |||
| this.tooltipStyle = { | |||
| left: `${e.clientX + offset}px`, | |||
| top: `${e.clientY + offset}px`, | |||
| }; | |||
| } | |||
| }; | |||
| document.addEventListener("mousemove", updatePosition); | |||
| this.currentUpdatePosition = updatePosition; | |||
| }, | |||
| // 隐藏工具提示 | |||
| hideTooltip() { | |||
| this.tooltipVisible = false; | |||
| if (this.currentUpdatePosition) { | |||
| document.removeEventListener("mousemove", this.currentUpdatePosition); | |||
| this.currentUpdatePosition = null; | |||
| } | |||
| }, | |||
| // 处理窗口大小变化 | |||
| handleResize() { | |||
| if (this.tooltipVisible) { | |||
| this.tooltipVisible = false; | |||
| } | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| .month-view { | |||
| width: 100%; | |||
| min-height: 100%; | |||
| background-color: #172533; | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| } | |||
| .month-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 20px; | |||
| background-color: #101d29; | |||
| padding: 15px 20px; | |||
| border-radius: 8px; | |||
| flex-shrink: 0; | |||
| } | |||
| .month-header h2 { | |||
| margin: 0; | |||
| font-size: 20px; | |||
| color: #fff !important; | |||
| } | |||
| .month-controls { | |||
| display: flex; | |||
| gap: 10px; | |||
| align-items: center; | |||
| } | |||
| .nav-buttons { | |||
| display: flex; | |||
| gap: 5px; | |||
| } | |||
| .nav-btn { | |||
| width: 80px; | |||
| height: 30px; | |||
| background-color: #202f3e; | |||
| color: #fff; | |||
| border: 2px solid #40486a; | |||
| border-radius: 4px; | |||
| cursor: pointer; | |||
| display: flex; | |||
| justify-content: center; | |||
| align-items: center; | |||
| transition: all 0.3s ease; | |||
| } | |||
| .nav-btn:hover { | |||
| background-color: #40486a; | |||
| color: #ffffff; | |||
| } | |||
| .calendar-grid { | |||
| background-color: #101d29; | |||
| border-radius: 8px; | |||
| overflow: hidden; | |||
| border: 2px solid #40486a; | |||
| flex: 1; | |||
| display: flex; | |||
| flex-direction: column; | |||
| min-height: 0; | |||
| } | |||
| .weekday-header { | |||
| display: grid; | |||
| grid-template-columns: repeat(7, 1fr); | |||
| background-color: #101d29; | |||
| padding: 10px 0; | |||
| border-bottom: 2px solid #40486a; | |||
| flex-shrink: 0; | |||
| } | |||
| .weekday { | |||
| text-align: center; | |||
| font-weight: bold; | |||
| color: #fff; | |||
| padding: 5px; | |||
| border-right: 2px solid #40486a; | |||
| } | |||
| .weekday:last-child { | |||
| border-right: none; | |||
| } | |||
| .days-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(7, 1fr); | |||
| grid-template-rows: repeat(auto-fill, minmax(120px, 1fr)); | |||
| background-color: #172533; | |||
| flex: 1; | |||
| min-height: 0; | |||
| } | |||
| /* 固定日历格子的宽度和高度自适应 */ | |||
| .day-cell { | |||
| padding: 5px; | |||
| position: relative; | |||
| min-height: 120px; | |||
| border-right: 2px solid #40486a; | |||
| border-bottom: 2px solid #40486a; | |||
| background-color: #172533; | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| } | |||
| /* 确保所有格子等宽 */ | |||
| .day-cell { | |||
| width: 100%; | |||
| min-width: 0; /* 允许宽度压缩 */ | |||
| } | |||
| /* 响应式:小屏幕时适当调整最小高度 */ | |||
| @media (max-width: 1200px) { | |||
| .day-cell { | |||
| min-height: 100px; | |||
| } | |||
| } | |||
| @media (max-width: 992px) { | |||
| .day-cell { | |||
| min-height: 90px; | |||
| } | |||
| } | |||
| /* 行背景色保持不变 */ | |||
| .days-grid > .day-cell:nth-child(7n + 1):nth-child(-n + 7), | |||
| .days-grid > .day-cell:nth-child(7n + 2):nth-child(-n + 7), | |||
| .days-grid > .day-cell:nth-child(7n + 3):nth-child(-n + 7), | |||
| .days-grid > .day-cell:nth-child(7n + 4):nth-child(-n + 7), | |||
| .days-grid > .day-cell:nth-child(7n + 5):nth-child(-n + 7), | |||
| .days-grid > .day-cell:nth-child(7n + 6):nth-child(-n + 7), | |||
| .days-grid > .day-cell:nth-child(7n + 7):nth-child(-n + 7) { | |||
| background-color: #172533; | |||
| } | |||
| /* 偶数行颜色 */ | |||
| .days-grid > .day-cell:nth-child(7n + 1):nth-child(n + 8), | |||
| .days-grid > .day-cell:nth-child(7n + 2):nth-child(n + 8), | |||
| .days-grid > .day-cell:nth-child(7n + 3):nth-child(n + 8), | |||
| .days-grid > .day-cell:nth-child(7n + 4):nth-child(n + 8), | |||
| .days-grid > .day-cell:nth-child(7n + 5):nth-child(n + 8), | |||
| .days-grid > .day-cell:nth-child(7n + 6):nth-child(n + 8), | |||
| .days-grid > .day-cell:nth-child(7n + 7):nth-child(n + 8) { | |||
| background-color: #202f3e; | |||
| } | |||
| /* 第三行颜色 */ | |||
| .days-grid > .day-cell:nth-child(7n + 1):nth-child(n + 15), | |||
| .days-grid > .day-cell:nth-child(7n + 2):nth-child(n + 15), | |||
| .days-grid > .day-cell:nth-child(7n + 3):nth-child(n + 15), | |||
| .days-grid > .day-cell:nth-child(7n + 4):nth-child(n + 15), | |||
| .days-grid > .day-cell:nth-child(7n + 5):nth-child(n + 15), | |||
| .days-grid > .day-cell:nth-child(7n + 6):nth-child(n + 15), | |||
| .days-grid > .day-cell:nth-child(7n + 7):nth-child(n + 15) { | |||
| background-color: #172533; | |||
| } | |||
| /* 第四行颜色 */ | |||
| .days-grid > .day-cell:nth-child(7n + 1):nth-child(n + 22), | |||
| .days-grid > .day-cell:nth-child(7n + 2):nth-child(n + 22), | |||
| .days-grid > .day-cell:nth-child(7n + 3):nth-child(n + 22), | |||
| .days-grid > .day-cell:nth-child(7n + 4):nth-child(n + 22), | |||
| .days-grid > .day-cell:nth-child(7n + 5):nth-child(n + 22), | |||
| .days-grid > .day-cell:nth-child(7n + 6):nth-child(n + 22), | |||
| .days-grid > .day-cell:nth-child(7n + 7):nth-child(n + 22) { | |||
| background-color: #202f3e; | |||
| } | |||
| /* 第五行颜色 */ | |||
| .days-grid > .day-cell:nth-child(7n + 1):nth-child(n + 29), | |||
| .days-grid > .day-cell:nth-child(7n + 2):nth-child(n + 29), | |||
| .days-grid > .day-cell:nth-child(7n + 3):nth-child(n + 29), | |||
| .days-grid > .day-cell:nth-child(7n + 4):nth-child(n + 29), | |||
| .days-grid > .day-cell:nth-child(7n + 5):nth-child(n + 29), | |||
| .days-grid > .day-cell:nth-child(7n + 6):nth-child(n + 29), | |||
| .days-grid > .day-cell:nth-child(7n + 7):nth-child(n + 29) { | |||
| background-color: #172533; | |||
| } | |||
| /* 第六行颜色 */ | |||
| .days-grid > .day-cell:nth-child(7n + 1):nth-child(n + 36), | |||
| .days-grid > .day-cell:nth-child(7n + 2):nth-child(n + 36), | |||
| .days-grid > .day-cell:nth-child(7n + 3):nth-child(n + 36), | |||
| .days-grid > .day-cell:nth-child(7n + 4):nth-child(n + 36), | |||
| .days-grid > .day-cell:nth-child(7n + 5):nth-child(n + 36), | |||
| .days-grid > .day-cell:nth-child(7n + 6):nth-child(n + 36), | |||
| .days-grid > .day-cell:nth-child(7n + 7):nth-child(n + 36) { | |||
| background-color: #202f3e; | |||
| } | |||
| /* 去掉最右边的边框 */ | |||
| .day-cell:nth-child(7n) { | |||
| border-right: none; | |||
| } | |||
| /* 去掉最后一行的底部边框 */ | |||
| .days-grid > .day-cell:nth-last-child(-n + 7) { | |||
| border-bottom: none; | |||
| } | |||
| .day-cell.empty { | |||
| background-color: #101d29 !important; | |||
| border-right: 2px solid #40486a; | |||
| border-bottom: 2px solid #40486a; | |||
| } | |||
| .day-cell.today { | |||
| background-color: rgba(36, 115, 130, 0.2) !important; | |||
| border: 1px solid #247382; | |||
| z-index: 1; | |||
| } | |||
| /* 新增:日期+统计的头部容器,弹性布局左右分布 */ | |||
| .day-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: flex-start; | |||
| margin-bottom: 5px; | |||
| flex-shrink: 0; | |||
| } | |||
| .day-number { | |||
| font-size: 25px; | |||
| font-weight: bold; | |||
| color: #7984b1; | |||
| line-height: 1; | |||
| } | |||
| /* 新增:任务统计样式 - 小字体、对应颜色、横向排列 */ | |||
| .task-stats { | |||
| display: flex; | |||
| gap: 4px; | |||
| } | |||
| .stat-item { | |||
| font-size: 10px; /* 小字体 */ | |||
| padding: 1px 4px; | |||
| border-radius: 2px; | |||
| min-width: 16px; | |||
| text-align: center; | |||
| } | |||
| .tasks-list { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 3px; | |||
| flex: 1; | |||
| overflow-y: auto; | |||
| min-height: 0; | |||
| max-height: calc(100% - 30px); | |||
| } | |||
| /* 任务较多时的自适应高度 */ | |||
| .day-cell:has(.tasks-list) { | |||
| min-height: 120px; | |||
| } | |||
| .day-cell:has(.task-item:nth-child(n + 4)) { | |||
| min-height: 140px; | |||
| } | |||
| .day-cell:has(.task-item:nth-child(n + 6)) { | |||
| min-height: 160px; | |||
| } | |||
| .day-cell:has(.task-item:nth-child(n + 8)) { | |||
| min-height: 180px; | |||
| } | |||
| .task-item { | |||
| padding: 2px 4px; | |||
| border-radius: 3px; | |||
| font-size: 12px; | |||
| cursor: default; | |||
| transition: all 0.3s ease; | |||
| flex-shrink: 0; | |||
| width: 100%; | |||
| overflow: hidden; | |||
| position: relative; | |||
| } | |||
| .task-name { | |||
| display: block; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| width: 100%; | |||
| } | |||
| .status-executing { | |||
| background-color: #247382; | |||
| color: #ffffff; | |||
| } | |||
| .status-completed { | |||
| background-color: #296a5a; | |||
| color: #ffffff; | |||
| } | |||
| .status-not-executed { | |||
| background-color: #6d7d92; | |||
| color: #ffffff; | |||
| } | |||
| /* 工具提示样式 */ | |||
| .task-tooltip { | |||
| position: fixed; | |||
| z-index: 9999; | |||
| background-color: #202f3e; | |||
| border: 2px solid #40486a; | |||
| border-radius: 4px; | |||
| padding: 8px 12px; | |||
| font-size: 12px; | |||
| color: #ffffff; | |||
| max-width: 300px; | |||
| word-break: break-all; | |||
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); | |||
| pointer-events: none; | |||
| animation: fadeIn 0.2s ease; | |||
| } | |||
| @keyframes fadeIn { | |||
| from { | |||
| opacity: 0; | |||
| transform: translateY(-5px); | |||
| } | |||
| to { | |||
| opacity: 1; | |||
| transform: translateY(0); | |||
| } | |||
| } | |||
| /* 滚动条样式 */ | |||
| .tasks-list::-webkit-scrollbar { | |||
| width: 4px; | |||
| } | |||
| .tasks-list::-webkit-scrollbar-track { | |||
| background: transparent; | |||
| } | |||
| .tasks-list::-webkit-scrollbar-thumb { | |||
| background: #40486a; | |||
| border-radius: 2px; | |||
| } | |||
| .tasks-list::-webkit-scrollbar-thumb:hover { | |||
| background: #7984b1; | |||
| } | |||
| /* 覆盖Element UI的默认样式 */ | |||
| :deep(.el-date-editor--month) { | |||
| background-color: #101d29; | |||
| border: none; | |||
| border-radius: 4px; | |||
| } | |||
| :deep(.el-input__inner) { | |||
| background-color: #101d29; | |||
| color: #7984b1; | |||
| border: 2px solid #40486a; | |||
| } | |||
| :deep(.el-input__inner::placeholder) { | |||
| color: #6d7d92; | |||
| } | |||
| :deep(.el-date-picker__header-label) { | |||
| color: #7984b1; | |||
| } | |||
| :deep(.el-date-picker__month-btn) { | |||
| color: #7984b1; | |||
| } | |||
| :deep(.el-date-picker__month-btn:hover) { | |||
| color: #247382; | |||
| } | |||
| :deep(.el-picker-panel) { | |||
| background-color: #101d29; | |||
| border: 2px solid #40486a; | |||
| } | |||
| .card-date-editor { | |||
| background-color: #101d29; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,300 @@ | |||
| <template> | |||
| <div class="year-view"> | |||
| <div class="year-header"> | |||
| <h2>{{ currentYear }}年</h2> | |||
| <div class="year-controls"> | |||
| <el-date-picker | |||
| v-model="selectedYear" | |||
| style="float: right; width: 120px" | |||
| type="year" | |||
| size="mini" | |||
| placeholder="选择年" | |||
| @change="onYearChange" | |||
| > | |||
| </el-date-picker> | |||
| </div> | |||
| </div> | |||
| <div class="months-grid"> | |||
| <div class="month-card" v-for="month in 12" :key="month"> | |||
| <div class="month-title">{{ getChineseMonth(month) }}</div> | |||
| <!-- <div class="month-stats" v-if="hasData(month)"> --> | |||
| <div class="month-stats"> | |||
| <div class="stat-item"> | |||
| 正在执行: | |||
| <span class="status-executing">{{ | |||
| getMonthData(month).executing | |||
| }}</span> | |||
| </div> | |||
| <div class="stat-item"> | |||
| 已经完成: | |||
| <span class="status-completed">{{ | |||
| getMonthData(month).executed | |||
| }}</span> | |||
| </div> | |||
| <div class="stat-item"> | |||
| 未 执 行 : | |||
| <span class="status-not-executed">{{ | |||
| getMonthData(month).pending | |||
| }}</span> | |||
| </div> | |||
| </div> | |||
| <!-- <div class="month-stats empty" v-else> | |||
| <div class="stat-item">无数据</div> | |||
| </div> --> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { calenderYear } from "@/api/videosMonitor/index"; | |||
| export default { | |||
| name: "YearView", | |||
| props: { | |||
| year: { | |||
| type: Number, | |||
| required: true, | |||
| }, | |||
| }, | |||
| data() { | |||
| // 设置默认年份为当前年份 | |||
| const currentYear = new Date().getFullYear(); | |||
| return { | |||
| selectedYear: new Date(currentYear, 0, 1), | |||
| // 使用本地变量存储当前年份 | |||
| currentYear: currentYear, | |||
| chineseMonths: [ | |||
| "一月", | |||
| "二月", | |||
| "三月", | |||
| "四月", | |||
| "五月", | |||
| "六月", | |||
| "七月", | |||
| "八月", | |||
| "九月", | |||
| "十月", | |||
| "十一月", | |||
| "十二月", | |||
| ], | |||
| // 移除假数据,初始化空对象 | |||
| yearDataMap: {}, | |||
| loading: false, | |||
| }; | |||
| }, | |||
| watch: { | |||
| // 监听 prop 的变化,更新内部数据 | |||
| year(newYear) { | |||
| this.currentYear = newYear; | |||
| this.selectedYear = new Date(newYear, 0, 1); | |||
| // 年份变化时重新获取数据 | |||
| this.getYearData(); | |||
| }, | |||
| }, | |||
| mounted() { | |||
| // 组件挂载时获取数据 | |||
| this.getYearData(); | |||
| }, | |||
| methods: { | |||
| onYearChange() { | |||
| if (this.selectedYear) { | |||
| this.currentYear = this.selectedYear.getFullYear(); | |||
| // 通知父组件年份已更改 | |||
| this.$emit("year-changed", this.currentYear); | |||
| // 获取新年份的数据 | |||
| this.getYearData(); | |||
| } | |||
| }, | |||
| // 获取年份数据 | |||
| async getYearData() { | |||
| try { | |||
| this.loading = true; | |||
| const response = await calenderYear({ | |||
| year: this.currentYear, | |||
| }); | |||
| // 确保响应数据存在 | |||
| const data = response?.rows || response || []; | |||
| // 将接口数据转换为需要的格式 | |||
| const monthData = {}; | |||
| data.forEach(item => { | |||
| monthData[item.month] = { | |||
| executed: item.executed || 0, | |||
| executing: item.executing || 0, | |||
| pending: item.pending || 0, | |||
| total: item.total || 0 | |||
| }; | |||
| }); | |||
| // 更新数据映射 | |||
| this.yearDataMap = { | |||
| [this.currentYear]: monthData | |||
| }; | |||
| } catch (error) { | |||
| console.error('获取年份数据失败:', error); | |||
| // 出错时设置为空数据 | |||
| this.yearDataMap = { | |||
| [this.currentYear]: {} | |||
| }; | |||
| } finally { | |||
| this.loading = false; | |||
| } | |||
| }, | |||
| getMonthData(month) { | |||
| // 修复:确保总是返回一个包含默认值的对象,而不是null | |||
| const yearData = this.yearDataMap[this.currentYear]; | |||
| // 如果年份数据不存在,返回默认值 | |||
| if (!yearData) { | |||
| return { | |||
| executed: 0, | |||
| executing: 0, | |||
| pending: 0, | |||
| total: 0 | |||
| }; | |||
| } | |||
| // 如果月份数据不存在,返回默认值 | |||
| const monthStats = yearData[month]; | |||
| if (!monthStats) { | |||
| return { | |||
| executed: 0, | |||
| executing: 0, | |||
| pending: 0, | |||
| total: 0 | |||
| }; | |||
| } | |||
| // 返回实际数据 | |||
| return monthStats; | |||
| }, | |||
| hasData(month) { | |||
| const data = this.getMonthData(month); | |||
| return data.executed > 0 || data.executing > 0 || data.pending > 0 || data.total > 0; | |||
| }, | |||
| getChineseMonth(monthNumber) { | |||
| return this.chineseMonths[monthNumber - 1] || monthNumber + "月"; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| .year-view { | |||
| width: 100%; | |||
| } | |||
| .year-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 20px; | |||
| } | |||
| .year-header h2 { | |||
| margin: 0; | |||
| font-size: 20px; | |||
| color: #a1aad3; | |||
| } | |||
| .year-controls { | |||
| display: flex; | |||
| gap: 10px; | |||
| align-items: center; | |||
| } | |||
| .months-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(4, 1fr); | |||
| gap: 15px; | |||
| } | |||
| .month-card { | |||
| background-color: #172533; | |||
| padding: 50px 15px; | |||
| border-radius: 8px; | |||
| border: 2px solid #40486a; | |||
| transition: transform 0.3s ease; | |||
| } | |||
| .month-card:hover { | |||
| transform: translateY(-5px); | |||
| box-shadow: 0 5px 15px rgba(64, 72, 106, 0.3); | |||
| } | |||
| .month-title { | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| margin-bottom: 15px; | |||
| color: #a1aad3; | |||
| text-align: center; | |||
| } | |||
| .month-stats { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 8px; | |||
| } | |||
| .month-stats.empty { | |||
| text-align: center; | |||
| padding: 20px 0; | |||
| } | |||
| .stat-item { | |||
| font-size: 14px; | |||
| color: #6b7491; | |||
| } | |||
| .status-executing { | |||
| color: #fff; | |||
| font-weight: bold; | |||
| } | |||
| .status-completed { | |||
| color: #fff; | |||
| font-weight: bold; | |||
| } | |||
| .status-not-executed { | |||
| color: #fff; | |||
| font-weight: bold; | |||
| } | |||
| /* 覆盖Element UI的默认样式 */ | |||
| :deep(.el-date-editor--year) { | |||
| background-color: #172533; | |||
| border: 2px solid #40486a; | |||
| border-radius: 4px; | |||
| } | |||
| :deep(.el-input__inner) { | |||
| background-color: #172533; | |||
| color: #a1aad3; | |||
| border: 2px solid #40486a; | |||
| } | |||
| :deep(.el-input__inner::placeholder) { | |||
| color: #6b7491; | |||
| } | |||
| :deep(.el-date-picker__header-label) { | |||
| color: #a1aad3; | |||
| } | |||
| :deep(.el-date-picker__year-btn) { | |||
| color: #a1aad3; | |||
| } | |||
| :deep(.el-date-picker__year-btn:hover) { | |||
| color: #1a53ff; | |||
| } | |||
| :deep(.el-picker-panel) { | |||
| background-color: #172533; | |||
| border: 2px solid #40486a; | |||
| } | |||
| </style> | |||