| | |
| | | "echarts": "^5.6.0", |
| | | "element-ui": "^2.15.14", |
| | | "regenerator-runtime": "^0.14.1", |
| | | "sass": "^1.86.0", |
| | | "sass-loader": "^16.0.5", |
| | | "vue": "^2.7.7", |
| | | "vue-router": "^3.5.4" |
| | | "vue-router": "^3.5.4", |
| | | "vuex": "^4.1.0" |
| | | }, |
| | | "devDependencies": { |
| | | "@vitejs/plugin-legacy": "^2.3.1", |
| | |
| | | <template> |
| | | <div id="app"> |
| | | <div class="screen-adapter"> |
| | | <router-view /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script> |
| | | export default { |
| | | name: 'App', |
| | | mounted() { |
| | | this.handleScreenAdapter() |
| | | window.addEventListener('resize', this.handleScreenAdapter) |
| | | }, |
| | | beforeDestroy() { |
| | | window.removeEventListener('resize', this.handleScreenAdapter) |
| | | }, |
| | | methods: { |
| | | handleScreenAdapter() { |
| | | const designWidth = 1920 |
| | | const designHeight = 1080 |
| | | const scale = Math.min( |
| | | window.innerWidth / designWidth, |
| | | window.innerHeight / designHeight |
| | | ) |
| | | const screenAdapter = document.querySelector('.screen-adapter') |
| | | screenAdapter.style.transform = `scale(${scale})` |
| | | screenAdapter.style.transformOrigin = 'left top' |
| | | screenAdapter.style.width = `${window.innerWidth / scale}px` |
| | | screenAdapter.style.height = `${window.innerHeight / scale}px` |
| | | } |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <style> |
| | | * { |
| | |
| | | width: 100%; |
| | | height: 100%; |
| | | overflow: hidden; |
| | | margin: 0 !important; |
| | | padding: 0 !important; |
| | | } |
| | | |
| | | .screen-adapter { |
| | | transform-origin: left top; |
| | | } |
| | | </style> |
| | |
| | | <template> |
| | | <div class="bottom-charts"> |
| | | <div class="chart-container"> |
| | | <div class="chart-group"> |
| | | <div class="chart-title">收入趋势</div> |
| | | <div class="chart-title">租金收入趋势图</div> |
| | | <div class="income-chart" ref="incomeChart"></div> |
| | | </div> |
| | | <div class="chart-group"> |
| | | <div class="chart-title">租金收入趋势图</div> |
| | | <div class="chart-title">租户数量趋势图</div> |
| | | <div class="trend-chart" ref="rentTrendChart"></div> |
| | | </div> |
| | | </div> |
| | | <div class="map-container"> |
| | | <div class="map-marker-item" @click="handleMapMarkerClick('已出租')" :class="{ active: activeStatus === '已出租' }"> |
| | | <div class="map-marker-item-icon"></div> |
| | | <div class="map-marker-item-text">已出租</div> |
| | | </div> |
| | | <div class="map-marker-item" @click="handleMapMarkerClick('待出租')" :class="{ active: activeStatus === '待出租' }"> |
| | | <div class="map-marker-item-icon"></div> |
| | | <div class="map-marker-item-text">待出租</div> |
| | | </div> |
| | | <div class="map-marker-item" @click="handleMapMarkerClick('维修中')" :class="{ active: activeStatus === '维修中' }"> |
| | | <div class="map-marker-item-icon"></div> |
| | | <div class="map-marker-item-text">维修中</div> |
| | | </div> |
| | | <div class="map-marker-item" @click="handleMapMarkerClick('欠费')" :class="{ active: activeStatus === '欠费' }"> |
| | | <div class="map-marker-item-icon"></div> |
| | | <div class="map-marker-item-text">欠费</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | |
| | | export default { |
| | | name: 'BottomCharts', |
| | | props: { |
| | | rentIncomeTrend: { |
| | | type: Array, |
| | | default: () => [] |
| | | }, |
| | | tenantCountTrend: { |
| | | type: Array, |
| | | default: () => [] |
| | | } |
| | | }, |
| | | data() { |
| | | return { |
| | | charts: { |
| | | income: null, |
| | | rentTrend: null |
| | | } |
| | | }, |
| | | activeStatus: null |
| | | } |
| | | }, |
| | | mounted() { |
| | |
| | | }) |
| | | }, |
| | | methods: { |
| | | handleMapMarkerClick(status) { |
| | | if (this.activeStatus === status) { |
| | | this.activeStatus = null |
| | | this.$store.commit('SET_MAP_MARKER_STATUS', 'all') |
| | | } else { |
| | | this.activeStatus = status |
| | | this.$store.commit('SET_MAP_MARKER_STATUS', status) |
| | | } |
| | | }, |
| | | initCharts() { |
| | | // 收入趋势图表 |
| | | // 租金收入趋势图表 |
| | | if (Object.keys(this.rentIncomeTrend).length > 0) { |
| | | this.charts.income = echarts.init(this.$refs.incomeChart) |
| | | this.charts.income.setOption({ |
| | | grid: { |
| | | top: '15%', |
| | | left: '3%', |
| | | right: '3%', |
| | | bottom: '3%', |
| | | left: '5%', |
| | | right: '5%', |
| | | bottom: '5%', |
| | | containLabel: true |
| | | }, |
| | | tooltip: { |
| | | trigger: 'axis' |
| | | trigger: 'axis', |
| | | confine: true |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: ['23-4月', '23-12月', '24-3月', '24-4月', '24-5月', '24-6月', '25-3月'], |
| | | data: this.rentIncomeTrend.quarters, |
| | | axisLine: { |
| | | lineStyle: { |
| | | color: '#fff' |
| | | } |
| | | }, |
| | | axisLabel: { |
| | | color: '#fff' |
| | | color: '#fff', |
| | | interval: 0, |
| | | rotate: 30 |
| | | } |
| | | }, |
| | | yAxis: { |
| | |
| | | } |
| | | }, |
| | | series: [{ |
| | | data: [100, 400, 200, 300, 200, 400, 300], |
| | | data: this.rentIncomeTrend.incomeData, |
| | | type: 'line', |
| | | smooth: true, |
| | | symbol: 'circle', |
| | |
| | | } |
| | | }] |
| | | }) |
| | | |
| | | // 租金趋势图表 |
| | | } |
| | | // 租户数量趋势图表 |
| | | if (this.tenantCountTrend.length > 0) { |
| | | this.charts.rentTrend = echarts.init(this.$refs.rentTrendChart) |
| | | this.charts.rentTrend.setOption({ |
| | | grid: { |
| | | top: '15%', |
| | | left: '3%', |
| | | right: '3%', |
| | | bottom: '3%', |
| | | left: '5%', |
| | | right: '5%', |
| | | bottom: '5%', |
| | | containLabel: true |
| | | }, |
| | | tooltip: { |
| | | trigger: 'axis' |
| | | trigger: 'axis', |
| | | confine: true |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: ['23-4月', '23-12月', '24-3月', '24-4月', '24-5月', '24-6月', '25-3月'], |
| | | data: this.tenantCountTrend.map(item => item.date), |
| | | axisLine: { |
| | | lineStyle: { |
| | | color: '#fff' |
| | | } |
| | | }, |
| | | axisLabel: { |
| | | color: '#fff' |
| | | color: '#fff', |
| | | interval: 0, |
| | | rotate: 30 |
| | | } |
| | | }, |
| | | yAxis: { |
| | |
| | | } |
| | | }, |
| | | series: [{ |
| | | data: [300, 200, 400, 300, 500, 400, 300], |
| | | data: this.tenantCountTrend.map(item => item.count), |
| | | type: 'line', |
| | | smooth: true, |
| | | symbol: 'circle', |
| | | symbolSize: 8, |
| | | lineStyle: { |
| | | color: '#4facfe', |
| | | color: '#87F7C7', |
| | | width: 2 |
| | | }, |
| | | itemStyle: { |
| | | color: '#4facfe' |
| | | color: '#87F7C7' |
| | | }, |
| | | areaStyle: { |
| | | color: { |
| | |
| | | y2: 1, |
| | | colorStops: [{ |
| | | offset: 0, |
| | | color: 'rgba(79,172,254,0.3)' |
| | | color: 'rgba(135,247,195,0.3)' |
| | | }, { |
| | | offset: 1, |
| | | color: 'rgba(79,172,254,0.1)' |
| | | color: 'rgba(135,247,195,0.1)' |
| | | }] |
| | | } |
| | | } |
| | | }] |
| | | }) |
| | | |
| | | } |
| | | window.addEventListener('resize', () => { |
| | | Object.values(this.charts).forEach(chart => { |
| | | chart && chart.resize() |
| | | }) |
| | | }) |
| | | } |
| | | }, |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | <style scoped lang="scss"> |
| | | .bottom-charts { |
| | | height: 200px; |
| | | width: 700px; |
| | | position: fixed; |
| | | width: 100%; |
| | | position: absolute; |
| | | bottom: 0; |
| | | left: 0; |
| | | z-index: 1003; |
| | | display: flex; |
| | | gap: 10px; |
| | | justify-content: space-between; |
| | | padding: 0 20px; |
| | | pointer-events: none; |
| | | } |
| | | |
| | | .chart-container { |
| | | display: flex; |
| | | gap: 20px; |
| | | flex-shrink: 0; |
| | | width: 800px; |
| | | pointer-events: auto; |
| | | } |
| | | |
| | | .chart-group { |
| | | flex: 1; |
| | | width: 390px; |
| | | background: rgba(255, 255, 255, 0.05); |
| | | border-radius: 8px; |
| | | padding: 10px; |
| | |
| | | .income-chart, |
| | | .trend-chart { |
| | | height: calc(100% - 30px); |
| | | width: 100%; |
| | | } |
| | | |
| | | .map-container { |
| | | color: #fff; |
| | | display: flex; |
| | | align-items: end; |
| | | gap: 20px; |
| | | margin-bottom: 35px; |
| | | margin-right: 35px; |
| | | pointer-events: auto; |
| | | } |
| | | |
| | | .map-marker-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | cursor: pointer; |
| | | |
| | | .map-marker-item-icon { |
| | | width: 20px; |
| | | height: 20px; |
| | | border-radius: 50%; |
| | | position: relative; |
| | | |
| | | &::before, |
| | | &::after { |
| | | content: ''; |
| | | position: absolute; |
| | | top: 50%; |
| | | left: 50%; |
| | | transform: translate(-50%, -50%); |
| | | width: 100%; |
| | | height: 100%; |
| | | border-radius: 50%; |
| | | opacity: 0; |
| | | } |
| | | } |
| | | |
| | | &.active { |
| | | .map-marker-item-icon { |
| | | &::before, |
| | | &::after { |
| | | animation: ripple 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; |
| | | } |
| | | |
| | | &::after { |
| | | animation-delay: 0.75s; |
| | | } |
| | | } |
| | | } |
| | | |
| | | &:nth-child(1) { |
| | | .map-marker-item-icon { |
| | | background-color: #4fd9ff; |
| | | &::before, |
| | | &::after { |
| | | border: 2px solid #4fd9ff; |
| | | } |
| | | } |
| | | } |
| | | |
| | | &:nth-child(2) { |
| | | .map-marker-item-icon { |
| | | background-color: #faad14; |
| | | &::before, |
| | | &::after { |
| | | border: 2px solid #faad14; |
| | | } |
| | | } |
| | | } |
| | | |
| | | &:nth-child(3) { |
| | | .map-marker-item-icon { |
| | | background-color: #ff4d4f; |
| | | &::before, |
| | | &::after { |
| | | border: 2px solid #ff4d4f; |
| | | } |
| | | } |
| | | } |
| | | |
| | | &:nth-child(4) { |
| | | .map-marker-item-icon { |
| | | background-color: #39c5bb; |
| | | &::before, |
| | | &::after { |
| | | border: 2px solid #39c5bb; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | @keyframes ripple { |
| | | 0% { |
| | | transform: translate(-50%, -50%) scale(1); |
| | | opacity: 0.8; |
| | | } |
| | | 100% { |
| | | transform: translate(-50%, -50%) scale(2.5); |
| | | opacity: 0; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <span class="time">{{ currentTime }}</span> |
| | | <span class="date">{{ currentDate }}</span> |
| | | <span class="weather"> |
| | | <i class="weather-icon"></i> |
| | | {{ temperature }}°C |
| | | <div class="weather-box"></div> |
| | | <iframe allowtransparency="true" frameborder="0" width="100" height="36" scrolling="no" |
| | | src="//tianqi.2345.com/plugin/widget/index.htm?s=3&z=3&t=1&v=0&d=3&bd=0&k=&f=ffffff<f=ffffff&htf=ffffff&q=1&e=1&a=1&c=55591&w=100&h=36&align=left"></iframe> |
| | | </span> |
| | | </div> |
| | | </div> |
| | |
| | | .header { |
| | | height: 120px; |
| | | display: flex; |
| | | position: fixed; |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100vw; |
| | | width: 100%; |
| | | background: linear-gradient(to bottom, rgba(1, 85, 121, 0.8) 0%, rgba(1, 85, 121, 0.4) 40%, rgba(151, 190, 192, 0) 100%); |
| | | z-index: 1000; |
| | | justify-content: flex-end; |
| | | align-items: center; |
| | | padding: 10px 20px; |
| | | backdrop-filter: blur(10px); |
| | | pointer-events: none; |
| | | } |
| | | |
| | | .time-weather { |
| | | color: #fff; |
| | | display: flex; |
| | | gap: 20px; |
| | | align-items: center; |
| | | pointer-events: auto; |
| | | } |
| | | |
| | | .time { |
| | |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .date { |
| | | color: #fff; |
| | | .weather { |
| | | position: relative; |
| | | } |
| | | |
| | | .weather { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 5px; |
| | | .weather-box { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 100px; |
| | | height: 36px; |
| | | z-index: 1000; |
| | | } |
| | | </style> |
| | |
| | | <div class="stat-item"> |
| | | <div class="stat-info"> |
| | | <div class="stat-label"> <i class="icon-area"></i> 房屋总面积</div> |
| | | <div class="stat-value">{{ totalArea }}<span class="unit">m²</span></div> |
| | | <div class="stat-value">{{ staticsData.houseTotalArea }}<span class="unit">m²</span></div> |
| | | </div> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <div class="stat-info"> |
| | | <div class="stat-label"> <i class="icon-area"></i>已出租面积</div> |
| | | <div class="stat-value">{{ rentedArea }}<span class="unit">m²</span></div> |
| | | <div class="stat-value">{{ staticsData.houseRentedArea }}<span class="unit">m²</span></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | <div class="stat-group"> |
| | | <div class="stat-item mt-10"> |
| | | <div class="stat-info"> |
| | | <div class="stat-label"> <i class="icon-money"></i>今日已收租金</div> |
| | | <div class="stat-value">{{ todayRent }}<span class="unit">万元</span></div> |
| | | <div class="stat-label"> <i class="icon-money"></i>总计应收租金</div> |
| | | <div class="stat-value">{{ staticsData.totalReceivableRent }}<span class="unit">万元</span></div> |
| | | </div> |
| | | </div> |
| | | <div class="stat-item mt-10"> |
| | | <div class="stat-info"> |
| | | <div class="stat-label"><i class="icon-money"></i>今日已收租金</div> |
| | | <div class="stat-value">{{ todayIncome }}<span class="unit">万元</span></div> |
| | | <div class="stat-label"><i class="icon-money"></i>总计已收租金</div> |
| | | <div class="stat-value">{{ staticsData.totalReceivedRent }}<span class="unit">万元</span></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="chart-card" style="width: 350px;"> |
| | | <div class="chart-title">区域租金分析</div> |
| | | <div class="area-chart" ref="areaRentChart"></div> |
| | | <div class="chart-title">区域租金排名</div> |
| | | <div class="chart-unit">单位(万元)</div> |
| | | <div class="rent-rank-list" ref="scrollContainer" @mouseenter="stopScroll" @mouseleave="startScroll"> |
| | | <div class="rank-list-content" ref="scrollContent"> |
| | | <div class="rank-list-group"> |
| | | <div v-for="(item, index) in displayRank" :key="index" class="rent-rank-item"> |
| | | <div class="rank-name">{{ item.streetName }}</div> |
| | | <div class="rank-progress"> |
| | | <div class="rank-bar"> |
| | | <div class="rank-bar-inner" :style="{ |
| | | width: (item.rentAmount / Math.max(...rentRank.map(i => i.rentAmount)) * 100) + '%', |
| | | background: getBarColor(item.rentAmount) |
| | | }"></div> |
| | | </div> |
| | | <div class="rank-value">{{ item.rentAmount }}</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script> |
| | | import * as echarts from 'echarts' |
| | | |
| | | export default { |
| | | name: 'LeftPanel', |
| | | props: { |
| | | staticsData: { |
| | | type: Object, |
| | | default: () => { } |
| | | }, |
| | | rentRank: { |
| | | type: Array, |
| | | default: () => [] |
| | | } |
| | | }, |
| | | data() { |
| | | return { |
| | | totalArea: '386.5', |
| | | rentedArea: '316.5', |
| | | todayRent: '316.5', |
| | | todayIncome: '124.5', |
| | | charts: { |
| | | areaRent: null |
| | | scrollTimer: null, |
| | | isScrolling: true, |
| | | currentTranslate: 0, |
| | | firstItemHeight: 0, |
| | | animationFrameId: null, |
| | | lastTimestamp: 0, |
| | | displayRank: [] |
| | | } |
| | | }, |
| | | watch: { |
| | | rentRank: { |
| | | immediate: true, |
| | | handler(newVal) { |
| | | if (newVal && newVal.length) { |
| | | // 复制两组数据用于无缝滚动 |
| | | this.displayRank = [...newVal, ...newVal]; |
| | | } |
| | | } |
| | | } |
| | | }, |
| | | mounted() { |
| | | this.$nextTick(() => { |
| | | this.initCharts() |
| | | }) |
| | | const firstItem = this.$el.querySelector('.rent-rank-item'); |
| | | if (firstItem) { |
| | | this.firstItemHeight = firstItem.offsetHeight + 8; |
| | | } |
| | | this.startScroll(); |
| | | }); |
| | | }, |
| | | beforeDestroy() { |
| | | this.clearScrollTimer(); |
| | | if (this.animationFrameId) { |
| | | cancelAnimationFrame(this.animationFrameId); |
| | | } |
| | | }, |
| | | methods: { |
| | | initCharts() { |
| | | this.charts.areaRent = echarts.init(this.$refs.areaRentChart) |
| | | this.charts.areaRent.setOption({ |
| | | grid: { |
| | | left: '20%', |
| | | right: '5%', |
| | | top: '10%', |
| | | bottom: '10%' |
| | | }, |
| | | xAxis: { |
| | | type: 'value', |
| | | axisLine: { |
| | | show: false |
| | | }, |
| | | splitLine: { |
| | | show: false |
| | | }, |
| | | axisLabel: { |
| | | color: '#fff' |
| | | startScroll() { |
| | | this.isScrolling = true; |
| | | if (!this.animationFrameId) { |
| | | this.lastTimestamp = performance.now(); |
| | | this.startAutoScroll(); |
| | | } |
| | | }, |
| | | yAxis: { |
| | | type: 'category', |
| | | data: ['新城大道', '拉萨路', '新城大道', '拉萨路', '新城大道', '新城大道', '拉萨路', '新城大道', '拉萨路', '新城大道'], |
| | | axisLine: { |
| | | lineStyle: { |
| | | color: '#fff' |
| | | stopScroll() { |
| | | this.isScrolling = false; |
| | | }, |
| | | clearScrollTimer() { |
| | | if (this.animationFrameId) { |
| | | cancelAnimationFrame(this.animationFrameId); |
| | | this.animationFrameId = null; |
| | | } |
| | | }, |
| | | axisLabel: { |
| | | color: '#fff' |
| | | startAutoScroll() { |
| | | const animate = (timestamp) => { |
| | | if (!this.isScrolling) { |
| | | this.lastTimestamp = timestamp; |
| | | this.animationFrameId = requestAnimationFrame(animate); |
| | | return; |
| | | } |
| | | }, |
| | | series: [{ |
| | | type: 'bar', |
| | | data: [1900, 1850, 1800, 1750, 1700, 1600, 1500, 1400, 1300, 1200], |
| | | barWidth: '30%', |
| | | itemStyle: { |
| | | color: { |
| | | type: 'linear', |
| | | x: 0, |
| | | y: 0, |
| | | x2: 1, |
| | | y2: 0, |
| | | colorStops: [{ |
| | | offset: 0, |
| | | color: '#0ff' |
| | | }, { |
| | | offset: 1, |
| | | color: '#0ff' |
| | | }] |
| | | } |
| | | } |
| | | }] |
| | | }) |
| | | |
| | | window.addEventListener('resize', () => { |
| | | this.charts.areaRent && this.charts.areaRent.resize() |
| | | }) |
| | | const deltaTime = timestamp - this.lastTimestamp; |
| | | const speed = 0.03; |
| | | const step = speed * deltaTime; |
| | | |
| | | const content = this.$refs.scrollContent; |
| | | if (!content || !this.firstItemHeight) { |
| | | this.animationFrameId = requestAnimationFrame(animate); |
| | | return; |
| | | } |
| | | |
| | | this.currentTranslate -= step; |
| | | |
| | | // 当滚动到第一组数据的末尾时,重置位置到第二组数据的开始 |
| | | const halfHeight = this.firstItemHeight * this.rentRank.length; |
| | | if (Math.abs(this.currentTranslate) >= halfHeight) { |
| | | this.currentTranslate = 0; |
| | | } |
| | | |
| | | content.style.transform = `translateY(${this.currentTranslate}px)`; |
| | | this.lastTimestamp = timestamp; |
| | | this.animationFrameId = requestAnimationFrame(animate); |
| | | }; |
| | | |
| | | this.animationFrameId = requestAnimationFrame(animate); |
| | | }, |
| | | getBarColor(value) { |
| | | if (value == 0) return 'transparent'; |
| | | return '#87F7C7'; |
| | | } |
| | | } |
| | | } |
| | |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | height: calc(100vh); |
| | | height: 100%; |
| | | background: linear-gradient(to right, rgba(1, 85, 121, 0.8) 0%, rgba(1, 85, 121, 0.8) 20%, rgba(151, 190, 192, 0)); |
| | | z-index: 1002; |
| | | flex-direction: column; |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .icon-area, |
| | |
| | | font-size: 24px; |
| | | font-weight: bold; |
| | | color: #0ff; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | width: 100%; |
| | | } |
| | | |
| | | .unit { |
| | |
| | | border-radius: 8px; |
| | | padding: 15px; |
| | | min-height: 300px; |
| | | max-height: calc(100vh - 600px); |
| | | overflow: hidden; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .chart-title { |
| | | font-size: 16px; |
| | | margin-bottom: 15px; |
| | | color: #fff; |
| | | } |
| | | |
| | | .area-chart { |
| | | height: calc(100% - 30px); |
| | | .chart-unit { |
| | | font-size: 12px; |
| | | margin-bottom: 15px; |
| | | } |
| | | |
| | | .rent-rank-list { |
| | | height: calc(100% - 60px); |
| | | overflow: hidden; |
| | | padding: 10px 0; |
| | | position: relative; |
| | | max-height: calc(100% - 60px); |
| | | } |
| | | |
| | | .rank-list-content { |
| | | position: relative; |
| | | transition: none; |
| | | transform: translateY(0); |
| | | will-change: transform; |
| | | } |
| | | |
| | | .rank-list-group { |
| | | position: relative; |
| | | } |
| | | |
| | | .rent-rank-item { |
| | | display: flex; |
| | | flex-direction: column; |
| | | padding: 8px 0; |
| | | } |
| | | |
| | | .rank-name { |
| | | color: #fff; |
| | | font-size: 14px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .rank-progress { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .rank-bar { |
| | | flex: 1; |
| | | height: 8px; |
| | | background: rgba(255, 255, 255, 0.1); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .rank-bar-inner { |
| | | height: 100%; |
| | | transition: all 0.3s ease; |
| | | } |
| | | |
| | | .rank-value { |
| | | color: #fff; |
| | | font-size: 14px; |
| | | min-width: 45px; |
| | | text-align: right; |
| | | } |
| | | </style> |
| | |
| | | <template> |
| | | <div class="center-panel"> |
| | | <div class="map-container" ref="mapContainer"></div> |
| | | <div id="map-container" class="map-container"></div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script> |
| | | import AMapLoader from '@amap/amap-jsapi-loader' |
| | | import { mapState } from 'vuex' |
| | | |
| | | export default { |
| | | name: 'MapPanel', |
| | |
| | | infoWindow: null |
| | | } |
| | | }, |
| | | watch: { |
| | | mapMarkerStatus(newVal) { |
| | | console.log(newVal) |
| | | } |
| | | }, |
| | | computed: { |
| | | ...mapState([ |
| | | 'mapMarkerStatus' |
| | | ]), |
| | | }, |
| | | mounted() { |
| | | this.$nextTick(() => { |
| | | this.initMap() |
| | | window.addEventListener('resize', () => { |
| | | this.map && this.map.resize() |
| | | }) |
| | | }) |
| | | }, |
| | | beforeDestroy() { |
| | | window.removeEventListener('resize', () => { |
| | | this.map && this.map.resize() |
| | | }) |
| | | }, |
| | | methods: { |
| | | async initMap() { |
| | | try { |
| | | const AMap = await AMapLoader.load({ |
| | | key: '526e04b30ceba8f217c5def5a92392f9', |
| | | const map = await AMapLoader.load({ |
| | | key: '67968c82f27c7e2cb9f40c1a9aa3042b', |
| | | version: '2.0', |
| | | plugins: ['AMap.ToolBar', 'AMap.Scale'] |
| | | plugins: ['AMap.MarkerClusterer'] |
| | | }) |
| | | |
| | | this.map = new AMap.Map(this.$refs.mapContainer, { |
| | | zoom: 16, |
| | | center: [91.1172, 29.6487], |
| | | viewMode: '3D', |
| | | pitch: 35, |
| | | mapStyle: 'amap://styles/normal', |
| | | features: ['bg', 'road', 'building', 'point'], |
| | | buildingAnimation: true |
| | | this.map = new map.Map('map-container', { |
| | | zoom: 13, |
| | | center: [91.172119, 29.652941], |
| | | mapStyle: 'amap://styles/normal' |
| | | }) |
| | | |
| | | this.map.addControl(new AMap.ToolBar({ |
| | | position: 'RB' |
| | | })) |
| | | this.map.addControl(new AMap.Scale({ |
| | | position: 'RB' |
| | | })) |
| | | |
| | | this.infoWindow = new AMap.InfoWindow({ |
| | | offset: new AMap.Pixel(0, -30), |
| | | closeWhenClickMap: true, |
| | | autoMove: true, |
| | | anchor: 'bottom-center' |
| | | }) |
| | | |
| | | this.map.on('click', () => { |
| | | if (this.infoWindow) { |
| | | this.infoWindow.close() |
| | | } |
| | | }) |
| | | |
| | | this.addMarkers() |
| | | } catch (error) { |
| | | console.error('地图加载失败:', error) |
| | | this.addMapMarkers() |
| | | }, |
| | | addMapMarkers() { |
| | | const markers = [ |
| | | { |
| | | position: [91.172119, 29.652941], |
| | | info: { |
| | | address: '西花部汾罚藏医院', |
| | | name: '名称名称名称', |
| | | status: 'vacant', |
| | | statusText: '待出租', |
| | | tenant: '张三', |
| | | currentRent: 1600, |
| | | totalRent: 20000, |
| | | quarterlyPaid: 500, |
| | | quarterlyRent: 2000, |
| | | color: '#faad14' |
| | | } |
| | | }, |
| | | addMarkers() { |
| | | const houses = [ |
| | | { |
| | | position: [91.1172, 29.6487], |
| | | title: '新城大道房源', |
| | | status: 'waiting', |
| | | content: ` |
| | | <div class="info-window" data-status="waiting"> |
| | | <div class="info-title">房屋状态:待租出</div> |
| | | <div class="info-content"> |
| | | <p>房屋编号:新城大道</p> |
| | | <p>房屋状态:待租出</p> |
| | | <p>租赁面积:1000/2000㎡</p> |
| | | <p>本季租金:500/2000元/月</p> |
| | | </div> |
| | | </div> |
| | | ` |
| | | }, |
| | | { |
| | | position: [91.1272, 29.6587], |
| | | title: '拉萨路房源', |
| | | position: [91.182119, 29.662941], |
| | | info: { |
| | | address: '拉萨市城关区江苏路15号', |
| | | name: '泊江大道B栋', |
| | | status: 'rented', |
| | | content: ` |
| | | <div class="info-window" data-status="rented"> |
| | | <div class="info-title">房屋状态:已租出</div> |
| | | <div class="info-content"> |
| | | <p>房屋编号:拉萨路</p> |
| | | <p>房屋状态:已租出</p> |
| | | <p>租赁面积:800/1500㎡</p> |
| | | <p>本季租金:400/1800元/月</p> |
| | | </div> |
| | | </div> |
| | | ` |
| | | statusText: '已出租', |
| | | tenant: '-', |
| | | currentRent: 3000, |
| | | totalRent: 15000, |
| | | quarterlyPaid: 1000, |
| | | quarterlyRent: 4500, |
| | | color: '#4fd9ff' |
| | | } |
| | | }, |
| | | { |
| | | position: [91.162119, 29.642941], |
| | | info: { |
| | | address: '拉萨市城关区北京中路8号', |
| | | name: '新城大道C区', |
| | | status: 'maintenance', |
| | | statusText: '维修中', |
| | | tenant: '-', |
| | | currentRent: 0, |
| | | totalRent: 16000, |
| | | quarterlyPaid: 0, |
| | | quarterlyRent: 5400, |
| | | color: '#ff4d4f' |
| | | } |
| | | }, |
| | | { |
| | | position: [91.192119, 29.672941], |
| | | info: { |
| | | address: '拉萨市城关区夺底路33号', |
| | | name: '高江路D座', |
| | | status: 'overdue', |
| | | statusText: '欠费', |
| | | tenant: '西藏联通', |
| | | currentRent: 12000, |
| | | totalRent: 20000, |
| | | quarterlyPaid: 4000, |
| | | quarterlyRent: 6600, |
| | | color: '#39c5bb' |
| | | } |
| | | } |
| | | ] |
| | | |
| | | houses.forEach(house => { |
| | | const circles = [] |
| | | const baseRadius = 15 |
| | | const numCircles = 3 |
| | | markers.forEach(marker => { |
| | | const content = ` |
| | | <div class="marker-container"> |
| | | <svg width="120" height="120" viewBox="0 0 120 120"> |
| | | <defs> |
| | | <filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> |
| | | <feGaussianBlur stdDeviation="3" result="coloredBlur"/> |
| | | <feMerge> |
| | | <feMergeNode in="coloredBlur"/> |
| | | <feMergeNode in="SourceGraphic"/> |
| | | </feMerge> |
| | | </filter> |
| | | </defs> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="0s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="0s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="1s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="1s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="ripple" cx="60" cy="60" r="12" fill="none" stroke="${marker.info.color}" stroke-width="3" style="opacity: 0.4"> |
| | | <animate attributeName="r" from="12" to="45" dur="3s" begin="2s" repeatCount="indefinite" /> |
| | | <animate attributeName="opacity" from="0.8" to="0" dur="3s" begin="2s" repeatCount="indefinite" /> |
| | | </circle> |
| | | <circle class="marker" cx="60" cy="60" r="8" fill="${marker.info.color}" filter="url(#glow)"> |
| | | <animate attributeName="r" values="8;10;8" dur="2s" repeatCount="indefinite" /> |
| | | <animate attributeName="fill-opacity" values="1;0.8;1" dur="2s" repeatCount="indefinite" /> |
| | | </circle> |
| | | </svg> |
| | | </div> |
| | | ` |
| | | |
| | | const centerCircle = new AMap.Circle({ |
| | | center: house.position, |
| | | radius: baseRadius / 2, |
| | | fillColor: house.status === 'waiting' ? '#ff9800' : '#4CAF50', |
| | | fillOpacity: 0.8, |
| | | strokeWeight: 0, |
| | | zIndex: numCircles + 2, |
| | | bubble: true, |
| | | cursor: 'pointer' |
| | | const markerObj = new AMap.Marker({ |
| | | position: marker.position, |
| | | content: content, |
| | | anchor: 'center', |
| | | offset: new AMap.Pixel(0, 0), |
| | | zIndex: 100 |
| | | }) |
| | | |
| | | for (let i = 0; i < numCircles; i++) { |
| | | circles.push(new AMap.Circle({ |
| | | center: house.position, |
| | | radius: baseRadius * (i + 1), |
| | | fillColor: house.status === 'waiting' ? '#ff9800' : '#4CAF50', |
| | | fillOpacity: 0.3 - (i * 0.05), |
| | | strokeColor: house.status === 'waiting' ? '#ff9800' : '#4CAF50', |
| | | strokeWeight: 2, |
| | | strokeOpacity: 0.5 - (i * 0.08), |
| | | zIndex: numCircles - i, |
| | | bubble: true, |
| | | cursor: 'pointer' |
| | | })) |
| | | } |
| | | |
| | | const clickHandler = () => { |
| | | if (this.infoWindow) { |
| | | this.infoWindow.setContent(house.content) |
| | | this.infoWindow.open(this.map, house.position) |
| | | } |
| | | } |
| | | |
| | | centerCircle.on('click', clickHandler) |
| | | circles.forEach(circle => circle.on('click', clickHandler)) |
| | | |
| | | this.map.add([centerCircle, ...circles]) |
| | | this.markers.push({ center: centerCircle, rings: circles }) |
| | | |
| | | let phase = 0 |
| | | const animate = () => { |
| | | phase += 0.15 |
| | | const scale = 1 + 0.6 * Math.sin(phase) |
| | | |
| | | circles.forEach((circle, index) => { |
| | | const currentRadius = baseRadius * (index + 1) |
| | | circle.setRadius(currentRadius * scale) |
| | | |
| | | const baseOpacity = 0.3 - (index * 0.05) |
| | | const opacityScale = 1 + 0.5 * Math.sin(phase) |
| | | circle.setOptions({ |
| | | fillOpacity: baseOpacity * opacityScale, |
| | | strokeOpacity: (0.5 - (index * 0.08)) * opacityScale |
| | | }) |
| | | markerObj.on('click', () => { |
| | | this.showInfoWindow(markerObj, marker.info) |
| | | }) |
| | | |
| | | requestAnimationFrame(animate) |
| | | } |
| | | animate() |
| | | markerObj.setMap(this.map) |
| | | }) |
| | | } |
| | | }, |
| | | showInfoWindow(marker, info) { |
| | | const content = ` |
| | | <div class="map-info-card" style="color:#000;background-color: #fff !important;padding: 10px;border-radius: 12px;box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);"> |
| | | <div class="info-header"> |
| | | <div class="info-title">房屋详情</div> |
| | | </div> |
| | | <div class="info-body"> |
| | | <div class="info-item">房屋地址:${info.address}</div> |
| | | <div class="info-item">房屋名称:${info.name}</div> |
| | | <div class="info-item">房屋状态:<span class="status-tag" style="background: rgba(${info.color.replace(/[^\d,]/g, '')}, 0.1); color: ${info.color};">${info.statusText}</span></div> |
| | | <div class="info-item">租户:${info.tenant}</div> |
| | | <div class="info-item"> |
| | | 租金状态:${info.currentRent}/${info.totalRent} |
| | | </div> |
| | | <div class="info-item"> |
| | | 本季租金:${info.quarterlyPaid}/${info.quarterlyRent} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | ` |
| | | |
| | | const infoWindow = new AMap.InfoWindow({ |
| | | content: content, |
| | | offset: new AMap.Pixel(0, -20), |
| | | closeWhenClickMap: true, |
| | | anchor: 'bottom-center', |
| | | isCustom: true |
| | | }) |
| | | |
| | | infoWindow.open(this.map, marker.getPosition()) |
| | | }, |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | <style scoped lang="scss"> |
| | | .center-panel { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | position: relative; |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .map-container { |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | background: rgba(255, 255, 255, 0.05); |
| | | width: 100%; |
| | | height: 100vh; |
| | | height: 100%; |
| | | flex: 1; |
| | | position: relative; |
| | | } |
| | | |
| | | .info-window { |
| | |
| | | } |
| | | |
| | | .info-window[data-status="waiting"] .info-title { |
| | | color: #fff; |
| | | color: #2196F3; |
| | | } |
| | | |
| | | .info-window[data-status="rented"] .info-title { |
| | | color: #fff; |
| | | color: #FF9800; |
| | | } |
| | | |
| | | .info-window[data-status="repairing"] .info-title { |
| | | color: #F44336; |
| | | } |
| | | |
| | | .info-window[data-status="overdue"] .info-title { |
| | | color: #39C5BB; |
| | | } |
| | | |
| | | .amap-info { |
| | |
| | | .amap-info-sharp { |
| | | display: none !important; |
| | | } |
| | | |
| | | .marker-container { |
| | | width: 120px; |
| | | height: 120px; |
| | | transform: translate(-50%, -50%); |
| | | pointer-events: all; |
| | | filter: drop-shadow(0 4px 12px rgba(79, 217, 255, 0.4)); |
| | | |
| | | } |
| | | |
| | | svg { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | .ripple { |
| | | transform-origin: center; |
| | | stroke-width: 3; |
| | | } |
| | | |
| | | .marker { |
| | | filter: drop-shadow(0 0 8px #4fd9ff); |
| | | } |
| | | |
| | | .map-info-card { |
| | | border: 1px solid rgba(79, 217, 255, 0.2); |
| | | overflow: hidden; |
| | | width: 300px; |
| | | backdrop-filter: blur(12px); |
| | | |
| | | .info-header { |
| | | padding: 12px 15px; |
| | | border-bottom: 1px solid rgba(79, 217, 255, 0.15); |
| | | |
| | | .info-title { |
| | | color: #fff; |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | position: relative; |
| | | padding-left: 12px; |
| | | |
| | | &::before { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 0; |
| | | top: 50%; |
| | | transform: translateY(-50%); |
| | | width: 3px; |
| | | height: 14px; |
| | | background: linear-gradient(to bottom, #4fd9ff, #568aea); |
| | | border-radius: 2px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .info-body { |
| | | padding: 15px; |
| | | background: rgba(15, 19, 37, 0.95); |
| | | |
| | | .info-item { |
| | | font-size: 14px; |
| | | color: rgba(255, 255, 255, 0.9); |
| | | margin-bottom: 12px; |
| | | line-height: 1.5; |
| | | |
| | | &:last-child { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .status-tag { |
| | | display: inline-block; |
| | | padding: 2px 8px; |
| | | border-radius: 4px; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .rent-status { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-top: 4px; |
| | | width: 100%; |
| | | |
| | | .progress-bar { |
| | | flex: 1; |
| | | height: 4px; |
| | | background: rgba(255, 255, 255, 0.1); |
| | | border-radius: 2px; |
| | | overflow: hidden; |
| | | position: relative; |
| | | |
| | | .progress { |
| | | position: absolute; |
| | | left: 0; |
| | | top: 0; |
| | | height: 100%; |
| | | border-radius: 2px; |
| | | transition: width 0.3s ease; |
| | | } |
| | | } |
| | | |
| | | .rent-text { |
| | | font-size: 13px; |
| | | color: rgba(255, 255, 255, 0.9); |
| | | white-space: nowrap; |
| | | min-width: 80px; |
| | | text-align: right; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <div class="right-panel-top"> |
| | | <div class="data-card"> |
| | | <div class="data-header flex"> |
| | | <div class="data-title">本月新增用户</div> |
| | | <div class="data-title">本月新增租户数</div> |
| | | <div class="data-tabs"> |
| | | <div class="tab-value">{{ newUsers }}<span class="unit">户</span></div> |
| | | <div class="tab-value">{{ staticsData.newTenantCount }}<span class="unit">户</span></div> |
| | | <div class="tab-chart"></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="data-card"> |
| | | <div class="data-header flex"> |
| | | <div class="data-title">本季度交租率</div> |
| | | <div class="data-title">总计租户数</div> |
| | | <div class="data-tabs"> |
| | | <div class="tab-value">{{ quarterlyRate }}<span class="unit">元</span></div> |
| | | <div class="tab-value">{{ staticsData.totalTenantCount }}<span class="unit">户</span></div> |
| | | <div class="tab-chart"></div> |
| | | </div> |
| | | </div> |
| | |
| | | <div class="right-panel-top1"> |
| | | <div class="data-card"> |
| | | <div class="data-header flex"> |
| | | <div class="data-title">本月新增用户</div> |
| | | <div class="data-title">本季度已交租金</div> |
| | | <div class="data-tabs"> |
| | | <div class="tab-value">{{ newUsers }}<span class="unit">万元</span></div> |
| | | <div class="tab-value">{{ staticsData.totalRentPaid }}<span class="unit">万元</span></div> |
| | | <div class="tab-chart"></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="data-card"> |
| | | <div class="data-header flex"> |
| | | <div class="data-title">本季度交租率</div> |
| | | <div class="data-title">本季度应交租金</div> |
| | | <div class="data-tabs"> |
| | | <div class="tab-value">{{ quarterlyRate }}<span class="unit">万元</span></div> |
| | | <div class="tab-value">{{ staticsData.totalRentShould }}<span class="unit">万元</span></div> |
| | | <div class="tab-chart"></div> |
| | | </div> |
| | | </div> |
| | |
| | | <div class="status-title">实时租赁数据</div> |
| | | <div class="status-items-container"> |
| | | <div class="status-items"> |
| | | <div v-for="(item, index) in rentList" :key="index" class="status-item"> |
| | | <span class="area-name">{{ item.area }}</span> |
| | | <div v-for="(item, index) in realTimeRentData" :key="'original-' + index" class="status-item"> |
| | | <span class="area-name">{{ item.streetName }}</span> |
| | | <span class="area-name">{{ item.roomName }}</span> |
| | | <div class="status-actions"> |
| | | <span class="action-btn">启用中</span> |
| | | <span class="action-btn">合同签署</span> |
| | | <span class="action-btn" :style="{ backgroundColor: item.leaseStatus == 2 ? '#FFB822' : item.leaseStatus == 1 ? '#66BAF8' : '#FF6B6B' }">{{ ['待出租', '已出租', '维修中'][item.leaseStatus - 1] }}</span> |
| | | </div> |
| | | </div> |
| | | <div v-for="(item, index) in realTimeRentData" :key="'duplicate-' + index" class="status-item"> |
| | | <span class="area-name">{{ item.streetName }}</span> |
| | | <span class="area-name">{{ item.roomName }}</span> |
| | | <div class="status-actions"> |
| | | <span class="action-btn" :style="{ backgroundColor: item.leaseStatus == 2 ? '#FFB822' : item.leaseStatus == 1 ? '#66BAF8' : '#FF6B6B' }">{{ ['待出租', '已出租', '维修中'][item.leaseStatus - 1] }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | <script> |
| | | export default { |
| | | name: 'RightPanel', |
| | | data() { |
| | | return { |
| | | newUsers: '877', |
| | | quarterlyRate: '4302', |
| | | rentList: [ |
| | | { area: '新城大道', status: 'online' }, |
| | | { area: '拉萨路', status: 'offline' }, |
| | | { area: '新城大道', status: 'online' }, |
| | | { area: '拉萨路', status: 'online' }, |
| | | { area: '新城大道', status: 'offline' }, |
| | | { area: '拉萨路', status: 'online' }, |
| | | { area: '新城大道', status: 'offline' }, |
| | | { area: '新城大道', status: 'online' } |
| | | ] |
| | | props: { |
| | | staticsData: { |
| | | type: Object, |
| | | default: () => { } |
| | | }, |
| | | realTimeRentData: { |
| | | type: Array, |
| | | default: () => [] |
| | | } |
| | | }, |
| | | data() { |
| | | return {} |
| | | } |
| | | } |
| | | </script> |
| | |
| | | } |
| | | |
| | | .right-panel { |
| | | width: 300px; |
| | | width: 400px; |
| | | display: flex; |
| | | position: fixed; |
| | | top: 0; |
| | |
| | | z-index: 1000; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | padding-right: 20px; |
| | | } |
| | | |
| | | .data-card { |
| | |
| | | border-radius: 8px; |
| | | padding: 15px; |
| | | height: 80px; |
| | | min-width: 0; |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | |
| | | .data-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | .data-title { |
| | | font-size: 16px; |
| | | color: #fff; |
| | | white-space: nowrap; |
| | | margin-right: 10px; |
| | | } |
| | | |
| | | .data-tabs { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | flex: 1; |
| | | justify-content: flex-end; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .tab-value { |
| | |
| | | font-family: 'DIN ', 'DIN', sans-serif; |
| | | font-weight: bold; |
| | | color: #fff; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | text-align: right; |
| | | } |
| | | |
| | | .right-panel-top, |
| | | .right-panel-top1 { |
| | | display: flex; |
| | | background: rgba(146, 146, 146, 0.5); |
| | | padding: 15px; |
| | | border-radius: 8px; |
| | | gap: 10px; |
| | | height: 110px; |
| | | } |
| | | |
| | | .right-panel-top { |
| | | display: flex; |
| | | background: rgba(146, 146, 146, 0.5); |
| | | margin-top: 50px; |
| | | padding-top: 10px; |
| | | padding-bottom: 30px; |
| | | border-radius: 2px; |
| | | margin-right: 20px; |
| | | border-left: 4px solid #87F7C7; |
| | | } |
| | | |
| | | .right-panel-top1 { |
| | | display: flex; |
| | | background: rgba(146, 146, 146, 0.5); |
| | | margin-top: 10px; |
| | | padding-top: 10px; |
| | | padding-bottom: 30px; |
| | | border-radius: 2px; |
| | | margin-right: 20px; |
| | | border-left: 4px solid #FFB822; |
| | | } |
| | | |
| | | .status-list { |
| | | background: rgba(146, 146, 146, 0.5); |
| | | border-radius: 2px; |
| | | margin-right: 20px; |
| | | border-radius: 8px; |
| | | margin-top: 10px; |
| | | padding: 15px; |
| | | height: 500px; |
| | | max-height: 500px; |
| | | overflow: hidden; |
| | | flex: 1; |
| | | } |
| | | |
| | | .status-title { |
| | | font-size: 16px; |
| | | color: #fff; |
| | | margin-bottom: 15px; |
| | | } |
| | | |
| | | .status-items { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | height: calc(100% - 45px); |
| | | animation: scrollUp 20s linear infinite; |
| | | } |
| | | |
| | |
| | | .status-items-container { |
| | | height: 100%; |
| | | overflow: hidden; |
| | | position: relative; |
| | | } |
| | | |
| | | .status-items-container::before, |
| | | .status-items-container::after { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | height: 10px; |
| | | z-index: 1; |
| | | } |
| | | |
| | | .status-items-container::before { |
| | | top: 0; |
| | | background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, transparent 100%); |
| | | } |
| | | |
| | | .status-items-container::after { |
| | | bottom: 0; |
| | | background: linear-gradient(to top, rgba(0, 0, 0, 0.1) 0%, transparent 100%); |
| | | } |
| | | |
| | | @keyframes scrollUp { |
| | | 0% { |
| | | transform: translateY(0); |
| | | } |
| | | |
| | | 100% { |
| | | transform: translateY(-50%); |
| | | } |
| | | } |
| | | |
| | | .status-items::-webkit-scrollbar { |
| | | display: none; |
| | | } |
| | | |
| | | .status-items { |
| | | -ms-overflow-style: none; |
| | | scrollbar-width: none; |
| | | } |
| | | |
| | | .status-item { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | display: grid; |
| | | grid-template-columns: minmax(80px, 1fr) minmax(80px, 1fr) 80px; /* 设置最小宽度确保文字显示 */ |
| | | gap: 10px; |
| | | align-items: center; |
| | | background: rgba(255, 255, 255, 0.05); |
| | | border-radius: 4px; |
| | | padding: 10px; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .area-name { |
| | | font-size: 12px; |
| | | font-size: 14px; |
| | | color: #fff; |
| | | white-space: normal; /* 允许文字换行 */ |
| | | line-height: 1.2; /* 添加适当的行高 */ |
| | | } |
| | | |
| | | .status-actions { |
| | | display: flex; |
| | | gap: 10px; |
| | | justify-self: end; |
| | | } |
| | | |
| | | .action-btn { |
| | | padding: 2px 8px; |
| | | padding: 4px 12px; |
| | | border-radius: 4px; |
| | | font-size: 12px; |
| | | background: rgba(0, 255, 255, 0.2); |
| | | color: #fff; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .unit { |
| New file |
| | |
| | | import request from '@/utils/request' |
| | | |
| | | // 获取房屋地图分布 |
| | | export function getStaticsData(data) { |
| | | return request({ |
| | | url: '/screen/statics-data', |
| | | method: 'get', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // 区域租金排名 |
| | | export function getRentRank(data) { |
| | | return request({ |
| | | url: '/screen/rent-rank', |
| | | method: 'get', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // 租金收入趋势 |
| | | export function getRentIncomeTrend(data) { |
| | | return request({ |
| | | url: '/screen/rent-income-trend', |
| | | method: 'get', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // 租户数量趋势 |
| | | export function getTenantCountTrend(data) { |
| | | return request({ |
| | | url: '/screen/getTenantCountTrend', |
| | | method: 'get', |
| | | data |
| | | }) |
| | | } |
| | | |
| | | // 获取实时租赁数据 |
| | | export function getRealTimeRentData(data) { |
| | | return request({ |
| | | url: '/screen/getRealTimeRentData', |
| | | method: 'get', |
| | | data |
| | | }) |
| | | } |
| | |
| | | |
| | | import App from './App.vue' |
| | | import router from './router' |
| | | import store from './store' |
| | | |
| | | import './assets/main.css' |
| | | |
| | | Vue.config.productionTip = false |
| | | Vue.prototype.$store = store |
| | | |
| | | new Vue({ |
| | | router, |
| | | store, |
| | | render: h => h(App) |
| | | }).$mount('#app') |
| New file |
| | |
| | | import Vue from 'vue' |
| | | import Vuex from 'vuex' |
| | | |
| | | Vue.use(Vuex) |
| | | |
| | | export default new Vuex.Store({ |
| | | state: { |
| | | mapMarkerStatus: 'all', |
| | | }, |
| | | getters: { |
| | | getMapMarkerStatus(state) { |
| | | return state.mapMarkerStatus |
| | | } |
| | | }, |
| | | mutations: { |
| | | SET_MAP_MARKER_STATUS(state, status) { |
| | | state.mapMarkerStatus = status |
| | | } |
| | | }, |
| | | actions: { |
| | | setMapMarkerStatus({ commit }, status) { |
| | | commit('SET_MAP_MARKER_STATUS', status) |
| | | } |
| | | }, |
| | | modules: { |
| | | } |
| | | }) |
| | |
| | | <template> |
| | | <div > |
| | | <div class="data-screen"> |
| | | <Header /> |
| | | <div class="main-content"> |
| | | <LeftPanel /> |
| | | <div v-if="loading" class="main-content"> |
| | | <LeftPanel :staticsData="staticsData" :rentRank="rentRank" /> |
| | | <MapPanel /> |
| | | <RightPanel /> |
| | | <RightPanel :staticsData="staticsData" :realTimeRentData="realTimeRentData" /> |
| | | </div> |
| | | <BottomCharts /> |
| | | <BottomCharts v-if="loading" :rentIncomeTrend="rentIncomeTrend" :tenantCountTrend="tenantCountTrend" /> |
| | | <div class="footer"></div> |
| | | </div> |
| | | </template> |
| | |
| | | import MapPanel from '@/components/MapPanel.vue' |
| | | import RightPanel from '@/components/RightPanel.vue' |
| | | import BottomCharts from '@/components/BottomCharts.vue' |
| | | import { getData } from './service' |
| | | import { getStaticsData, getRentRank, getRentIncomeTrend, getTenantCountTrend, getRealTimeRentData } from '@/components/service' |
| | | |
| | | export default { |
| | | name: 'DataScreen', |
| | |
| | | RightPanel, |
| | | BottomCharts |
| | | }, |
| | | data() { |
| | | return { |
| | | staticsData: {}, |
| | | rentRank: [], |
| | | rentIncomeTrend: [], |
| | | tenantCountTrend: [], |
| | | realTimeRentData: [], |
| | | loading: false |
| | | } |
| | | }, |
| | | created() { |
| | | console.log(this.$route); |
| | | if (this.$route.query.token || localStorage.getItem('token')) { |
| | | localStorage.setItem('token', 'Bearer ' + this.$route.query.token) |
| | | this.fetchData() |
| | |
| | | }, |
| | | methods: { |
| | | fetchData() { |
| | | getData().then(res => { |
| | | console.log(res) |
| | | Promise.all([getStaticsData(), getRentRank(), getRentIncomeTrend(), getTenantCountTrend(), getRealTimeRentData()]).then(res => { |
| | | this.staticsData = res[0].data |
| | | this.rentRank = res[1].data |
| | | this.rentIncomeTrend = res[2].data |
| | | this.tenantCountTrend = res[3].data |
| | | this.realTimeRentData = res[4].data |
| | | this.loading = true |
| | | }) |
| | | } |
| | | } |
| | |
| | | </script> |
| | | |
| | | <style> |
| | | #app { |
| | | width: 100vw; |
| | | height: 100vh; |
| | | margin: 0; |
| | | padding: 0; |
| | | padding: 0 !important; |
| | | overflow: hidden; |
| | | background: linear-gradient(to bottom, #1a2b3c 0%, rgba(26, 43, 60, 0.8) 20%, rgba(26, 43, 60, 0) 100%); |
| | | color: #fff; |
| | | font-family: 'DIN ', 'DIN', sans-serif; |
| | | .data-screen { |
| | | width: 100%; |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | position: relative; |
| | | color: #fff; |
| | | font-family: 'DIN', sans-serif; |
| | | } |
| | | |
| | | /* 添加全局字体声明 */ |
| | |
| | | flex: 1; |
| | | display: flex; |
| | | gap: 10px; |
| | | height: calc(100% - 300px); |
| | | position: relative; |
| | | margin-top: 100px; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .footer { |
| | | height: 200px; |
| | | position: fixed; |
| | | position: absolute; |
| | | bottom: 0; |
| | | left: 0; |
| | | width: 100vw; |
| | | width: 100%; |
| | | z-index: 1000; |
| | | background: linear-gradient(to top, rgba(1, 85, 121, 0.8) 0%, rgba(1, 85, 121, 0.4) 20%, rgba(151, 190, 192, 0) 100%); |
| | | pointer-events: none; |
| | | } |
| | | </style> |
| | |
| | | publicPath: './', |
| | | server: { |
| | | proxy: { |
| | | "/api": { |
| | | "/screen": { |
| | | target: "http://192.168.110.111:8081", |
| | | changeOrigin: true, |
| | | rewrite: (path) => path.replace(/^\/api/, '') |
| | | rewrite: (path) => path.replace(/^\/screen/, '/screen') |
| | | } |
| | | }, |
| | | }, |