1328 lines
48 KiB
Vue
1328 lines
48 KiB
Vue
<template>
|
||
<div class="warehouse-screen">
|
||
<!-- 全屏动态背景 -->
|
||
<div class="dynamic-bg">
|
||
<div class="grid-container">
|
||
<div class="grid-lines horizontal"></div>
|
||
<div class="grid-lines vertical"></div>
|
||
</div>
|
||
<div class="scan-laser">
|
||
<div class="laser-beam"></div>
|
||
<div class="laser-glow"></div>
|
||
</div>
|
||
<div class="particles-container">
|
||
<div v-for="i in 20" :key="i" class="particle" :style="getParticleStyle(i)"></div>
|
||
</div>
|
||
<div class="glow-globe globe-1"></div>
|
||
<div class="glow-globe globe-2"></div>
|
||
<div class="glow-globe globe-3"></div>
|
||
</div>
|
||
|
||
<!-- 四角装饰 -->
|
||
<div class="corner-decor corner-tl"></div>
|
||
<div class="corner-decor corner-tr"></div>
|
||
<div class="corner-decor corner-bl"></div>
|
||
<div class="corner-decor corner-br"></div>
|
||
|
||
<!-- 侧边光柱 -->
|
||
<div class="side-light side-light-left"></div>
|
||
<div class="side-light side-light-right"></div>
|
||
|
||
<!-- 缩放内容区 -->
|
||
<div class="scale-wrapper" :style="scaleWrapperStyle">
|
||
<div class="screen-content">
|
||
<!-- 头部标题 -->
|
||
<header class="screen-header">
|
||
<dv-border-box-1 class="title-border">
|
||
<div class="title-box">
|
||
<span class="title-icon">
|
||
<svg class="radar-svg" viewBox="0 0 24 24" width="22" height="22" color="#00d4ff">
|
||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="0.8" opacity="0.3"/>
|
||
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="0.3" opacity="0.15"/>
|
||
<line x1="12" y1="2" x2="12" y2="22" stroke="currentColor" stroke-width="0.3" opacity="0.15"/>
|
||
<g class="radar-beam">
|
||
<path d="M12,12 L12,3 A9,9 0 0,1 21,12 Z" fill="currentColor" opacity="0.2"/>
|
||
</g>
|
||
<circle cx="12" cy="12" r="1.5" fill="#00d4ff" opacity="0.9"/>
|
||
<circle cx="12" cy="12" r="3" fill="none" stroke="#00d4ff" stroke-width="0.5" opacity="0.3">
|
||
<animate attributeName="r" values="3;6;3" dur="2s" repeatCount="indefinite"/>
|
||
<animate attributeName="opacity" values="0.3;0;0.3" dur="2s" repeatCount="indefinite"/>
|
||
</circle>
|
||
</svg>
|
||
</span>
|
||
<h1 class="screen-title">销售信息大屏</h1>
|
||
<span class="subtitle">Sales Information Dashboard</span>
|
||
<div class="time-filter">
|
||
<span v-for="r in timeRanges" :key="r.key" class="time-btn" :class="{ active: timeRange === r.key }" @click="setTimeRange(r.key)">{{ r.label }}</span>
|
||
</div>
|
||
<span class="live-indicator">
|
||
<span class="live-dot"></span>
|
||
<span class="live-text">实时</span>
|
||
</span>
|
||
<span class="clock-text">{{ currentDate }}</span>
|
||
</div>
|
||
</dv-border-box-1>
|
||
</header>
|
||
|
||
<!-- 主内容 -->
|
||
<main class="screen-body">
|
||
<!-- KPI 行 -->
|
||
<div class="kpi-row">
|
||
<div class="kpi-card" v-for="k in kpiList" :key="k.label">
|
||
<div class="kpi-label">{{ k.label }}</div>
|
||
<div class="kpi-value" :style="{ color: k.color }">{{ k.value }}</div>
|
||
<div class="kpi-unit">{{ k.unit }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表行 4 列 -->
|
||
<div class="charts-row">
|
||
<!-- 销售员业绩排行 -->
|
||
<dv-border-box-8 :reverse="true" class="chart-panel">
|
||
<div class="chart-wrap" @mouseenter="stopSalesmanScan" @mouseleave="startSalesmanScan">
|
||
<div class="panel-header">
|
||
<span class="panel-dot"></span>
|
||
<span class="panel-title">销售员业绩排行</span>
|
||
</div>
|
||
<div ref="salesmanChartRef" class="chart-body"></div>
|
||
</div>
|
||
</dv-border-box-8>
|
||
<!-- 客户等级分布 -->
|
||
<dv-border-box-8 :reverse="true" class="chart-panel">
|
||
<div class="chart-wrap" @mouseenter="stopLevelCarousel" @mouseleave="startLevelCarousel(levelCached)">
|
||
<div class="panel-header">
|
||
<span class="panel-dot"></span>
|
||
<span class="panel-title">客户等级分布</span>
|
||
</div>
|
||
<div ref="levelChartRef" class="chart-body"></div>
|
||
</div>
|
||
</dv-border-box-8>
|
||
<!-- 行业分布 -->
|
||
<dv-border-box-8 :reverse="true" class="chart-panel">
|
||
<div class="chart-wrap" @mouseenter="stopIndustryCarousel" @mouseleave="startIndustryCarousel(industryCached)">
|
||
<div class="panel-header">
|
||
<span class="panel-dot"></span>
|
||
<span class="panel-title">行业分布</span>
|
||
</div>
|
||
<div ref="industryChartRef" class="chart-body"></div>
|
||
</div>
|
||
</dv-border-box-8>
|
||
</div>
|
||
|
||
<!-- 订单自动轮播 -->
|
||
<dv-border-box-8 :reverse="true" class="order-panel">
|
||
<div class="order-wrap">
|
||
<div class="panel-header">
|
||
<span class="panel-dot"></span>
|
||
<span class="panel-title">最近订单</span>
|
||
</div>
|
||
<div
|
||
class="order-scroll"
|
||
ref="orderScrollRef"
|
||
@mousedown="onDragStart"
|
||
>
|
||
<div
|
||
v-for="(o, i) in orderList"
|
||
:key="i"
|
||
class="order-card"
|
||
:style="{ animationDelay: i * 0.03 + 's' }"
|
||
@click="openDetail(i)"
|
||
>
|
||
<div class="card-top">
|
||
<span class="card-order-no">{{ o.orderNo || '-' }}</span>
|
||
<span class="card-time">{{ o.time || '' }}</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<span class="card-customer">{{ o.customer || '-' }}</span>
|
||
<span class="card-status" :class="statusClass(o.status)">{{ o.status || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</dv-border-box-8>
|
||
|
||
<!-- 订单详情弹窗 -->
|
||
<div v-if="detailOrder" class="detail-overlay" @click.self="closeDetail">
|
||
<div class="detail-panel">
|
||
<div class="detail-header">
|
||
<span class="detail-header-dot"></span>
|
||
<span>订单详情</span>
|
||
<span class="detail-close" @click="closeDetail">✕</span>
|
||
</div>
|
||
<div class="detail-body">
|
||
<div class="detail-row">
|
||
<span class="detail-label">订单编号</span>
|
||
<span class="detail-value code">{{ detailOrder.orderNo || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">客户名称</span>
|
||
<span class="detail-value">{{ detailOrder.customer || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">联系人</span>
|
||
<span class="detail-value">{{ detailOrder.contactPerson || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">订单金额</span>
|
||
<span class="detail-value code">¥{{ formatAmount(detailOrder.amount) }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">订单状态</span>
|
||
<span class="detail-value">
|
||
<span class="status-badge" :class="statusClass(detailOrder.status)">{{ detailOrder.status || '-' }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">销售员</span>
|
||
<span class="detail-value">{{ detailOrder.salesman || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">客户等级</span>
|
||
<span class="detail-value">{{ detailOrder.customerLevel || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">所属行业</span>
|
||
<span class="detail-value">{{ detailOrder.industry || '-' }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">未结款金额</span>
|
||
<span class="detail-value code">¥{{ formatAmount(detailOrder.unpaidAmount) }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="detail-label">创建时间</span>
|
||
<span class="detail-value code">{{ detailOrder.time || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||
import * as echarts from 'echarts'
|
||
import dayjs from 'dayjs'
|
||
import 'dayjs/locale/zh-cn'
|
||
dayjs.locale('zh-cn')
|
||
import {
|
||
getSalesSummary,
|
||
getSalesmanStats,
|
||
getCustomerLevelStats,
|
||
getIndustryStats,
|
||
getOrderDetails
|
||
} from '@/api/sales'
|
||
|
||
const currentDate = ref('')
|
||
const scaleRatio = ref(1)
|
||
let timeInterval = null
|
||
|
||
// ===== 时间过滤 =====
|
||
const timeRange = ref('year')
|
||
const timeRanges = [
|
||
{ key: 'all', label: '全部' },
|
||
{ key: 'today', label: '今日' },
|
||
{ key: 'week', label: '本周' },
|
||
{ key: 'month', label: '本月' },
|
||
{ key: 'year', label: '本年' }
|
||
]
|
||
|
||
const calcTimeParams = (range) => {
|
||
const now = dayjs()
|
||
switch (range) {
|
||
case 'all': return {}
|
||
case 'today': return { startTime: now.startOf('day').format('YYYY-MM-DD'), endTime: now.endOf('day').format('YYYY-MM-DD') }
|
||
case 'week': return { startTime: now.startOf('week').format('YYYY-MM-DD'), endTime: now.endOf('week').format('YYYY-MM-DD') }
|
||
case 'month': return { startTime: now.startOf('month').format('YYYY-MM-DD'), endTime: now.endOf('month').format('YYYY-MM-DD') }
|
||
case 'year': return { startTime: now.startOf('year').format('YYYY-MM-DD'), endTime: now.endOf('year').format('YYYY-MM-DD') }
|
||
default: return {}
|
||
}
|
||
}
|
||
|
||
const setTimeRange = (key) => {
|
||
if (timeRange.value === key) return
|
||
timeRange.value = key
|
||
loadData()
|
||
}
|
||
|
||
// ===== 直接使用内置 Mock 数据 =====
|
||
const mockSummary = {
|
||
totalOrderCount: 450, totalSalesAmount: 9860,
|
||
completedOrderCount: 320, completedSalesAmount: 7230,
|
||
totalUnpaidAmount: 2630, avgOrderAmount: 21.9
|
||
}
|
||
const mockSalesmanStats = [
|
||
{ salesmanName: '张伟', totalAmount: 12560000 },
|
||
{ salesmanName: '李强', totalAmount: 9820000 },
|
||
{ salesmanName: '王芳', totalAmount: 8750000 },
|
||
{ salesmanName: '赵磊', totalAmount: 6540000 },
|
||
{ salesmanName: '刘洋', totalAmount: 5210000 },
|
||
{ salesmanName: '陈静', totalAmount: 3980000 },
|
||
{ salesmanName: '杨光', totalAmount: 2850000 }
|
||
]
|
||
const mockLevelStats = [
|
||
{ customerLevel: '高', count: 400 },
|
||
{ customerLevel: '中', count: 44 },
|
||
{ customerLevel: '低', count: 1 },
|
||
{ customerLevel: 'VIP', count: 1 }
|
||
]
|
||
const mockIndustryStats = [
|
||
{ industry: '制造业', count: 168 },
|
||
{ industry: '贸易', count: 165 },
|
||
{ industry: '加工业', count: 115 }
|
||
]
|
||
const mockOrders = [
|
||
{ orderNo: 'ORD20260515001', customer: '周口钢铁', amount: 125000, status: '生产中', time: '10:30' },
|
||
{ orderNo: 'ORD20260515002', customer: '南阳重工', amount: 89000, status: '已完成', time: '09:45' },
|
||
{ orderNo: 'ORD20260515003', customer: '洛阳机械', amount: 156000, status: '待生产', time: '11:20' },
|
||
{ orderNo: 'ORD20260515004', customer: '开封汽配', amount: 67000, status: '生产中', time: '08:15' },
|
||
{ orderNo: 'ORD20260515005', customer: '商丘金属', amount: 45000, status: '已完成', time: '07:30' },
|
||
{ orderNo: 'ORD20260515006', customer: '山东福安德', amount: 234000, status: '生产中', time: '14:20' },
|
||
{ orderNo: 'ORD20260515007', customer: '临沂屹钢', amount: 187000, status: '待生产', time: '13:45' },
|
||
{ orderNo: 'ORD20260515008', customer: '江苏永腾', amount: 92000, status: '已完成', time: '15:10' },
|
||
{ orderNo: 'ORD20260515009', customer: '武汉欣航晟', amount: 76000, status: '生产中', time: '16:30' },
|
||
{ orderNo: 'ORD20260515010', customer: '天津盛盈', amount: 198000, status: '待生产', time: '17:00' },
|
||
{ orderNo: 'ORD20260515011', customer: '河南宏之澳', amount: 54000, status: '生产中', time: '09:20' },
|
||
{ orderNo: 'ORD20260515012', customer: '上海圳洋', amount: 312000, status: '已完成', time: '11:50' },
|
||
{ orderNo: 'ORD20260515013', customer: '许昌涵博', amount: 43000, status: '待生产', time: '08:40' },
|
||
{ orderNo: 'ORD20260515014', customer: '济宁钰昌', amount: 88000, status: '生产中', time: '14:10' },
|
||
{ orderNo: 'ORD20260515015', customer: '无锡昌德', amount: 165000, status: '已完成', time: '10:00' },
|
||
{ orderNo: 'ORD20260515016', customer: '邯郸鼎力', amount: 72000, status: '生产中', time: '13:20' },
|
||
{ orderNo: 'ORD20260515017', customer: '唐山华冶', amount: 211000, status: '待生产', time: '16:10' },
|
||
{ orderNo: 'ORD20260515018', customer: '青岛宝井', amount: 98000, status: '部分发货', time: '09:30' },
|
||
{ orderNo: 'ORD20260515019', customer: '烟台东恒', amount: 56000, status: '已完成', time: '11:05' },
|
||
{ orderNo: 'ORD20260515020', customer: '徐州金虹', amount: 178000, status: '生产中', time: '15:40' },
|
||
{ orderNo: 'ORD20260515021', customer: '合肥银润', amount: 83000, status: '待生产', time: '14:55' },
|
||
{ orderNo: 'ORD20260515022', customer: '芜湖新兴', amount: 146000, status: '已完成', time: '08:25' },
|
||
{ orderNo: 'ORD20260515023', customer: '马鞍山钢铁', amount: 267000, status: '生产中', time: '12:10' },
|
||
{ orderNo: 'ORD20260515024', customer: '佛山金錩', amount: 61000, status: '部分发货', time: '10:45' },
|
||
{ orderNo: 'ORD20260515025', customer: '乐从钢铁', amount: 195000, status: '待生产', time: '17:20' },
|
||
{ orderNo: 'ORD20260515026', customer: '广州鼎盛', amount: 104000, status: '已完成', time: '07:55' },
|
||
{ orderNo: 'ORD20260515027', customer: '深圳华美', amount: 142000, status: '生产中', time: '13:35' },
|
||
{ orderNo: 'ORD20260515028', customer: '东莞宏创', amount: 77000, status: '待生产', time: '11:15' },
|
||
{ orderNo: 'ORD20260515029', customer: '成都宝龙', amount: 231000, status: '已完成', time: '15:50' },
|
||
{ orderNo: 'ORD20260515030', customer: '重庆万达', amount: 89000, status: '部分发货', time: '09:10' },
|
||
{ orderNo: 'ORD20260515031', customer: '西安丰源', amount: 123000, status: '生产中', time: '16:40' },
|
||
{ orderNo: 'ORD20260515032', customer: '兰州金川', amount: 68000, status: '待生产', time: '08:50' },
|
||
{ orderNo: 'ORD20260515033', customer: '云南铜业', amount: 289000, status: '生产中', time: '14:05' },
|
||
{ orderNo: 'ORD20260515034', customer: '贵州钢绳', amount: 94000, status: '已完成', time: '12:30' },
|
||
{ orderNo: 'ORD20260515035', customer: '湖南华菱', amount: 176000, status: '部分发货', time: '10:15' },
|
||
{ orderNo: 'ORD20260515036', customer: '江西新钢', amount: 81000, status: '待生产', time: '17:45' },
|
||
{ orderNo: 'ORD20260515037', customer: '福建三钢', amount: 159000, status: '生产中', time: '11:40' },
|
||
{ orderNo: 'ORD20260515038', customer: '广西柳钢', amount: 203000, status: '已完成', time: '09:05' },
|
||
{ orderNo: 'ORD20260515039', customer: '山西太钢', amount: 248000, status: '待生产', time: '15:20' },
|
||
{ orderNo: 'ORD20260515040', customer: '包头钢铁', amount: 73000, status: '生产中', time: '08:35' },
|
||
{ orderNo: 'ORD20260515041', customer: '酒泉钢铁', amount: 131000, status: '部分发货', time: '13:50' },
|
||
{ orderNo: 'ORD20260515042', customer: '安阳钢铁', amount: 185000, status: '已完成', time: '10:55' },
|
||
{ orderNo: 'ORD20260515043', customer: '邯郸钢铁', amount: 97000, status: '待生产', time: '16:15' },
|
||
{ orderNo: 'ORD20260515044', customer: '石家庄钢铁', amount: 214000, status: '生产中', time: '12:05' },
|
||
{ orderNo: 'ORD20260515045', customer: '南京钢铁', amount: 152000, status: '已完成', time: '07:40' },
|
||
{ orderNo: 'ORD20260515046', customer: '杭州钢铁', amount: 69000, status: '部分发货', time: '14:30' },
|
||
{ orderNo: 'ORD20260515047', customer: '宁波钢铁', amount: 237000, status: '待生产', time: '11:10' },
|
||
{ orderNo: 'ORD20260515048', customer: '日照钢铁', amount: 108000, status: '生产中', time: '09:55' },
|
||
{ orderNo: 'ORD20260515049', customer: '潍坊特钢', amount: 174000, status: '已完成', time: '15:05' },
|
||
{ orderNo: 'ORD20260515050', customer: '烟台钢铁', amount: 88000, status: '待生产', time: '08:20' }
|
||
]
|
||
|
||
// 订单状态数字 → 文本映射(与后端 crm 字典一致)
|
||
const ORDER_STATUS_MAP = { 0: '待生产', 1: '生产中', 2: '部分发货', 3: '已发货', 4: '已签收' }
|
||
|
||
// 后端字段 → 前端字段 归一化函数(兼容后端格式和 mock 格式)
|
||
const normalizeSalesmanStats = (data) => {
|
||
if (!Array.isArray(data)) return []
|
||
return data.map(d => ({
|
||
salesmanName: d.salesman || d.salesmanName || '-',
|
||
totalAmount: d.salesAmount !== undefined ? d.salesAmount : (d.totalAmount || 0)
|
||
}))
|
||
}
|
||
const normalizeLevelStats = (data) => {
|
||
if (!Array.isArray(data)) return []
|
||
return data.map(d => ({
|
||
customerLevel: d.customerLevel || '',
|
||
count: d.customerCount !== undefined ? d.customerCount : (d.count || 0)
|
||
}))
|
||
}
|
||
const normalizeIndustryStats = (data) => {
|
||
if (!Array.isArray(data)) return []
|
||
return data.map(d => ({
|
||
industry: d.industry || '',
|
||
count: d.customerCount !== undefined ? d.customerCount : (d.count || 0)
|
||
}))
|
||
}
|
||
const normalizeOrders = (data) => {
|
||
if (!Array.isArray(data)) return []
|
||
return data.map(d => ({
|
||
orderNo: d.orderCode || d.orderNo || '-',
|
||
customer: d.companyName || d.customer || '-',
|
||
amount: d.orderAmount !== undefined ? d.orderAmount : (d.amount || 0),
|
||
status: d.orderStatus !== undefined ? (ORDER_STATUS_MAP[d.orderStatus] || '-') : (d.status || '-'),
|
||
time: d.createTime || d.time || ''
|
||
}))
|
||
}
|
||
|
||
// KPI
|
||
const kpiList = ref([
|
||
{ label: '总订单数', value: mockSummary.totalOrderCount, unit: '单', color: '#00d4ff' },
|
||
{ label: '总销售额', value: mockSummary.totalSalesAmount, unit: '万元', color: '#7c63ff' },
|
||
{ label: '已完成订单', value: mockSummary.completedOrderCount, unit: '单', color: '#00ff88' },
|
||
{ label: '已完成金额', value: mockSummary.completedSalesAmount, unit: '万元', color: '#f0ad4e' },
|
||
{ label: '未结款金额', value: mockSummary.totalUnpaidAmount, unit: '万元', color: '#ff6b81' },
|
||
{ label: '平均订单额', value: mockSummary.avgOrderAmount, unit: '万元', color: '#ff85c0' }
|
||
])
|
||
|
||
const orderList = ref(mockOrders)
|
||
const rawOrderList = ref([]) // 后端原始数据(用于详情弹窗)
|
||
const detailOrder = ref(null) // 当前查看的订单详情
|
||
|
||
// 金额格式化
|
||
const formatAmount = (v) => {
|
||
if (v === null || v === undefined || v === '') return '0'
|
||
const n = Number(v)
|
||
return isNaN(n) ? '0' : n.toLocaleString('zh-CN')
|
||
}
|
||
|
||
// 订单状态 → CSS 类名
|
||
const statusClass = (status) => {
|
||
if (!status) return 'st-wait'
|
||
if (['已完成', '已发货', '已签收'].includes(status)) return 'st-done'
|
||
if (['生产中', '部分发货'].includes(status)) return 'st-doing'
|
||
return 'st-wait'
|
||
}
|
||
|
||
// 订单轮播(持续缓慢滚动)
|
||
const orderScrollRef = ref(null)
|
||
let carouselRafId = null
|
||
|
||
const startCarousel = () => {
|
||
const el = orderScrollRef.value
|
||
if (!el) return
|
||
pauseCarousel()
|
||
const step = () => {
|
||
const maxScroll = el.scrollHeight - el.clientHeight
|
||
if (maxScroll > 0) {
|
||
if (el.scrollTop >= maxScroll - 1) {
|
||
el.scrollTop = 0
|
||
} else {
|
||
el.scrollTop += Math.min(30 / 60, maxScroll - el.scrollTop)
|
||
}
|
||
}
|
||
carouselRafId = requestAnimationFrame(step)
|
||
}
|
||
carouselRafId = requestAnimationFrame(step)
|
||
}
|
||
|
||
const pauseCarousel = () => {
|
||
if (carouselRafId) {
|
||
cancelAnimationFrame(carouselRafId)
|
||
carouselRafId = null
|
||
}
|
||
}
|
||
|
||
// 图表轮播
|
||
let levelTimer = null
|
||
let industryTimer = null
|
||
let levelIndex = 0
|
||
let industryIndex = 0
|
||
const levelCached = ref([])
|
||
const industryCached = ref([])
|
||
|
||
const startLevelCarousel = (data) => {
|
||
stopLevelCarousel()
|
||
const list = Array.isArray(data) ? data : []
|
||
levelCached.value = list
|
||
if (!levelChart || list.length === 0) return
|
||
levelIndex = 0
|
||
levelTimer = setInterval(() => {
|
||
levelChart.dispatchAction({ type: 'downplay', seriesIndex: 0 })
|
||
levelChart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: levelIndex })
|
||
levelChart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: levelIndex })
|
||
levelIndex = (levelIndex + 1) % list.length
|
||
}, 2500)
|
||
}
|
||
|
||
const stopLevelCarousel = () => {
|
||
if (levelTimer) { clearInterval(levelTimer); levelTimer = null }
|
||
}
|
||
|
||
const startIndustryCarousel = (data) => {
|
||
stopIndustryCarousel()
|
||
const list = Array.isArray(data) ? data : []
|
||
industryCached.value = list
|
||
if (!industryChart || list.length === 0) return
|
||
industryIndex = 0
|
||
industryTimer = setInterval(() => {
|
||
industryChart.dispatchAction({ type: 'downplay', seriesIndex: 0 })
|
||
industryChart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: industryIndex })
|
||
industryChart.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: industryIndex })
|
||
industryIndex = (industryIndex + 1) % list.length
|
||
}, 2500)
|
||
}
|
||
|
||
const stopIndustryCarousel = () => {
|
||
if (industryTimer) { clearInterval(industryTimer); industryTimer = null }
|
||
}
|
||
|
||
const stopAllCarousels = () => {
|
||
stopLevelCarousel()
|
||
stopIndustryCarousel()
|
||
}
|
||
|
||
// 拖拽滚动
|
||
let dragActive = false
|
||
let dragStartY = 0
|
||
let dragStartScroll = 0
|
||
let dragClickOnly = true
|
||
let resumeTimer = null
|
||
|
||
const onDragStart = (e) => {
|
||
const el = orderScrollRef.value
|
||
if (!el) return
|
||
pauseCarousel()
|
||
if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null }
|
||
dragActive = true
|
||
dragClickOnly = true
|
||
dragStartY = e.clientY
|
||
dragStartScroll = el.scrollTop
|
||
document.addEventListener('mousemove', onDragMove)
|
||
document.addEventListener('mouseup', onDragEnd)
|
||
}
|
||
|
||
const onDragMove = (e) => {
|
||
if (!dragActive) return
|
||
const el = orderScrollRef.value
|
||
if (!el) return
|
||
const dy = e.clientY - dragStartY
|
||
if (Math.abs(dy) > 5) dragClickOnly = false
|
||
el.scrollTop = Math.max(0, Math.min(
|
||
el.scrollHeight - el.clientHeight,
|
||
dragStartScroll - dy
|
||
))
|
||
}
|
||
|
||
const onDragEnd = () => {
|
||
dragActive = false
|
||
document.removeEventListener('mousemove', onDragMove)
|
||
document.removeEventListener('mouseup', onDragEnd)
|
||
resumeTimer = setTimeout(() => { startCarousel() }, 4000)
|
||
}
|
||
|
||
const openDetail = (index) => {
|
||
if (!dragClickOnly) return
|
||
pauseCarousel()
|
||
if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null }
|
||
const raw = rawOrderList.value[index]
|
||
if (!raw) return
|
||
detailOrder.value = {
|
||
orderNo: raw.orderCode || raw.orderNo || '-',
|
||
customer: raw.companyName || raw.customer || '-',
|
||
amount: raw.orderAmount !== undefined ? raw.orderAmount : (raw.amount || 0),
|
||
status: raw.orderStatus !== undefined ? (ORDER_STATUS_MAP[raw.orderStatus] || '-') : (raw.status || '-'),
|
||
time: raw.createTime || raw.time || '',
|
||
contactPerson: raw.contactPerson || '-',
|
||
salesman: raw.salesman || '-',
|
||
customerLevel: raw.customerLevel || '-',
|
||
industry: raw.industry || '-',
|
||
unpaidAmount: raw.unpaidAmount !== undefined ? raw.unpaidAmount : 0
|
||
}
|
||
}
|
||
|
||
const closeDetail = () => {
|
||
detailOrder.value = null
|
||
setTimeout(() => startCarousel(), 300)
|
||
}
|
||
|
||
// Charts refs
|
||
const salesmanChartRef = ref(null)
|
||
const levelChartRef = ref(null)
|
||
const industryChartRef = ref(null)
|
||
let salesmanChart = null
|
||
let levelChart = null
|
||
let industryChart = null
|
||
|
||
const chartColors = ['#00d4ff', '#7c63ff', '#00ff88', '#f0ad4e', '#ff6b81', '#ff85c0', '#5ab1ef', '#2ec7c9']
|
||
|
||
// ===== 缩放 =====
|
||
const scaleWrapperStyle = computed(() => ({
|
||
transform: `scale(${scaleRatio.value})`
|
||
}))
|
||
|
||
const getParticleStyle = (index) => ({
|
||
left: `${Math.random() * 100}%`,
|
||
animationDelay: `${index * 0.4}s`,
|
||
animationDuration: `${8 + Math.random() * 6}s`
|
||
})
|
||
|
||
// ===== 数据加载(调用后端 API) =====
|
||
const loadData = async () => {
|
||
const timeParams = calcTimeParams(timeRange.value)
|
||
try {
|
||
const [summaryRes, salesmanRes, levelRes, industryRes, orderRes] = await Promise.all([
|
||
getSalesSummary(timeParams),
|
||
getSalesmanStats(timeParams),
|
||
getCustomerLevelStats(timeParams),
|
||
getIndustryStats(timeParams),
|
||
getOrderDetails({ ...timeParams, pageNum: 1, pageSize: 99999 })
|
||
])
|
||
|
||
// 汇总指标
|
||
const s = summaryRes && summaryRes.data ? summaryRes.data : summaryRes || {}
|
||
kpiList.value = [
|
||
{ label: '总订单数', value: s.totalOrderCount ?? 0, unit: '单', color: '#00d4ff' },
|
||
{ label: '总销售额', value: s.totalSalesAmount ?? 0, unit: '万元', color: '#7c63ff' },
|
||
{ label: '已完成订单', value: s.completedOrderCount ?? 0, unit: '单', color: '#00ff88' },
|
||
{ label: '已完成金额', value: s.completedSalesAmount ?? 0, unit: '万元', color: '#f0ad4e' },
|
||
{ label: '未结款金额', value: s.totalUnpaidAmount ?? 0, unit: '万元', color: '#ff6b81' },
|
||
{ label: '平均订单额', value: s.avgOrderAmount ?? 0, unit: '万元', color: '#ff85c0' }
|
||
]
|
||
|
||
// 销售员排行(归一化后端字段 → 前端字段)
|
||
const rawSalesman = salesmanRes && salesmanRes.data ? salesmanRes.data : salesmanRes || []
|
||
nextTick(() => updateSalesmanChart(normalizeSalesmanStats(rawSalesman)))
|
||
|
||
// 客户等级(归一化)
|
||
const rawLevel = levelRes && levelRes.data ? levelRes.data : levelRes || []
|
||
nextTick(() => { updateLevelChart(normalizeLevelStats(rawLevel)); startLevelCarousel(normalizeLevelStats(rawLevel)) })
|
||
|
||
// 行业分布(归一化)
|
||
const rawIndustry = industryRes && industryRes.data ? industryRes.data : industryRes || []
|
||
nextTick(() => { updateIndustryChart(normalizeIndustryStats(rawIndustry)); startIndustryCarousel(normalizeIndustryStats(rawIndustry)) })
|
||
|
||
// 订单列表(归一化)
|
||
const rawOrder = orderRes && orderRes.data ? orderRes.data : orderRes || {}
|
||
const rawRows = Array.isArray(rawOrder) ? rawOrder : (rawOrder.rows || [])
|
||
rawOrderList.value = rawRows
|
||
orderList.value = normalizeOrders(rawRows)
|
||
|
||
// 地图已移除
|
||
} catch (e) {
|
||
console.warn('销售大屏数据加载失败,使用 mock 数据兜底', e)
|
||
const s = mockSummary
|
||
kpiList.value = [
|
||
{ label: '总订单数', value: s.totalOrderCount, unit: '单', color: '#00d4ff' },
|
||
{ label: '总销售额', value: s.totalSalesAmount, unit: '万元', color: '#7c63ff' },
|
||
{ label: '已完成订单', value: s.completedOrderCount, unit: '单', color: '#00ff88' },
|
||
{ label: '已完成金额', value: s.completedSalesAmount, unit: '万元', color: '#f0ad4e' },
|
||
{ label: '未结款金额', value: s.totalUnpaidAmount, unit: '万元', color: '#ff6b81' },
|
||
{ label: '平均订单额', value: s.avgOrderAmount, unit: '万元', color: '#ff85c0' }
|
||
]
|
||
rawOrderList.value = mockOrders
|
||
orderList.value = mockOrders
|
||
nextTick(() => {
|
||
updateSalesmanChart(mockSalesmanStats)
|
||
updateLevelChart(mockLevelStats)
|
||
updateIndustryChart(mockIndustryStats)
|
||
startLevelCarousel(mockLevelStats)
|
||
startIndustryCarousel(mockIndustryStats)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ===== 图表渲染 =====
|
||
const initCharts = () => {
|
||
nextTick(() => {
|
||
// 销售员排行 - 横向柱状图
|
||
if (salesmanChartRef.value && !salesmanChart) {
|
||
salesmanChart = echarts.init(salesmanChartRef.value)
|
||
}
|
||
// 客户等级分布 - 环形图
|
||
if (levelChartRef.value && !levelChart) {
|
||
levelChart = echarts.init(levelChartRef.value)
|
||
}
|
||
// 行业分布 - 饼图
|
||
if (industryChartRef.value && !industryChart) {
|
||
industryChart = echarts.init(industryChartRef.value)
|
||
}
|
||
})
|
||
}
|
||
|
||
const updateSalesmanChart = (data) => {
|
||
if (!salesmanChart) return
|
||
const list = Array.isArray(data) ? data : []
|
||
const names = list.map(d => d.salesmanName || d.name || '-')
|
||
const values = list.map(d => Number(d.totalAmount || d.amount || 0))
|
||
const maxV = Math.max(...values, 1)
|
||
const avgValue = values.length ? (values.reduce((a, b) => a + b, 0) / values.length) : 0
|
||
salesmanChart.setOption({
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, backgroundColor: 'rgba(10,20,40,0.9)', borderColor: '#1e3a5f', textStyle: { color: '#fff' } },
|
||
grid: { left: 10, right: 50, top: 8, bottom: 8, containLabel: true },
|
||
xAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } }, axisLabel: { color: 'rgba(255,255,255,0.5)', fontSize: 10 } },
|
||
yAxis: { type: 'category', data: names, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: 'rgba(255,255,255,0.7)', fontSize: 11 } },
|
||
series: [{
|
||
type: 'bar',
|
||
barWidth: 10,
|
||
data: values.map((v, i) => ({
|
||
value: v,
|
||
itemStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||
{ offset: 0, color: chartColors[i % chartColors.length] },
|
||
{ offset: 1, color: chartColors[(i + 2) % chartColors.length] }
|
||
]),
|
||
borderRadius: [0, 4, 4, 0]
|
||
}
|
||
})),
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
color: 'rgba(255,255,255,0.6)',
|
||
fontSize: 10,
|
||
fontFamily: 'Courier New',
|
||
formatter: (p) => p.value > 0 ? (p.value / 10000).toFixed(1) + 'w' : ''
|
||
},
|
||
z: 10,
|
||
markLine: {
|
||
silent: true,
|
||
animation: false,
|
||
lineStyle: { color: '#ff6b81', type: 'dashed', width: 1.5 },
|
||
label: {
|
||
position: 'insideEndTop',
|
||
formatter: '平均 ' + (avgValue / 10000).toFixed(1) + 'w',
|
||
color: '#ff6b81',
|
||
fontSize: 10
|
||
},
|
||
data: [{ xAxis: avgValue }]
|
||
}
|
||
}]
|
||
})
|
||
startSalesmanScan()
|
||
}
|
||
|
||
// 销售员图表扫光动画
|
||
let salesmanScanTimer = null
|
||
const scanGradient = new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||
{ offset: 0, color: 'rgba(0,212,255,0)' },
|
||
{ offset: 0.3, color: 'rgba(0,212,255,0.12)' },
|
||
{ offset: 0.7, color: 'rgba(0,212,255,0.12)' },
|
||
{ offset: 1, color: 'rgba(0,212,255,0)' }
|
||
])
|
||
|
||
const startSalesmanScan = () => {
|
||
stopSalesmanScan()
|
||
if (!salesmanChart) return
|
||
const grid = salesmanChart.getModel().getComponent('grid').getRect()
|
||
const left = grid.x
|
||
const right = grid.x + grid.width
|
||
let pos = right
|
||
salesmanScanTimer = setInterval(() => {
|
||
if (!salesmanChart) { stopSalesmanScan(); return }
|
||
pos -= 2
|
||
if (pos < left - 25) pos = right
|
||
salesmanChart.setOption({
|
||
graphic: [{
|
||
type: 'rect',
|
||
shape: { x: Math.round(pos), y: grid.y, width: 25, height: grid.height },
|
||
style: { fill: scanGradient },
|
||
z: 100
|
||
}]
|
||
}, { replaceMerge: 'graphic' })
|
||
}, 40)
|
||
}
|
||
|
||
const stopSalesmanScan = () => {
|
||
if (salesmanScanTimer) {
|
||
clearInterval(salesmanScanTimer)
|
||
salesmanScanTimer = null
|
||
}
|
||
if (salesmanChart) {
|
||
salesmanChart.setOption({ graphic: [] }, { replaceMerge: 'graphic' })
|
||
}
|
||
}
|
||
|
||
const updateLevelChart = (data) => {
|
||
if (!levelChart) return
|
||
const list = Array.isArray(data) ? data : []
|
||
// 合并 "高" 和 "2" 为高
|
||
const map = {}
|
||
list.forEach(d => {
|
||
const name = d.customerLevel || d.name || d.levelName || ''
|
||
const val = Number(d.count || d.value || 0)
|
||
if (name === '2' || name === '高') {
|
||
map['高'] = (map['高'] || 0) + val
|
||
} else if (name === '1' || name === '中') {
|
||
map['中'] = (map['中'] || 0) + val
|
||
} else if (name === '0' || name === '低') {
|
||
map['低'] = (map['低'] || 0) + val
|
||
} else if (name === '3' || name === 'vip' || name === 'VIP') {
|
||
map['VIP'] = (map['VIP'] || 0) + val
|
||
} else if (name) {
|
||
map[name] = (map[name] || 0) + val
|
||
}
|
||
})
|
||
const pieData = Object.entries(map).map(([k, v]) => ({ name: k, value: v }))
|
||
const colorMap = { '高': '#00d4ff', '中': '#f0ad4e', '低': '#7c63ff', 'VIP': '#ff6b81' }
|
||
levelChart.setOption({
|
||
tooltip: { trigger: 'item', backgroundColor: 'rgba(10,20,40,0.9)', borderColor: '#1e3a5f', textStyle: { color: '#fff' } },
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['45%', '68%'],
|
||
center: ['50%', '50%'],
|
||
label: { color: 'rgba(255,255,255,0.7)', fontSize: 12, formatter: '{b}\n{d}%' },
|
||
labelLine: { lineStyle: { color: 'rgba(255,255,255,0.2)' } },
|
||
data: pieData.map(d => ({ ...d, itemStyle: { color: colorMap[d.name] || '#00d4ff' } })),
|
||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' } }
|
||
}]
|
||
})
|
||
}
|
||
|
||
const updateIndustryChart = (data) => {
|
||
if (!industryChart) return
|
||
const list = Array.isArray(data) ? data : []
|
||
const pieData = list
|
||
.filter(d => (d.industry || d.name || '') !== '')
|
||
.map(d => ({ name: d.industry || d.name || '', value: Number(d.count || d.value || 0) }))
|
||
industryChart.setOption({
|
||
tooltip: { trigger: 'item', backgroundColor: 'rgba(10,20,40,0.9)', borderColor: '#1e3a5f', textStyle: { color: '#fff' } },
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['45%', '68%'],
|
||
center: ['50%', '50%'],
|
||
label: { color: 'rgba(255,255,255,0.7)', fontSize: 12, formatter: '{b}\n{d}%' },
|
||
labelLine: { lineStyle: { color: 'rgba(255,255,255,0.2)' } },
|
||
data: pieData.map((d, i) => ({
|
||
...d,
|
||
itemStyle: { color: chartColors[i % chartColors.length] }
|
||
})),
|
||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' } }
|
||
}]
|
||
})
|
||
}
|
||
|
||
const handleResize = () => {
|
||
salesmanChart?.resize()
|
||
levelChart?.resize()
|
||
industryChart?.resize()
|
||
stopSalesmanScan()
|
||
if (salesmanChart) startSalesmanScan()
|
||
}
|
||
|
||
// ===== 生命周期 =====
|
||
onMounted(() => {
|
||
updateTime()
|
||
timeInterval = setInterval(updateTime, 1000)
|
||
nextTick(() => {
|
||
updateScale()
|
||
initCharts()
|
||
loadData()
|
||
startCarousel()
|
||
window.addEventListener('resize', updateScale)
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (timeInterval) clearInterval(timeInterval)
|
||
pauseCarousel()
|
||
stopAllCarousels()
|
||
stopSalesmanScan()
|
||
detailOrder.value = null
|
||
dragActive = false
|
||
if (resumeTimer) clearTimeout(resumeTimer)
|
||
document.removeEventListener('mousemove', onDragMove)
|
||
document.removeEventListener('mouseup', onDragEnd)
|
||
window.removeEventListener('resize', updateScale)
|
||
window.removeEventListener('resize', handleResize)
|
||
;[salesmanChart, levelChart, industryChart].forEach(c => {
|
||
if (c) { c.dispose(); c = null }
|
||
})
|
||
})
|
||
|
||
const updateScale = () => {
|
||
const w = window.innerWidth
|
||
const h = window.innerHeight
|
||
scaleRatio.value = Math.min(w / 1920, h / 1080)
|
||
}
|
||
|
||
const updateTime = () => {
|
||
const now = new Date()
|
||
currentDate.value = now.toLocaleString('zh-CN', {
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ===== 整体容器 ===== */
|
||
.warehouse-screen {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: linear-gradient(135deg, #050a15 0%, #0a1428 50%, #0d1b34 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
margin: 0;
|
||
}
|
||
|
||
.scale-wrapper {
|
||
width: 1920px;
|
||
height: 1080px;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform-origin: center center;
|
||
}
|
||
|
||
.screen-content {
|
||
position: relative;
|
||
z-index: 10;
|
||
width: 100%;
|
||
height: 100%;
|
||
padding: 14px 18px;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* ===== 动态背景 ===== */
|
||
.dynamic-bg {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
.grid-lines { position: absolute; inset: 0; }
|
||
.grid-lines::before { content: ''; position: absolute; inset: 0; }
|
||
.grid-lines.horizontal::before { background: repeating-linear-gradient(90deg, transparent, transparent 95px, rgba(0,212,255,0.04) 95px, rgba(0,212,255,0.04) 96px); }
|
||
.grid-lines.vertical::before { background: repeating-linear-gradient(0deg, transparent, transparent 95px, rgba(0,212,255,0.04) 95px, rgba(0,212,255,0.04) 96px); }
|
||
.particles-container { position: absolute; inset: 0; overflow: hidden; }
|
||
.particle {
|
||
position: absolute;
|
||
width: 3px; height: 3px;
|
||
background: radial-gradient(circle, #00d4ff 0%, rgba(0,212,255,0.4) 50%, transparent 100%);
|
||
border-radius: 50%;
|
||
animation: particleFloat 12s ease-in-out infinite;
|
||
box-shadow: 0 0 6px #00d4ff, 0 0 16px rgba(0,212,255,0.4);
|
||
}
|
||
@keyframes particleFloat {
|
||
0%,100% { top: 100%; transform: translateX(0) scale(0.3); opacity: 0; }
|
||
10% { top: 85%; transform: translateX(0) scale(1); opacity: 1; }
|
||
50% { top: 45%; transform: translateX(25px) scale(1.2); }
|
||
85% { opacity: 0.6; top: 10%; transform: translateX(-15px) scale(0.7); }
|
||
100% { top: -5%; opacity: 0; }
|
||
}
|
||
.glow-globe { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.2; animation: globePulse 8s ease-in-out infinite; }
|
||
.glow-globe.globe-1 { width: 350px; height: 350px; background: radial-gradient(circle, rgba(0,212,255,0.3) 0%, transparent 70%); top: 15%; right: 15%; animation-delay: 0s; }
|
||
.glow-globe.globe-2 { width: 300px; height: 300px; background: radial-gradient(circle, rgba(124,99,255,0.25) 0%, transparent 70%); bottom: 20%; left: 8%; animation-delay: 2.5s; }
|
||
.glow-globe.globe-3 { width: 280px; height: 280px; background: radial-gradient(circle, rgba(0,255,136,0.15) 0%, transparent 70%); top: 45%; left: 50%; transform: translate(-50%,-50%); animation-delay: 5s; }
|
||
@keyframes globePulse { 0%,100% { transform: scale(1); opacity: 0.2; } 50% { transform: scale(1.25); opacity: 0.35; } }
|
||
.scan-laser { position: absolute; inset: 0; overflow: hidden; opacity: 0.06; }
|
||
.laser-beam { position: absolute; top: -100%; left: 0; right: 0; height: 40%; background: linear-gradient(180deg, transparent, rgba(0,212,255,0.5), transparent); animation: scanDown 4s ease-in-out infinite; }
|
||
@keyframes scanDown { 0% { top: -40%; } 100% { top: 100%; } }
|
||
|
||
/* 四角装饰 */
|
||
.corner-decor { position: fixed; z-index: 5; width: 40px; height: 40px; pointer-events: none; }
|
||
.corner-decor::before, .corner-decor::after { content: ''; position: absolute; background: #00d4ff; box-shadow: 0 0 8px rgba(0,212,255,0.4); }
|
||
.corner-tl { top: 10px; left: 10px; }
|
||
.corner-tl::before { top: 0; left: 0; width: 2px; height: 100%; }
|
||
.corner-tl::after { top: 0; left: 0; width: 100%; height: 2px; }
|
||
.corner-tr { top: 10px; right: 10px; }
|
||
.corner-tr::before { top: 0; right: 0; width: 2px; height: 100%; }
|
||
.corner-tr::after { top: 0; right: 0; width: 100%; height: 2px; }
|
||
.corner-bl { bottom: 10px; left: 10px; }
|
||
.corner-bl::before { bottom: 0; left: 0; width: 2px; height: 100%; }
|
||
.corner-bl::after { bottom: 0; left: 0; width: 100%; height: 2px; }
|
||
.corner-br { bottom: 10px; right: 10px; }
|
||
.corner-br::before { bottom: 0; right: 0; width: 2px; height: 100%; }
|
||
.corner-br::after { bottom: 0; right: 0; width: 100%; height: 2px; }
|
||
|
||
/* 侧边光柱 */
|
||
.side-light { position: fixed; top: 0; bottom: 0; width: 1px; z-index: 5; pointer-events: none; }
|
||
.side-light::before { content: ''; position: absolute; top: 0; width: 1px; height: 100%; }
|
||
.side-light-left { left: 0; }
|
||
.side-light-left::before { left: 0; background: linear-gradient(180deg, transparent, #00d4ff, transparent); box-shadow: 0 0 10px rgba(0,212,255,0.3),0 0 30px rgba(0,212,255,0.15); }
|
||
.side-light-right { right: 0; }
|
||
.side-light-right::before { right: 0; background: linear-gradient(180deg, transparent, #7c63ff, transparent); box-shadow: 0 0 10px rgba(124,99,255,0.3),0 0 30px rgba(124,99,255,0.15); }
|
||
|
||
/* ===== 头部标题 ===== */
|
||
.screen-header {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
.title-box {
|
||
padding: 10px 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 14px;
|
||
background: rgba(10, 20, 40, 0.8);
|
||
}
|
||
.title-icon { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; }
|
||
.radar-svg { display: block; }
|
||
.radar-beam { transform-origin: 12px 12px; animation: radarSpin 3s linear infinite; }
|
||
@keyframes radarSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||
.screen-title {
|
||
font-size: 28px;
|
||
color: #00d4ff;
|
||
margin: 0;
|
||
text-shadow: 0 0 20px rgba(0,212,255,0.8),0 0 40px rgba(0,212,255,0.4);
|
||
letter-spacing: 6px;
|
||
font-weight: bold;
|
||
}
|
||
.subtitle { font-size: 12px; color: #6a8cb5; letter-spacing: 1px; }
|
||
.time-filter { display: flex; align-items: center; gap: 3px; margin-left: auto; margin-right: 8px; }
|
||
.time-btn {
|
||
font-size: 11px; color: rgba(255,255,255,0.45); padding: 2px 10px; cursor: pointer;
|
||
border: 1px solid rgba(0,212,255,0.15); border-radius: 4px; background: rgba(0,212,255,0.04);
|
||
transition: all 0.2s; letter-spacing: 0.5px;
|
||
}
|
||
.time-btn:hover { color: rgba(255,255,255,0.8); border-color: rgba(0,212,255,0.3); background: rgba(0,212,255,0.1); }
|
||
.time-btn.active { color: #00d4ff; border-color: rgba(0,212,255,0.4); background: rgba(0,212,255,0.12); text-shadow: 0 0 8px rgba(0,212,255,0.3); }
|
||
.live-indicator { display: flex; align-items: center; gap: 5px; margin-left: auto; padding: 2px 10px; border: 1px solid rgba(0,255,136,0.3); border-radius: 12px; background: rgba(0,255,136,0.08); }
|
||
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: #00ff88; box-shadow: 0 0 6px #00ff88,0 0 12px rgba(0,255,136,0.4); animation: liveDotPulse 1.5s ease-in-out infinite; }
|
||
@keyframes liveDotPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } }
|
||
.live-text { font-size: 11px; color: #00ff88; letter-spacing: 1px; }
|
||
.clock-text { font-size: 14px; color: #00d4ff; font-family: 'Courier New', monospace; letter-spacing: 1px; margin-left: 4px; }
|
||
|
||
/* ===== 主内容区 ===== */
|
||
.screen-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-height: 0;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
/* ===== KPI 行 ===== */
|
||
.kpi-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.kpi-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 10px 6px 8px;
|
||
background: rgba(10, 20, 40, 0.6);
|
||
border: 1px solid rgba(0, 212, 255, 0.12);
|
||
border-radius: 6px;
|
||
gap: 2px;
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
font-family: 'Courier New', monospace;
|
||
text-shadow: 0 0 12px currentColor;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.kpi-unit {
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.3);
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
/* ===== 图表行 ===== */
|
||
.charts-row {
|
||
flex: 0 0 240px;
|
||
display: flex;
|
||
gap: 8px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.chart-panel {
|
||
flex: 1;
|
||
background: rgba(10, 20, 40, 0.5);
|
||
}
|
||
|
||
.chart-wrap {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 10px 10px 6px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.panel-dot {
|
||
width: 3px;
|
||
height: 12px;
|
||
background: #00d4ff;
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 6px rgba(0, 212, 255, 0.5);
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 13px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.chart-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ===== 订单轮播 ===== */
|
||
.order-panel {
|
||
flex: 1;
|
||
background: rgba(10, 20, 40, 0.5);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.order-wrap {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 10px 10px 6px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.order-scroll {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-content: flex-start;
|
||
gap: 6px;
|
||
padding: 2px;
|
||
}
|
||
|
||
.order-card {
|
||
width: calc(20% - 5px);
|
||
background: rgba(0, 212, 255, 0.04);
|
||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||
border-radius: 5px;
|
||
padding: 8px 10px;
|
||
animation: cardFadeIn 0.4s ease-out both;
|
||
transition: all 0.2s ease;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.order-card:hover {
|
||
background: rgba(0, 212, 255, 0.1);
|
||
border-color: rgba(0, 212, 255, 0.3);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.12);
|
||
}
|
||
|
||
@keyframes cardFadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.card-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.card-order-no {
|
||
font-size: 11px;
|
||
font-family: 'Courier New', monospace;
|
||
color: #00d4ff;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.card-time {
|
||
font-size: 10px;
|
||
font-family: 'Courier New', monospace;
|
||
color: rgba(255, 255, 255, 0.35);
|
||
}
|
||
|
||
.card-body {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.card-customer {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.card-amount {
|
||
font-size: 13px;
|
||
font-family: 'Courier New', monospace;
|
||
color: #f0ad4e;
|
||
font-weight: bold;
|
||
margin-left: auto;
|
||
text-shadow: 0 0 8px rgba(240, 173, 78, 0.3);
|
||
}
|
||
|
||
.card-status {
|
||
font-size: 11px;
|
||
padding: 1px 8px;
|
||
border-radius: 3px;
|
||
text-align: center;
|
||
|
||
&.st-done {
|
||
color: #00ff88;
|
||
background: rgba(0, 255, 136, 0.1);
|
||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||
}
|
||
&.st-doing {
|
||
color: #f0ad4e;
|
||
background: rgba(240, 173, 78, 0.1);
|
||
border: 1px solid rgba(240, 173, 78, 0.2);
|
||
}
|
||
&.st-wait {
|
||
color: rgba(0, 212, 255, 0.6);
|
||
background: rgba(0, 212, 255, 0.06);
|
||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||
}
|
||
}
|
||
|
||
/* ===== 订单详情弹窗 ===== */
|
||
.detail-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.detail-panel {
|
||
width: 480px;
|
||
max-height: 80vh;
|
||
background: linear-gradient(135deg, #0a1428 0%, #0d1b34 100%);
|
||
border: 1px solid rgba(0, 212, 255, 0.25);
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 40px rgba(0, 212, 255, 0.15), 0 8px 32px rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 14px 20px;
|
||
background: rgba(0, 212, 255, 0.06);
|
||
border-bottom: 1px solid rgba(0, 212, 255, 0.12);
|
||
font-size: 15px;
|
||
color: #00d4ff;
|
||
letter-spacing: 1px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.detail-header-dot {
|
||
width: 4px;
|
||
height: 14px;
|
||
background: #00d4ff;
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
|
||
}
|
||
|
||
.detail-close {
|
||
margin-left: auto;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
transition: color 0.2s;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.detail-close:hover {
|
||
color: #ff6b81;
|
||
background: rgba(255, 107, 129, 0.1);
|
||
}
|
||
|
||
.detail-body {
|
||
padding: 16px 20px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
|
||
&::-webkit-scrollbar { width: 4px; }
|
||
&::-webkit-scrollbar-track { background: rgba(0,212,255,0.05); border-radius: 2px; }
|
||
&::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.2); border-radius: 2px; }
|
||
}
|
||
|
||
.detail-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-label {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.45);
|
||
min-width: 80px;
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.detail-value {
|
||
font-size: 13px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
letter-spacing: 0.5px;
|
||
|
||
&.code {
|
||
font-family: 'Courier New', monospace;
|
||
color: #00d4ff;
|
||
}
|
||
}
|
||
|
||
.status-badge {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
padding: 2px 10px;
|
||
border-radius: 3px;
|
||
|
||
&.st-done {
|
||
color: #00ff88;
|
||
background: rgba(0, 255, 136, 0.1);
|
||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||
}
|
||
&.st-doing {
|
||
color: #f0ad4e;
|
||
background: rgba(240, 173, 78, 0.1);
|
||
border: 1px solid rgba(240, 173, 78, 0.2);
|
||
}
|
||
&.st-wait {
|
||
color: rgba(0, 212, 255, 0.6);
|
||
background: rgba(0, 212, 255, 0.06);
|
||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||
}
|
||
}
|
||
</style>
|