fix: first commit

This commit is contained in:
听云的猫 2025-06-17 15:51:49 +08:00
commit 4b6a6eb8ef
59 changed files with 14333 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# cockpit
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "cockpit",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^0.24.0",
"core-js": "^3.6.5",
"dayjs": "^1.10.7",
"default-passive-events": "^2.0.0",
"echarts": "^5.4.2",
"echarts-wordcloud": "^2.1.0",
"element-ui": "^2.15.13",
"node-sass": "^4.0.0",
"sass-loader": "^10.1.0",
"three": "^0.177.0",
"v-scale-screen": "1.0.2",
"vue": "^2.6.11",
"vue-axios": "^3.4.0",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.15",
"@vue/cli-plugin-router": "~4.5.15",
"@vue/cli-plugin-vuex": "~4.5.15",
"@vue/cli-service": "~4.5.15",
"vue-template-compiler": "^2.6.11"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

23
public/index.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
<style>
body{
padding: 0;
margin: 0;
}
</style>
</html>

14
src/App.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<div id="app">
<router-view/>
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
}
</style>

46
src/api/index.js Normal file
View File

@ -0,0 +1,46 @@
import axios from 'axios';
axios.defaults.baseURL = 'http://180.108.205.89:8011/'
// 请求拦截
axios.interceptors.request.use(
(config) => {
console.log(`请求地址:${axios.duefaults.baseURL}${config.url} axios拦截器`);
config.headers['Authorization'] = 'Bearer ' + sessionStorage.getItem('token')
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截
axios.interceptors.response.use(
function (response) {
console.log('↓↓↓ axios响应的数据 ↓↓↓');
console.log(JSON.parse(JSON.stringify(response.data)));
console.log('↑↑↑ axios响应的数据 ↑↑↑');
return response;
},
function (error) {
return Promise.reject(error);
}
);
// get
export function get() {
return axios({
method: 'get',
url: '/get',
})
}
// post
export function post(data) {
return axios({
method: 'POST',
url: '/post',
data: data
})
}

View File

@ -0,0 +1,31 @@
.section-left, .section-right {
flex: 1;
background-color: #42b98330;
opacity: 0; /* 初始状态不可见 */
animation-fill-mode: forwards; /* 保持动画完成时状态 */
}
.section-left {
animation: slideInFromLeft 1s ease-out forwards;
animation-delay: 1s;
}
.section-right {
animation: slideInFromRight 1s ease-out forwards;
animation-delay: 1s;
}
.section-container {
flex: 2;
.screen-title {
width: 100%;
height: 10vh;
line-height: 10vh;
text-align: center;
color: white;
font-size: 36px;
font-weight: 900;
}
}

Binary file not shown.

7
src/assets/font/font.css Normal file
View File

@ -0,0 +1,7 @@
@font-face {
font-family: 'YouSheBiaoTiHei';
src: url('./YouSheBiaoTiHei-2.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,323 @@
<template>
<div
ref="chartContainer"
:style="{ width: '100%', height: chartHeight }"
></div>
</template>
<script>
export default {
name: 'JilinCityBar',
props: {
chartHeight: { type: String, default: '220px' },
interval: { type: Number, default: 2500 }, // (ms)
districtName: { type: String, default: null }, //
districtData: { type: Object, default: () => ({}) }, //
dataType: { type: String, default: 'patents' } // patents()research()conversion()
},
data() {
return {
myChart: null,
timer: null,
pageSize: 4,
cursor: 0,
// 使
defaultXData: [
'朝阳区', '南关区', '宽城区', '二道区',
'绿园区', '双阳区', '九台区', '农安县'
],
defaultValues: [220, 180, 190, 170, 185, 120, 140, 130]
};
},
computed: {
// props
xData() {
return Object.keys(this.districtData).length > 0 ? Object.keys(this.districtData) : this.defaultXData;
},
values() {
if (Object.keys(this.districtData).length === 0) {
return this.defaultValues;
}
// dataType
return this.xData.map(district => {
if (this.districtData[district]) {
return this.districtData[district][this.dataType] || 0;
}
return 0;
});
},
//
chartTitle() {
switch(this.dataType) {
case 'research':
return '研究机构数量';
case 'conversion':
return '专利转化数量';
default:
return '专利数量';
}
},
//
highlightIndex() {
return this.districtName ? this.xData.indexOf(this.districtName) : -1;
}
},
watch: {
chartHeight() {
this.myChart && this.myChart.resize();
},
districtName() {
this.updateChart();
},
dataType() {
this.updateChart();
}
},
mounted() {
this.initChart();
},
beforeDestroy() {
clearInterval(this.timer);
},
methods: {
/* ---------- 立方体形状 ---------- */
registerCubeShapes() {
const dx = 0;
const g = this.$echarts.graphic;
const L = g.extendShape({
shape: { x: 0, y: 0 },
buildPath(ctx, s) {
const { x, y, xAxisPoint } = s;
ctx.moveTo(x + dx, y)
.lineTo(x - 9 + dx, y - 9)
.lineTo(xAxisPoint[0] - 9 + dx, xAxisPoint[1] - 9)
.lineTo(xAxisPoint[0] + dx, xAxisPoint[1])
.closePath();
}
});
const R = g.extendShape({
shape: { x: 0, y: 0 },
buildPath(ctx, s) {
const { x, y, xAxisPoint } = s;
ctx.moveTo(x + dx, y)
.lineTo(xAxisPoint[0] + dx, xAxisPoint[1])
.lineTo(xAxisPoint[0] + 12 + dx, xAxisPoint[1] - 6)
.lineTo(x + 12 + dx, y - 6)
.closePath();
}
});
const T = g.extendShape({
shape: { x: 0, y: 0 },
buildPath(ctx, s) {
const { x, y } = s;
ctx.moveTo(x + dx, y)
.lineTo(x + 12 + dx, y - 6)
.lineTo(x + 3 + dx, y - 15)
.lineTo(x - 9 + dx, y - 9)
.closePath();
}
});
g.registerShape('CubeLeft', L);
g.registerShape('CubeRight', R);
g.registerShape('CubeTop', T);
},
/* ---------- 初始化 ---------- */
initChart() {
this.myChart = this.$echarts.init(this.$refs.chartContainer);
this.registerCubeShapes();
this.render(); //
if (this.xData.length > this.pageSize) this.startScroll();
},
/* ---------- 更新图表 ---------- */
updateChart() {
if (this.myChart) {
//
clearInterval(this.timer);
//
if (this.highlightIndex >= 0) {
// 使
const startIdx = Math.max(0, Math.min(this.highlightIndex, this.xData.length - this.pageSize));
this.cursor = startIdx;
this.myChart.setOption({
dataZoom: [{
startValue: startIdx,
endValue: startIdx + this.pageSize - 1
}]
});
}
//
this.myChart.setOption({
xAxis: [{
data: this.xData
}],
series: [
{
data: this.values,
itemStyle: {
color: (params) => {
// 使
if (this.highlightIndex === params.dataIndex) {
return '#FFE777'; //
}
return undefined; // 使
}
}
},
{
data: this.values
}
]
});
//
if (this.xData.length > this.pageSize) this.startScroll();
}
},
/* ---------- 配置 & 渲染 ---------- */
render() {
const TEAL_LIGHT = '#86F3CE';
const TEAL_DEEP = '#1EAF9A';
const HIGHLIGHT_COLOR = '#FFE777'; //
const option = {
backgroundColor: 'transparent',
animationDurationUpdate: 600,
animationEasingUpdate: 'cubicOut',
grid: { left: 30, right: 30, bottom: 20, top: 40, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: d =>
`${d[0].axisValue}<br/>
<span style="display:inline-block;margin-right:5px;border-radius:10px;width:9px;height:9px;background:${d[0].color || TEAL_LIGHT}"></span>
${this.chartTitle} <span style="padding-left:13px;">${d[0].value}</span>`
},
legend: {
data: [{ name: this.chartTitle, itemStyle: {color: TEAL_LIGHT}}],
textStyle: {fontSize: 14, color: '#A7C4F6'},
itemWidth: 10, itemHeight: 8, top: '1%', right: '3%'
},
/* ——— 关键dataZoom 控制窗口宽度=4 ——— */
dataZoom: [{
type: 'inside',
zoomLock: true,
startValue: 0,
endValue: this.pageSize - 1
}],
xAxis: [{
type: 'category',
data: this.xData,
axisLine: {lineStyle: {color: '#FFFFFF80'}},
axisTick: {show: false},
axisLabel: {fontSize: 14, color: '#A7C4F6'}
}],
yAxis: [{
type: 'value',
min: 0,
splitLine: {lineStyle: {color: '#FFFFFF20', type: 'dashed'}},
axisLine: {show: false},
axisTick: {show: false},
axisLabel: {fontSize: 12, color: '#A7C4F6'}
}],
series: [
{
type: 'custom',
name: this.chartTitle,
renderItem: (p, api) => {
const loc = api.coord([api.value(0), api.value(1)]);
const axis = api.coord([api.value(0), 0]);
// - 使
const isHighlight = this.highlightIndex === api.dataIndex();
const topColor = isHighlight ? HIGHLIGHT_COLOR : TEAL_LIGHT;
const deepColor = isHighlight ? '#E5C848' : TEAL_DEEP;
const sideColor = isHighlight ? '#B19A3A' : '#157B6E';
return {
type: 'group',
children: [
{
type: 'CubeLeft',
shape: {x: loc[0], y: loc[1], xAxisPoint: axis},
style: {
fill: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{offset: 0, color: deepColor},
{offset: 1, color: sideColor}
])
}
},
{
type: 'CubeRight',
shape: {x: loc[0], y: loc[1], xAxisPoint: axis},
style: {
fill: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{offset: 0, color: topColor},
{offset: 1, color: deepColor}
])
}
},
{
type: 'CubeTop',
shape: {x: loc[0], y: loc[1], xAxisPoint: axis},
style: {fill: topColor}
}
]
};
},
data: this.values,
label: {
show: true,
position: 'top',
fontSize: 12,
color: '#fff',
offset: [0, -8],
fontWeight: 'bold'
}
},
{
type: 'bar',
data: this.values,
itemStyle: {color: 'transparent'},
name: this.chartTitle
}
]
};
this.myChart.setOption(option);
},
/* ---------- 平滑滚动 ---------- */
startScroll() {
clearInterval(this.timer); //
const len = this.xData.length;
this.timer = setInterval(() => {
this.cursor = (this.cursor + 1) % len;
this.myChart.setOption({
dataZoom: [{
startValue: this.cursor,
endValue: this.cursor + this.pageSize - 1
}]
});
}, this.interval);
}
}
};
</script>

View File

