| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185 |
- <script lang="ts" setup>
- import { computed, ref } from 'vue'
- import { useRoute } from 'vue-router'
- import { IotDeviceApi } from '@/api/pms/device'
- import {
- Odometer,
- CircleCheckFilled,
- CircleCloseFilled,
- DataLine,
- Crop,
- FullScreen,
- Setting
- } from '@element-plus/icons-vue'
- import { AnimatedCountTo } from '@/components/AnimatedCountTo'
- import { neonColors } from '@/utils/td-color'
- import dayjs from 'dayjs'
- import * as echarts from 'echarts'
- import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
- import { Dimensions, formatIotValue, HeaderItem } from '@/utils/useSocketBus'
- import { rangeShortcuts } from '@/utils/formatTime'
- import { useFullscreen } from '@vueuse/core'
- import { snapdom } from '@zumer/snapdom'
- import { ElMessage } from 'element-plus'
- import { useMqtt } from '@/utils/useMqtt'
- const { query } = useRoute()
- const data = ref({
- deviceCode: query.code || '',
- deviceName: query.name || '',
- lastInlineTime: query.time || '',
- ifInline: query.ifInline === '3',
- dept: query.dept || '',
- vehicle: query.vehicle || '',
- carOnline: query.carOnline === 'true',
- mqttUrl: query.mqttUrl || ''
- })
- const { connect, destroy, isConnected, subscribe } = useMqtt()
- const handleMessageUpdate = (_topic: string, data: any) => {
- const valueMap = new Map<string, number>()
- for (const item of data) {
- const { id: identity, value: logValue, remark } = item
- const value = logValue ? Number(logValue) : 0
- if (identity) {
- valueMap.set(identity, value)
- }
- const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
- if (modelName && chartData.value[modelName]) {
- chartData.value[modelName].push({
- ts: dayjs.unix(remark).valueOf(),
- value
- })
- updateSingleSeries(modelName)
- }
- }
- const updateDimensions = (list) => {
- list.forEach((item) => {
- const v = valueMap.get(item.identifier)
- if (v !== undefined) {
- item.value = v
- }
- })
- }
- updateDimensions(dimensions.value)
- updateDimensions(gatewayDimensions.value)
- updateDimensions(carDimensions.value)
- genderIntervalArr()
- }
- watch(isConnected, (newVal) => {
- if (newVal && data.value.mqttUrl) {
- subscribe(data.value.mqttUrl as string)
- }
- })
- // const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
- // onAny((msg) => {
- // if (!Array.isArray(msg) || msg.length === 0) return
- // const valueMap = new Map<string, number>()
- // for (const item of msg) {
- // const { identity, modelName, readTime, logValue } = item
- // const value = logValue ? Number(logValue) : 0
- // if (identity) {
- // valueMap.set(identity, value)
- // }
- // if (modelName && chartData.value[modelName]) {
- // chartData.value[modelName].push({
- // ts: dayjs(readTime).valueOf(),
- // value
- // })
- // updateSingleSeries(modelName)
- // }
- // }
- // const updateDimensions = (list) => {
- // list.forEach((item) => {
- // const v = valueMap.get(item.identifier)
- // if (v !== undefined) {
- // item.value = v
- // }
- // })
- // }
- // updateDimensions(dimensions.value)
- // updateDimensions(gatewayDimensions.value)
- // updateDimensions(carDimensions.value)
- // // 3️⃣ 统一一次调用
- // genderIntervalArr()
- // })
- function hexToRgba(hex: string, alpha: number) {
- const r = parseInt(hex.slice(1, 3), 16)
- const g = parseInt(hex.slice(3, 5), 16)
- const b = parseInt(hex.slice(5, 7), 16)
- return `rgba(${r}, ${g}, ${b}, ${alpha})`
- }
- const headerCenterContent: HeaderItem[] = [
- { label: '设备名称', key: 'deviceName' },
- { label: '所属部门', key: 'dept' },
- { label: '车牌号码', key: 'vehicle', judgment: true },
- { label: '最后上报时间', key: 'lastInlineTime' }
- ]
- const tagProps = { size: 'default', round: true } as const
- const headerTagContent: HeaderItem[] = [
- { label: '网关', key: 'ifInline' },
- { label: '北斗', key: 'carOnline', judgment: true }
- ]
- const dimensions = ref<Dimensions[]>([])
- const gatewayDimensions = ref<Dimensions[]>([])
- const carDimensions = ref<Dimensions[]>([])
- const dimensionsContent = computed(() => [
- {
- label: '网关数采',
- icon: DataLine,
- value: gatewayDimensions.value,
- countColor: 'text-blue-600',
- countBg: 'bg-blue-50',
- judgment: false
- }
- // {
- // label: '中航北斗',
- // icon: TrendCharts,
- // value: carDimensions.value,
- // countColor: 'text-indigo-600',
- // countBg: 'bg-indigo-50',
- // judgment: true
- // }
- ])
- const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
- const selectedDimension = ref<Record<string, boolean>>({})
- const dimensionLoading = ref(false)
- async function loadDimensions() {
- if (!query.id) return
- dimensionLoading.value = true
- try {
- const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
- .sort((a, b) => b.modelOrder - a.modelOrder)
- .map((item) => {
- const { value, suffix, isText } = formatIotValue(item.value)
- return {
- identifier: item.identifier,
- name: item.modelName,
- value: value,
- suffix: suffix,
- isText: isText,
- response: false,
- id: item.alarmSettingId,
- maxValue: Number(item.maxValue),
- minValue: Number(item.minValue)
- }
- })
- // const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
- // .sort((a, b) => b.modelOrder - a.modelOrder)
- // .map((item) => {
- // const { value, suffix, isText } = formatIotValue(item.value)
- // console.log(`${item.modelName} :>> `, value)
- // return {
- // identifier: item.identifier,
- // name: item.modelName,
- // value: value,
- // suffix: suffix,
- // isText: isText,
- // response: false
- // }
- // })
- // const rawList = [...gateway, ...car]
- const rawList = [...gateway]
- const uniqueMap = new Map()
- rawList.forEach((item) => {
- const uniqueKey = `${item.identifier}|${item.name}`
- // if (!uniqueMap.has(uniqueKey)) {
- uniqueMap.set(uniqueKey, item)
- // }
- })
- dimensions.value = Array.from(uniqueMap.values()).map((item, index) => {
- const color = neonColors[index % neonColors.length]
- return {
- ...item,
- color: color,
- bgHover: hexToRgba(color, 0.08),
- bgActive: hexToRgba(color, 0.12)
- }
- })
- gatewayDimensions.value = dimensions.value.filter((d) =>
- gateway.some((g) => g.identifier === d.identifier)
- )
- // carDimensions.value = dimensions.value.filter((d) =>
- // car.some((c) => c.identifier === d.identifier)
- // )
- selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
- if (dimensions.value.length > 0) {
- selectedDimension.value[dimensions.value[0].name] = true
- }
- } catch (e) {
- console.error(e)
- } finally {
- dimensionLoading.value = false
- }
- }
- const selectedDate = ref<string[]>([
- dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
- dayjs().format('YYYY-MM-DD HH:mm:ss')
- ])
- interface ChartData {
- [key: Dimensions['name']]: { ts: number; value: number }[]
- }
- const chartData = ref<ChartData>({})
- let intervalArr = ref<number[]>([])
- let maxInterval = ref(0)
- let minInterval = ref(0)
- const chartRef = ref<HTMLDivElement | null>(null)
- let chart: echarts.ECharts | null = null
- function genderIntervalArr(init: boolean = false) {
- const values: number[] = []
- for (const [key, value] of Object.entries(selectedDimension.value)) {
- if (value) {
- values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
- }
- }
- const maxVal = values.length === 0 ? 10000 : Math.max(...values)
- const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
- const maxDigits = (Math.floor(maxVal) + '').length
- const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
- const interval = Math.max(maxDigits, minDigits)
- maxInterval.value = interval
- minInterval.value = minDigits
- intervalArr.value = [0]
- for (let i = 1; i <= interval; i++) {
- intervalArr.value.push(Math.pow(10, i))
- }
- if (!init) {
- chart?.setOption({
- yAxis: {
- min: -minInterval.value,
- max: maxInterval.value
- }
- })
- }
- }
- function chartInit() {
- if (!chart) return
- chart.on('legendselectchanged', (params: any) => {
- selectedDimension.value = params.selected
- })
- window.addEventListener('resize', () => {
- if (chart) chart.resize()
- })
- }
- function render() {
- if (!chartRef.value) return
- if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
- chartInit()
- genderIntervalArr(true)
- chart.setOption({
- animation: true,
- animationDuration: 200,
- animationEasing: 'linear',
- animationDurationUpdate: 200,
- animationEasingUpdate: 'linear',
- grid: {
- left: '0%',
- top: '5%',
- right: '5%',
- bottom: '12%'
- },
- tooltip: {
- trigger: 'axis',
- axisPointer: {
- type: 'line'
- },
- formatter: (params) => {
- let d = `${params[0].axisValueLabel}<br>`
- const exist: string[] = []
- params = params.filter((el) => {
- if (exist.includes(el.seriesName)) return false
- exist.push(el.seriesName)
- return true
- })
- let item = params.map(
- (el) => `<div class="flex items-center justify-between mt-1">
- <span>${el.marker} ${el.seriesName}</span>
- <span>${el.value[2]?.toFixed(2)}</span>
- </div>`
- )
- return d + item.join('')
- }
- },
- xAxis: {
- type: 'time',
- boundaryGap: ['0%', '25%'],
- axisLabel: {
- formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
- rotate: 0,
- align: 'left'
- }
- },
- dataZoom: [
- { type: 'inside', xAxisIndex: 0 },
- { type: 'slider', xAxisIndex: 0 }
- ],
- yAxis: {
- type: 'value',
- min: -minInterval.value,
- max: maxInterval.value,
- interval: 1,
- axisLabel: {
- formatter: (v) => {
- const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
- return num.toLocaleString()
- }
- },
- show: false
- },
- legend: {
- data: dimensions.value.map((item) => item.name),
- selected: selectedDimension.value,
- show: false
- },
- series: dimensions.value.map((item) => ({
- name: item.name,
- type: 'line',
- smooth: 0.2,
- showSymbol: false,
- endLabel: {
- show: true,
- formatter: (params) => params.value[2]?.toFixed(2),
- offset: [6, 0],
- color: item.color,
- fontSize: 12
- },
- emphasis: {
- focus: 'series'
- },
- lineStyle: {
- width: 2
- },
- color: item.color,
- data: [] // 占位数组
- }))
- })
- }
- function mapData({ value, ts }) {
- if (value === null || value === undefined || value === 0) return [ts, 0, 0]
- const isPositive = value > 0
- const absItem = Math.abs(value)
- if (!intervalArr.value.length) return [ts, 0, value]
- const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
- const min_index = intervalArr.value.findIndex((v) => v === min_value)
- let denominator = 1
- if (min_index < intervalArr.value.length - 1) {
- denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
- } else {
- denominator = intervalArr.value[min_index] || 1
- }
- const new_value = (absItem - min_value) / denominator + min_index
- return [ts, isPositive ? new_value : -new_value, value]
- }
- function updateSingleSeries(name: string) {
- if (!chart) render()
- if (!chart) return
- const idx = dimensions.value.findIndex((item) => item.name === name)
- if (idx === -1) return
- const data = chartData.value[name].map((v) => mapData(v))
- chart.setOption({
- series: [{ name, data }]
- })
- }
- const lastTsMap = ref<Record<Dimensions['name'], number>>({})
- const chartLoading = ref(false)
- const token = ref('')
- async function ensureToken() {
- if (token.value) return
- token.value = await IotDeviceApi.getToken()
- }
- async function initLoadChartData(real_time: boolean = true) {
- if (!dimensions.value.length) return
- chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
- chartLoading.value = true
- dimensions.value = dimensions.value.map((item) => {
- item.response = true
- return item
- })
- for (const item of dimensions.value) {
- const { identifier, name } = item
- try {
- const res = await IotStatApi.getDeviceInfoChart(
- data.value.deviceCode,
- identifier,
- selectedDate.value[0],
- selectedDate.value[1]
- )
- const sorted = res
- .sort((a, b) => a.ts - b.ts)
- .map((item) => ({ ts: item.ts, value: item.value }))
- chartData.value[name] = sorted
- lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
- genderIntervalArr()
- updateSingleSeries(name)
- chartLoading.value = false
- } finally {
- item.response = false
- }
- }
- if (real_time && data.value.mqttUrl) {
- await ensureToken()
- connect(`wss://aims.deepoil.cc/mqtt`, { password: token.value }, handleMessageUpdate)
- }
- }
- async function initfn(load: boolean = true, real_time: boolean = true) {
- if (load) await loadDimensions()
- render()
- await initLoadChartData(real_time)
- }
- onMounted(() => {
- initfn()
- })
- function reset() {
- cancelAllRequests().then(() => {
- selectedDate.value = [
- dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
- dayjs().format('YYYY-MM-DD HH:mm:ss')
- ]
- destroy()
- if (chart) chart.clear()
- initfn(false)
- })
- }
- function handleDateChange() {
- cancelAllRequests().then(() => {
- destroy()
- // stopAutoFetch()
- if (chart) chart.clear()
- initfn(false, false)
- })
- }
- function handleClickSpec(modelName: string) {
- selectedDimension.value[modelName] = !selectedDimension.value[modelName]
- chart?.setOption({
- legend: {
- selected: selectedDimension.value
- }
- })
- genderIntervalArr()
- if (selectedDimension.value[modelName]) {
- updateSingleSeries(modelName)
- }
- nextTick(() => {
- chart?.resize()
- })
- }
- const downloadRef = ref(null)
- const exportChart = async () => {
- try {
- if (!downloadRef.value) return
- const result = await snapdom(downloadRef.value, {
- scale: 2,
- backgroundColor: '#fff'
- })
- await result.download({
- filename: `${data.value.deviceName}-设备监控`,
- type: 'png'
- })
- } catch (error) {
- console.error(error)
- ElMessage.error('导出图表失败')
- }
- }
- const maxmin = computed(() => {
- if (!dimensions.value.length) return []
- return dimensions.value
- .filter((v) => selectedDimension.value[v.name])
- .map((v) => ({
- name: v.name,
- color: v.color,
- bgHover: v.bgHover,
- max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
- min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
- }))
- })
- onUnmounted(() => {
- destroy()
- window.removeEventListener('resize', () => {
- if (chart) chart.resize()
- })
- })
- const targetArea = ref(null)
- const { toggle, isFullscreen } = useFullscreen(targetArea)
- const message = useMessage()
- async function handleSave(item: Dimensions) {
- const { minValue, maxValue } = item
- // 1. 判断是否为空 (包括空字符串、null、undefined)
- if (!minValue || !maxValue) {
- return message.warning('最大值和最小值不能为空')
- }
- // 2. 判断是否为有效的数字
- // Number() 处理字符串数字,isNaN 排除非数字字符
- const min = Number(minValue)
- const max = Number(maxValue)
- if (isNaN(min) || isNaN(max)) {
- return message.warning('请输入有效的数字')
- }
- // 3. (附加逻辑) 比较大小
- if (min > max) {
- return message.warning('最小值不能大于最大值')
- }
- const body = {
- minValue: min,
- maxValue: max,
- deviceId: query.id,
- propertyCode: item.identifier,
- alarmProperty: item.name,
- deviceName: data.value.deviceName,
- id: item.id
- }
- const res = await IotDeviceApi.saveMaxMin(body)
- if (res.id) item.id = res.id
- message.success('设置成功')
- }
- async function handleReset(item: Dimensions) {
- item.minValue = undefined
- item.maxValue = undefined
- await IotDeviceApi.deleteMaxMin({ id: item.id })
- item.id = undefined
- message.success('清除重置成功')
- }
- </script>
- <template>
- <div
- class="grid grid-rows-[80px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
- >
- <div
- class="rounded-xl shadow-sm border border-gray-100 border-solid px-6 flex items-center justify-between shrink-0 bg-gradient-to-r from-blue-100 to-white"
- >
- <div class="flex items-center gap-4">
- <div
- class="size-12 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center shadow-inner"
- >
- <el-icon :size="24"><Odometer /></el-icon>
- </div>
- <div>
- <div class="text-xs text-gray-400 font-medium tracking-wider">资产编码</div>
- <div class="text-xl font-bold font-mono text-gray-800">{{ data.deviceCode }}</div>
- </div>
- </div>
- <div class="flex-1 flex justify-center divide-x divide-gray-100">
- <template v-for="item in headerCenterContent" :key="item.key">
- <div
- class="px-8 flex flex-col items-center"
- v-if="item.judgment ? Boolean(query[item.key]) : true"
- >
- <span class="text-xs text-gray-400 mb-1">{{ item.label }}</span>
- <span class="font-semibold text-gray-700">{{ data[item.key] }}</span>
- </div>
- </template>
- </div>
- <div class="flex items-center gap-6">
- <template v-for="item in headerTagContent" :key="item.key">
- <div class="text-center" v-if="item.judgment ? Boolean(query[item.key]) : true">
- <div class="text-xs text-gray-400 mb-1">{{ item.label }}</div>
- <el-tag v-if="data[item.key]" type="success" v-bind="tagProps">
- <el-icon class="mr-1"><CircleCheckFilled /></el-icon>在线
- </el-tag>
- <el-tag v-else type="danger" v-bind="tagProps">
- <el-icon class="mr-1"><CircleCloseFilled /></el-icon>离线
- </el-tag>
- </div>
- </template>
- </div>
- </div>
- <div ref="targetArea" class="relative">
- <div class="flex flex-col gap-4 h-full">
- <template v-for="citem in dimensionsContent" :key="citem.label">
- <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
- <div
- class="rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden bg-gradient-to-b from-blue-100 to-white p-4 flex flex-col gap-2"
- v-loading="dimensionLoading"
- >
- <div class="flex justify-center items-center gap-2 border-0 border-solid">
- <span class="font-bold text-gray-700! flex items-center gap-2">
- <el-icon><component :is="citem.icon" /></el-icon>
- {{ citem.label }}
- </span>
- <span
- class="text-xs px-2 py-0.5 rounded-full font-mono bg-gray-200"
- :class="[citem.countBg, citem.countColor]"
- >
- {{ citem.value.length }}
- </span>
- </div>
- <div class="grid grid-cols-4 gap-2">
- <div
- :data-disabled="disabledDimensions.includes(item.identifier)"
- v-for="item in citem.value"
- :key="item.identifier"
- @click="handleClickSpec(item.name)"
- class="dimension-card group relative p-3 rounded-lg border border-solid bg-transparent border-gray-300 transition-all duration-300 cursor-pointer select-none data-[disabled=true]:pointer-events-none"
- :class="{ 'is-active': selectedDimension[item.name] }"
- :style="{
- '--theme-color': item.color,
- '--theme-bg-hover': item.bgHover,
- '--theme-bg-active': item.bgActive
- }"
- >
- <div class="flex justify-between items-center mb-1">
- <span
- class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
- :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
- >
- {{ item.name }}
- </span>
- <el-popover placement="bottom" :width="280" trigger="click">
- <template #reference>
- <el-button class="group" link>
- <el-icon
- class="transition-transform duration-500 group-hover:rotate-180"
- :size="16"
- >
- <Setting />
- </el-icon>
- </el-button>
- </template>
- <div class="flex flex-col gap-3">
- <div class="text-sm font-bold text-gray-700 pb-1 border-b border-gray-100">
- 设置范围
- </div>
- <div class="grid grid-cols-[auto_1fr] gap-y-3 gap-x-2 items-center">
- <span class="text-xs text-gray-500 text-right">最小值:</span>
- <el-input-number
- v-model="item.minValue"
- size="default"
- class="!w-full"
- placeholder="Min"
- :controls="false"
- align="left"
- />
- <span class="text-xs text-gray-500 text-right">最大值:</span>
- <el-input-number
- v-model="item.maxValue"
- size="default"
- class="!w-full"
- placeholder="Max"
- :controls="false"
- align="left"
- />
- </div>
- <div class="flex justify-end gap-2 pt-1">
- <el-button size="small" text bg @click="handleReset(item)">
- 清除重置
- </el-button>
- <el-button size="small" type="primary" @click="handleSave(item)">
- 保存
- </el-button>
- </div>
- </div>
- </el-popover>
- <!-- <div
- class="size-2 rounded-full transition-all duration-300 shadow-sm"
- :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
- :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
- ></div> -->
- </div>
- <!-- <div class="flex items-baseline justify-between relative z-9">
- <animated-count-to
- v-if="!item.isText"
- :value="Number(item.value)"
- :duration="500"
- :suffix="item.suffix"
- class="text-lg font-bold font-mono tracking-tight text-slate-800"
- />
- <span v-else class="text-lg font-bold font-mono tracking-tight text-slate-800">
- {{ item.value }}
- </span>
- </div> -->
- <div class="flex items-center justify-between relative z-9 mt-1">
- <div class="flex-1 mr-2">
- <animated-count-to
- v-if="!item.isText"
- :value="Number(item.value)"
- :duration="500"
- :suffix="item.suffix"
- class="text-2xl font-black font-mono tracking-tight text-slate-800 leading-none"
- />
- <span
- v-else
- class="text-2xl font-black font-mono tracking-tight text-slate-800 leading-none"
- >
- {{ item.value }}
- </span>
- </div>
- <div v-if="item.minValue || item.maxValue" class="flex gap-1.5 items-center">
- <div
- v-if="item.maxValue"
- class="flex items-center px-2 py-1 rounded-md bg-emerald-50/80 border border-solid border-emerald-100/80 shadow-sm transition-all duration-300 hover:bg-emerald-100 hover:border-emerald-200"
- >
- <div
- class="flex items-center justify-center w-4 h-4 mr-1 rounded-full bg-emerald-100 text-emerald-600 group-hover/max:bg-white group-hover/max:scale-110 transition-all"
- >
- <div class="i-material-symbols:arrow-upward-alt-rounded"></div>
- </div>
- <span class="text-[10px] font-bold text-emerald-400/80 mr-1.5">MAX</span>
- <span class="text-sm font-bold font-mono text-emerald-700">{{
- item.maxValue
- }}</span>
- </div>
- <div
- v-if="item.minValue"
- class="flex items-center px-2 py-0.5 rounded-md bg-rose-50/80 border border-solid border-rose-100/80 shadow-sm transition-all duration-300 hover:bg-rose-100 hover:border-rose-200"
- >
- <div
- class="flex items-center justify-center w-4 h-4 mr-1 rounded-full bg-rose-100 text-rose-600 group-hover/min:bg-white group-hover/min:scale-110 transition-all"
- >
- <div class="i-material-symbols:arrow-downward-alt-rounded"></div>
- </div>
- <span class="text-[10px] font-bold text-rose-400/80 mr-1.5">MIN</span>
- <span class="text-sm font-bold font-mono text-rose-700">{{
- item.minValue
- }}</span>
- </div>
- </div>
- </div>
- <div
- class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
- :class="
- selectedDimension[item.name]
- ? 'opacity-100 shadow-[0_0_8px_currentColor]'
- : 'opacity-0'
- "
- :style="{ backgroundColor: item.color, color: item.color }"
- >
- </div>
- </div>
- </div>
- </div>
- </template>
- </template>
- <div
- class="flex-1 min-h-200 rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col bg-gradient-to-b from-blue-100 to-white"
- >
- <header class="flex items-center justify-between mb-4">
- <h3 class="flex items-center gap-2">
- <div
- class="i-material-symbols:area-chart-outline-rounded text-sky size-6"
- text-sky
- ></div>
- 数据趋势
- </h3>
- <div class="flex gap-4">
- <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
- <el-button size="default" @click="reset">重置</el-button>
- <el-date-picker
- v-model="selectedDate"
- value-format="YYYY-MM-DD HH:mm:ss"
- type="datetimerange"
- unlink-panels
- start-placeholder="开始日期"
- end-placeholder="结束日期"
- :shortcuts="rangeShortcuts"
- size="default"
- class="w-100!"
- placement="bottom-end"
- @change="handleDateChange"
- />
- <el-button
- size="default"
- :type="isFullscreen ? 'info' : 'primary'"
- :icon="isFullscreen ? Crop : FullScreen"
- @click="toggle"
- >
- {{ isFullscreen ? '退出全屏' : '全屏' }}
- </el-button>
- </div>
- </header>
- <div ref="downloadRef" class="flex flex-1">
- <div class="flex gap-1 select-none">
- <div
- v-for="item of maxmin"
- :key="item.name"
- :style="{
- '--theme-bg-hover': item.bgHover
- }"
- class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-transparent border border-solid border-transparent transition-all duration-300 hover:bg-[var(--theme-bg-hover)] hover-border-gray-200 hover:shadow-md cursor-pointer active:scale-95"
- @click="handleClickSpec(item.name)"
- >
- <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
- <div
- class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
- :style="{ backgroundColor: item.color }"
- ></div>
- <span
- class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
- :style="{ color: item.color }"
- >
- {{ item.name }}
- </span>
- <div
- class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
- :style="{ backgroundColor: item.color }"
- ></div>
- <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
- </div>
- </div>
- <div
- class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:20px_20px]"
- >
- <div
- v-loading="chartLoading"
- element-loading-background="transparent"
- ref="chartRef"
- class="w-full h-full"
- >
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- <div ref="targetArea" class="h-full min-h-0 relative">
- <div class="grid grid-cols-[260px_1fr] gap-4 h-full">
- <el-scrollbar
- class="rounded-xl shadow-sm border border-gray-100 border-solid overflow-hidden bg-gradient-to-b from-blue-100 to-white"
- view-class="flex flex-col min-h-full"
- v-loading="dimensionLoading"
- >
- <template v-for="citem in dimensionsContent" :key="citem.label">
- <template v-if="citem.judgment ? Boolean(citem.value.length) : true">
- <div
- class="sticky-title bg-blue-100 z-10 flex justify-between items-center py-3 px-4 border-0 border-solid"
- >
- <span class="font-bold text-sm text-gray-700! flex items-center gap-2">
- <el-icon><component :is="citem.icon" /></el-icon>
- {{ citem.label }}
- </span>
- <span
- class="text-xs px-2 py-0.5 rounded-full font-mono"
- :class="[citem.countBg, citem.countColor]"
- >
- {{ citem.value.length }}
- </span>
- </div>
- <div class="px-3 pb-4 pt-2 space-y-3">
- <div
- :data-disabled="disabledDimensions.includes(item.identifier)"
- v-for="item in citem.value"
- :key="item.identifier"
- @click="handleClickSpec(item.name)"
- class="dimension-card group relative p-3 rounded-lg border border-solid bg-transparent border-gray-300 transition-all duration-300 cursor-pointer select-none data-[disabled=true]:pointer-events-none"
- :class="{ 'is-active': selectedDimension[item.name] }"
- :style="{
- '--theme-color': item.color,
- '--theme-bg-hover': item.bgHover,
- '--theme-bg-active': item.bgActive
- }"
- >
- <div class="flex justify-between items-center mb-1">
- <span
- class="text-xs font-medium text-gray-500 transition-colors truncate pr-2 group-hover:text-[var(--theme-color)]"
- :class="{ 'text-[var(--theme-color)]!': selectedDimension[item.name] }"
- >
- {{ item.name }}
- </span>
- <div
- class="size-2 rounded-full transition-all duration-300 shadow-sm"
- :class="selectedDimension[item.name] ? 'scale-100' : 'scale-0'"
- :style="{ backgroundColor: item.color, boxShadow: `0 0 6px ${item.color}` }"
- ></div>
- </div>
- <div class="flex items-baseline justify-between relative z-9">
- <animated-count-to
- v-if="!item.isText"
- :value="Number(item.value)"
- :duration="500"
- :suffix="item.suffix"
- class="text-lg font-bold font-mono tracking-tight text-slate-800"
- />
- <span v-else class="text-lg font-bold font-mono tracking-tight text-slate-800">
- {{ item.value }}
- </span>
- </div>
- <div
- class="absolute left-0 top-3 bottom-3 w-1 rounded-r transition-all duration-300"
- :class="
- selectedDimension[item.name]
- ? 'opacity-100 shadow-[0_0_8px_currentColor]'
- : 'opacity-0'
- "
- :style="{ backgroundColor: item.color, color: item.color }"
- >
- </div>
- </div>
- </div>
- </template>
- </template>
- </el-scrollbar>
- <div
- class="rounded-xl shadow-sm border border-gray-100 border-solid p-4 flex flex-col bg-gradient-to-b from-blue-100 to-white"
- >
- <header class="flex items-center justify-between mb-4">
- <h3 class="flex items-center gap-2">
- <div
- class="i-material-symbols:area-chart-outline-rounded text-sky size-6"
- text-sky
- ></div>
- 数据趋势
- </h3>
- <div class="flex gap-4">
- <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
- <el-button size="default" @click="reset">重置</el-button>
- <el-date-picker
- v-model="selectedDate"
- value-format="YYYY-MM-DD HH:mm:ss"
- type="datetimerange"
- unlink-panels
- start-placeholder="开始日期"
- end-placeholder="结束日期"
- :shortcuts="rangeShortcuts"
- size="default"
- class="w-100!"
- placement="bottom-end"
- @change="handleDateChange"
- />
- <el-button
- size="default"
- :type="isFullscreen ? 'info' : 'primary'"
- :icon="isFullscreen ? Crop : FullScreen"
- @click="toggle"
- >
- {{ isFullscreen ? '退出全屏' : '全屏' }}
- </el-button>
- </div>
- </header>
- <div ref="downloadRef" class="flex flex-1">
- <div class="flex gap-1 select-none">
- <div
- v-for="item of maxmin"
- :key="item.name"
- :style="{
- '--theme-bg-hover': item.bgHover
- }"
- class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded-full group relative bg-transparent border border-solid border-transparent transition-all duration-300 hover:bg-[var(--theme-bg-hover)] hover-border-gray-200 hover:shadow-md cursor-pointer active:scale-95"
- @click="handleClickSpec(item.name)"
- >
- <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.max }}</span>
- <div
- class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
- :style="{ backgroundColor: item.color }"
- ></div>
- <span
- class="[writing-mode:sideways-lr] text-sm font-bold tracking-widest"
- :style="{ color: item.color }"
- >
- {{ item.name }}
- </span>
- <div
- class="flex-1 w-0.5 rounded-full opacity-40 group-hover:opacity-100 transition-opacity duration-300"
- :style="{ backgroundColor: item.color }"
- ></div>
- <span class="[writing-mode:sideways-lr] text-xs text-gray-400">{{ item.min }}</span>
- </div>
- </div>
- <div
- class="flex flex-1 min-w-0 bg-gray-50/30 rounded-lg border border-dashed border-gray-200 ml-2 relative overflow-hidden bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:20px_20px]"
- >
- <div
- v-loading="chartLoading"
- element-loading-background="transparent"
- ref="chartRef"
- class="w-full h-full"
- >
- </div>
- </div>
- </div>
- </div>
- </div>
- </div> -->
- </div>
- </template>
- <style scoped>
- :deep(.el-tag__content) {
- display: flex;
- align-items: center;
- gap: 2px;
- }
- .sticky-title {
- position: sticky;
- top: 0;
- }
- .dimension-card:hover {
- background-color: var(--theme-bg-hover);
- border-color: var(--theme-bg-active);
- box-shadow: 0 4px 12px -2px rgb(0 0 0 / 5%);
- }
- .dimension-card.is-active {
- background-color: var(--theme-bg-active);
- border-color: var(--theme-color);
- box-shadow:
- 0 0 0 1px var(--theme-bg-active),
- 0 4px 12px -2px var(--theme-bg-active);
- }
- :deep(.el-scrollbar__bar.is-vertical) {
- right: 2px;
- width: 4px;
- }
- :deep(.el-scrollbar__thumb) {
- background-color: #cbd5e1;
- opacity: 0.6;
- }
- :deep(.el-scrollbar__thumb:hover) {
- background-color: #94a3b8;
- opacity: 1;
- }
- :fullscreen {
- padding: 16px;
- background-color: #fff;
- }
- /* 兼容写法 */
- ::backdrop {
- background-color: #fff;
- }
- </style>
|