This commit is contained in:
浅念
2026-02-12 09:38:27 +08:00
commit 5ba8456a88
42 changed files with 8372 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+149
View File
@@ -0,0 +1,149 @@
# 数据大屏 React 项目
这是使用 React 18 + Vite 重构的数据大屏项目,功能与原HTML版本完全一致。
## 项目结构
```
react-app/
├── src/
│ ├── components/
│ │ ├── TopData.jsx # 顶部数据区域(年度目标、销售额、进度条、季度进度)
│ │ ├── GroupRanking.jsx # 小组排名组件
│ │ └── PersonRanking.jsx # 个人排名组件
│ ├── App.jsx # 主应用组件
│ ├── main.jsx # 入口文件
│ └── style.css # 全局样式
├── public/
│ └── img/ # 图片资源
├── index.html # HTML模板
├── package.json # 项目配置
└── vite.config.js # Vite配置
```
## 安装步骤
### 1. 安装依赖
```bash
cd react-app
npm install
```
### 2. 启动开发服务器
```bash
npm run dev
```
浏览器会自动打开 `http://localhost:3001`
### 3. 构建生产版本
```bash
npm run build
```
构建产物在 `react-app/dist/` 目录
## 功能特性
✅ **响应式设计**
- PC端自动等比例缩放
- 移动端自适应布局
- 支持4K屏幕、普通PC、移动端
✅ **数据展示**
- 年度目标和销售额动态加载
- 进度条动画效果
- 季度周数进度显示
✅ **图表功能**
- 小组排名柱状图
- 个人排名柱状图(移动端横向布局)
- ECharts响应式图表
✅ **性能优化**
- React Hooks (useState, useEffect, useRef)
- 组件化开发
- 防抖处理窗口resize
- 图表自动销毁重建
## 技术栈
- React 18.2+ (Hooks)
- Vite 5.0+
- ECharts 5.4+
- JavaScript ES6+
## 对接真实API
在 `src/components/TopData.jsx` 的 `fetchYearData` 函数中修改:
```javascript
const fetchYearData = async () => {
try {
const response = await fetch('/api/year-data')
const data = await response.json()
return data
} catch (error) {
console.error('获取数据失败:', error)
return { target: 0, actual: 0 }
}
}
```
## 数据格式
### API返回格式
```json
{
"target": 3500,
"actual": 2750
}
```
### 排名数据格式
在 `GroupRanking.jsx` 和 `PersonRanking.jsx` 中修改数据:
```javascript
const groupData = [
{ name: '名称', target: 目标值, actual: 实际值 }
]
```
## React Hooks 使用
- **useState**: 组件状态管理
- **useEffect**: 副作用处理(图表初始化、事件监听)
- **useRef**: DOM引用和图表实例存储
- **useCallback**: 性能优化(可选)
## 浏览器支持
- Chrome (推荐)
- Firefox
- Safari
- Edge
## 注意事项
1. 确保 `public/img/` 目录包含所有图片资源
2. PC端缩放范围为 0.6-1.0(缩小不放大)
3. 移动端断点为 768px
4. 图表使用防抖处理resize事件(300ms延迟)
5. React组件使用函数式组件 + Hooks
## 与Vue版本对比
| 特性 | React版本 | Vue版本 |
|------|-----------|---------|
| 状态管理 | useState | ref/reactive |
| 副作用 | useEffect | onMounted/onUnmounted |
| DOM引用 | useRef | ref |
| 组件定义 | 函数式组件 | setup script |
| 样式处理 | CSS Modules/全局CSS | scoped CSS |
| 学习曲线 | 中等 | 较低 |
| 生态成熟度 | 非常成熟 | 快速发展 |
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据大屏</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1700
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "screen-dashboard-react",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"echarts": "^5.4.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react'
import TopData from './components/TopData'
import GroupRanking from './components/GroupRanking'
import PersonRanking from './components/PersonRanking'
import headerImage from '/img/heard.png'
import footerImage from '/img/foot.png'
function App() {
const containerRef = useRef(null)
const MOBILE_BREAKPOINT = 768
const DESIGN_WIDTH = 1920
// PC端等比例缩放函数
const setScale = () => {
const screenWidth = window.innerWidth
// 移动端不缩放,保持响应式布局
if (screenWidth <= MOBILE_BREAKPOINT) {
document.body.style.transform = 'none'
document.body.style.transformOrigin = 'left top'
document.body.style.marginLeft = '0px'
document.body.style.width = '100%'
document.body.style.minWidth = '320px'
document.body.style.height = 'auto'
document.body.style.overflowX = 'hidden'
return
}
// PC端等比例缩放
const scale = screenWidth / DESIGN_WIDTH
const finalScale = Math.max(scale, 0.6)
// 重置样式以获取真实内容高度
document.body.style.transform = 'none'
document.body.style.height = 'auto'
// 获取真实内容高度
const contentHeight = document.body.scrollHeight
// 计算缩放后的高度
const scaledHeight = Math.ceil(contentHeight * finalScale)
// 应用缩放
document.body.style.transform = `scale(${finalScale})`
document.body.style.transformOrigin = 'left top'
document.body.style.width = `${DESIGN_WIDTH}px`
document.body.style.minWidth = `${DESIGN_WIDTH}px`
document.body.style.height = `${scaledHeight}px`
document.body.style.marginLeft = '0px'
document.body.style.overflowX = 'hidden'
}
const handleProgressRate = (rate) => {
console.log('进度率:', rate)
}
useEffect(() => {
setScale()
let resizeTimer = null
const handleResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(setScale, 100)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
clearTimeout(resizeTimer)
}
}, [])
return (
<div className="container" ref={containerRef}>
{/* 头部图片 */}
<div className="header-image">
<img src={headerImage} alt="头部装饰图片" />
</div>
<div className="cont">
{/* 顶部数据区域 */}
<TopData onProgressRate={handleProgressRate} />
{/* 小组排名区域 */}
<h2 className="section-title">小组排名</h2>
<GroupRanking />
{/* 个人排名区域 */}
<h2 className="section-title">个人排名</h2>
<PersonRanking />
{/* 尾部图片 */}
<div className="footer-image">
<img src={footerImage} alt="尾部装饰图片" />
</div>
</div>
</div>
)
}
export default App
+257
View File
@@ -0,0 +1,257 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
function GroupRanking() {
const chartRef = useRef(null)
const chartInstance = useRef(null)
const MOBILE_BREAKPOINT = 768
// 小组数据
const groupData = [
{ name: '华东一组', target: 50000000000, actual: 62000000000 },
{ name: '华南二组', target: 45000000000, actual: 51000000000 },
{ name: '华北三组', target: 40000000000, actual: 43500000000 },
{ name: '西南四组', target: 35000000000, actual: 28000000000 },
{ name: '东北五组', target: 30000000000, actual: 26500000000 }
]
// 格式化数字(万元)
const formatNumber = (num) => {
return (num / 10000).toFixed(0) + '万'
}
// 渲染图表
const renderChart = () => {
if (!chartRef.current) return
// 销毁旧图表
if (chartInstance.current) {
chartInstance.current.dispose()
}
chartInstance.current = echarts.init(chartRef.current)
const names = groupData.map(item => item.name)
const actuals = groupData.map(item => item.actual / 100000000)
const targets = groupData.map(item => item.target / 100000000)
const isMobile = window.innerWidth <= MOBILE_BREAKPOINT
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: function(params) {
let result = params[0].name + '<br/>'
params.forEach(param => {
result += param.marker + param.seriesName + ': ' + param.value + '万<br/>'
})
return result
},
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderColor: '#ff6600',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
}
},
grid: isMobile ? {
left: '5%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
} : {
left: '15%',
right: '10%',
bottom: '10%',
top: '20%',
containLabel: true
},
legend: {
data: ['目标销售额', '实际销售额'],
textStyle: {
color: '#fff',
fontSize: 12
},
top: '2%'
},
xAxis: isMobile ? {
type: 'category',
data: names,
name: '小组',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 10,
interval: 0,
rotate: 30
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
} : {
type: 'value',
name: '万元',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 11,
formatter: function(value) {
return value * 100
}
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
yAxis: isMobile ? {
type: 'value',
name: '万',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 11
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
} : {
type: 'category',
data: names,
axisLabel: {
color: '#fff',
fontSize: 11
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
},
series: [
{
name: '目标销售额',
type: 'bar',
data: targets,
itemStyle: {
color: new echarts.graphic.LinearGradient(isMobile ? 0 : 0, isMobile ? 1 : 0, isMobile ? 0 : 1, isMobile ? 0 : 0, [
{ offset: 0, color: '#F77436' },
{ offset: 1, color: '#F77436' }
]),
borderRadius: isMobile ? [4, 4, 0, 0] : [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(102, 102, 102, 0.8)'
}
}
},
{
name: '实际销售额',
type: 'bar',
data: actuals,
itemStyle: {
color: new echarts.graphic.LinearGradient(isMobile ? 0 : 0, isMobile ? 1 : 0, isMobile ? 0 : 1, isMobile ? 0 : 0, [
{ offset: 0, color: '#65491D' },
{ offset: 1, color: '#65491D' }
]),
borderRadius: isMobile ? [4, 4, 0, 0] : [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 102, 0, 0.8)'
}
}
}
]
}
chartInstance.current.setOption(option)
}
useEffect(() => {
const timer = setTimeout(() => {
renderChart()
}, 500)
let resizeTimer = null
const handleResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
renderChart()
}, 300)
}
window.addEventListener('resize', handleResize)
return () => {
clearTimeout(timer)
window.removeEventListener('resize', handleResize)
clearTimeout(resizeTimer)
if (chartInstance.current) {
chartInstance.current.dispose()
}
}
}, [])
return (
<div className="group-ranking">
<div className="ranking-content">
{/* 排名列表 */}
<div className="ranking-list">
{groupData.map((item, index) => {
const rate = ((item.actual / item.target) * 100).toFixed(1)
const rankClass = index < 3 ? `top-${index + 1}` : 'other'
return (
<div key={index} className={`ranking-item ${rankClass}`}>
<div className="rank-number">{index + 1}</div>
<div className="rank-name">{item.name}</div>
<div className="rank-sales">销售额: {formatNumber(item.actual)} | 目标: {formatNumber(item.target)}</div>
<div className="rank-rate">完成率: {rate}%</div>
</div>
)
})}
</div>
{/* 图表容器 */}
<div ref={chartRef} className="chart-container"></div>
</div>
</div>
)
}
export default GroupRanking
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
function PersonRanking() {
const chartRef = useRef(null)
const chartInstance = useRef(null)
const MOBILE_BREAKPOINT = 768
// 个人数据
const personData = [
{ name: '张伟', target: 5000000000, actual: 7200000000 },
{ name: '李娜', target: 4500000000, actual: 5800000000 },
{ name: '王强', target: 4000000000, actual: 4900000000 },
{ name: '刘洋', target: 3800000000, actual: 4100000000 },
{ name: '陈静', target: 3500000000, actual: 3600000000 },
{ name: '杨明', target: 3200000000, actual: 2800000000 },
{ name: '赵丽', target: 3000000000, actual: 2500000000 }
]
// 格式化数字(万元)
const formatNumber = (num) => {
return (num / 10000).toFixed(0) + '万'
}
// 渲染图表
const renderChart = () => {
if (!chartRef.current) return
// 销毁旧图表
if (chartInstance.current) {
chartInstance.current.dispose()
}
chartInstance.current = echarts.init(chartRef.current)
const names = personData.map(item => item.name)
const actuals = personData.map(item => item.actual / 100000000)
const targets = personData.map(item => item.target / 100000000)
const isMobile = window.innerWidth <= MOBILE_BREAKPOINT
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: function(params) {
let result = params[0].name + '<br/>'
params.forEach(param => {
result += param.marker + param.seriesName + ': ' + param.value + '万<br/>'
})
return result
},
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderColor: '#ff6600',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
}
},
grid: isMobile ? {
left: '10%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
} : {
left: '15%',
right: '10%',
bottom: '10%',
top: '20%',
containLabel: true
},
legend: {
data: ['目标销售额', '实际销售额'],
textStyle: {
color: '#fff',
fontSize: 12
},
top: '2%'
},
xAxis: isMobile ? {
type: 'value',
name: '万',
nameLocation: 'end',
nameGap: 5,
nameTextStyle: {
color: '#fff',
fontSize: 10
},
axisLabel: {
color: '#fff',
fontSize: 10
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
} : {
type: 'value',
name: '万',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 11,
formatter: function(value) {
return value * 100
}
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
yAxis: isMobile ? {
type: 'category',
data: names,
axisLabel: {
color: '#fff',
fontSize: 10
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
} : {
type: 'category',
data: names,
axisLabel: {
color: '#fff',
fontSize: 11
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
},
series: [
{
name: '目标销售额',
type: 'bar',
data: targets,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#F77436' },
{ offset: 1, color: '#F77436' }
]),
borderRadius: [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(102, 102, 102, 0.8)'
}
}
},
{
name: '实际销售额',
type: 'bar',
data: actuals,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#65491D' },
{ offset: 1, color: '#65491D' }
]),
borderRadius: [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 102, 0, 0.8)'
}
}
}
]
}
chartInstance.current.setOption(option)
}
useEffect(() => {
const timer = setTimeout(() => {
renderChart()
}, 500)
let resizeTimer = null
const handleResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
renderChart()
}, 300)
}
window.addEventListener('resize', handleResize)
return () => {
clearTimeout(timer)
window.removeEventListener('resize', handleResize)
clearTimeout(resizeTimer)
if (chartInstance.current) {
chartInstance.current.dispose()
}
}
}, [])
return (
<div className="person-ranking">
<div className="ranking-content">
{/* 排名列表 */}
<div className="ranking-list">
{personData.map((item, index) => {
const rate = ((item.actual / item.target) * 100).toFixed(1)
const rankClass = index < 3 ? `top-${index + 1}` : 'other'
return (
<div key={index} className={`ranking-item ${rankClass}`}>
<div className="rank-number">{index + 1}</div>
<div className="rank-name">{item.name}</div>
<div className="rank-sales">销售额: {formatNumber(item.actual)} | 目标: {formatNumber(item.target)}</div>
<div className="rank-rate">完成率: {rate}%</div>
</div>
)
})}
</div>
{/* 图表容器 */}
<div ref={chartRef} className="chart-container"></div>
</div>
</div>
)
}
export default PersonRanking
+151
View File
@@ -0,0 +1,151 @@
import { useState, useEffect, useRef } from 'react'
function TopData({ onProgressRate }) {
const [yearData, setYearData] = useState({ target: 3500, actual: 2750 })
const [progressWidth, setProgressWidth] = useState('0%')
const [progressRate, setProgressRate] = useState('0.00')
const MOBILE_BREAKPOINT = 768
// 获取当前周数
const getCurrentWeek = () => {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 1)
const diff = now - start
return Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1
}
const [currentWeek, setCurrentWeek] = useState(getCurrentWeek())
// 季度数据
const [quarters] = useState([
{ id: 'q1', label: 'Q1', start: 1, end: 13, weeks: Array.from({ length: 13 }, (_, i) => i + 1) },
{ id: 'q2', label: 'Q2', start: 14, end: 26, weeks: Array.from({ length: 13 }, (_, i) => i + 14) },
{ id: 'q3', label: 'Q3', start: 27, end: 39, weeks: Array.from({ length: 13 }, (_, i) => i + 27) },
{ id: 'q4', label: 'Q4', start: 40, end: 52, weeks: Array.from({ length: 13 }, (_, i) => i + 40) }
])
// 获取年度数据
const fetchYearData = async () => {
console.log('正在获取年度数据...')
// TODO: 替换为实际的API调用
return {
target: 3500,
actual: 2750
}
}
// 格式化大数字
const formatLargeNumber = (num) => {
return num.toLocaleString('zh-CN') + ' 万元'
}
// 启动进度条动画
const startProgressAnimation = (targetRate) => {
const PROGRESS_STEP = 0.4
const PROGRESS_INTERVAL = 20
let value = 0
let lastTime = 0
const animateProgress = (currentTime) => {
if (!lastTime) lastTime = currentTime
const deltaTime = currentTime - lastTime
if (deltaTime >= PROGRESS_INTERVAL) {
value += PROGRESS_STEP
if (value >= targetRate) {
value = targetRate
setProgressWidth(value + '%')
return
}
setProgressWidth(value + '%')
lastTime = currentTime
}
requestAnimationFrame(animateProgress)
}
requestAnimationFrame(animateProgress)
}
useEffect(() => {
const initData = async () => {
const data = await fetchYearData()
setYearData(data)
const rate = ((data.actual / data.target) * 100).toFixed(2)
setProgressRate(rate)
if (onProgressRate) {
onProgressRate(parseFloat(rate))
}
setTimeout(() => {
startProgressAnimation(parseFloat(rate))
}, 800)
}
initData()
}, [onProgressRate])
return (
<div className="top-data">
{/* 销售信息 */}
<div className="sales-info">
<div className="sales-info-top">
<div className="target-info">
<h3>年度目标</h3>
<div className="target-amount">{formatLargeNumber(yearData.target)}</div>
</div>
<div className="total-info">
<h3>年度总销售额</h3>
<div className="total-amount">{formatLargeNumber(yearData.actual)}</div>
</div>
</div>
<div className="sales-info-cont">
<div className="sales-info-bottom">
<div className="progress-container">
<div className="progress-bg">
<div className="progress-bar" style={{ width: progressWidth }}>
<div className="progress-info">
<div className="play-btn"></div>
</div>
</div>
<div className="progress-text">{progressRate}%</div>
</div>
</div>
</div>
<div className="progress-label">完成率</div>
</div>
</div>
{/* 年度进度 */}
<div className="countdown">
<h3>年度进度</h3>
<div className="quarters-container">
{quarters.map((quarter, index) => (
<div
key={quarter.id}
className="quarter-item"
style={{ borderBottom: index === quarters.length - 1 ? 'none' : '1px solid #523A14' }}
>
<div className="quarter-label">{quarter.label}</div>
<div className="weeks-container">
{quarter.weeks.map((week) => (
<div
key={week}
className={`week-circle ${week === currentWeek ? 'current' : ''} ${week < currentWeek ? 'passed' : ''}`}
title={`第${week}周`}
>
{week}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default TopData
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './style.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+620
View File
@@ -0,0 +1,620 @@
/* PC端等比例缩放,根字号固定 */
html {
font-size: 16px;
}
/* 移动端基础字号 */
@media screen and (max-width: 768px) {
html {
font-size: 14px;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
color: #fff;
min-width: 320px;
overflow-x: hidden;
/* PC端缩放相关样式 */
transform-origin: left top;
transition: transform 0.1s ease-out;
}
.container {
width: 100%;
min-height: 100vh;
flex-direction: column;
justify-content: space-between;
background: #000;
/* 固定最小宽度,避免缩放时内容挤压 */
min-width: 1200px;
}
.cont{
width: 90%;
max-width: 1400px;
margin: 0 auto;
padding: 0 1rem;
/* 确保内容区域最小宽度 */
min-width: 1080px;
}
/* 移动端下调整宽度 */
@media screen and (max-width: 768px) {
body {
transform: none !important;
width: 100% !important;
min-width: 320px !important;
height: auto !important;
overflow-x: hidden !important;
}
.container {
min-width: 320px;
width: 100%;
}
.cont {
width: 95%;
max-width: 100%;
min-width: auto;
}
}
/* 顶部数据区域 */
.top-data {
display: grid;
grid-template-columns: 2fr 1fr;
gap: clamp(6px, 1.5vw, 14px);
margin-bottom: clamp(10px, 1.5vw, 20px);
}
.sales-info {
background: url(/img/hicon.png);
background-repeat: no-repeat;
background-size: 100% 100%;
border-radius: clamp(4px, 0.5vw, 8px);
padding: clamp(10px, 1.5vw, 20px);
display: flex;
flex-direction: column;
gap: clamp(10px, 1.5vw, 15px);
}
.sales-info-top {
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.target-info, .total-info {
text-align: center;
}
.target-info h3,.total-info h3{
font-size: clamp(0.75rem, 1vw, 1rem);
color: #fff;
margin-bottom: clamp(5px, 0.8vw, 8px);
font-weight: normal;
}
.target-amount {
font-size: clamp(1.25rem, 2.5vw, 2.25rem);
font-weight: bold;
color: #F77436;
margin-bottom: clamp(5px, 0.8vw, 10px);
line-height: 1.2;
}
.total-amount {
font-size: clamp(1.25rem, 2.5vw, 2.25rem);
font-weight: bold;
color: #FA551F;
margin-bottom: clamp(5px, 0.8vw, 10px);
line-height: 1.2;
}
.sales-info-cont{
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.sales-info-bottom {
width: 90%;
flex: 1;
}
.progress-container {
width: 100%;
height: clamp(20px, 2.5vw, 30px);
padding: 3px;
border-radius: 20px;
background: linear-gradient(180deg, #FF923C, #FF923C);
box-shadow: 0px 0px 8px rgb(255 255 255 / 80%);
overflow: hidden;
position: relative;
}
.progress-bg {
position: relative;
width: 100%;
height: 100%;
background: #DC7439;
border-radius: 16px;
overflow: hidden;
display: flex;
align-items: center;
}
.progress-bar {
position: relative;
height: 100%;
width: 0%;
border-radius: 16px;
background: repeating-linear-gradient(45deg,rgba(255, 255, 255, 0.25) 0,rgba(255, 255, 255, 0.25) 8px,rgba(255, 255, 255, 0.12) 8px,rgba(255, 255, 255, 0.12) 16px),linear-gradient(180deg, #FFD85E, #FFBE25);
animation: move 1.2s linear infinite;
box-shadow: inset 0 0 6px rgba(255, 255, 255, 0.6);
will-change: width;
transition: width 0.3s ease;
}
.progress-info {
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 6px;
color: #fff;
font-size: clamp(10px, 1.2vw, 13px);
font-weight: 600;
white-space: nowrap;
z-index: 1;
}
.play-btn {
width: clamp(18px, 2vw, 24px);
height: clamp(18px, 2vw, 24px);
border-radius: 50%;
background: #DC7439;
border: 2px solid #FFBE25;
display: flex;
align-items: center;
justify-content: center;
}
.play-btn::before {
content: '';
margin-left: 2px;
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 7px solid #fff;
}
.progress-text {
color: #fff;
font-weight: bold;
font-size: clamp(10px, 1.2vw, 13px);
white-space: nowrap;
display: inline-block;
}
.progress-label {
color: #DC7439;
font-weight: bold;
font-size: clamp(10px, 1.2vw, 13px);
white-space: nowrap;
}
/* 条纹滚动动画 */
@keyframes move {
from { background-position: 0 0; }
to { background-position: 40px 0; }
}
.completion-rate {
display: none;
}
.countdown {
border: 1px solid #523A14;
border-radius: 8px;
padding: clamp(8px, 1.5vw,10px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.countdown h3 {
font-size: clamp(0.875rem, 1.25vw, 1.25rem);
color: #e7552f;
margin-bottom: clamp(10px, 1.5vw, 15px);
font-weight: bold;
}
/* 季度容器 */
.quarters-container {
display: flex;
flex-direction: column;
gap: clamp(5px, 1.5vw, 5px);
width: 100%;
}
/* 单个季度 */
.quarter-item {
display: flex;
align-items: center;
gap: clamp(5px, 1vw, 8px);
flex: 1;
min-width: 0;
border-bottom: 1px solid #523A14;
padding-bottom: 8px;
}
.last-quarter{
border-bottom: none;
padding-bottom: 0px;
}
.quarter-label {
background: #e7552f;
color: #fff;
font-size: clamp(0.875rem, 1.2vw, 1rem);
font-weight: bold;
padding: clamp(3px, 0.5vw, 5px) clamp(8px, 1vw, 12px);
border-radius: 4px;
min-width: clamp(35px, 5vw, 45px);
text-align: center;
box-shadow: 0 0 10px rgba(255, 102, 0, 0.5);
flex-shrink: 0;
}
/* 周数圆圈容器 */
.weeks-container {
display: flex;
flex-wrap: wrap;
gap: clamp(3px, 0.5vw, 5px);
flex: 1;
}
/* 单个周数圆圈 */
.week-circle {
width: clamp(18px, 2.5vw, 24px);
height: clamp(18px, 2.5vw, 24px);
border-radius: 50%;
border: 2px solid #815B37;
background: rgba(129, 91, 55, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(0.625rem, 1vw, 0.75rem);
color: #f5bbab;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.week-circle:hover {
background: rgba(231, 85, 47, 0.4);
border-color: #e7552f;
transform: scale(1.1);
}
/* 当前周高亮 */
.week-circle.current {
background: #e7552f;
border-color: #ff6600;
box-shadow: 0 0 10px rgba(255, 102, 0, 0.8);
animation: pulse 2s infinite;
}
/* 已过去的周 */
.week-circle.passed {
background: #e7552f;
border-color: #ff6600;
box-shadow: 0 0 10px rgba(255, 102, 0, 0.8);
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 10px rgba(255, 102, 0, 0.8);
}
50% {
box-shadow: 0 0 20px rgba(255, 102, 0, 1);
}
}
/* 小组排名区域 */
.group-ranking {
border: 1px solid #523A14;
border-radius: 8px;
padding: clamp(10px, 1.5vw, 20px);
margin-bottom: clamp(10px, 1.5vw, 20px);
}
.section-title {
font-size: clamp(0.875rem, 1.25vw, 1rem);
color: #fff;
background: #ff6600;
padding: clamp(5px, 0.8vw, 5px) clamp(10px, 1.5vw, 15px);
display: inline-block;
font-weight: bold;
}
.ranking-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(10px, 1.5vw, 20px);
}
.ranking-list {
display: flex;
flex-direction: column;
gap: clamp(8px, 1vw, 10px);
}
.ranking-item {
display: flex;
align-items: center;
padding: clamp(8px, 1.2vw, 12px) clamp(10px, 1.5vw, 15px);
border-radius: 4px;
font-size: clamp(0.75rem, 1vw, 0.875rem);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ranking-item.top-1 {
background: linear-gradient(90deg, rgba(199, 173, 61, 0.4), rgba(199, 173, 61, 0.1));
border: 1px solid rgba(199, 173, 61, 0.4);
}
.ranking-item.top-2 {
background: linear-gradient(90deg, rgba(136, 136, 135, 0.5), rgba(136, 136, 135, 0.1));
border: 1px solid rgba(136, 136, 135, 0.4);
}
.ranking-item.top-3 {
background: linear-gradient(90deg, rgba(59, 96, 93, 0.6), rgba(59, 96, 93,0.1));
border: 1px solid rgba(59, 96, 93, 0.4);
}
.ranking-item.other {
background: linear-gradient(90deg, rgba(100, 100, 100, 0.3), rgba(100, 100, 100, 0.1));
border: 1px solid rgba(100, 100, 100, 0.4);
}
.rank-number {
font-size: clamp(0.875rem, 1.25vw, 1.25rem);
font-weight: bold;
margin-right: clamp(10px, 1.5vw, 15px);
min-width: 20px;
width: clamp(20px, 3vw, 25px);
height: clamp(20px, 3vw, 25px);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.ranking-item.top-1 .rank-number {
background: #C7AD3D;
color: #000;
box-shadow: 0 0 8px rgba(255, 204, 0, 0.8);
}
.ranking-item.top-2 .rank-number {
background: #888887;
color: #000;
box-shadow: 0 0 8px rgba(192, 192, 192, 0.8);
}
.ranking-item.top-3 .rank-number {
background: #3B605D;
color: #fff;
box-shadow: 0 0 8px rgba(205, 127, 50, 0.8);
}
.ranking-item.other .rank-number {
background: #815B37;
color: #fff;
}
.rank-name {
font-weight: bold;
margin-right: clamp(10px, 1.5vw, 20px);
min-width: clamp(60px, 8vw, 90px);
color: #fff;
font-size: clamp(0.75rem, 1.1vw, 0.875rem);
}
.rank-sales {
flex: 1;
margin-right: clamp(10px, 1.5vw, 20px);
color: #F77436;
font-size: clamp(0.6875rem, 1vw, 0.8125rem);
}
.rank-rate {
font-weight: bold;
color: #FA3925;
font-size: clamp(0.6875rem, 1vw, 0.8125rem);
}
.chart-container {
height: 100%;
min-height: clamp(250px, 30vw, 300px);
}
.ranking-content {
align-items: stretch;
}
/* 个人排名区域 */
.person-ranking {
border: 1px solid #523A14;
border-radius: 8px;
padding: 20px;
}
/* 响应式设计 */
/* 移动端优化 (768px及以下) */
@media (max-width: 768px) {
.top-data {
grid-template-columns: 1fr;
}
.ranking-content {
grid-template-columns: 1fr;
}
.sales-info {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.sales-info-top {
flex-direction: column;
gap: 1rem;
}
.sales-info-cont {
flex-direction: column;
gap: 0.5rem;
}
.sales-info-bottom {
width: 100%;
}
.section-title {
font-size: clamp(0.875rem, 2.5vw, 1.125rem);
}
.quarter-label {
font-size: clamp(0.75rem, 2vw, 0.875rem);
padding: clamp(2px, 0.5vw, 4px) clamp(6px, 1.5vw, 10px);
min-width: clamp(30px, 5vw, 40px);
}
.week-circle {
width: clamp(16px, 4vw, 20px);
height: clamp(16px, 4vw, 20px);
font-size: clamp(0.5rem, 1.5vw, 0.625rem);
}
.ranking-item {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
gap: clamp(5px, 1vw, 8px);
font-size: clamp(0.6875rem, 2vw, 0.875rem);
padding: clamp(10px, 2vw, 15px);
align-items: center;
}
.ranking-item .rank-number {
grid-column: 1;
grid-row: 1 / 3;
font-size: clamp(0.875rem, 2.5vw, 1.125rem);
width: clamp(18px, 5vw, 25px);
height: clamp(18px, 5vw, 25px);
min-width: 18px;
margin-right: 0;
}
.ranking-item .rank-name {
grid-column: 2;
grid-row: 1;
margin-bottom: 0;
margin-right: 0;
font-size: clamp(0.75rem, 2.2vw, 0.875rem);
text-align: left;
}
.ranking-item .rank-sales {
grid-column: 2;
grid-row: 2;
margin-right: 0;
margin-bottom: 0;
margin-left: 0;
margin-top: 0;
font-size: clamp(0.625rem, 1.8vw, 0.75rem);
text-align: left;
}
.ranking-item .rank-rate {
grid-column: 3;
grid-row: 1 / 3;
margin-left: 0;
flex-shrink: 0;
font-size: clamp(0.6875rem, 2vw, 0.8125rem);
align-self: center;
text-align: right;
}
.chart-container {
min-height: clamp(200px, 40vw, 250px);
}
}
/* 超小屏幕优化 (480px及以下) */
@media (max-width: 480px) {
.quarters-container {
gap: clamp(5px, 2vw, 8px);
}
.quarter-label {
font-size: clamp(0.625rem, 2.5vw, 0.75rem);
padding: clamp(2px, 0.5vw, 3px) clamp(5px, 1.5vw, 8px);
min-width: clamp(25px, 6vw, 35px);
}
.week-circle {
width: clamp(14px, 4.5vw, 18px);
height: clamp(14px, 4.5vw, 18px);
font-size: clamp(0.4375rem, 2vw, 0.5625rem);
gap: clamp(2px, 0.5vw, 3px);
}
}
/* 头部和尾部图片 */
.header-image {
margin-bottom: 20px;
text-align: center;
width: 100%;
}
.footer-image {
margin-top: 20px;
text-align: center;
width: 100%;
}
.header-image img,
.footer-image img {
width: 100%;
height: auto;
display: block;
margin: 0 auto;
object-fit: contain;
}
/* 排名数字内联样式替代 */
.ranking-item .rank-number {
color: #000;
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
open: true
}
})