Files
screen/src/views/screens/sales/index.vue
2026-06-01 15:43:46 +08:00

1328 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>