@ -0,0 +1,409 @@
<template>
<div ref="chartContainer" :style="{width: '100%', height: '200px'}"
></div>
</template>
<script>
export default {
name: 'DynamicChart',
data() {
return {
myChart: null,
};
},
watch: {
chartHeight() {
if (this.myChart) {
this.myChart.resize(); // ECharts
}
}
},
mounted() {
console.log('->>>>')
this.initChart();
},
methods: {
initChart() {
this.myChart = this.$echarts.init(this.$refs.chartContainer);
let colors = ['#1FDEA3'];
const backgroundData = [50, 50, 50, 50]; //
let c = 0;
const CubeLeft = this.$echarts.graphic.extendShape({
shape: { x: 0, y: 0 },
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x + c, shape.y];
const c1 = [shape.x - 9 + c, shape.y - 9];
const c2 = [xAxisPoint[0] - 9 + c, xAxisPoint[1] - 9];
const c3 = [xAxisPoint[0] + c, xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1])
.lineTo(c1[0], c1[1])
.lineTo(c2[0], c2[1])
.lineTo(c3[0], c3[1])
.closePath();
},
});
const CubeRight = this.$echarts.graphic.extendShape({
shape: { x: 0, y: 0 },
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x + c, shape.y];
const c2 = [xAxisPoint[0] + c, xAxisPoint[1]];
const c3 = [xAxisPoint[0] + 12 + c, xAxisPoint[1] - 6];
const c4 = [shape.x + 12 + c, shape.y - 6];
ctx.moveTo(c1[0], c1[1])
.lineTo(c2[0], c2[1])
.lineTo(c3[0], c3[1])
.lineTo(c4[0], c4[1])
.closePath();
},
});
const CubeTop = this.$echarts.graphic.extendShape({
shape: { x: 0, y: 0 },
buildPath: function (ctx, shape) {
const c1 = [shape.x + c, shape.y];
const c2 = [shape.x + 12 + c, shape.y - 6];
const c3 = [shape.x + 3 + c, shape.y - 15];
const c4 = [shape.x - 9 + c, shape.y - 9];
ctx.moveTo(c1[0], c1[1])
.lineTo(c2[0], c2[1])
.lineTo(c3[0], c3[1])
.lineTo(c4[0], c4[1])
.closePath();
},
});
const CubeLeft1 = this.$echarts.graphic.extendShape({
shape: { x: 0, y: 0 },
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x - c, shape.y];
const c1 = [shape.x - 9 - c, shape.y - 9];
const c2 = [xAxisPoint[0] - 9 - c, xAxisPoint[1] - 9];
const c3 = [xAxisPoint[0] - c, xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1])
.lineTo(c1[0], c1[1])
.lineTo(c2[0], c2[1])
.lineTo(c3[0], c3[1])
.closePath();
},
});
const CubeRight1 = this.$echarts.graphic.extendShape({
shape: { x: 0, y: 0 },
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x - c, shape.y];
const c2 = [xAxisPoint[0] - c, xAxisPoint[1]];
const c3 = [xAxisPoint[0] + 12 - c, xAxisPoint[1] - 6];
const c4 = [shape.x + 12 - c, shape.y - 6];
ctx.moveTo(c1[0], c1[1])
.lineTo(c2[0], c2[1])
.lineTo(c3[0], c3[1])
.lineTo(c4[0], c4[1])
.closePath();
},
});
const CubeTop1 = this.$echarts.graphic.extendShape({
shape: { x: 0, y: 0 },
buildPath: function (ctx, shape) {
const c1 = [shape.x - c, shape.y];
const c2 = [shape.x + 12 - c, shape.y - 6];
const c3 = [shape.x + 3 - c, shape.y - 15];
const c4 = [shape.x - 9 - c, shape.y - 9];
ctx.moveTo(c1[0], c1[1])
.lineTo(c2[0], c2[1])
.lineTo(c3[0], c3[1])
.lineTo(c4[0], c4[1])
.closePath();
},
});
this.$echarts.graphic.registerShape('CubeLeft', CubeLeft);
this.$echarts.graphic.registerShape('CubeRight', CubeRight);
this.$echarts.graphic.registerShape('CubeTop', CubeTop);
this.$echarts.graphic.registerShape('CubeLeft1', CubeLeft1);
this.$echarts.graphic.registerShape('CubeRight1', CubeRight1);
this.$echarts.graphic.registerShape('CubeTop1', CubeTop1);
let xData = ['50万以下', '100万以下', '200万以下', '200万以上']
const planned = [50, 10, 30, 40];
const option = {
backgroundColor: 'transparent',
grid: {
left: 30,
right: 30,
bottom: 20,
top: 40,
containLabel: true,
},
tooltip: {
trigger: 'axis',
// (axis)
axisPointer: {
type: 'shadow', // 线'line' | 'shadow'
},
formatter: function (params) {
let returnData = params[0].axisValue + '<br/>';
for (let i = 0; i < params.length; i++) {
returnData += '<span style="display:inline-block;margin-right:5px;border-radius:10px;width:9px;height:9px;background:' + colors[i] + '"></span>';
returnData += params[i].seriesName + '<span style="padding-left:13px;">' + params[i].value + '</span><br/>';
if(i>0){
break;
}
}
return returnData;
},
},
legend: {
data: [
{
name: '主体数量',
itemStyle: {
color: colors[0]
}
},
{
name: '品牌数量',
itemStyle: {
color: colors[1]
}
}
],
textStyle: {
fontFamily: 'MicrosoftYaHei',
fontSize: 14,
color: 'rgba(178, 175, 173, 1)'
},
itemWidth: 10,
itemHeight: 8,
itemGap: 15,
top: '1%',
right: '3%'
},
xAxis: {
type: 'category',
data: xData,
axisLine: {
show: false
},
offset: 10,
axisTick: {
show: false,
length: 9,
alignWithLabel: true,
lineStyle: {
color: '#748EAB',
},
},
axisLabel: {
fontFamily: 'MicrosoftYaHei',
fontSize: 12,
fontWeight: 'bold',
color: '#85B6BC',
margin: 6,
interval: 0,
formatter: function (value) {
return `{down|${value}}`;
},
rich: {
down: {
padding: [5, 0, 0, 0],
fontSize: 12,
fontWeight: 'bold',
color: '#85B6BC',
fontFamily: 'MicrosoftYaHei',
}
}
}
},
yAxis: {
type: 'value',
axisLine: {
show: false
},
splitLine: {
show: true,
lineStyle: {
color: '#FFFFFF20',
type: 'dashed', // 线
width: 1,
},
},
axisTick: {
show: false,
},
axisLabel: {
fontFamily: 'MicrosoftYaHei',
fontSize: 12,
color: '#85B6BC'
},
min: 0,
},
series: [
{
type: 'custom',
name: '主体数量(个)',
label: {
normal: {
show: true,
position: 'top',
fontSize: 16,
color: '#fff',
offset: [2, -25]
}
},
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
return {
type: 'group',
children: [
{
type: 'CubeLeft',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
fill: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#023A8A' },
{ offset: 1, color: '#0EC071' }
]),
},
},
{
type: 'CubeRight',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
fill: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#023A8A' },
{ offset: 1, color: '#27E588' }
]),
},
},
{
type: 'CubeTop',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
fill: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#6EFFBA' },
{ offset: 1, color: '#1EE783' }
]),
},
},
],
};
},
data: planned,
},
{
//x
type: "bar",
name:'主体数量(个)',
label: {
normal: {
distance:20,
show: true,
position: "top",
fontSize: 12,
color: "#fff",
align: "center",
verticalAlign: "bottom",
offset: [0, 0],
fontWeight: "bold"
},
},
itemStyle: {
color: "transparent",
},
data:planned,
},
{
type: 'custom',
name: '背景柱',
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
return {
type: 'group',
children: [
{
type: 'CubeLeft',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
fill: '#2D9DD628' //
},
},
{
type: 'CubeRight',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
fill: '##01E0B360' //
},
},
{
type: 'CubeTop',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
fill: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#0F795D40' },
{ offset: 1, color: '#52FFCF40' }
]),
},
},
],
};
},
data: backgroundData,
z: 1 //
},
],
};
this.myChart.setOption(option);
},
}
};
</script>

View File

@ -0,0 +1,218 @@
<template>
<div>
<div
ref="chartContainer"
:style="{width: '500px', height: '400px'}"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
></div>
<button @click="move('left')"></button>
<button @click="move('right')"></button>
</div>
</template>
<script>
export default {
name: 'DynamicChart',
data() {
return {
myChart: null,
count: 11,
categories: this.generateCategories(),
categories2: Array.from({length: 10}, (_, i) => 10 - i - 1),
data: Array.from({length: 10}, () => Math.round(Math.random() * 1000)),
data2: Array.from({length: 10}, () => +(Math.random() * 10 + 5).toFixed(1)),
isMouseDown: false,
lastMousePosition: null
};
},
mounted() {
this.initChart();
this.startDataUpdater();
},
beforeDestroy() {
clearInterval(this.dataUpdater);
},
methods: {
move(direction) {
//
clearInterval(this.dataUpdater);
if (direction === 'left') {
this.data.unshift(Math.round(Math.random() * 1000));
this.data.pop();
this.data2.unshift(+(Math.random() * 10 + 5).toFixed(1));
this.data2.pop();
this.categories.unshift(new Date().toLocaleTimeString().replace(/^\D*/, ''));
this.categories.pop();
this.categories2.unshift(this.count--);
this.categories2.pop();
} else if (direction === 'right') {
this.data.push(Math.round(Math.random() * 1000));
this.data.shift();
this.data2.push(+(Math.random() * 10 + 5).toFixed(1));
this.data2.shift();
this.categories.push(new Date().toLocaleTimeString().replace(/^\D*/, ''));
this.categories.shift();
this.categories2.push(this.count++);
this.categories2.shift();
}
this.myChart.setOption({
xAxis: [
{data: this.categories},
{data: this.categories2}
],
series: [
{data: this.data},
{data: this.data2}
]
});
},
generateCategories() {
let now = new Date();
let res = [];
let len = 10;
while (len--) {
res.unshift(now.toLocaleTimeString().replace(/^\D*/, ''));
now = new Date(+now - 2000);
}
return res;
},
initChart() {
// ECharts
this.myChart = this.$echarts.init(this.$refs.chartContainer);
const option = {
title: {
text: 'Dynamic Data'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#283b56'
}
}
},
legend: {},
dataZoom: {
show: false,
start: 0,
end: 100
},
xAxis: [
{
type: 'category',
boundaryGap: true,
data: this.categories
},
{
type: 'category',
boundaryGap: true,
data: this.categories2
}
],
yAxis: [
{
type: 'value',
scale: true,
name: 'Price',
max: 30,
min: 0,
boundaryGap: [0.2, 0.2]
},
{
type: 'value',
scale: true,
name: 'Order',
max: 1200,
min: 0,
boundaryGap: [0.2, 0.2]
}
],
series: [
{
name: 'Dynamic Bar',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: this.data
},
{
name: 'Dynamic Line',
type: 'line',
data: this.data2
}
]
};
this.myChart.setOption(option);
},
startDataUpdater() {
this.dataUpdater = setInterval(() => {
let axisData = new Date().toLocaleTimeString().replace(/^\D*/, '');
this.data.shift();
this.data.push(Math.round(Math.random() * 1000));
this.data2.shift();
this.data2.push(+(Math.random() * 10 + 5).toFixed(1));
this.categories.shift();
this.categories.push(axisData);
this.categories2.shift();
this.categories2.push(this.count++);
this.myChart.setOption({
xAxis: [
{data: this.categories},
{data: this.categories2}
],
series: [
{data: this.data},
{data: this.data2}
]
});
}, 2100);
},
handleMouseDown(event) {
this.isMouseDown = true;
this.lastMousePosition = event.clientX;
},
handleMouseMove(event) {
if (!this.isMouseDown) return;
const currentMousePosition = event.clientX;
const threshold = 100; //
if (Math.abs(currentMousePosition - this.lastMousePosition) >= threshold) {
if (currentMousePosition > this.lastMousePosition) {
this.move('right');
} else {
this.move('left');
}
this.lastMousePosition = currentMousePosition;
}
},
handleMouseUp() {
this.isMouseDown = false;
this.lastMousePosition = null;
},
handleMouseLeave() {
this.isMouseDown = false;
this.lastMousePosition = null;
}
}
};
</script>

View File

@ -0,0 +1,263 @@
<template>
<div class="card" :style="{ height: cardHeight }">
<!-- 头部 -->
<div :class="type === 'long' ? 'card-long-header' : 'card-header'">
<div class="header-container">
<div class="card-title">{{ title }}</div>
<!-- 可选下拉 -->
<div v-if="isShowSelect" class="card-select-block" ref="dropdown">
<div class="select-display" @click="toggleDropdown">
<span class="display-text">{{ selectedText }}</span>
<img
class="arrow-img"
:class="{ open: showDropdown }"
src="@/assets/images/icon/icon_down_arrow.png"
alt="arrow"
/>
</div>
<ul v-show="showDropdown" class="select-dropdown">
<li
v-for="opt in processedOptions"
:key="opt.value"
class="select-option"
@click="selectOption(opt)"
>
{{ opt.label }}
</li>
</ul>
</div>
</div>
</div>
<!-- 内容插槽 -->
<div class="card-container">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'CardWithCustomSelect',
props: {
title: { type: String, default: '请填写标题' },
type: { type: String, default: 'short' },
isShowSelect: { type: Boolean, default: false },
heightPercentage: { type: Number, default: 100 },
/* 原下拉数据 */
selectOptions: { type: Array, default: () => [] },
/* 新增:是否要带“全部” */
includeAll: { type: Boolean, default: false }
},
data() {
return {
selected: '', // value
showDropdown: false
}
},
computed: {
cardHeight() { return `${this.heightPercentage}%` },
/* 归一化 options保证有 label / value */
processedOptions() {
const norm = this.selectOptions.map((o, i) => ({
label : o.label ?? o.name ?? `选项${i+1}`,
value : o.value ?? o.id ?? i
}));
return this.includeAll
? [{ label: '全部', value: '' }, ...norm]
: norm;
},
/* 当前显示文本 */
selectedText() {
const found = this.processedOptions.find(o => o.value === this.selected);
return found ? found.label : '请选择';
}
},
mounted() {
document.addEventListener('click', this.handleClickOutside);
/* ----- 默认值逻辑 ----- */
if (this.includeAll) {
this.selected = ''; //
} else if (this.processedOptions.length) {
this.selected = this.processedOptions[0].value; //
this.$emit('change', this.selected); //
}
},
beforeDestroy() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
toggleDropdown() { this.showDropdown = !this.showDropdown },
selectOption(opt) {
this.selected = opt.value;
this.showDropdown = false;
this.$emit('change', this.selected); //
},
handleClickOutside(e) {
if (this.$refs.dropdown && !this.$refs.dropdown.contains(e.target)) {
this.showDropdown = false;
}
}
}
}
</script>
<style scoped lang="scss">
.card {
position: relative;
width: 100%;
height: 280px;
background: linear-gradient(
270deg,
#031B2E 0%, /* 深午夜蓝:对应原 #052E23 */
#104266 53%, /* 深海军蓝:对应原 #124937 */
#031B2E 100% /* 回到首色,首尾呼应 */
);
border: 1px solid;
border-image: linear-gradient(180deg, rgba(44, 154, 255, 0), rgba(44, 154, 255, 1)) 1 1;
display: flex;
flex-direction: column;
}
.card-header {
height: 55px;
background: url("./../../assets/images/bg/card_title_bg3.png") no-repeat;
background-size: 100% 100%;
color: white;
margin-top: -10px;
}
.card-long-header {
width: 100%;
height: 15%;
background: url("./../../assets/images/bg/card_title_bg3.png") no-repeat;
background-size: 100% 100%;
color: white;
margin-top: -10px;
}
.header-container {
position: relative;
width: calc(100% - 90px);
margin-left: 50px;
height: 100%;
display: flex;
justify-content: space-between;
line-height: 55px;
font-weight: 600;
font-size: 22px;
color: #FFFFFF;
text-shadow: 0 2px 5px rgba(44, 154, 255, 0.6);
text-align: left;
}
.card-title {
flex-shrink: 0;
}
.card-container {
width: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
}
/* 选择区域样式 */
.card-select-block {
position: relative;
width: 90px;
height: 26px;
margin-top: 10px;
margin-right: 20px;
cursor: pointer;
font-size: 14px;
z-index: 99;
}
.select-display {
position: relative;
width: 100%;
height: 26px;
line-height: 26px;
padding-left: 8px;
padding-right: 20px;
background: #0D2847;
border: 1px solid #2C9AFF;
box-shadow: 0 0 6px rgba(44, 154, 255, 0.20);
color: #C8D3E0;
font-weight: bold;
border-radius: 2px;
overflow: hidden;
}
.display-text {
display: inline-block;
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.arrow-img {
width: 10px;
height: 6px;
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%) rotate(0deg);
transition: transform 0.3s ease;
pointer-events: none;
}
.arrow-img.open {
transform: translateY(-50%) rotate(180deg);
}
.select-dropdown {
position: absolute;
top: 34px;
left: 0;
width: 110px;
background: #0D2847;
border: 1px solid #2C9AFF;
box-shadow: 0 2px 8px rgba(44, 154, 255, 0.20);
list-style: none;
margin: 0;
padding: 0;
border-radius: 2px;
}
.select-option {
min-height: 26px;
line-height: 26px;
padding: 0 10px;
color: #FFFFFF;
font-weight: 500;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
&:hover {
background-color: #418A7039;
}
&:last-child {
border-bottom: none;
}
}
</style>

