|
|
@@ -0,0 +1,523 @@
|
|
|
+,<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <el-form :model="queryParams" ref="queryRef" :inline="true" label-width="68px">
|
|
|
+ <el-form-item label="年份" prop="year">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="queryParams.year"
|
|
|
+ type="year"
|
|
|
+ value-format="YYYY"
|
|
|
+ placeholder="请选择年份"
|
|
|
+ @change="handleQuery">
|
|
|
+ </el-date-picker>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="最小天数" prop="minDays">
|
|
|
+ <el-input-number v-model="queryParams.minDays" :min="15" :step="5" placeholder="最小天数" @change="handleQuery" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <!-- 图表和统计卡片并排布局 -->
|
|
|
+ <el-row :gutter="10" style="margin-bottom: 20px;">
|
|
|
+ <div style="height: 400px; width: 49%; margin-right: 1%;">
|
|
|
+ <div class="table_caption" style="height: 30px; line-height: 30px;">境外滞留天数分布</div>
|
|
|
+ <div style="height: 370px; width: 100%; border: 1px solid #ededed; background: #fff;" ref="pieChartContainer">
|
|
|
+ <div id="pieChart" style="height: 100%; width: 100%;"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="width: 49%; margin-left: 1;">
|
|
|
+ <div class="table_caption" style="height: 30px; line-height: 30px;">统计信息</div>
|
|
|
+ <div style="height: 370px; width: 100%; border: 1px solid #ededed; background: #fff; padding: 20px;">
|
|
|
+ <!-- 统计数据卡片,3行均匀分布 -->
|
|
|
+ <el-row :gutter="20" class="mb20" style="height: 100%;">
|
|
|
+ <el-col :span="24" style="margin-bottom: 10px; height: calc(33.33% - 7px);">
|
|
|
+ <el-card class="stat-card total-people" style="height: 100%;">
|
|
|
+ <div class="stat-item" style="height: 100%; display: flex; flex-direction: column; justify-content: center;">
|
|
|
+ <div class="stat-number">{{ statistics.totalPeople }}</div>
|
|
|
+ <div class="stat-label">境外滞留总人数</div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="24" style="margin-bottom: 10px; height: calc(33.33% - 7px);">
|
|
|
+ <el-card class="stat-card avg-frequency" style="height: 100%;">
|
|
|
+ <div class="stat-item" style="height: 100%; display: flex; flex-direction: column; justify-content: center;">
|
|
|
+ <div class="stat-number">{{ statistics.avgFrequency }}</div>
|
|
|
+ <div class="stat-label">平均滞留天数</div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="24" style="height: calc(33.33% - 7px);">
|
|
|
+ <el-card class="stat-card max-frequency" style="height: 100%;">
|
|
|
+ <div class="stat-item" style="height: 100%; display: flex; flex-direction: column; justify-content: center;">
|
|
|
+ <div class="stat-number">{{ statistics.maxFrequency }}</div>
|
|
|
+ <div class="stat-label">最长滞留天数</div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-table v-loading="loading" :data="list" style="margin-top: 20px;" @row-click="handleRowClick">
|
|
|
+ <el-table-column label="序号" type="index" width="50" align="center" />
|
|
|
+ <el-table-column label="姓名" prop="fullName" align="center" />
|
|
|
+ <el-table-column label="证件号码" prop="idNumber" align="center" />
|
|
|
+ <el-table-column label="滞留天数" prop="absenceDays" align="center" />
|
|
|
+ <el-table-column label="出境时间" prop="departureTime" align="center" width="180" />
|
|
|
+ <el-table-column label="入境时间" prop="returnTime" align="center" width="180" />
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <!-- 使用公共组件 -->
|
|
|
+ <RecordListDetailDialog
|
|
|
+ :visible="recordListOpen"
|
|
|
+ :person-info="selectedPersonInfo"
|
|
|
+ :date-range="getDateRangeForQuery()"
|
|
|
+ @update:visible="recordListOpen = $event"
|
|
|
+ @close="recordListOpen = false"
|
|
|
+ />
|
|
|
+
|
|
|
+ <pagination
|
|
|
+ v-show="total>0"
|
|
|
+ :total="total"
|
|
|
+ v-model:page="queryParams.pageNum"
|
|
|
+ v-model:limit="queryParams.pageSize"
|
|
|
+ @pagination="handlePagination"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup name="Overstay">
|
|
|
+import * as echarts from 'echarts'
|
|
|
+import { listHighFrequencyInOut, getInOutStats, getRecords } from "@/api/biz/anal";
|
|
|
+import { getOverseasStayDetails, getOverseasStayStats } from "@/api/biz/anal";
|
|
|
+import RecordListDetailDialog from '@/components/RecordListDetailDialog.vue';
|
|
|
+
|
|
|
+const { proxy } = getCurrentInstance();
|
|
|
+
|
|
|
+const list = ref([]);
|
|
|
+const loading = ref(false);
|
|
|
+
|
|
|
+const total = ref(0);
|
|
|
+
|
|
|
+// 添加饼图容器引用
|
|
|
+const pieChartContainer = ref(null);
|
|
|
+
|
|
|
+const statistics = ref({
|
|
|
+ totalPeople: 0,
|
|
|
+ avgFrequency: 0,
|
|
|
+ maxFrequency: 0,
|
|
|
+ minFrequency: 0,
|
|
|
+ totalEntries: 0
|
|
|
+});
|
|
|
+
|
|
|
+// 出入境记录明细相关
|
|
|
+const recordDetailOpen = ref(false);
|
|
|
+const recordDetailLoading = ref(false);
|
|
|
+const recordDetailList = ref([]);
|
|
|
+const detailTitle = ref("详细信息");
|
|
|
+const detailForm = ref({});
|
|
|
+
|
|
|
+// 出入境记录列表相关
|
|
|
+const recordListOpen = ref(false);
|
|
|
+const recordListLoading = ref(false);
|
|
|
+const recordListTitle = ref("出入境记录列表");
|
|
|
+const recordList = ref([]);
|
|
|
+const selectedPersonInfo = ref({});
|
|
|
+
|
|
|
+const data = reactive({
|
|
|
+ queryParams: {
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ year: new Date().getFullYear().toString(),
|
|
|
+ minDays: 15 // 默认最小天数为15天
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+const { queryParams } = toRefs(data);
|
|
|
+
|
|
|
+/** 查询境外滞留列表 */
|
|
|
+function getList() {
|
|
|
+ loading.value = true;
|
|
|
+
|
|
|
+ // 准备查询参数
|
|
|
+ const params = {};
|
|
|
+ params.year = queryParams.value.year;
|
|
|
+ // 添加最小天数参数
|
|
|
+ params.minDays = queryParams.value.minDays;
|
|
|
+
|
|
|
+ // 同时调用列表接口和统计接口
|
|
|
+ Promise.all([
|
|
|
+ getOverseasStayDetails(params),
|
|
|
+ getOverseasStayStats(params)
|
|
|
+ ]).then(responses => {
|
|
|
+ // 处理列表数据
|
|
|
+ const listResponse = responses[0];
|
|
|
+ list.value = listResponse.rows || [];
|
|
|
+ total.value = listResponse.total || 0;
|
|
|
+
|
|
|
+ // 处理统计信息
|
|
|
+ const statsResponse = responses[1];
|
|
|
+ if (statsResponse.code === 200 && statsResponse.data) {
|
|
|
+ const stats = statsResponse.data;
|
|
|
+ statistics.value = {
|
|
|
+ totalPeople: stats.totalPeople || 0,
|
|
|
+ avgFrequency: Number(stats.avgStayDays).toFixed(2) || 0,
|
|
|
+ maxFrequency: stats.maxStayDays || 0,
|
|
|
+ minFrequency: stats.minStayDays || 0,
|
|
|
+ totalEntries: stats.totalCount || 0
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染分布图
|
|
|
+ renderPieChart(stats.stayDaysDistribution || {});
|
|
|
+ } else {
|
|
|
+ // 如果新接口调用失败,使用原有方式计算统计数据
|
|
|
+ calculateStatistics();
|
|
|
+
|
|
|
+ // 渲染分布图(使用前端计算方式)
|
|
|
+ renderPieChart({});
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = false;
|
|
|
+ }).catch(() => {
|
|
|
+ loading.value = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/** 处理分页事件 */
|
|
|
+function handlePagination() {
|
|
|
+ // 分页只更新表格数据,不重新获取统计和图表数据
|
|
|
+ loading.value = true;
|
|
|
+
|
|
|
+ // 准备查询参数(仅用于表格数据)
|
|
|
+ const params = {};
|
|
|
+ params.year = queryParams.value.year;
|
|
|
+
|
|
|
+ // 添加分页参数
|
|
|
+ params.pageNum = queryParams.value.pageNum;
|
|
|
+ params.pageSize = queryParams.value.pageSize;
|
|
|
+ params.minDays = queryParams.value.minDays;
|
|
|
+
|
|
|
+ getOverseasStayDetails(params).then(response => {
|
|
|
+ list.value = response.rows || [];
|
|
|
+ total.value = response.total || 0;
|
|
|
+
|
|
|
+ loading.value = false;
|
|
|
+ }).catch(() => {
|
|
|
+ loading.value = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/** 计算统计数据 */
|
|
|
+function calculateStatistics() {
|
|
|
+ if (!list.value || list.value.length === 0) {
|
|
|
+ statistics.value = {
|
|
|
+ totalPeople: 0,
|
|
|
+ avgFrequency: 0,
|
|
|
+ maxFrequency: 0,
|
|
|
+ minFrequency: 0,
|
|
|
+ totalEntries: 0
|
|
|
+ };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let totalStayDays = 0;
|
|
|
+ let maxStayDays = 0;
|
|
|
+ let minStayDays = Infinity;
|
|
|
+
|
|
|
+ list.value.forEach(item => {
|
|
|
+ const stayDays = item.absenceDays || 0;
|
|
|
+ totalStayDays += stayDays;
|
|
|
+ if (stayDays > maxStayDays) {
|
|
|
+ maxStayDays = stayDays;
|
|
|
+ }
|
|
|
+ if (stayDays < minStayDays) {
|
|
|
+ minStayDays = stayDays;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ statistics.value = {
|
|
|
+ totalPeople: list.value.length,
|
|
|
+ avgFrequency: list.value.length > 0 ? (totalStayDays / list.value.length).toFixed(2) : 0,
|
|
|
+ maxFrequency: maxStayDays,
|
|
|
+ minFrequency: minStayDays === Infinity ? 0 : minStayDays,
|
|
|
+ totalEntries: totalStayDays
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/** 渲染饼图 */
|
|
|
+function renderPieChart(stayDaysDistribution = {}) {
|
|
|
+ if (!pieChartContainer.value) return;
|
|
|
+
|
|
|
+ // 销毁现有图表实例(如果存在)
|
|
|
+ const chartDom = pieChartContainer.value.querySelector('#pieChart');
|
|
|
+ if (chartDom && chartDom.__echarts_instance__) {
|
|
|
+ chartDom.__echarts_instance__.dispose();
|
|
|
+ }
|
|
|
+
|
|
|
+ const myChart = echarts.init(chartDom);
|
|
|
+
|
|
|
+ // 将分布数据转换为饼图所需格式
|
|
|
+ let distributionData = [];
|
|
|
+ if (Object.keys(stayDaysDistribution).length > 0) {
|
|
|
+ // 定义合理的排序顺序(按照天数递增)
|
|
|
+ const rangeOrder = ['0-15天', '15-30天', '30-60天', '60-90天', '90-120天', '120天以上'];
|
|
|
+
|
|
|
+ // 标记是否已经遇到非零值
|
|
|
+ let foundNonZero = false;
|
|
|
+
|
|
|
+ // 按照预定顺序处理数据
|
|
|
+ for (const range of rangeOrder) {
|
|
|
+ if (stayDaysDistribution.hasOwnProperty(range)) {
|
|
|
+ if (stayDaysDistribution[range] > 0) {
|
|
|
+ foundNonZero = true;
|
|
|
+ let color;
|
|
|
+ if (range.includes('0-15')) color = '#5470c6';
|
|
|
+ else if (range.includes('15-30')) color = '#91cc75';
|
|
|
+ else if (range.includes('30-60')) color = '#fac858';
|
|
|
+ else if (range.includes('60-90')) color = '#ee6666';
|
|
|
+ else if (range.includes('90')) color = '#73c0de'; // 90天以上
|
|
|
+ else color = '#9da4b0'; // 默认颜色
|
|
|
+
|
|
|
+ distributionData.push({
|
|
|
+ value: stayDaysDistribution[range],
|
|
|
+ name: range,
|
|
|
+ itemStyle: { color: color }
|
|
|
+ });
|
|
|
+ } else if (foundNonZero) {
|
|
|
+ // 如果已经遇到非零值,但当前值为0,则仍保留(中间的0值)
|
|
|
+ let color;
|
|
|
+ if (range.includes('0-15')) color = '#5470c6';
|
|
|
+ else if (range.includes('15-30')) color = '#91cc75';
|
|
|
+ else if (range.includes('30-60')) color = '#fac858';
|
|
|
+ else if (range.includes('60-90')) color = '#ee6666';
|
|
|
+ else if (range.includes('90')) color = '#73c0de'; // 90天以上
|
|
|
+ else color = '#9da4b0'; // 默认颜色
|
|
|
+
|
|
|
+ distributionData.push({
|
|
|
+ value: stayDaysDistribution[range],
|
|
|
+ name: range,
|
|
|
+ itemStyle: { color: color }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ // 如果是前面的0值,则跳过不添加
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理不在预定义区间中的其他区间
|
|
|
+ for (const [range, count] of Object.entries(stayDaysDistribution)) {
|
|
|
+ if (!rangeOrder.includes(range)) {
|
|
|
+ // 根据区间范围选择颜色
|
|
|
+ let color;
|
|
|
+ if (range.includes('0-15')) color = '#5470c6';
|
|
|
+ else if (range.includes('15-30')) color = '#91cc75';
|
|
|
+ else if (range.includes('30-60')) color = '#fac858';
|
|
|
+ else if (range.includes('60-90')) color = '#ee6666';
|
|
|
+ else if (range.includes('90')) color = '#73c0de'; // 90天以上
|
|
|
+ else color = '#9da4b0'; // 默认颜色
|
|
|
+
|
|
|
+ distributionData.push({
|
|
|
+ value: count,
|
|
|
+ name: range,
|
|
|
+ itemStyle: { color: color }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 如果没有分布数据,使用默认值或从前端数据计算
|
|
|
+ // 这里可以根据实际数据进行计算,但为了简单起见,我们显示一个默认值
|
|
|
+ distributionData = [
|
|
|
+ { value: statistics.value.totalPeople, name: '滞留分布', itemStyle: { color: '#5470c6' } }
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: '{b}: {c}人 ({d}%)'
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ orient: 'vertical',
|
|
|
+ left: 'right'
|
|
|
+ },
|
|
|
+ series: [{
|
|
|
+ name: '滞留天数分布',
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['40%', '70%'], // 设置为环形图
|
|
|
+ avoidLabelOverlap: false,
|
|
|
+ itemStyle: {
|
|
|
+ borderRadius: 10,
|
|
|
+ borderColor: '#fff',
|
|
|
+ borderWidth: 2
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ formatter: '{b}: {c}人'
|
|
|
+ },
|
|
|
+ emphasis: {
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ fontSize: '16',
|
|
|
+ fontWeight: 'bold'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data: distributionData
|
|
|
+ }]
|
|
|
+ };
|
|
|
+
|
|
|
+ myChart.setOption(option);
|
|
|
+}
|
|
|
+
|
|
|
+/** 搜索按钮操作 */
|
|
|
+function handleQuery() {
|
|
|
+ queryParams.value.pageNum = 1;
|
|
|
+ getList();
|
|
|
+}
|
|
|
+
|
|
|
+/** 重置按钮操作 */
|
|
|
+function resetQuery() {
|
|
|
+ proxy.resetForm("queryRef");
|
|
|
+ queryParams.value.year = new Date().getFullYear().toString();
|
|
|
+ queryParams.value.minDays = 15;
|
|
|
+ handleQuery();
|
|
|
+}
|
|
|
+
|
|
|
+/** 处理表格行点击事件 */
|
|
|
+function handleRowClick(row) {
|
|
|
+ // 设置选中人员信息
|
|
|
+ selectedPersonInfo.value = { ...row };
|
|
|
+ // 打开记录列表对话框
|
|
|
+ recordListOpen.value = true;
|
|
|
+}
|
|
|
+
|
|
|
+/** 获取用于查询的日期范围 */
|
|
|
+function getDateRangeForQuery() {
|
|
|
+ // 按年查询,返回该年的开始和结束日期
|
|
|
+ const year = queryParams.value.year;
|
|
|
+ return [`${year}-01-01`, `${year}-12-31`];
|
|
|
+}
|
|
|
+
|
|
|
+// 页面加载时获取数据
|
|
|
+onMounted(() => {
|
|
|
+ // 初始化时直接查询,使用reactive中设置的默认值
|
|
|
+ handleQuery();
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* 统计卡片样式 */
|
|
|
+.stat-card {
|
|
|
+ text-align: center;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card:hover {
|
|
|
+ transform: translateY(-3px);
|
|
|
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+/* 不同统计卡片的颜色 */
|
|
|
+.stat-card.total-people {
|
|
|
+ background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card.total-entries {
|
|
|
+ background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card.avg-frequency {
|
|
|
+ background: linear-gradient(135deg, #fffbe6 0%, #fff5a0 100%);
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card.max-frequency {
|
|
|
+ background: linear-gradient(135deg, #fff2f0 0%, #ffccc7 100%);
|
|
|
+}
|
|
|
+
|
|
|
+.stat-item {
|
|
|
+ padding: 15px 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-number {
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: bold;
|
|
|
+ margin-bottom: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 不同统计数字的颜色 */
|
|
|
+.stat-number {
|
|
|
+ color: #1890ff;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card:nth-child(2) .stat-number {
|
|
|
+ color: #52c41a;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card:nth-child(3) .stat-number {
|
|
|
+ color: #faad14;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card:nth-child(4) .stat-number {
|
|
|
+ color: #f5222d;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+/* 新增统计信息项样式 */
|
|
|
+.stat-info-item {
|
|
|
+ margin: 10px 0;
|
|
|
+ padding: 8px 0;
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-info-label {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #606266;
|
|
|
+ margin-bottom: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-info-value {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-container {
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+#chart, #pieChart {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.table_caption {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ text-align: center;
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.app-container {
|
|
|
+ padding: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.el-row {
|
|
|
+ margin-bottom: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.el-table {
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.el-card {
|
|
|
+ border-radius: 8px;
|
|
|
+ border: none;
|
|
|
+}
|
|
|
+</style>
|