Files
screen/src/views/screens/sales/index.vue

1328 lines
48 KiB
Vue
Raw Normal View History

2026-06-01 15:43:46 +08:00
<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>