View File

@ -0,0 +1,143 @@
<template>
<div ref="chartContainer" :style="{width: '100%', height: chartHeight}"
></div>
</template>
<script>
export default {
name: 'LineIndex',
data() {
return {
myChart: null,
};
},
props:{
chartHeight: {
type: String,
default: '260px', //
}
},
watch: {
chartHeight() {
if (this.myChart) {
this.myChart.resize(); // ECharts
}
}
},
mounted() {
console.log('->>>>')
this.initChart();
},
methods: {
initChart() {
this.myChart = this.$echarts.init(this.$refs.chartContainer);
let xData = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
const list1 = [50, 10, 30, 40, 50, 60, 70, 110, 23, 90, 47, 60];
const list2 = [22, 10, 120, 10,30, 80, 100, 50, 22, 39, 31, 80];
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
legend: {
right: '5%',
top: '2.5%',
itemWidth: 16,
itemHeight: 8,
data: ['科技新闻政策', '新兴科技创新'],
textStyle: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: 'bold',
},
},
grid: {
left: '5%',
right: '5%',
top: '20%',
bottom: '30%',
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLabel: {
color: '#85B6BC',
margin: 10,
},
splitLine: {
show: true,
lineStyle: {
color: '#FFFFFF20',
type: 'dashed',
width: 1,
},
},
},
yAxis: {
type: 'value',
name: '单位(个)',
nameTextStyle: {
fontFamily: 'MicrosoftYaHei',
fontSize: 12,
color: '#85B6BC'
},
nameGap: 25,
axisLabel: {
color: '#85B6BC',
},
splitLine: {
show: true,
lineStyle: {
color: '#FFFFFF20',
type: 'dashed',
width: 1,
},
},
},
series: [
{
name: '科技新闻政策',
// smooth: true,
// showSymbol: false,
type: 'line',
data: list1,
color: '#28E9DA',
areaStyle: list1.filter(Boolean).length === 0 ? {} : {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [{
offset: 0,
color: '#53DDC550',
}, {
offset: 1,
color: '#53DDC520',
}]),
},
},
{
name: '新兴科技创新',
// smooth: true,
// showSymbol: false,
type: 'line',
data: list2,
color: '#FEEA42',
areaStyle: list2.filter(Boolean).length === 0 ? {} : {
// shadowColor: 'rgba(101, 223, 221, 0.2)',
// shadowBlur: 100, //
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [{
offset: 0,
color: '#A49E3550',
}, {
offset: 1,
color: '#A49E3520',
}]),
},
},
],
};
this.myChart.setOption(option);
},
}
};
</script>

View File

@ -0,0 +1,159 @@
<template>
<div ref="chartContainer" :style="{width: '100%', height: chartHeight}"
></div>
</template>
<script>
export default {
name: 'LineIndex',
data() {
return {
myChart: null,
};
},
props:{
chartHeight: {
type: String,
default: '260px', //
}
},
watch: {
chartHeight() {
if (this.myChart) {
this.myChart.resize(); // ECharts
}
}
},
mounted() {
console.log('->>>>')
this.initChart();
},
methods: {
initChart() {
this.myChart = this.$echarts.init(this.$refs.chartContainer);
let xData = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
const list1 = [50, 10, 30, 40, 50, 60, 70, 110, 23, 90, 47, 60];
const list2 = [22, 10, 120, 10,30, 80, 100, 50, 22, 39, 31, 80];
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: function (params) {
let tooltipHtml = params
.map((item) => {
let fixedColor = item.seriesName === '科技新闻政策' ? '#529F65' : '#FFC52F'; //
let marker = `<span style="display:inline-block;margin-right:5px;width:8px;height:8px;background:${fixedColor};border-radius:50%;"></span>`;
return `<div>${marker}${item.seriesName}: ${item.value}</div>`;
})
.join('');
return `<div>${tooltipHtml}</div>`;
},
},
legend: {
right: '5%',
top: '2.5%',
itemWidth: 16,
itemHeight: 8,
data: ['科技新闻政策', '新兴科技创新'],
textStyle: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: 'bold',
},
itemStyle: {
color: function (name) {
return name === '科技新闻政策' ? '#529F65' : '#FFC52F';
}
}
},
grid: {
left: '5%',
right: '5%',
top: '20%',
bottom: '30%',
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLabel: {
color: '#85B6BC',
margin: 10,
},
splitLine: {
show: true,
lineStyle: {
color: '#FFFFFF20',
type: 'dashed',
width: 1,
},
},
},
yAxis: {
type: 'value',
name: '单位(个)',
nameTextStyle: {
fontFamily: 'MicrosoftYaHei',
fontSize: 12,
color: '#85B6BC'
},
nameGap: 25,
axisLabel: {
color: '#85B6BC',
},
splitLine: {
show: true,
lineStyle: {
color: '#FFFFFF20',
type: 'dashed',
width: 1,
},
},
},
series: [
{
name: '涉农主体授权',
smooth: true,
showSymbol: false,
type: 'line',
data: list1,
color: '#FFC52F',
lineStyle: { // 线
type: 'dashed',
width: 2, // 线
},
},
{
name: '产品发布数量',
smooth: true,
showSymbol: false,
type: 'line',
data: list2,
lineStyle: { // 线
width: 2, // 线
color: new this.$echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#529F65' }, //
{ offset: 1, color: '#66BAD9' }, //
]),
},
areaStyle: list2.filter(Boolean).length === 0 ? {} : {
// shadowColor: 'rgba(101, 223, 221, 0.2)',
// shadowBlur: 100, //
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [{
offset: 0,
color: '#529F6550',
}, {
offset: 1,
color: '#66BAD920',
}]),
},
},
],
};
this.myChart.setOption(option);
},
}
};
</script>

View File

@ -0,0 +1,43 @@
# City GeoJSON Files
This directory contains GeoJSON files for each city in Jilin province. Each file should be named after its corresponding city (e.g., `长春市.json`, `吉林市.json`, etc.) and contain the district-level geographical data for that city.
## File Structure
Each GeoJSON file should follow this structure:
```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "区县名称",
"code": "行政区划代码"
},
"geometry": {
"type": "Polygon",
"coordinates": [[[经度, 纬度], ...]]
}
},
...
]
}
```
## Cities
- 长春市.json
- 吉林市.json
- 四平市.json
- 辽源市.json
- 通化市.json
- 白山市.json
- 松原市.json
- 白城市.json
- 延边自治州.json
## Data Source
The GeoJSON data should be obtained from official sources or reliable geographic data providers to ensure accuracy.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
import * as THREE from 'three'
/**
* @param {Array<Array<number>>} ring - 外环经纬度数组 [[lng,lat], ...]
* @returns {THREE.LineLoop}
*/
export default function createLine (ring) {
const vertices = new Float32Array(ring.length * 3)
ring.forEach((p, i) => {
vertices[i * 3] = p[0] // 经度 -> X
vertices[i * 3 + 1] = p[1] // 纬度 -> Y
vertices[i * 3 + 2] = 0
})
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3))
// const material = new THREE.LineBasicMaterial({ color: 0x00cccc })
const material = new THREE.LineBasicMaterial({ color: 0x00cccc })
return new THREE.LineLoop(geometry, material)
}

View File

