feat: 综合分析大屏功能新增。
This commit is contained in:
@@ -70,24 +70,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insight-panel">
|
||||
<div class="insight-card">
|
||||
<span>热点占比</span>
|
||||
<strong>{{ formatPercent(topPointShare) }}</strong>
|
||||
<em>{{ topPoint?.locationName || '暂无热点' }}</em>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<span>点均消费</span>
|
||||
<strong>{{ formatMoney(averageLocationAmount) }}</strong>
|
||||
<em>按定位地点均摊</em>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<span>当前点位占比</span>
|
||||
<strong>{{ formatPercent(selectedPointShare) }}</strong>
|
||||
<em>{{ selectedPoint?.locationName || '未选择点位' }}</em>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<span>最近交易</span>
|
||||
<strong>{{ latestRecordTime }}</strong>
|
||||
<em>{{ latestRecordName }}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-panel side-panel-left">
|
||||
<div class="panel-title">地点消费排行</div>
|
||||
<div v-if="locationPoints.length" class="rank-list">
|
||||
<button
|
||||
v-for="(item, index) in locationPoints.slice(0, 12)"
|
||||
v-for="(item, index) in rankItems"
|
||||
:key="item.locationKey"
|
||||
class="rank-item"
|
||||
:class="{ active: selectedPoint && selectedPoint.locationKey === item.locationKey }"
|
||||
:style="{ '--rank-color': getPointColor(Number(item.totalAmount || 0)), '--rank-percent': `${rankPercent(item)}%` }"
|
||||
@click="selectPoint(item)"
|
||||
>
|
||||
<span class="rank-main">
|
||||
<span class="rank-index">{{ index + 1 }}</span>
|
||||
<span class="rank-info">
|
||||
<span class="rank-name">{{ item.locationName || '未命名地点' }}</span>
|
||||
<span class="rank-address">{{ item.locationAddress || `${item.longitude}, ${item.latitude}` }}</span>
|
||||
<span class="rank-address">{{ item.locationAddress || '暂无地址' }}</span>
|
||||
</span>
|
||||
<span class="rank-value">
|
||||
<strong>{{ formatMoney(item.totalAmount) }}</strong>
|
||||
<em>{{ item.dealCount || 0 }}笔</em>
|
||||
<em>{{ item.dealCount || 0 }}笔 · {{ formatPercent(getAmountRatio(item.totalAmount)) }}</em>
|
||||
</span>
|
||||
</span>
|
||||
<span class="rank-progress">
|
||||
<i></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -97,23 +126,19 @@
|
||||
<div class="side-panel side-panel-right">
|
||||
<div class="panel-title">
|
||||
<span>点位明细</span>
|
||||
<small v-if="selectedPoint">{{ selectedPoint.dealCount || 0 }}笔交易</small>
|
||||
</div>
|
||||
<div v-if="selectedPoint" class="selected-info">
|
||||
<div class="selected-main">
|
||||
<div class="selected-copy">
|
||||
<span class="selected-kicker">当前点位</span>
|
||||
<strong>{{ selectedPoint.locationName || '未命名地点' }}</strong>
|
||||
<em>{{ selectedPoint.locationAddress || '暂无地址' }}</em>
|
||||
</div>
|
||||
<div class="selected-amount">
|
||||
<span>累计消费</span>
|
||||
<strong>{{ formatMoney(selectedPoint.totalAmount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-address">
|
||||
<span>地址</span>
|
||||
<strong>{{ selectedPoint.locationAddress || '暂无地址' }}</strong>
|
||||
</div>
|
||||
<div class="selected-stat-grid">
|
||||
<div>
|
||||
<span>交易笔数</span>
|
||||
@@ -123,13 +148,45 @@
|
||||
<span>单笔均值</span>
|
||||
<strong>{{ formatMoney(getAverageAmount(selectedPoint)) }}</strong>
|
||||
</div>
|
||||
<div class="selected-stat-wide">
|
||||
<span>经纬度</span>
|
||||
<strong>{{ formatCoordinate(selectedPoint) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedPoint && recordLoading" class="record-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>正在加载点位流水</span>
|
||||
</div>
|
||||
<div v-if="selectedPoint && !recordLoading && hasPointStats" class="point-breakdown">
|
||||
<div class="breakdown-column">
|
||||
<div class="breakdown-title">
|
||||
<span>子类构成</span>
|
||||
<em>{{ pointCategoryStats.length }}类</em>
|
||||
</div>
|
||||
<div v-for="item in pointCategoryStats" :key="item.label" class="breakdown-item">
|
||||
<div class="breakdown-line">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ formatMoney(item.amount) }}</strong>
|
||||
</div>
|
||||
<div class="breakdown-track">
|
||||
<i :style="{ width: `${item.percent}%` }"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recordList.length" class="detail-section">
|
||||
<div class="breakdown-column">
|
||||
<div class="breakdown-title">
|
||||
<span>账户分布</span>
|
||||
<em>{{ pointAccountStats.length }}个</em>
|
||||
</div>
|
||||
<div v-for="item in pointAccountStats" :key="item.label" class="breakdown-item">
|
||||
<div class="breakdown-line">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.count }}笔</strong>
|
||||
</div>
|
||||
<div class="breakdown-track accent">
|
||||
<i :style="{ width: `${item.percent}%` }"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recordList.length && !recordLoading" class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<span>交易流水</span>
|
||||
<em>最新 {{ recordList.length }} 条</em>
|
||||
@@ -153,7 +210,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="点击地图点查看明细" :image-size="80" />
|
||||
<el-empty v-else-if="!selectedPoint" description="点击地图点查看明细" :image-size="80" />
|
||||
<el-empty v-else-if="!recordLoading" description="该点位暂无交易流水" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="screen-loading">
|
||||
@@ -165,10 +223,11 @@
|
||||
|
||||
<script setup name="AccountDealLocationScreen">
|
||||
import dayjs from 'dayjs'
|
||||
import { PointLayer, PolygonLayer, Popup, RasterLayer } from '@antv/l7'
|
||||
import { PointLayer, Popup, RasterLayer, Scene } from '@antv/l7'
|
||||
import { RasterTileType } from '@antv/l7-core'
|
||||
import { Map as L7Map } from '@antv/l7-maps'
|
||||
import { listAccounts } from '@/api/invest/accounts'
|
||||
import { mapConfig, layerUrl } from '@/config/map'
|
||||
import { initMap } from '@/utils/map'
|
||||
import { layerUrl, mapConfig } from '@/config/map'
|
||||
import { getAccountDealLocationOverview, getAccountDealLocationPoints, getAccountDealLocationRecords } from '@/api/invest/statisticAnalysis'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
@@ -179,11 +238,11 @@ const scene = ref(null)
|
||||
const pointLayers = ref([])
|
||||
const popup = ref(null)
|
||||
const mapReady = ref(false)
|
||||
let darkBaseLayerReady = false
|
||||
const accountList = ref([])
|
||||
const locationPoints = ref([])
|
||||
const recordList = ref([])
|
||||
const selectedPoint = ref(null)
|
||||
const recordLoading = ref(false)
|
||||
const overview = ref({
|
||||
totalAmount: 0,
|
||||
dealCount: 0,
|
||||
@@ -200,29 +259,42 @@ const queryParams = ref({
|
||||
childCategory: null
|
||||
})
|
||||
|
||||
const darkMapMaskData = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: 'dark-map-mask'
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-180, -85],
|
||||
[180, -85],
|
||||
[180, 85],
|
||||
[-180, 85],
|
||||
[-180, -85]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
let darkBaseLayerReady = false
|
||||
const darkTileCache = new globalThis.Map()
|
||||
const darkTileCacheLimit = 240
|
||||
const darkTileFilter = 'invert(1) hue-rotate(180deg) brightness(0.52) contrast(0.92) saturate(0.62)'
|
||||
const darkLabelFilter = 'invert(1) hue-rotate(180deg) brightness(1.18) contrast(1.08) saturate(0.72)'
|
||||
const mapFocusZoom = 14
|
||||
|
||||
const sortedLocationPoints = computed(() => [...locationPoints.value].sort((first, second) => Number(second.totalAmount || 0) - Number(first.totalAmount || 0)))
|
||||
const topPoint = computed(() => sortedLocationPoints.value[0] || null)
|
||||
const rankItems = computed(() => sortedLocationPoints.value.slice(0, 12))
|
||||
const totalAmount = computed(() => Number(overview.value.totalAmount || 0))
|
||||
const averageLocationAmount = computed(() => {
|
||||
const count = Number(overview.value.locationCount || locationPoints.value.length || 0)
|
||||
if (!count) return 0
|
||||
return totalAmount.value / count
|
||||
})
|
||||
const topPointShare = computed(() => getAmountRatio(topPoint.value?.totalAmount))
|
||||
const selectedPointShare = computed(() => getAmountRatio(selectedPoint.value?.totalAmount))
|
||||
const latestRecord = computed(() => {
|
||||
const validRecords = recordList.value
|
||||
.map((record) => ({ ...record, recordTime: dayjs(record.createTime) }))
|
||||
.filter((record) => record.recordTime.isValid())
|
||||
.sort((first, second) => second.recordTime.valueOf() - first.recordTime.valueOf())
|
||||
return validRecords[0] || null
|
||||
})
|
||||
const latestRecordTime = computed(() => {
|
||||
if (!latestRecord.value) return '--'
|
||||
return latestRecord.value.recordTime.format('MM-DD HH:mm')
|
||||
})
|
||||
const latestRecordName = computed(() => {
|
||||
if (!latestRecord.value) return '选择点位后查看'
|
||||
return formatChildCategory(latestRecord.value)
|
||||
})
|
||||
const pointCategoryStats = computed(() => buildAmountStats(recordList.value, (record) => formatChildCategory(record), 4))
|
||||
const pointAccountStats = computed(() => buildCountStats(recordList.value, (record) => record.accountName || '未知账户', 4))
|
||||
const hasPointStats = computed(() => pointCategoryStats.value.length || pointAccountStats.value.length)
|
||||
|
||||
function buildQuery(extra = {}) {
|
||||
const params = {
|
||||
@@ -247,6 +319,59 @@ function formatMoney(value) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const percent = Number(value || 0)
|
||||
if (!Number.isFinite(percent)) return '0%'
|
||||
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`
|
||||
}
|
||||
|
||||
function getAmountRatio(value) {
|
||||
if (!totalAmount.value) return 0
|
||||
return (Number(value || 0) / totalAmount.value) * 100
|
||||
}
|
||||
|
||||
function rankPercent(item) {
|
||||
if (!Number(overview.value.maxAmount || 0)) return 0
|
||||
return Math.max(4, Math.min(100, (Number(item?.totalAmount || 0) / Number(overview.value.maxAmount || 1)) * 100))
|
||||
}
|
||||
|
||||
function buildAmountStats(records, getLabel, limit = 4) {
|
||||
const statMap = records.reduce((map, record) => {
|
||||
const label = getLabel(record)
|
||||
const current = map.get(label) || { label, amount: 0, count: 0 }
|
||||
current.amount += Math.abs(Number(record.amount || 0))
|
||||
current.count += 1
|
||||
map.set(label, current)
|
||||
return map
|
||||
}, new Map())
|
||||
const stats = Array.from(statMap.values())
|
||||
.sort((first, second) => second.amount - first.amount)
|
||||
.slice(0, limit)
|
||||
const maxAmount = Math.max(...stats.map((item) => item.amount), 0)
|
||||
return stats.map((item) => ({
|
||||
...item,
|
||||
percent: maxAmount ? Math.max(6, (item.amount / maxAmount) * 100) : 0
|
||||
}))
|
||||
}
|
||||
|
||||
function buildCountStats(records, getLabel, limit = 4) {
|
||||
const statMap = records.reduce((map, record) => {
|
||||
const label = getLabel(record)
|
||||
const current = map.get(label) || { label, count: 0 }
|
||||
current.count += 1
|
||||
map.set(label, current)
|
||||
return map
|
||||
}, new Map())
|
||||
const stats = Array.from(statMap.values())
|
||||
.sort((first, second) => second.count - first.count)
|
||||
.slice(0, limit)
|
||||
const maxCount = Math.max(...stats.map((item) => item.count), 0)
|
||||
return stats.map((item) => ({
|
||||
...item,
|
||||
percent: maxCount ? Math.max(6, (item.count / maxCount) * 100) : 0
|
||||
}))
|
||||
}
|
||||
|
||||
function formatParentCategory(record) {
|
||||
return record.dealCategoryName || record.dealCategory || '未分类'
|
||||
}
|
||||
@@ -360,23 +485,41 @@ async function loadScreenData() {
|
||||
recordList.value = []
|
||||
selectedPoint.value = null
|
||||
clearPopup()
|
||||
renderPointLayers()
|
||||
renderPointLayers({ fitFirst: false })
|
||||
if (topPoint.value) {
|
||||
await selectPoint(topPoint.value)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPoint(point) {
|
||||
async function selectPoint(point, options = {}) {
|
||||
selectedPoint.value = point
|
||||
recordLoading.value = true
|
||||
renderPointLayers({ fitFirst: false })
|
||||
scene.value?.setCenter([Number(point.longitude), Number(point.latitude)])
|
||||
if (options.focus !== false) {
|
||||
focusPointOnMap(point, options.zoom || mapFocusZoom)
|
||||
}
|
||||
const params = buildQuery({
|
||||
longitude: point.longitude,
|
||||
latitude: point.latitude
|
||||
})
|
||||
try {
|
||||
const response = await getAccountDealLocationRecords(params)
|
||||
recordList.value = response.data || []
|
||||
showPointPopup(point)
|
||||
} finally {
|
||||
recordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function focusPointOnMap(point, zoom = mapFocusZoom) {
|
||||
const longitude = Number(point?.longitude)
|
||||
const latitude = Number(point?.latitude)
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return
|
||||
scene.value?.setCenter([longitude, latitude])
|
||||
scene.value?.setZoom(zoom)
|
||||
}
|
||||
|
||||
function getPointColor(amount) {
|
||||
@@ -537,10 +680,90 @@ function renderPointLayers(options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
if (options.fitFirst !== false) {
|
||||
const firstPoint = data[0]
|
||||
scene.value.setCenter([Number(firstPoint.longitude), Number(firstPoint.latitude)])
|
||||
scene.value.setZoom(12)
|
||||
if (options.fitFirst !== false && topPoint.value) {
|
||||
focusPointOnMap(topPoint.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fillTileUrl(template, tile) {
|
||||
return template.replace(/\{x\}/gi, tile.x).replace(/\{y\}/gi, tile.y).replace(/\{z\}/gi, tile.z).replace(/\{l\}/gi, tile.z)
|
||||
}
|
||||
|
||||
function rememberDarkTile(cacheKey, buffer) {
|
||||
if (darkTileCache.size >= darkTileCacheLimit) {
|
||||
const firstKey = darkTileCache.keys().next().value
|
||||
darkTileCache.delete(firstKey)
|
||||
}
|
||||
darkTileCache.set(cacheKey, buffer)
|
||||
}
|
||||
|
||||
function blobToImage(blob) {
|
||||
if (window.createImageBitmap) {
|
||||
return window.createImageBitmap(blob)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = (event) => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(event)
|
||||
}
|
||||
image.src = objectUrl
|
||||
})
|
||||
}
|
||||
|
||||
function canvasToArrayBuffer(canvas) {
|
||||
if (canvas.convertToBlob) {
|
||||
return canvas.convertToBlob({ type: 'image/png' }).then((blob) => blob.arrayBuffer())
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('地图瓦片暗色转换失败'))
|
||||
return
|
||||
}
|
||||
blob.arrayBuffer().then(resolve).catch(reject)
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
function createDarkTileGetter(template, filter) {
|
||||
return async (tile, callback) => {
|
||||
const tileUrl = fillTileUrl(template, tile)
|
||||
const cacheKey = `${tileUrl}|${filter}`
|
||||
const cachedTile = darkTileCache.get(cacheKey)
|
||||
if (cachedTile) {
|
||||
callback(null, cachedTile)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(tileUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`地图瓦片加载失败:${response.status}`)
|
||||
}
|
||||
const image = await blobToImage(await response.blob())
|
||||
const canvas = document.createElement('canvas')
|
||||
const width = image.width || 256
|
||||
const height = image.height || 256
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
context.filter = filter
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
context.filter = 'none'
|
||||
const buffer = await canvasToArrayBuffer(canvas)
|
||||
rememberDarkTile(cacheKey, buffer)
|
||||
callback(null, buffer)
|
||||
} catch (error) {
|
||||
callback(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,31 +774,21 @@ function initDarkBaseLayer(mapScene) {
|
||||
layerUrl.forEach((item, index) => {
|
||||
const layer = new RasterLayer({
|
||||
zIndex: index,
|
||||
name: item.name
|
||||
name: `accountDealLocationDark${item.name || index}`
|
||||
})
|
||||
.source(item.url, {
|
||||
.source('account-deal-location-dark-tile', {
|
||||
parser: {
|
||||
type: 'rasterTile',
|
||||
tileSize: 256
|
||||
dataType: RasterTileType.CUSTOMIMAGE,
|
||||
tileSize: 256,
|
||||
getCustomData: createDarkTileGetter(item.url, item.name === 'title' ? darkLabelFilter : darkTileFilter)
|
||||
}
|
||||
})
|
||||
.style({
|
||||
opacity: item.name === 'title' ? 0.82 : 0.68
|
||||
opacity: item.name === 'title' ? 0.86 : 1
|
||||
})
|
||||
mapScene.addLayer(layer)
|
||||
})
|
||||
|
||||
const maskLayer = new PolygonLayer({
|
||||
zIndex: 8,
|
||||
name: 'accountDealLocationDarkMask'
|
||||
})
|
||||
.source(darkMapMaskData)
|
||||
.shape('fill')
|
||||
.color('#03111f')
|
||||
.style({
|
||||
opacity: 0.64
|
||||
})
|
||||
mapScene.addLayer(maskLayer)
|
||||
}
|
||||
|
||||
function clearPopup() {
|
||||
@@ -616,10 +829,17 @@ function showPointPopup(point) {
|
||||
}
|
||||
|
||||
async function initScreenMap() {
|
||||
scene.value = await initMap('accountDealLocationMap', {
|
||||
scene.value = new Scene({
|
||||
id: 'accountDealLocationMap',
|
||||
logoVisible: false,
|
||||
map: new L7Map({
|
||||
...mapConfig,
|
||||
zoom: 11
|
||||
zoom: 11,
|
||||
pitch: 0,
|
||||
logoVisible: false
|
||||
})
|
||||
})
|
||||
|
||||
scene.value.on('loaded', () => {
|
||||
mapReady.value = true
|
||||
initDarkBaseLayer(scene.value)
|
||||
@@ -640,6 +860,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPointLayers()
|
||||
darkBaseLayerReady = false
|
||||
if (scene.value) {
|
||||
scene.value.destroy()
|
||||
scene.value = null
|
||||
@@ -810,7 +1031,18 @@ onBeforeUnmount(() => {
|
||||
margin: 14px auto 0;
|
||||
}
|
||||
|
||||
.insight-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
width: min(760px, calc(100vw - 36px));
|
||||
margin: 10px auto 0;
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.insight-card,
|
||||
.side-panel {
|
||||
background: rgba(8, 20, 32, 0.86);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
@@ -836,9 +1068,55 @@ onBeforeUnmount(() => {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 58px;
|
||||
padding: 10px 12px;
|
||||
border-color: rgba(103, 232, 249, 0.18);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.insight-card::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 2px;
|
||||
content: '';
|
||||
background: linear-gradient(180deg, rgba(103, 232, 249, 0.2), rgba(45, 212, 191, 0.95), rgba(103, 232, 249, 0.2));
|
||||
}
|
||||
|
||||
.insight-card span,
|
||||
.insight-card em {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: rgba(186, 230, 253, 0.62);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-card strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: #e0faff;
|
||||
font-size: 18px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-card em {
|
||||
margin-top: 5px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: absolute;
|
||||
top: 178px;
|
||||
top: 92px;
|
||||
bottom: 18px;
|
||||
width: 360px;
|
||||
padding: 16px;
|
||||
@@ -890,12 +1168,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr 92px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
min-height: 70px;
|
||||
padding: 10px;
|
||||
color: #f8fafc;
|
||||
text-align: left;
|
||||
@@ -911,6 +1189,14 @@ onBeforeUnmount(() => {
|
||||
background: rgba(15, 118, 110, 0.3);
|
||||
}
|
||||
|
||||
.rank-main {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 104px;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -919,8 +1205,9 @@ onBeforeUnmount(() => {
|
||||
height: 26px;
|
||||
color: #06121e;
|
||||
font-weight: 700;
|
||||
background: #67e8f9;
|
||||
background: var(--rank-color, #67e8f9);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--rank-color, #67e8f9) 42%, transparent);
|
||||
}
|
||||
|
||||
.rank-info {
|
||||
@@ -930,9 +1217,10 @@ onBeforeUnmount(() => {
|
||||
.rank-name,
|
||||
.rank-address {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rank-name {
|
||||
@@ -965,6 +1253,24 @@ onBeforeUnmount(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rank-progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.rank-progress i {
|
||||
display: block;
|
||||
width: var(--rank-percent, 0%);
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--rank-color, #67e8f9), rgba(224, 250, 255, 0.9));
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--rank-color, #67e8f9) 55%, transparent);
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
@@ -1002,7 +1308,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
.selected-kicker,
|
||||
.selected-amount span,
|
||||
.selected-address span,
|
||||
.selected-stat-grid span {
|
||||
display: block;
|
||||
color: rgba(186, 230, 253, 0.58);
|
||||
@@ -1013,13 +1318,25 @@ onBeforeUnmount(() => {
|
||||
.selected-copy strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.selected-copy em {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: rgba(226, 232, 240, 0.62);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.selected-amount {
|
||||
@@ -1042,26 +1359,6 @@ onBeforeUnmount(() => {
|
||||
text-shadow: 0 0 14px rgba(103, 232, 249, 0.34);
|
||||
}
|
||||
|
||||
.selected-address {
|
||||
margin-top: 8px;
|
||||
padding: 7px 8px;
|
||||
background: rgba(15, 23, 42, 0.44);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selected-address strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1092,6 +1389,104 @@ onBeforeUnmount(() => {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.record-loading {
|
||||
flex: 1 1 auto;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: rgba(224, 250, 255, 0.78);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.point-breakdown {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breakdown-column {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
background: rgba(3, 15, 28, 0.54);
|
||||
border: 1px solid rgba(125, 211, 252, 0.12);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.breakdown-title,
|
||||
.breakdown-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breakdown-title {
|
||||
margin-bottom: 8px;
|
||||
color: rgba(224, 250, 255, 0.88);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breakdown-title em {
|
||||
flex: 0 0 auto;
|
||||
color: rgba(186, 230, 253, 0.52);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.breakdown-item + .breakdown-item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.breakdown-line span,
|
||||
.breakdown-line strong {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breakdown-line span {
|
||||
min-width: 0;
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
}
|
||||
|
||||
.breakdown-line strong {
|
||||
flex: 0 0 auto;
|
||||
max-width: 76px;
|
||||
color: #e0faff;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.breakdown-track {
|
||||
height: 4px;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.breakdown-track i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #67e8f9, #22d3ee);
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.32);
|
||||
}
|
||||
|
||||
.breakdown-track.accent i {
|
||||
background: linear-gradient(90deg, #2dd4bf, #bef264);
|
||||
box-shadow: 0 0 10px rgba(45, 212, 191, 0.28);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -1373,11 +1768,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
:global(.location-popup-title) {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
:global(.location-popup-metrics) {
|
||||
@@ -1427,12 +1823,18 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
top: 306px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.point-breakdown {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.summary-panel {
|
||||
.summary-panel,
|
||||
.insight-panel {
|
||||
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
|
||||
1858
src/views/invest/transactionAnalysisScreen/index.vue
Normal file
1858
src/views/invest/transactionAnalysisScreen/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user