@ -0,0 +1,461 @@
<template>
<div
ref="wrap"
class="three-wrap"
:style="canvasW && canvasH ? { width: canvasW + 'px', height: canvasH + 'px' } : {}"
></div>
</template>
<script>
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
export default {
name: 'CityMap',
props: {
canvasW: {type: Number, default: null},
canvasH: {type: Number, default: null},
bgColor: {type: String, default: '#ffffff'},
geojson: {type: Object, required: true},
},
data() {
return {
renderer: null,
scene: null,
camera: null,
controls: null,
frameId: null,
resizeObs: null,
inited: false,
halfSizeX: 1,
halfSizeY: 1,
center: new THREE.Vector3(),
// -
districtData: [],
labelSprites: [], //
districtMeshes: [], // meshz
hoveredDistrict: null, // mesh
flylines: [], // 线
flylineGroup: null, // 线
activeFlylines: new Map() // 线
}
},
mounted() {
if (this.canvasW && this.canvasH) {
this.initThree(this.canvasW, this.canvasH)
window.addEventListener('resize', this.onResize)
} else {
this.resizeObs = new ResizeObserver(entries => {
const {width, height} = entries[0].contentRect
if (width && height && !this.inited) {
this.initThree(width, height)
window.addEventListener('resize', this.onResize)
this.resizeObs.disconnect()
}
})
this.resizeObs.observe(this.$refs.wrap)
}
// 线
this.raycaster = new THREE.Raycaster()
this.mouse = new THREE.Vector2()
this.$refs.wrap.addEventListener('mousemove', this.onMapMouseMove)
this.$refs.wrap.addEventListener('click', this.onMapClick)
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize)
cancelAnimationFrame(this.frameId)
this.controls?.dispose()
this.renderer?.dispose()
this.resizeObs?.disconnect()
this.$refs.wrap?.removeEventListener('mousemove', this.onMapMouseMove)
this.$refs.wrap?.removeEventListener('click', this.onMapClick)
},
methods: {
/* ---------- 初始化 ---------- */
initThree(w, h) {
this.inited = true
/* 场景 & 渲染器 */
this.scene = new THREE.Scene()
this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: true})
this.renderer.setSize(w, h)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setClearColor(this.bgColor, 0)
/* 物理光照 + 色彩空间 */
this.renderer.physicallyCorrectLights = true
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.$refs.wrap.appendChild(this.renderer.domElement)
/* 灯光优化:增加白色方向光,让立体感更明显 */
this.scene.add(new THREE.AmbientLight(0x2d65cd, 1.5)) //
this.scene.add(new THREE.HemisphereLight(0x2d65cd, 0x113667, 1.2))
const keyLight = new THREE.DirectionalLight(0xffffff, 1.0) //
keyLight.position.set(300, 400, 500)
this.scene.add(keyLight)
/* 地图模型 */
this.buildMap()
/* 相机自适配 */
this.setCamera(w, h)
/* OrbitControls */
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enablePan = false
this.controls.enableRotate = false
this.controls.enableZoom = false
this.controls.target.copy(this.center)
this.controls.update()
/* 渲染循环 */
const renderLoop = () => {
this.controls.update()
this.renderer.render(this.scene, this.camera)
this.frameId = requestAnimationFrame(renderLoop)
}
renderLoop()
},
/* ---------- 构建市级地图 ---------- */
buildMap() {
const depth = 0.2
const lineGroup = new THREE.Group()
const meshGroup = new THREE.Group()
const labelGroup = new THREE.Group()
lineGroup.position.z = depth + 0.0001
meshGroup.position.z = 0
labelGroup.position.z = depth + 0.1
const mapGroup = new THREE.Group()
mapGroup.add(lineGroup, meshGroup, labelGroup)
this.scene.add(mapGroup)
// GeoJSON
if (this.geojson && this.geojson.features) {
this.geojson.features.forEach(feature => {
let polys = feature.geometry.coordinates
if (feature.geometry.type === 'Polygon') polys = [polys]
//
let centerX = 0, centerY = 0, pointCount = 0
polys.forEach(poly => {
const outer = poly[0]
outer.forEach(point => {
centerX += point[0]
centerY += point[1]
pointCount++
})
})
centerX /= pointCount
centerY /= pointCount
//
const districtName = feature.properties.name
this.districtData.push({
name: districtName,
center: [centerX, centerY]
})
//
polys.forEach(poly => {
const outer = poly[0]
const holes = poly.slice(1)
// 线
lineGroup.add(this.createLine(outer))
//
const mesh = this.createExtrudeMesh(outer, holes, depth)
mesh.userData.districtName = districtName
mesh.userData.originZ = mesh.position.z
//
meshGroup.add(mesh)
this.districtMeshes.push(mesh)
})
})
}
//
const box = new THREE.Box3().expandByObject(lineGroup)
box.getCenter(this.center)
const size = new THREE.Vector3()
box.getSize(size)
this.halfSizeX = size.x / 2
this.halfSizeY = size.y / 2
//
this.addDistrictLabels(labelGroup)
},
/* ---------- 边界线 ---------- */
createLine(ring) {
const vertices = new Float32Array(ring.length * 3)
ring.forEach((p, i) => {
vertices[i * 3] = p[0]
vertices[i * 3 + 1] = p[1]
vertices[i * 3 + 2] = 0
})
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(vertices, 3))
return new THREE.LineLoop(geo, new THREE.LineBasicMaterial({
color: 0x0d2a50,
opacity: 0.8,
transparent: true
}))
},
/* ---------- 立体 Mesh ---------- */
createExtrudeMesh(outer, holes, depth) {
const shape = new THREE.Shape(outer.map(p => new THREE.Vector2(p[0], p[1])))
holes.forEach(h => {
shape.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p[0], p[1]))))
})
const geo = new THREE.ExtrudeGeometry(shape, {depth, bevelEnabled: false})
return new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({
color: 0x4ba9bc,
roughness: 0.1,
metalness: 0.1,
side: THREE.DoubleSide
})
)
},
/* ---------- 处理鼠标移动 ---------- */
onMapMouseMove(event) {
if (!this.camera || !this.districtMeshes.length) return
const rect = this.$refs.wrap.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObjects(this.districtMeshes)
if (intersects.length > 0) {
const mesh = intersects[0].object
if (this.hoveredDistrict && this.hoveredDistrict !== mesh) {
this.hoveredDistrict.position.z = this.hoveredDistrict.userData.originZ
//
const prevDistrictName = this.hoveredDistrict.userData.districtName
this.resetDistrictLabelPosition(prevDistrictName)
}
if (this.hoveredDistrict !== mesh) {
mesh.position.z = mesh.userData.originZ + 0.15
this.hoveredDistrict = mesh
const districtName = mesh.userData.districtName
//
this.elevateDistrictLabel(districtName, 0.15)
//
this.$emit('district-hover', districtName)
}
} else if (this.hoveredDistrict) {
this.hoveredDistrict.position.z = this.hoveredDistrict.userData.originZ
this.hoveredDistrict = null
//
this.resetAllLabelPositions()
//
this.$emit('district-hover', null)
}
},
/* ---------- 提升区县标签高度 ---------- */
elevateDistrictLabel(districtName, elevationAmount) {
this.labelSprites.forEach(item => {
if (item.type === 'label' && item.districtName === districtName) {
item.mesh.position.z += elevationAmount
}
})
},
/* ---------- 重置区县标签高度 ---------- */
resetDistrictLabelPosition(districtName) {
this.labelSprites.forEach(item => {
if (item.type === 'label' && item.districtName === districtName) {
item.mesh.position.z = item.originalZ
}
})
},
/* ---------- 重置所有标签位置 ---------- */
resetAllLabelPositions() {
this.labelSprites.forEach(item => {
if (item.type === 'label') {
item.mesh.position.z = item.originalZ
}
})
},
/* ---------- 添加区县标签 ---------- */
addDistrictLabels(labelGroup) {
// Canvas
const createTextTexture = (text) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const fontSize = 10 //
const padding = 4 //
// Canvas
const devicePixelRatio = window.devicePixelRatio || 1
// Canvas
ctx.font = `${fontSize}px Arial`
const textWidth = ctx.measureText(text).width
const width = textWidth + padding * 2
const height = fontSize + padding * 2
//
canvas.width = width * devicePixelRatio
canvas.height = height * devicePixelRatio
//
ctx.scale(devicePixelRatio, devicePixelRatio)
//
ctx.font = `${fontSize}px Arial`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = '#ffffff'
//
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
//
ctx.fillText(text, width/2, height/2)
//
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
//
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
texture.format = THREE.RGBAFormat
return {
texture,
width: width,
height: height
}
}
//
this.districtData.forEach(district => {
const {texture, width, height} = createTextTexture(district.name)
//
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.95
})
//
const sprite = new THREE.Sprite(material)
// - Z
const baseZ = 0.05 //
sprite.position.set(
district.center[0],
district.center[1],
baseZ
)
//
const scale = 0.6
sprite.scale.set(width * scale * 0.01, height * scale * 0.01, 1)
// 便
this.labelSprites.push({
type: 'label',
mesh: sprite,
districtName: district.name,
originalZ: baseZ // Z
})
//
labelGroup.add(sprite)
})
},
/* ---------- 相机 ---------- */
setCamera(w, h) {
//
const aspect = w / h
const fov = 45 // ()
const tilt = Math.PI / 5 // 36
const mapWidth = this.halfSizeX * 2
const mapHeight = this.halfSizeY * 2
//
const vFov = fov * Math.PI / 180
//
const distanceY = (mapHeight / 2) / Math.tan(vFov / 2)
const distanceX = (mapWidth / 2) / (Math.tan(vFov / 2) * aspect)
//
const distance = Math.max(distanceX, distanceY) * 1.1
//
this.camera = new THREE.PerspectiveCamera(fov, aspect, 1, 2000)
//
this.camera.position.set(
this.center.x,
this.center.y - Math.sin(tilt) * distance,
this.center.z + Math.cos(tilt) * distance
)
this.camera.lookAt(this.center)
},
/* ---------- 窗口缩放 ---------- */
onResize() {
if (!this.camera) return
const w = this.$refs.wrap.clientWidth
const h = this.$refs.wrap.clientHeight
if (!w || !h) return
//
this.renderer.setSize(w, h)
//
this.camera.aspect = w / h
this.camera.updateProjectionMatrix()
},
/* ---------- 处理点击事件 ---------- */
onMapClick(event) {
if (!this.camera || !this.districtMeshes.length) return
const rect = this.$refs.wrap.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObjects(this.districtMeshes)
if (intersects.length > 0) {
const mesh = intersects[0].object
const districtName = mesh.userData.districtName
//
this.$emit('district-click', districtName)
}
}
}
}
</script>
<style scoped>
.three-wrap {
width: 100%;
height: 100%;
min-height: 400px;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,609 @@
<template>
<div
ref="wrap"
class="three-wrap"
:style="canvasW && canvasH ? { width: canvasW + 'px', height: canvasH + 'px' } : {}"
></div>
</template>
<script>
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import JilinGeoJson from '@/components/map/geojson/jilin.json'
export default {
name: 'ThreeScene',
props: {
canvasW: { type: Number, default: null },
canvasH: { type: Number, default: null },
bgColor : { type: String, default: '#ffffff' }
},
data () {
return {
renderer: null,
scene : null,
camera : null,
controls: null,
frameId : null,
resizeObs: null,
inited : false,
halfSizeX: 1,
halfSizeY: 1,
center : new THREE.Vector3(),
// -
cityData: [
{ name: '长春市', center: [125.3245, 43.886841] },
{ name: '吉林市', center: [126.55302, 43.843577] },
{ name: '四平市', center: [124.370785, 43.170344] },
{ name: '辽源市', center: [125.145349, 42.902692] },
{ name: '通化市', center: [125.936501, 41.721177] },
{ name: '白山市', center: [126.427839, 41.942505] },
{ name: '松原市', center: [124.823608, 45.118243] },
{ name: '白城市', center: [122.841114, 45.619026] },
{ name: '延边自治州', center: [129.513228, 42.904823] }
],
labelSprites: [], //
cityMeshes: [], // meshz
hoveredCity: null, // mesh
flylines: [], // 线
flylineGroup: null, // 线
activeFlylines: new Map() // 线
}
},
mounted () {
if (this.canvasW && this.canvasH) {
this.initThree(this.canvasW, this.canvasH)
window.addEventListener('resize', this.onResize)
} else {
this.resizeObs = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect
if (width && height && !this.inited) {
this.initThree(width, height)
window.addEventListener('resize', this.onResize)
this.resizeObs.disconnect()
}
})
this.resizeObs.observe(this.$refs.wrap)
}
// 线
this.raycaster = new THREE.Raycaster()
this.mouse = new THREE.Vector2()
this.$refs.wrap.addEventListener('mousemove', this.onMapMouseMove)
this.$refs.wrap.addEventListener('click', this.onMapClick)
},
beforeDestroy () {
window.removeEventListener('resize', this.onResize)
cancelAnimationFrame(this.frameId)
this.controls?.dispose()
this.renderer?.dispose()
this.resizeObs?.disconnect()
this.$refs.wrap?.removeEventListener('mousemove', this.onMapMouseMove)
this.$refs.wrap?.removeEventListener('click', this.onMapClick)
},
methods: {
/* ---------- 初始化 ---------- */
initThree (w, h) {
this.inited = true
/* 场景 & 渲染器 */
this.scene = new THREE.Scene()
this.renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true })
this.renderer.setSize(w, h)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setClearColor(this.bgColor, 0)
/* 物理光照 + 色彩空间 */
this.renderer.physicallyCorrectLights = true
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.$refs.wrap.appendChild(this.renderer.domElement)
/* 灯光优化:增加白色方向光,让立体感更明显 */
this.scene.add(new THREE.AmbientLight(0x2d65cd, 1.5)) //
this.scene.add(new THREE.HemisphereLight(0x2d65cd, 0x113667, 1.2))
const keyLight = new THREE.DirectionalLight(0xffffff, 1.0) //
keyLight.position.set(300, 400, 500)
this.scene.add(keyLight)
/* 地图模型 */
this.buildMap()
/* 飞线组 */
this.flylineGroup = new THREE.Group()
this.scene.add(this.flylineGroup)
/* 相机自适配 */
this.setCamera(w, h)
/* OrbitControls */
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enablePan = false
this.controls.enableRotate = false
this.controls.enableZoom = false
this.controls.target.copy(this.center)
this.controls.update()
/* 渲染循环 */
const renderLoop = () => {
this.controls.update()
// -
const time = Date.now() * 0.001 //
this.labelSprites.forEach(item => {
if (item.type === 'pulse') {
// - 0.81.5
const scale = 0.8 + Math.sin(time * 2 + item.phase) * 0.35 + 0.35
item.mesh.scale.set(scale, scale, 1)
//
item.mesh.material.opacity = 0.3 * (0.5 + Math.sin(time * 2 + item.phase) * 0.25 + 0.25)
}
})
// 线
this.updateFlylines(time)
this.renderer.render(this.scene, this.camera)
this.frameId = requestAnimationFrame(renderLoop)
}
renderLoop()
},
/* ---------- 构建吉林省地图 ---------- */
buildMap () {
const depth = 0.2
const lineGroup = new THREE.Group()
const meshGroup = new THREE.Group()
const labelGroup = new THREE.Group()
const markerGroup = new THREE.Group()
lineGroup.position.z = depth + 0.0001
meshGroup.position.z = 0
labelGroup.position.z = depth + 0.1
markerGroup.position.z = depth + 0.05
const mapGroup = new THREE.Group()
mapGroup.add(lineGroup, meshGroup, labelGroup, markerGroup)
this.scene.add(mapGroup)
//
const cityNameMap = new Map()
this.cityData.forEach(city => {
cityNameMap.set(city.name, city)
})
//
JilinGeoJson.features.forEach(f => {
let polys = f.geometry.coordinates
if (f.geometry.type === 'Polygon') polys = [polys]
//
let centerX = 0, centerY = 0, pointCount = 0
polys.forEach(poly => {
const outer = poly[0]
outer.forEach(point => {
centerX += point[0]
centerY += point[1]
pointCount++
})
})
centerX /= pointCount
centerY /= pointCount
//
const cityName = f.properties.name
const cityData = this.cityData.find(city => {
if (cityName.includes('延边')) {
return city.name === '延边自治州'
}
return city.name === cityName
})
if (cityData) {
cityData.center = [centerX, centerY]
}
//
polys.forEach(poly => {
const outer = poly[0]
const holes = poly.slice(1)
lineGroup.add(this.createLine(outer))
const mesh = this.createExtrudeMesh(outer, holes, depth)
mesh.userData.cityName = cityName
if (cityName.includes('延边')) {
mesh.userData.cityName = '延边自治州'
}
mesh.userData.originZ = mesh.position.z
meshGroup.add(mesh)
this.cityMeshes.push(mesh)
})
})
const box = new THREE.Box3().expandByObject(lineGroup)
box.getCenter(this.center)
const size = new THREE.Vector3()
box.getSize(size)
this.halfSizeX = size.x / 2
this.halfSizeY = size.y / 2
this.addCityLabels(labelGroup)
this.addCityMarkers(markerGroup, depth)
},
/* ---------- 边界线 ---------- */
createLine (ring) {
const vertices = new Float32Array(ring.length * 3)
ring.forEach((p, i) => {
vertices[i * 3] = p[0]
vertices[i * 3 + 1] = p[1]
vertices[i * 3 + 2] = 0
})
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(vertices, 3))
return new THREE.LineLoop(geo, new THREE.LineBasicMaterial({
color: 0x0d2a50,
opacity: 0.8,
transparent: true
}))
},
/* ---------- 立体 Mesh ---------- */
createExtrudeMesh(outer, holes, depth) {
const shape = new THREE.Shape(outer.map(p => new THREE.Vector2(p[0], p[1])))
holes.forEach(h => {
shape.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p[0], p[1]))))
})
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled:false })
return new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({
color: 0x4ba9bc,
roughness: 0.1,
metalness: 0.1,
side: THREE.DoubleSide
})
)
},
/* ---------- 创建飞线 ---------- */
createFlyline(startPoint, endPoint, index) {
// 线
const start = new THREE.Vector3(startPoint[0], startPoint[1], 0.3)
const end = new THREE.Vector3(endPoint[0], endPoint[1], 0.3)
//
const mid = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5)
const distance = start.distanceTo(end)
mid.z = 2 + distance * 0.15 // 使
// 线
const curve = new THREE.QuadraticBezierCurve3(start, mid, end)
//
const geometry = new THREE.BufferGeometry()
const points = curve.getPoints(50)
geometry.setFromPoints(points)
//
const material = new THREE.LineBasicMaterial({
color: 0x81d7bc,
transparent: true,
opacity: 0,
linewidth: 1
})
// 线
const line = new THREE.Line(geometry, material)
// 线
const flyline = {
mesh: line,
curve: curve,
progress: 0,
speed: 0.4 + Math.random() * 0.2,
delay: index * 0.15,
active: false,
opacity: 0,
targetOpacity: 0.8,
fadeSpeed: 0.05,
start: start.clone(),
end: end.clone()
}
this.flylines.push(flyline)
return line
},
/* ---------- 更新飞线动画 ---------- */
updateFlylines(time) {
this.flylines.forEach(flyline => {
if (time > flyline.delay) {
flyline.active = true
}
if (flyline.active) {
//
flyline.progress += flyline.speed * 0.01
if (flyline.progress >= 1) {
flyline.progress = 0
flyline.opacity = Math.max(0, flyline.opacity - 0.1)
} else {
flyline.opacity = Math.min(0.8, flyline.opacity + 0.1)
}
// 线
const positions = flyline.mesh.geometry.attributes.position.array
const currentPoint = new THREE.Vector3()
const lastPoint = new THREE.Vector3()
flyline.curve.getPoint(Math.min(flyline.progress, 1), lastPoint)
const total = positions.length / 3
for (let i = 0; i < total; i++) {
const t = i / (total - 1)
if (t <= flyline.progress) {
flyline.curve.getPoint(t, currentPoint)
positions[i * 3] = currentPoint.x
positions[i * 3 + 1] = currentPoint.y
positions[i * 3 + 2] = currentPoint.z
} else {
positions[i * 3] = lastPoint.x
positions[i * 3 + 1] = lastPoint.y
positions[i * 3 + 2] = lastPoint.z
}
}
flyline.mesh.geometry.attributes.position.needsUpdate = true
flyline.mesh.material.opacity = flyline.opacity
}
})
},
/* ---------- 处理鼠标移动 ---------- */
onMapMouseMove(event) {
if (!this.camera || !this.cityMeshes.length) return
const rect = this.$refs.wrap.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObjects(this.cityMeshes)
if (intersects.length > 0) {
const mesh = intersects[0].object
if (this.hoveredCity && this.hoveredCity !== mesh) {
this.hoveredCity.position.z = this.hoveredCity.userData.originZ
this.clearFlylines()
}
if (this.hoveredCity !== mesh) {
mesh.position.z = mesh.userData.originZ + 0.15
this.hoveredCity = mesh
const cityName = mesh.userData.cityName
const cityData = this.cityData.find(city => city.name === cityName)
if (cityData) {
const processedCities = new Set()
this.cityData.forEach((targetCity, index) => {
if (targetCity.name !== cityName && !processedCities.has(targetCity.name)) {
processedCities.add(targetCity.name)
const flyline = this.createFlyline(cityData.center, targetCity.center, index)
this.flylineGroup.add(flyline)
}
})
}
}
} else if (this.hoveredCity) {
this.hoveredCity.position.z = this.hoveredCity.userData.originZ
this.hoveredCity = null
this.clearFlylines()
}
},
/* ---------- 清除飞线 ---------- */
clearFlylines() {
while (this.flylineGroup.children.length > 0) {
this.flylineGroup.remove(this.flylineGroup.children[0])
}
this.flylines = []
},
/* ---------- 添加城市标签 ---------- */
addCityLabels(labelGroup) {
// Canvas
const createTextTexture = (text) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const fontSize = 16
const padding = 8
// Canvas
ctx.font = `bold ${fontSize}px Arial`
const textWidth = ctx.measureText(text).width
canvas.width = textWidth + padding * 2
canvas.height = fontSize + padding * 2
//
ctx.font = `bold ${fontSize}px Arial`
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, canvas.width / 2, canvas.height / 2)
//
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
return { texture, width: canvas.width, height: canvas.height }
}
//
this.cityData.forEach(city => {
const { texture, width, height } = createTextTexture(city.name)
//
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true
})
//
const sprite = new THREE.Sprite(material)
// - Y
sprite.position.set(
city.center[0],
city.center[1] + 0.3, //
0.2 //
)
//
const scale = 1.0
sprite.scale.set(width * scale * 0.01, height * scale * 0.01, 1)
// 便
this.labelSprites.push({
type: 'label',
mesh: sprite
})
//
labelGroup.add(sprite)
})
},
/* ---------- 添加城市标记点 ---------- */
addCityMarkers(markerGroup, depth) {
//
const ringGeometry = new THREE.RingGeometry(0.1, 0.2, 32)
this.cityData.forEach(city => {
// -
const innerMarker = new THREE.Mesh(
new THREE.CircleGeometry(0.1, 32),
new THREE.MeshBasicMaterial({
color: 0x81d7bc, // 绿
transparent: true,
opacity: 0.9
})
)
// -
const outerRing = new THREE.Mesh(
ringGeometry,
new THREE.MeshBasicMaterial({
color: 0x81d7bc, // 绿
transparent: true,
opacity: 0.5
})
)
//
innerMarker.position.set(city.center[0], city.center[1], depth + 0.05)
outerRing.position.set(city.center[0], city.center[1], depth + 0.05)
//
markerGroup.add(innerMarker)
markerGroup.add(outerRing)
//
const pulseMarker = new THREE.Mesh(
new THREE.RingGeometry(0.2, 0.3, 32),
new THREE.MeshBasicMaterial({
color: 0x81d7bc, // 绿
transparent: true,
opacity: 0.3
})
)
pulseMarker.position.set(city.center[0], city.center[1], depth + 0.05)
markerGroup.add(pulseMarker)
//
this.labelSprites.push({
type: 'pulse',
mesh: pulseMarker,
initialScale: 1.0,
phase: Math.random() * Math.PI * 2 //
})
})
},
/* ---------- 相机 ---------- */
setCamera(w, h) {
const aspect = w / h
const fov = 45 //
const tilt = Math.PI / 5 // 36
//
const mapWidth = this.halfSizeX * 2
const mapHeight = this.halfSizeY * 2
//
const maxMapSize = Math.max(mapWidth, mapHeight / aspect)
// 使canvas
// : distance = (maxMapSize/2) / tan(fov/2)
let distance = (maxMapSize / 2) / Math.tan(fov / 2 * Math.PI / 180)
distance = distance * 0.8 //
//
this.camera = new THREE.PerspectiveCamera(
fov,
aspect,
1,
2000
)
// y z
this.camera.position.set(
this.center.x,
this.center.y - Math.sin(tilt) * distance,
this.center.z + Math.cos(tilt) * distance
)
this.camera.lookAt(this.center)
},
/* ---------- 窗口缩放 ---------- */
onResize() {
if (!this.camera) return
const w = this.$refs.wrap.clientWidth
const h = this.$refs.wrap.clientHeight
if (!w || !h) return
this.renderer.setSize(w, h)
const aspect = w / h
let halfW = this.halfSizeX, halfH = this.halfSizeY
if (aspect >= halfW / halfH) halfW = halfH * aspect
else halfH = halfW / aspect
this.camera.left = -halfW
this.camera.right = halfW
this.camera.top = halfH
this.camera.bottom = -halfH
this.camera.updateProjectionMatrix()
},
// Add new click handler method
onMapClick(event) {
if (!this.camera || !this.cityMeshes.length) return
const rect = this.$refs.wrap.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObjects(this.cityMeshes)
if (intersects.length > 0) {
const mesh = intersects[0].object
const cityName = mesh.userData.cityName
this.$router.push({
name: 'CityPage',
params: { cityName }
})
}
}
}
}
</script>
<style scoped>
.three-wrap {
width: 100%;
height: 100%;
min-height: 400px;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,23 @@
import * as THREE from 'three'
/**
* @param {Array<Array<number>>} outer - 外环 [[lng,lat], ...]
* @param {Array<Array<Array<number>>>} holes - 内环数组可选
* @returns {THREE.Mesh}
*/
export default function createMesh (outer, holes = []) {
const outerV2 = outer.map(p => new THREE.Vector2(p[0], p[1]))
const shape = new THREE.Shape(outerV2)
holes.forEach(hole => {
const holeV2 = hole.map(p => new THREE.Vector2(p[0], p[1]))
shape.holes.push(new THREE.Path(holeV2))
})
const geometry = new THREE.ShapeGeometry(shape) // r154+ 使用 ShapeGeometry
const material = new THREE.MeshBasicMaterial({
color: 0x004444,
side: THREE.DoubleSide
})
return new THREE.Mesh(geometry, material)
}

View File

@ -0,0 +1,190 @@
<template>
<div ref="chartContainer" :style="{width: '100%', height: chartHeight}"
></div>
</template>
<script>
export default {
name: 'LineIndex',
data() {
return {
myChart: null,
};
},
props: {
chartHeight: {
type: String,
default: '260px', //
}
},
watch: {
chartHeight() {
if (this.myChart) {
this.myChart.resize(); // ECharts
}
}
},
mounted() {
console.log('->>>>')
this.initChart();
},
methods: {
initChart() {
this.myChart = this.$echarts.init(this.$refs.chartContainer);
const option = {
tooltip: {
trigger: 'item',
formatter: function (params) {
return `${params.name}: ${params.value}`;
}
},
graphic: [
{
type: 'group',
left: 'center',
top: '45%',
children: [
{
id: 'text-name',
type: 'text',
left: 'center',
top: '0', //
style: {
text: '', //
textAlign: 'center',
fontSize: 12,
fill: '#fff' //
}
},
{
id: 'text-value',
type: 'text',
left: 'center',
top: '10', //
style: {
text: '',
textAlign: 'center',
fontSize: 18,
fontWeight: 'bold',
fill: '#fff' //
}
}
]
}
],
series: [
{
name: '人员类型',
type: 'pie',
zlevel: 1,
colorMappingBy: 'id',
center: ['50%', '50%'],
radius: ['40%', '55%'],
label: {
normal: {
show: false, //
}
},
labelLine: {
normal: {
show: false
},
},
tooltip: {
show: false,
},
color: ['#18755B', '#0D618E', '#168990', '#738353', '#7c5b24'],
data: [
{value: 500, name: '信息技术', itemStyle: {color: '#18755B'}},
{value: 150, name: '生物医药', itemStyle: {color: '#0D618E'}},
{value: 100, name: '能源环境', itemStyle: {color: '#168990'}},
{value: 50, name: '工业制造', itemStyle: {color: '#738353'}},
{value: 300, name: '食品工业', itemStyle: {color: '#7c5b24'}}
]
},
{
name: '人员类型',
type: 'pie',
zlevel: 2,
colorMappingBy: 'id',
center: ['50%', '50%'],
radius: ['55%', '70%'],
label: {
normal: {
show: false,
}
},
labelLine: {
normal: {
show: false
},
},
tooltip: {
show: false,
},
color: ['#229d7c', '#0f6fbf', '#1badc2', '#adb066', '#b8681e'],
data: [
{value: 500, name: '信息技术', itemStyle: {color: '#229d7c'}},
{value: 150, name: '生物医药', itemStyle: {color: '#0f6fbf'}},
{value: 100, name: '能源环境', itemStyle: {color: '#1badc2'}},
{value: 50, name: '工业制造', itemStyle: {color: '#adb066'}},
{value: 300, name: '食品工业', itemStyle: {color: '#b8681e'}}
]
},
{
name: '人员类型',
type: 'pie',
zlevel: 3,
colorMappingBy: 'id',
center: ['50%', '50%'],
radius: ['70%', '90%'],
label: {
normal: {
show: false,
}
},
labelLine: {
normal: {
show: false
},
},
tooltip: {
show: false,
},
color: ['#37FFC9', '#0084FF', '#19D6FF', '#FFE777', '#FF7908'],
data: [
{value: 500, name: '信息技术', itemStyle: {color: '#37FFC9'}},
{value: 150, name: '生物医药', itemStyle: {color: '#0084FF'}},
{value: 100, name: '能源环境', itemStyle: {color: '#19D6FF'}},
{value: 50, name: '工业制造', itemStyle: {color: '#FFE777'}},
{value: 300, name: '食品工业', itemStyle: {color: '#FF7908'}}
]
}
]
};
this.myChart.setOption(option);
//
this.myChart.on('mouseover', (params) => {
this.myChart.setOption({
graphic: [
{id: 'text-name', style: {text: params.name}},
{id: 'text-value', style: {text: params.value + '个'}}
]
});
});
//
this.myChart.on('mouseout', () => {
this.myChart.setOption({
graphic: [
{id: 'text-name', style: {text: ''}},
{id: 'text-value', style: {text: ''}}
]
});
});
},
}
};
</script>

View File

@ -0,0 +1,124 @@
<template>
<div ref="chartContainer" :style="{width: '100%', height: '200px'}"
></div>
</template>
<script>
export default {
name: 'LineIndex',
data() {
return {
myChart: null,
};
},
watch: {
chartHeight() {
if (this.myChart) {
this.myChart.resize(); // ECharts
}
}
},
mounted() {
console.log('->>>>')
this.initChart();
},
methods: {
initChart() {
this.myChart = this.$echarts.init(this.$refs.chartContainer);
const option = {
color: ['#159AFF', '#76FAB3', '#65DFDD', '#FCC779'], // icon1 ~ icon4
tooltip: {
trigger: 'item',
},
legend: {
left: 'center',
top: 'bottom',
data: [
'rose1',
'rose2',
'rose3',
'rose4'
]
},
series: [
{
name: '金融产品发布情况',
type: 'pie',
radius: [15, 100],
center: ['55%', '50%'],
roseType: 'radius',
startAngle: 270,
label: {
normal: {
show: false, //
}
},
labelLine: {
normal: {
show: false
},
},
emphasis: {
label: {
show: true
}
},
data: [
{value: 40, name: 'rose 1'},
{value: 33, name: 'rose 2'},
{value: 28, name: 'rose 3'},
{value: 22, name: 'rose 4'}
]
},
{
name: '透明圆圈',
type: 'pie',
radius: [15, 30],
center: ['55%', '50%'],
itemStyle: {
color: 'rgba(250, 250, 250, 0.3)',
},
data: [
{value: 10, name: ''}
]
},
{
name: '透明圆圈',
type: 'pie',
radius: [30, 31],
center: ['55%', '50%'],
itemStyle: {
color: 'rgba(250, 250, 250)',
},
data: [
{value: 10, name: ''}
]
}
]
};
this.myChart.setOption(option);
//
this.myChart.on('mouseover', (params) => {
this.myChart.setOption({
graphic: [
{id: 'text-name', style: {text: params.name}},
{id: 'text-value', style: {text: params.value + '个'}}
]
});
});
//
this.myChart.on('mouseout', () => {
this.myChart.setOption({
graphic: [
{id: 'text-name', style: {text: ''}},
{id: 'text-value', style: {text: ''}}
]
});
});
},
}
};
</script>

View File

@ -0,0 +1,183 @@
<template>
<div class="carousel-container">
<div
class="carousel-list"
ref="carousel"
:style="{ transform: `translateY(${-currentIndex * 40}px)` }"
>
<div v-for="(item, index) in loopedList" :key="item.id" class="carousel-item">
<div class="sort" :style="{ backgroundImage: getSortBackground(item.sort) }">
No.{{ item.sort }}
</div>
<div class="progress-name">{{ item.name }}</div>
<div class="progress-bar-container">
<div class="progress-bar-background"></div>
<div class="progress-bar" :style="getBarStyle(item.sort, item.count)"></div>
</div>
<div class="progress-num">
<span style="font-weight: bold; font-size: 16px">{{ item.count }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import rank1bg from '../../assets/images/bg/rank1_bg.png';
import rank2bg from '../../assets/images/bg/rank2_bg.png';
import rank3bg from '../../assets/images/bg/rank3_bg.png';
import rank4bg from '../../assets/images/bg/rank4_bg.png';
export default {
data() {
return {
currentIndex: 0, //
list: [
{id: 1, sort: 1, name: '科技论文', count: 3693},
{id: 2, sort: 2, name: '发明专利', count: 2791},
{id: 3, sort: 3, name: '行业标准', count: 2635},
{id: 4, sort: 4, name: '公司名称', count: 11187},
{id: 5, sort: 5, name: '公司名称', count: 1672},
{id: 6, sort: 6, name: '公司名称', count: 1573},
{id: 7, sort: 7, name: '公司名称', count: 1274},
{id: 8, sort: 8, name: '公司名称', count: 1085},
{id: 9, sort: 9, name: '公司名称', count: 976},
{id: 10, sort: 10, name: '公司名称', count: 901},
],
};
},
computed: {
//
loopedList() {
if (this.list.length > 5) {
return [
...this.list,
...this.list.slice(0, 5).map((item, index) => ({
...item,
id: `${item.id}-extra-${index}`, // key
})),
];
} else {
return [
...this.list,
]
}
},
},
methods: {
getSortBackground(sort) {
if (sort === 1) return `url(${rank1bg})`;
if (sort === 2) return `url(${rank2bg})`;
if (sort === 3) return `url(${rank3bg})`;
return `url(${rank4bg})`;
},
getBarStyle(sort, count) {
const maxCount = Math.max(...this.list.map((item) => item.count));
const width = (count / maxCount) * 100; //
const gradient =
sort <= 3
? 'linear-gradient(270deg, #79D8FF 0%, #2C9AFF 100%)' // A
: 'linear-gradient(270deg, #4CA4FF 0%, #1155CC 100%)';
return {width: `${width}%`, background: gradient};
},
startAutoScroll() {
this.interval = setInterval(() => {
this.currentIndex++;
// `currentIndex`
if (this.currentIndex >= this.list.length) {
setTimeout(() => {
this.currentIndex = 0; //
}, 500); //
}
}, 3000);
},
},
mounted() {
if (this.list.length > 5) {
this.startAutoScroll();
} else {
}
},
beforeDestroy() {
clearInterval(this.interval);
},
};
</script>
<style scoped>
.carousel-container {
position: relative;
width: 100%;
height: 200px;
margin: auto;
overflow: hidden;
}
.carousel-list {
position: relative;
display: flex;
flex-direction: column;
transition: transform 0.5s ease-in-out; /* 平滑滚动 */
}
.carousel-item {
display: flex;
flex-direction: row;
justify-content: flex-start;
font-size: 16px;
border-radius: 8px;
color: white;
height: 40px;
width: 100%;
gap: 20px;
}
.sort {
position: relative;
height: 30px;
width: 50px;
color: white;
margin-top: 5px;
align-items: center;
text-align: center;
display: flex;
justify-content: center;
margin-left: 10px;
}
.progress-name {
position: relative;
width: 70px;
height: 30px;
margin-top: 5px;
line-height: 30px;
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.progress-num {
height: 30px;
margin-top: 5px;
line-height: 30px;
}
.progress-bar-container {
position: relative;
width: 230px;
height: 10px;
background-color: #103258;
border-radius: 5px;
margin-top: 15px;
}
.progress-bar {
position: absolute;
height: 100%;
border-radius: 5px;
}
</style>

View File

@ -0,0 +1,139 @@
<template>
<div class="carousel-container">
<div
class="carousel-list"
ref="carousel"
:style="{ transform: `translateY(${-currentIndex * 40}px)` }"
>
<div v-for="(item, index) in loopedList" :key="item.id" class="carousel-item" :class="{ odd: index % 2 === 0 }"
>
<div class="item-rank">{{ item.rank }}</div>
<div class="item-num-name">{{ item.name }}</div>
<div class="item-num">{{ item.num2 }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
currentIndex: 0, //
list: [
{id: 1, rank: '1', name: '一种基于区块链的车联网安全通信方法与系统', num2: 12},
{id: 2, rank: '2', name: '一种面向智能制造的高效节能热交换设备及其控制方法', num2: 24},
{id: 3, rank: '3', name: '一种多模式结合的深海无人潜航器姿态稳定装置与算法', num2: 53},
{id: 4, rank: '4', name: '基于光谱融合分析的农作物病虫害快速诊断系统及装置', num2: 77},
{id: 5, rank: '5', name: '面向智慧城市的多源异构数据实时融合处理平台的实现方法', num2: 12},
{id: 6, rank: '6', name: '一种新型抗菌耐磨复合医用植入材料及其制备工艺', num2: 83},
{id: 7, rank: '7', name: '一种利用风光互补的海上浮式绿色能源供给装置与方法', num2: 21},
{id: 8, rank: '8', name: '一种高分辨率卫星影像的多尺度语义分割训练模型', num2: 99},
{id: 9, rank: '9', name: '面向自动驾驶的激光雷达与毫米波融合环境感知算法系统', num2: 34},
{id: 10, rank: '10', name: '一种可降解高强度柔性电子皮肤传感器的制备方法', num2: 51},
{id: 11, rank: '11', name: '用于电池梯次利用的智能健康评估与剩余寿命预测平台', num2: 10},
{id: 12, rank: '12', name: '一种基于量子点增强的高效率全彩微型显示器及制备工艺', num2: 99}
]
};
},
computed: {
//
loopedList() {
if (this.list.length > 10) {
return [
...this.list,
...this.list.slice(0, 10).map((item, index) => ({
...item,
id: `${item.id}-extra-${index}`, // key
})),
];
} else {
return [
...this.list,
]
}
},
},
methods: {
startAutoScroll() {
this.interval = setInterval(() => {
this.currentIndex++;
// `currentIndex`
if (this.currentIndex >= this.list.length) {
setTimeout(() => {
this.currentIndex = 0; //
}, 500); //
}
}, 3000);
},
},
mounted() {
if (this.list.length > 10) {
this.startAutoScroll();
}
},
beforeDestroy() {
clearInterval(this.interval);
},
};
</script>
<style scoped>
.carousel-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
}
.carousel-list {
position: relative;
display: flex;
flex-direction: column;
transition: transform 0.5s ease-in-out;
}
.carousel-item {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 16px;
height: 40px;
width: calc(100% - 40px);
margin-left: 20px;
line-height: 40px;
text-align: center;
background: linear-gradient(
270deg,
#0D2847 0%,
rgba(13, 40, 71, 0.21) 48%,
#103258 100%
);
color: #CBE2FF;
font-weight: bold;
}
.carousel-item.odd {
background: none;
}
.item-rank {
flex: 1;
}
.item-num {
flex: 1;
}
.item-num-name {
flex: 4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<div ref="cloud" :style="{ width: '100%', height }"></div>
</template>
<script>
import 'echarts-wordcloud' // wordcloud ( echarts )
export default {
name: 'WordCloud',
props: {
/**
* words 数组支持两种格式
* ["AI", "IoT", "Digital Humans"]
* [{ text:"AI", value:120 }, { text:"IoT", value:80 }]
*/
words: { type: Array, required: true },
height: { type: String, default: '300px' }
},
data() {
return { chart: null }
},
watch: {
words: {
deep: true,
handler() { this.renderCloud() }
},
height() {
this.$nextTick(() => { this.chart && this.chart.resize() })
}
},
mounted() { this.init() },
beforeDestroy() { this.chart && this.chart.dispose() },
methods: {
init() {
this.chart = this.$echarts.init(this.$refs.cloud)
this.renderCloud()
window.addEventListener('resize', this.chart.resize)
},
renderCloud() {
if (!this.chart) return
//
const data = this.words.map(w =>
typeof w === 'string'
? { name: w, value: w.length * 8 } //
: { name: w.text, value: w.value }
)
//
const { height: h } = this.$refs.cloud.getBoundingClientRect()
const maxFont = Math.floor(h * 0.30) // 30%
const minFont = Math.max(10, Math.floor(maxFont / 3))
this.chart.setOption({
backgroundColor: 'transparent',
tooltip: { show: false },
series: [{
type: 'wordCloud',
/* --- 关键:贴满父容器,无留白 --- */
left: 70, top: 0, right: 0, bottom: 0,
shape: 'circle',
sizeRange: [minFont, maxFont], //
gridSize: 2, //
shrinkToFit: true, //
rotationRange: [ -30, 30 ], //
rotationStep: 30,
textStyle: {
fontFamily: 'Impact',
color(params) {
const toppers = ['#FC8A66', '#FED95D', '#40DA93']
if (params.dataIndex < 3) return toppers[params.dataIndex]
const palette = ['#79D8FF', '#2C9AFF', '#C99CFF', '#6A44FF', '#8EC4FF']
return palette[Math.floor(Math.random() * palette.length)]
}
},
data
}]
})
}
}
}
</script>
<style scoped>
/* 可选:给 canvas 再加点淡淡的炫光背景 */
</style>

17
src/main.js Normal file
View File

@ -0,0 +1,17 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './assets/font/font.css';
Vue.config.productionTip = false
import * as echarts from 'echarts'
Vue.prototype.$echarts = echarts
import VScaleScreen from 'v-scale-screen'
import 'default-passive-events'
Vue.use(VScaleScreen)
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

36
src/router/index.js Normal file
View File

@ -0,0 +1,36 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: Home,
children: [
{
path: '/',
name: 'generalPage',
meta: { keepAlive: true },
component: () => import('../views/generalPage.vue')
},
{
path: '/city/:cityName',
name: 'CityPage',
props: true,
component: () => import('../views/cityPage.vue')
}
]
},
]
const router = new VueRouter({
mode: 'hash',
base: process.env.BASE_URL,
routes
})
export default router

20
src/store/index.js Normal file
View File

@ -0,0 +1,20 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
windowHeight: 1080,
},
mutations: {
setWindowHeight(state, height) {
state.windowHeight = height
console.log('输出储存的windowHeight', state.windowHeight)
}
},
actions: {
},
modules: {
}
})

76
src/views/Home.vue Normal file
View File

@ -0,0 +1,76 @@
<template>
<div>
<!-- 1920×1080-->
<v-scale-screen :width="windowWidth<1920?windowWidth:1920" :height="windowHeight<1080?windowHeight:1080"
v-if="isShowScreen" >
<div class="main">
<router-view></router-view>
</div>
</v-scale-screen>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
isShowScreen: false,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
}
},
methods: {
handleResize() {
this.windowWidth = window.innerWidth;
this.windowHeight = window.innerHeight;
this.$store.commit('setWindowHeight', this.windowHeight);
}
},
mounted() {
setTimeout(() => {
this.isShowScreen = true
}, 100)
window.addEventListener('resize', this.handleResize);
},
destroyed() {
window.removeEventListener('resize', this.handleResize);
},
}
</script>
<style lang="scss" scoped>
.header {
user-select: none;
position: relative;
width: 100%;
height: 100px;
display: flex;
margin: 0 auto;
justify-content: center;
}
.main {
user-select: none;
position: relative;
width: 100%;
height: 100%;
//background: linear-gradient( 270deg, #0A362A 0%, #0F4433 54%, #0A362A 100%);
}
.footer {
user-select: none;
position: relative;
width: 100%;
height: 100px;
display: flex;
margin: 0 auto;
justify-content: center;
}
</style>

850
src/views/cityPage.vue Normal file
View File

@ -0,0 +1,850 @@
<template>
<div class="page-style">
<div class="section-header" @click="goBack">
<div class="screen-title">
{{ cityName }}科技大脑智慧服务平台
</div>
<div class="back-button">返回省级</div>
</div>
<div class="section-container">
<div class="section-top">
<div class="section-top-left">
<MapCity class="map-card" :geojson="cityGeoJson" v-if="cityGeoJson" @district-hover="onDistrictHover" @district-click="onDistrictClick"></MapCity>
</div>
<div class="section-top-right">
<card title="专利转化潜力值排行" :heightPercentage="100">
<div class="patent-ranking">
<div class="ranking-header">
<div class="ranking-city">{{cityName}}</div>
<div class="ranking-count">145</div>
</div>
<div class="ranking-chart" ref="potentialChartContainer">
<div ref="potentialChart" style="width: 100%; height:350px;"></div>
</div>
</div>
</card>
</div>
</div>
<div class="section-bottom">
<div class="chart-container">
<card title="各类科技成果占比" :heightPercentage="100">
<div ref="achievementChart" class="chart" style="height: 240px;"></div>
</card>
</div>
<div class="chart-container">
<card :title="`${cityName}地区专利数量`" :heightPercentage="100">
<div ref="patentChart" class="chart" style="height: 240px;"></div>
</card>
</div>
<div class="chart-container">
<card title="地区科研单位占比" :heightPercentage="100">
<div ref="institutionChart" class="chart" style="height: 240px;"></div>
</card>
</div>
</div>
</div>
</div>
</template>
<script>
import card from '../components/card/index'
import barChart from '../components/bar/3DIndex.vue'
import lineChart from '../components/line/index.vue'
import pieChart from '../components/pie/index.vue'
import rank from '../components/scroll/index.vue'
import rank2 from '../components/scroll/index2.vue'
import WordCloud from '../components/wordCloud/index.vue'
import MapCity from '../components/map/mapCity.vue'
import * as echarts from 'echarts'
export default {
name: 'CityPage',
components: {
card,
barChart,
lineChart,
pieChart,
rank,
rank2,
WordCloud,
MapCity
},
props: {
cityName: {
type: String,
required: true
}
},
data() {
return {
containerHeight: '220px',
cityGeoJson: null,
hoveredDistrict: null,
selectedDistrict: null,
districtData: {
//
'朝阳区': { patents: 256, research: 18, conversion: 145 },
'南关区': { patents: 189, research: 12, conversion: 98 },
'宽城区': { patents: 210, research: 15, conversion: 120 },
'二道区': { patents: 178, research: 10, conversion: 85 },
'绿园区': { patents: 195, research: 14, conversion: 110 },
'双阳区': { patents: 120, research: 8, conversion: 65 },
'九台区': { patents: 145, research: 9, conversion: 78 },
'农安县': { patents: 132, research: 7, conversion: 70 }
},
pieList: [
{id: 1, value: 72.49, name: '数据名称', color: '#37FFC9'},
{id: 2, value: 32.55, name: '数据名称', color: '#0084FF'},
{id: 3, value: 28.6, name: '数据名称', color: '#19D6FF'},
{id: 4, value: 10.91, name: '数据名称', color: '#FFE777'}
],
peopleList: [
{id: 1, label: '行业应用'},
{id: 2, label: '工作性质'}
],
keywords: [
'Digital Humans', 'Deep Learning', 'VR', 'AR', 'IoT',
'Decentralization', 'Sustainability', 'Data Mining',
'Autonomous Driving', 'MR'
],
achievementChart: null,
patentChart: null,
institutionChart: null,
potentialChart: null,
cityLineData: {
'长春市': [150, 160, 150, 140, 160, 150, 140, 155, 160, 140, 135, 150],
'延边州': [120, 130, 110, 120, 125, 115, 120, 125, 115, 120, 125, 115],
'吉林市': [80, 75, 70, 85, 95, 85, 75, 80, 95, 85, 60, 90]
}
}
},
watch: {
windowHeight(newValue) {
if (newValue >= 1000) {
this.containerHeight = '240px'
} else {
this.containerHeight = '190px'
}
}
},
computed: {
windowHeight() {
return this.$store.state.windowHeight
}
},
async created() {
try {
// Load city GeoJSON data based on city name
this.cityGeoJson = await this.loadCityGeoJson(this.cityName)
console.log('this.cityGeoJson', this.cityGeoJson)
} catch (error) {
console.error('Failed to load city GeoJSON:', error)
}
},
mounted() {
this.$nextTick(() => {
this.initAchievementChart();
this.initPatentChart();
this.initInstitutionChart();
this.initPotentialChart();
//
window.addEventListener('resize', this.handleResize);
});
},
updated() {
this.$nextTick(() => {
this.handleResize();
});
},
beforeDestroy() {
//
this.achievementChart?.dispose()
this.patentChart?.dispose()
this.institutionChart?.dispose()
this.potentialChart?.dispose()
window.removeEventListener('resize', this.handleResize)
},
methods: {
async loadCityGeoJson(cityName) {
try {
const geoJson = await import(`@/components/map/geojson/${cityName}.json`)
return geoJson.default
} catch (error) {
console.error(`Failed to load GeoJSON for ${cityName}:`, error)
return null
}
},
onDistrictHover(districtName) {
this.hoveredDistrict = districtName
// UI
// UI
},
onDistrictClick(districtName) {
//
this.selectedDistrict = districtName
//
if (districtName && this.districtData[districtName]) {
const data = this.districtData[districtName]
//
// 线
console.log(`区县 ${districtName} 被选中,专利数量: ${data.patents}, 研究机构: ${data.research}, 转化数量: ${data.conversion}`)
// UI
document.title = `${this.cityName} - ${districtName} - 科技大脑智慧服务平台`
}
},
goBack() {
this.$router.push('/')
},
handleResize() {
this.$nextTick(() => {
if (this.achievementChart) this.achievementChart.resize();
if (this.patentChart) this.patentChart.resize();
if (this.institutionChart) this.institutionChart.resize();
if (this.potentialChart) this.potentialChart.resize();
});
},
initAchievementChart() {
this.achievementChart = echarts.init(this.$refs.achievementChart)
const option = {
backgroundColor: '#031845',
tooltip: {
trigger: 'item',
formatter: "{a} <br/>{b}: {c} ({d}%)"
},
graphic: {
elements: [{
type: 'text',
left: '33%',
top: '42%',
style: {
text: '43.56%',
textAlign: 'center',
fill: '#fff',
fontSize: 24,
fontWeight: 'bold'
},
z: 3
}, {
type: 'text',
left: '38%',
top: '52%',
style: {
text: '专利',
textAlign: 'center',
fill: '#00FFFF',
fontSize: 16
},
z: 3
}]
},
legend: {
orient: 'vertical',
right: '5%',
top: 'middle',
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
data: ['论文', '专利', '报告', '文献'],
textStyle: {
color: '#fff',
fontSize: 14,
padding: [0, 0, 0, 8]
},
formatter: function(name) {
const data = option.series[0].data;
let value, percent;
for (let i = 0; i < data.length; i++) {
if (data[i].name === name) {
value = data[i].value;
percent = Math.round(value / data.reduce((sum, item) => sum + item.value, 0) * 100) + '%';
break;
}
}
return `${name} ${value} ${percent}`;
}
},
series: [{
name: '科技成果',
type: 'pie',
hoverAnimation: false,
legendHoverLink: false,
radius: ['45%', '55%'],
center: ['40%', '50%'],
color: ['#00FFFF', '#4B85E2', '#F0CA00', '#41D195'],
label: {
normal: {
show: false
}
},
labelLine: {
normal: {
show: false
}
},
tooltip: {
show: false
},
data: [{
value: 2560,
name: '论文'
}, {
value: 1980,
name: '专利'
}, {
value: 1466,
name: '报告'
}, {
value: 1203,
name: '文献'
}]
}, {
name: '科技成果',
type: 'pie',
radius: ['55%', '70%'],
center: ['40%', '50%'],
color: ['#00FFFF', '#4B85E2', '#F0CA00', '#41D195'],
itemStyle: {
normal: {
shadowBlur: 20,
shadowColor: 'rgba(0, 255, 255, 0.3)',
opacity: 0.9
}
},
labelLine: {
normal: {
show: false
}
},
label: {
normal: {
show: false
}
},
data: [{
value: 2560,
name: '论文'
}, {
value: 1980,
name: '专利'
}, {
value: 1466,
name: '报告'
}, {
value: 1203,
name: '文献'
}]
}]
}
this.achievementChart.setOption(option)
},
initPatentChart() {
this.patentChart = echarts.init(this.$refs.patentChart)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '3%',
containLabel: true
},
xAxis: {
type: 'value',
axisLine: {
show: false
},
axisTick: {
show: false
},
splitLine: {
show: false
},
axisLabel: {
color: '#fff'
}
},
yAxis: {
type: 'category',
data: ['长春', '吉林', '四平', '白城', '四平'],
axisLine: {
lineStyle: {
color: '#0A2E5D'
}
},
axisTick: {
show: false
},
axisLabel: {
color: '#fff'
}
},
series: [{
type: 'bar',
data: [320, 450, 280, 380, 220],
barWidth: 10,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{
offset: 0,
color: '#0074D9'
}, {
offset: 1,
color: '#00FFFF'
}])
}
}]
}
this.patentChart.setOption(option)
},
initInstitutionChart() {
this.institutionChart = echarts.init(this.$refs.institutionChart)
const option = {
backgroundColor: '#031845',
tooltip: {
trigger: 'item',
formatter: '{b}: {c}m² ({d}%)'
},
legend: {
orient: 'vertical',
right: '5%',
top: 'middle',
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
textStyle: {
color: '#fff',
fontSize: 14,
padding: [0, 0, 0, 8]
},
formatter: function(name) {
const data = option.series[0].data;
let value, percent;
for (let i = 0; i < data.length; i++) {
if (data[i].name === name) {
value = data[i].value;
percent = Math.round(value / data.reduce((sum, item) => sum + item.value, 0) * 100) + '%';
break;
}
}
return `${name} ${value}${percent}`;
}
},
series: [{
name: '科研单位',
type: 'pie',
radius: ['20%', '65%'],
center: ['40%', '50%'],
roseType: 'radius',
itemStyle: {
borderRadius: 0,
borderColor: '#031845',
borderWidth: 2
},
label: {
show: false
},
labelLine: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
color: ['#00FFFF', '#4B85E2', '#F0CA00', '#41D195'],
data: [{
value: 72.49,
name: '数据名称1',
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(0, 255, 255, 0.3)'
}
}, {
value: 32.55,
name: '数据名称2',
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(75, 133, 226, 0.3)'
}
}, {
value: 28.6,
name: '数据名称3',
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(240, 202, 0, 0.3)'
}
}, {
value: 10.91,
name: '数据名称4',
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(65, 209, 149, 0.3)'
}
}]
}]
}
this.institutionChart.setOption(option)
},
initPotentialChart() {
// DOM
this.$nextTick(() => {
if (!this.$refs.potentialChart) {
console.error('potentialChart ref not found');
return;
}
//
if (this.potentialChart) {
this.potentialChart.dispose();
}
//
this.potentialChart = echarts.init(this.$refs.potentialChart);
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0,0,0,0.7)',
borderColor: 'rgba(0,147,233,0.2)',
textStyle: {
color: '#fff'
}
},
legend: {
data: ['朝阳区', '南关区', '宽城区', '二道区', '绿园区'],
textStyle: {
color: '#8CAFE0'
},
top: 0,
itemWidth: 15,
itemHeight: 10,
itemGap: 15
},
grid: {
left: '3%',
right: '4%',
bottom: '8%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['2020年', '2021年', '2022年', '2023年', '2024年'],
axisLine: {
lineStyle: {
color: '#1E3F66'
}
},
axisTick: {
show: false
},
axisLabel: {
color: '#8CAFE0',
fontSize: 12
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: '#1E3F66',
type: 'dashed'
}
},
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: '#8CAFE0',
fontSize: 12
}
},
series: [
{
name: '朝阳区',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: [256, 245, 260, 255, 270],
itemStyle: {
color: '#00FFFF'
},
lineStyle: {
color: '#00FFFF',
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(0, 255, 255, 0.3)'
},
{
offset: 1,
color: 'rgba(0, 255, 255, 0)'
}
])
}
},
{
name: '南关区',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: [189, 195, 185, 192, 198],
itemStyle: {
color: '#19D6FF'
},
lineStyle: {
color: '#19D6FF',
width: 2
}
},
{
name: '宽城区',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: [210, 205, 215, 208, 220],
itemStyle: {
color: '#FFC900'
},
lineStyle: {
color: '#FFC900',
width: 2
}
},
{
name: '二道区',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: [178, 182, 175, 180, 185],
itemStyle: {
color: '#FF7A45'
},
lineStyle: {
color: '#FF7A45',
width: 2
}
},
{
name: '绿园区',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: [195, 190, 200, 193, 205],
itemStyle: {
color: '#41D195'
},
lineStyle: {
color: '#41D195',
width: 2
}
}
]
};
try {
this.potentialChart.setOption(option);
console.log('Potential chart initialized successfully');
} catch (error) {
console.error('Failed to initialize potential chart:', error);
}
});
}
}
}
</script>
<style lang="scss" scoped>
.page-style {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
margin: 0 auto;
background: linear-gradient(270deg,
#081B34 0%, /* 深海军蓝 */
#0D2A50 35%, /* 略亮一点,增加层次 */
#113667 54%, /* 主"亮点" */
#0D2A50 70%, /* 对称回落 */
#081B34 100% /* 与首色呼应 */
);
}
.section-header {
position: relative;
width: 100%;
height: 80px;
background: url("./../assets/images/screen1/blockTop/header_bg3.png") no-repeat;
background-size: 100% 100%;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
}
.screen-title {
position: relative;
height: 80px;
line-height: 65px;
text-align: center;
margin-left: 20px;
font-family: YouSheBiaoTiHei;
font-size: 40px;
color: white;
text-shadow: 0px 2px 3px #1E736C, 0px 0px 15px rgba(228, 239, 255, 0.1);
font-style: normal;
text-transform: none;
}
.back-button {
background: rgba(24, 144, 255, 0.1);
color: #fff;
margin-right: 20px;
margin-bottom: 10px;
cursor: pointer;
transition: background 0.3s;
&:hover {
background: rgba(24, 144, 255, 0.2);
}
}
.section-container {
position: relative;
width: 100%;
height: calc(100% - 80px);
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 10px 10px 30px 10px;
box-sizing: border-box;
}
.section-top{
position: relative;
width: 100%;
flex: 6;
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.section-top-left{
height: 100%;
flex: 2;
}
.section-top-right{
height: 100%;
flex: 1;
margin-left: 120px;
}
.section-bottom {
position: relative;
width: 100%;
flex: 4;
display: flex;
justify-content: center;
gap: 20px;
min-height: 260px;
.chart-container {
flex: 1;
background: rgba(3, 20, 47, 0.7);
border: 1px solid rgba(0, 147, 233, 0.2);
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
.chart-title {
color: #fff;
font-size: 16px;
display: flex;
align-items: center;
.icon-arrow {
display: inline-block;
width: 4px;
height: 16px;
background: #00FFFF;
margin-right: 8px;
}
}
.chart {
flex: 1;
width: 100%;
min-height: 0;
}
}
}
.patent-ranking {
width: 100%;
height: 100%;
padding: 15px;
display: flex;
flex-direction: column;
.ranking-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
margin-bottom: 15px;
height: 40px;
.ranking-city {
font-size: 16px;
color: #fff;
background-color: rgba(0, 147, 233, 0.2);
padding: 4px 15px;
border-radius: 4px;
}
.ranking-count {
margin-right: 40px;
font-size: 14px;
color: #8CAFE0;
}
}
.ranking-chart {
width: 100%;
overflow: visible;
}
}
.map-card {
width: 100%;
height: 100%;
}
</style>

464
src/views/generalPage.vue Normal file
View File

@ -0,0 +1,464 @@
<template>
<div class="page-style">
<div class="section-header">
<div class="screen-title">科技大脑智慧服务平台</div>
</div>
<div class="section-container">
<div class="container-left">
<card title="地区专利数量(项)" :heightPercentage="31">
<barChart :chartHeight="containerHeight" :key="containerHeight"></barChart>
</card>
<card title="科技资源排行" :heightPercentage="31">
<rank></rank>
</card>
<card title="热点科技词云" :heightPercentage="31">
<WordCloud :words="keywords" height="280px"/>
</card>
</div>
<div class="container-middle">
<MapProvincial class="map-card"></MapProvincial>
<card title="科技信息" :heightPercentage="31">
<lineChart :key="containerHeight"></lineChart>
</card>
</div>
<div class="container-right">
<card title="专利转化潜力值排行" :heightPercentage="64.5">
<div class="overview-card">
<div class="rank-unit">单位</div>
<div class="rank-title">
<div class="rank-title-name">排名</div>
<div class="rank-title-name" style="flex: 4">专利名称</div>
<div class="rank-title-name">潜力值</div>
</div>
<rank2></rank2>
</div>
</card>
<card title="科技人才分类" :heightPercentage="31" :is-show-select="true" :select-options="peopleList">
<div class="pie-block">
<pieChart :chartHeight="containerHeight" :key="containerHeight" style="width: 50%"></pieChart>
<div class="pie-legend-block">
<div class="pie-legend-container" v-for="item in pieList" :key="item.id">
<div class="pie-legend-icon" :style="{background: item.color}"></div>
<div class="pie-legend-name">{{ item.name }}</div>
<div class="pie-legend-num"><span style="font-weight: bold">{{ item.value }}</span></div>
</div>
</div>
</div>
</card>
</div>
</div>
</div>
</template>
<script>
import card from './../components/card/index'
import barChart from '../components/bar/3DIndex.vue'
import barChart2 from '../components/bar/3DIndex2.vue'
import lineChart from '../components/line/index.vue'
import lineChart2 from '../components/line/index2.vue'
import pieChart from '../components/pie/index.vue'
import pieChart2 from '../components/pie/index2.vue'
import rank from '../components/scroll/index.vue'
import rank2 from '../components/scroll/index2.vue'
import WordCloud from '../components/wordCloud/index.vue'
import MapProvincial from '../components/map/mapProvincial.vue'
export default {
name: 'generalPage',
components: {
card,
barChart,
barChart2,
lineChart,
lineChart2,
pieChart,
pieChart2,
rank,
rank2,
WordCloud,
MapProvincial
},
watch: {
windowHeight(newValue) {
console.log('触发')
if (newValue >= 1000) {
this.containerHeight = '240px'
} else {
this.containerHeight = '190px'
}
}
},
computed: {
windowHeight() {
return this.$store.state.windowHeight; // computed 访 Vuex state
}
},
data() {
return {
containerHeight: '220px',
pieList: [
{id: 1, value: 60, name: '信息技术', color: '#37FFC9'},
{id: 2, value: 23, name: '生物医药', color: '#0084FF'},
{id: 3, value: 16, name: '能源环境', color: '#19D6FF'},
{id: 4, value: 4, name: '工业制造', color: '#FFE777'},
{id: 5, value: 3, name: '食品工业', color: '#FF7908'}
],
peopleList: [
{id: 1, label: '行业应用'},
{id: 2, label: '工作性质'}
],
pieLegendList: [
{ iconClass: 'pie-chart-legend-icon1', des: '6个月及以下', num: 123 },
{ iconClass: 'pie-chart-legend-icon2', des: '12个月及以下', num: 456 },
{ iconClass: 'pie-chart-legend-icon3', des: '36个月及以下', num: 789 },
{ iconClass: 'pie-chart-legend-icon4', des: '36个月以上', num: 321 }
],
selectedBanks: [],
keywords: [
'Digital Humans', 'Deep Learning', 'VR', 'AR', 'IoT',
'Decentralization', 'Sustainability', 'Data Mining',
'Autonomous Driving', 'MR'
]
}
},
mounted() {
if (this.windowHeight < 1000) {
this.containerHeight = '190px'
}
},
beforeDestroy() {
clearInterval(this.interval); //
},
methods: {
}
}
</script>
<style lang="scss" scoped>
.page-style {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
margin: 0 auto;
background: linear-gradient(270deg,
#081B34 0%, /* 深海军蓝 (≈原 #0A362A) */
#0D2A50 35%, /* 略亮一点,增加层次 */
#113667 54%, /* 主“亮点”,对应原 #0F4433 */
#0D2A50 70%, /* 对称回落 */
#081B34 100% /* 与首色呼应 */
);
}
.section-header {
position: relative;
width: 100%;
height: 80px;
background: url("./../assets/images/screen1/blockTop/header_bg2.png") no-repeat;
background-size: 100% 100%;
overflow: hidden;
}
.screen-title {
position: absolute;
width: 100%;
height: 80px;
line-height: 65px;
text-align: center;
top: 0;
left: 0;
font-family: YouSheBiaoTiHei;
font-size: 40px;
color: white;
text-shadow: 0px 2px 3px #1E736C, 0px 0px 15px rgba(228, 239, 255, 0.1);
font-style: normal;
text-transform: none;
}
.section-container {
position: relative;
width: 100%;
height: calc(100% - 80px);
display: flex;
justify-content: center;
gap: 25px;
}
.container-left, .container-right {
position: relative;
width: 529px;
height: 100%;
display: flex;
justify-content: flex-start;
gap: 20px;
flex-direction: column;
}
.container-middle {
position: relative;
width: 750px;
height: 100%;
display: flex;
justify-content: flex-start;
gap: 20px;
flex-direction: column;
}
.map-card{
position: relative;
width: 700px;
height: 59.5vh;
}
.bar-chart-top-block {
position: relative;
width: calc(100% - 30px);
margin-left: 30px;
height: 30px;
display: flex;
justify-content: space-between;
font-size: 12px;
}
.bar-unit-block {
color: #85B6BC;
margin-top: 10px;
}
.bar-point {
position: relative;
width: 200px;
height: 25px;
display: flex;
justify-content: flex-end;
color: white;
}
.bar-point-block {
position: relative;
width: 100px;
display: flex;
justify-content: center;
}
.bar-point-name {
position: relative;
width: 80px;
margin-top: 10px;
font-weight: bold;
font-size: 14px;
}
.pie-block {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
}
.pie-legend-block {
position: relative;
width: 50%;
height: 100%;
display: flex;
justify-content: center;
flex-direction: column;
gap: 10px;
}
.pie-legend-container {
position: relative;
width: 90%;
height: 30px;
background: url("./../assets/images/bg/pie_chart_legend_bg.png") no-repeat;
background-size: 100% 100%;
display: flex;
justify-content: flex-start;
gap: 10px;
line-height: 30px;
color: #DFF2F2;
}
.pie-legend-icon {
position: relative;
width: 10px;
height: 10px;
margin-left: 10px;
margin-top: 10px;
}
.pie-legend-name {
flex: 1;
text-align: left;
font-size: 14px;
}
.pie-legend-num {
font-size: 14px;
margin-right: 10px;
}
.preview-main {
position: relative;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
.overview-card{
position: relative;
width: 100%;
height: 100%;
left: 0;
top: 0;
overflow: hidden;
}
.rank-unit {
position: relative;
width: calc(100% - 40px);
height: 40px;
line-height: 40px;
margin-left: 20px;
display: flex;
justify-content: flex-start;
color: #8CAFE0;
}
.rank-title {
position: relative;
width: calc(100% - 40px);
height: 40px;
line-height: 40px;
margin-left: 20px;
display: flex;
justify-content: space-between;
background-color: #0D2847;
color: #CBE2FF;
font-size: 16px;
font-weight: bold;
}
.rank-title-name{
flex: 1;
text-align: center;
}
.project-main{
position: relative;
width: 100%;
height: calc(100% - 20px);
display: flex;
justify-content: flex-start;
}
.tip-card{
position: relative;
width: calc(100% - 80px);
margin-left: 40px;
height: 20px;
line-height: 35px;
display: flex;
justify-content: space-between;
color: #37D3A5;
font-size: 14px;
font-weight: bold;
}
.tip-info{
position: relative;
width: 47.5%;
height: 20px;
display: flex;
justify-content: space-between;
}
.pie-chart{
width: 50%;
position: relative;
display: flex;
justify-content: center;
}
.pie-chart-canvas{
width: 60%;
}
.pie-chart-legend{
width: 40%;
position: relative;
display: flex;
justify-content: center;
flex-direction: column;
gap: 10px;
}
.pie-chart-legend-container{
position: relative;
width: 100%;
height: 20px;
line-height: 20px;
display: flex;
justify-content: space-between;
font-size: 14px;
color: #DFF2F2;
gap: 5px;
}
.pie-chart-legend-num{
font-size: 16px;
font-weight: bold;
color: white;
}
.pie-chart-legend-icon1, .pie-chart-legend-icon2, .pie-chart-legend-icon3, .pie-chart-legend-icon4{
width: 6px;
height: 6px;
background-color: #159AFF;
margin-top: 6px;
}
.pie-chart-legend-icon2{
background-color: #76FAB3;
}
.pie-chart-legend-icon3{
background-color: #65DFDD;
}
.pie-chart-legend-icon4{
background-color: #FCC779;
}
.pie-chart-legend-des{
text-align: left;
width: 120px;
}
.bar-chart{
width: 50%;
}
.bar-chart-canvas{
position: relative;
width: 100%;
background: url("./../assets/images/bg/bar_bottom_bg.png") no-repeat;
background-size: 100% auto;
background-position: center calc(100% - 22px);
}
</style>
<style>
@media (max-width: 1919px) {
.container-left, .container-right {
width: 470px !important;
}
.container-middle {
width: 700px !important;
}
.section-container {
gap: 10px !important;
}
}
</style>

3
vue.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
publicPath: './', // 使用相对路径
}

9256
yarn.lock Normal file

File diff suppressed because it is too large Load Diff