阅读视图

发现新文章,点击刷新页面。

商务部:一视同仁支持外企参与提振消费、政府采购、招投标等

1月26日,商务部外国投资管理司负责人王亚在国新办新闻发布会上表示,今年将以服务业为重点,扩大市场准入和开放领域,有序扩大电信、医疗、教育等领域自主开放,推动试点项目尽早落地,支持服务业外资企业延伸价值链,实现专业化、融合化、数字化发展。同时,将进一步优化外商投资支持政策。落实好境外投资者已在华取得的利润直接投资税收抵免、鼓励外商投资产业目录等政策措施,一视同仁支持外资企业参与提振消费、政府采购、招投标等活动,助力外资企业在华长期生根发展。(证券时报)

深圳水贝金饰克价达到1300元

1月26日,国际金价再创新高,现货黄金首次突破每盎司5100美元,COMEX黄金一度升至每盎司5107美元。记者采访发现,26日下午,深圳水贝市场的金饰克价达到1300元。而在今年1月1日,深圳水贝市场的金饰克价在1126元左右。(证券时报)

2025年我国全年服务零售额增长5.5%

今天(1月26日),国新办就2025年商务工作及运行情况举行新闻发布会。发布会介绍,2025年全年服务零售额增长5.5%。文体休闲、旅游咨询租赁、交通出行等服务零售额保持着两位数的增长。(央视新闻)

联影医疗双宽体双源CT获批上市

36氪获悉,1月26日,联影医疗宣布自主研发的全球首创双宽体双源CT uCT SiriuX正式获得国家药品监督管理局(NMPA)批准上市。

我国已与31个国家和地区签署24个自贸协定

今天(1月26日),国新办就2025年商务工作及运行情况举行新闻发布会。发布会上,相关负责人介绍,目前,我国已与31个国家和地区签署24个自贸协定,自贸伙伴占货物贸易总额的45%。(央视新闻)

CSS-彻底搞懂选择器优先级与CSS单位

前言

为什么我写的样式不生效?为什么那个单位在移动端会缩放?理解 CSS 的优先级(Specificity)和度量单位,是每一个前端开发者从“画页面”走向“精通布局”的必经之路。

一、 CSS 优先级(权重)全解析

CSS 样式的应用遵循“就近原则”和“权重计算原则”。

1. 权重等级排列

我们将不同选择器划分为不同的等级:

  1. !important:无视规则,权重最高。
  2. 行内样式 (Inline style) :写在标签 style 属性里的样式。
  3. ID 选择器:如 #header
  4. 类、伪类、属性选择器:如 .content:hover[type="text"]
  5. 元素(标签)、伪元素选择器:如 div::before
  6. 通用选择器 (*) 、子选择器 (> )、相邻选择器 (+):权重极低(通常计为 0)。
  7. 继承的样式:权重最低。

2. 权重计算法(三位计数法)

为了方便对比,我们可以给它们分配分值(非绝对数值,仅作逻辑参考):

  • ID 选择器:100
  • 类/属性/伪类:10
  • 标签/伪元素:1

规则:从左往右比较,数值大的获胜。如果数值相等,则后者覆盖前者

⚠️ 注意!important 是“核武器”,如果它用于简写属性(如 background: !important),则其包含的所有子属性(color, image 等)都会获得最高权重,应谨慎使用


二、 CSS 常用度量单位

根据参考参照物的不同,单位可分为以下几类:

1. 绝对单位

  • px (像素) :最常用的基本单位,物理像素点,不随环境改变。

2. 相对单位(基于字体)

  • em:相对于当前元素font-size

    • 注意:如果当前元素未设置,则参考父元素;如果整个页面都没设置,则参考浏览器默认值(16px)。
  • rem (Root em) :相对于根元素 (<html>)font-size。是移动端适配的首选。

3. 相对单位(基于视口)

  • vw / vh:相对于浏览器可视窗口的宽度/高度。1vw 等于视口宽度的 1%。
  • % (百分比) :通常相对于父元素的对应属性(宽度、高度等)。

三、 现代布局黑科技

1. calc() 计算属性

允许在声明 CSS 属性值时执行加减乘除运算。

  • 语法width: calc(100% - 20px);
  • 注意:运算符前后必须保留空格。

2. aspect-ratio 设置宽高比

过去我们需要用 padding-top 技巧来实现等比缩放,现在一行代码搞定:

.box {
  width: 500px;
  aspect-ratio: 16 / 9; /*这时的宽高比就为16比9*/
  background: lightblue;
}

四、 总结:优先级冲突时的排查思路

  1. !important:有没有被强行置顶。
  2. 算权重值:ID > 类 > 标签。
  3. 看顺序:权重相同时,写在后面的样式生效。
  4. 看距离:行内样式 > 内部/外部样式表。
  5. 看加载:内部样式和外联样式的优先级,取决于它们在 HTML 中出现的先后顺序。

A股三大指数集体收跌,黄金股大涨

36氪获悉,A股三大指数集体收跌,沪指跌0.09%,深成指跌0.85%,创业板指跌0.91%;卫星互联网、商业航天、汽车零部件板块跌幅居前,中国卫星、凯众股份跌停,亚光科技跌超13%;黄金、油气、能源设备板块领涨,招金黄金、和顺石油涨停,山东墨龙涨超7%。

学习Three.js--基于GeoJSON绘制2D矢量地图

学习Three.js--基于GeoJSON绘制2D矢量地图

前置核心说明

开发目标

基于Three.js实现纯矢量2D地图,核心能力包括:

  1. 将GeoJSON地理数据(经纬度)转换为Three.js可渲染的平面图形;
  2. 支持墨卡托投影(经纬度→平面坐标)、地图居中缩放适配视口;
  3. 实现鼠标点击省份高亮(填充+边框变色);
  4. 纯2D交互(仅平移/缩放,禁用旋转),模拟传统地图体验。
  5. 开发效果如下

dc087832-635b-4da4-85d7-1d09b70b1e73.png

核心技术栈

技术点 作用
OrthographicCamera(正交相机) 实现无透视的2D效果(无近大远小),是2D地图的核心相机类型
墨卡托投影函数 将地理经纬度(lon/lat)转换为平面笛卡尔坐标(x/y)
Shape/ShapeGeometry 将GeoJSON的多边形坐标转换为Three.js可渲染的几何形状
Raycaster(射线检测) 实现鼠标点击与省份图形的交互(命中检测)
OrbitControls(轨道控制器) 自定义交互规则(禁用旋转,仅保留平移/缩放)
GeoJSON 行业标准地理数据格式,存储省份的多边形坐标信息

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

1.1 核心代码
// 存储所有省份Mesh,用于射线检测
const provinceMeshes = []; 

// 1. 场景初始化(地图容器)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

// 2. 正交相机(核心!2D地图必须用正交相机)
const aspect = window.innerWidth / window.innerHeight; // 窗口宽高比
const frustumSize = 800; // 相机视口高度(控制地图初始显示范围)
const camera = new THREE.OrthographicCamera(
  -frustumSize * aspect / 2, // 左边界
  frustumSize * aspect / 2,  // 右边界
  frustumSize / 2,           // 上边界
  -frustumSize / 2,          // 下边界
  1,                         // 近裁切面(最近可见距离)
  1000                       // 远裁切面(最远可见距离)
);
camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
camera.lookAt(0, 0, 0);

// 3. 渲染器(抗锯齿+高清适配)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(自定义交互规则)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableRotate = false; // 禁用旋转(纯2D地图)
controls.enablePan = true;     // 启用平移(拖拽地图)
controls.enableZoom = true;    // 启用缩放(滚轮)
controls.zoomSpeed = 1.5;      // 缩放速度(适配体验)
1.2 关键参数解析
  • 正交相机参数:区别于透视相机(PerspectiveCamera),正交相机的视口是矩形,所有物体无论距离远近大小一致,完美适配2D地图;
  • frustumSize:控制相机视口高度,值越大地图初始显示范围越大;
  • 控制器配置:禁用旋转是2D地图的核心要求,避免视角倾斜。

步骤2:墨卡托投影函数(经纬度→平面坐标)

2.1 核心代码
// 墨卡托投影:将经纬度(lon/lat)转换为平面坐标(x/y)
function mercator(lon, lat) {
  const R = 6378137; // WGS84坐标系地球半径(米)
  const x = (lon * Math.PI / 180) * R; // 经度转弧度 × 地球半径
  const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R; // 纬度投影公式
  return { x, y }; // y轴北为正,x轴东为正
}
2.2 原理说明
  • 墨卡托投影是地图领域的标准投影方式,将球形地球的经纬度转换为平面矩形坐标;
  • 经度(lon)范围:-180°180°,纬度(lat)范围:-90°90°;
  • 核心公式:
    • 经度转换:直接将角度转为弧度后乘以地球半径;
    • 纬度转换:通过正切+对数函数,解决纬度越靠近极点拉伸越大的问题。

步骤3:GeoJSON加载与全局边界计算

3.1 核心代码
let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

// 异步加载GeoJSON并绘制地图
async function loadAndDrawMap() {
  // 1. 加载GeoJSON数据(本地文件)
  const response = await fetch('./china.json');
  const geojson = await response.json();

  // 2. 手动校正港澳位置(墨卡托投影后的偏移,单位:米)
  const SPECIAL_REGION_OFFSETS = {
    '香港特别行政区': { dx: 80000, dy: -60000 },
    '澳门特别行政区': { dx: 100000, dy: -80000 }
  };

  // 3. 遍历所有坐标,计算全局边界(用于地图居中缩放)
  geojson.features.forEach(feature => {
    traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
      const { x, y } = mercator(lon, lat);
      // 更新边界值(最小/最大x/y)
      bounds.minX = Math.min(bounds.minX, x);
      bounds.maxX = Math.max(bounds.maxX, x);
      bounds.minY = Math.min(bounds.minY, y);
      bounds.maxY = Math.max(bounds.maxY, y);
    });
  });

  // 4. 计算地图中心和缩放比例(适配视口)
  const centerX = (bounds.minX + bounds.maxX) / 2; // 地图中心X
  const centerY = (bounds.minY + bounds.maxY) / 2; // 地图中心Y
  const width = bounds.maxX - bounds.minX;         // 地图宽度
  const height = bounds.maxY - bounds.minY;        // 地图高度
  const scale = 700 / Math.max(width, height);     // 缩放比例(使地图适配视口)

  // 后续创建省份Shape...
}

// 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套结构)
function traverseCoordinates(coords, callback) {
  if (typeof coords[0] === 'number') {
    // 基础情况:[lon, lat] 数组,执行回调
    callback(coords[0], coords[1]);
  } else {
    // 递归情况:嵌套数组,继续遍历
    coords.forEach(c => traverseCoordinates(c, callback));
  }
}
3.2 关键逻辑解析
  • 边界计算:遍历所有省份的所有坐标,得到地图的最小/最大x/y,用于后续居中;
  • 港澳偏移:解决GeoJSON中港澳坐标投影后位置偏差的问题;
  • 缩放比例700 / Math.max(width, height) 保证地图的最大维度适配视口(700为经验值,可调整);
  • 递归遍历坐标:GeoJSON的Polygon是单层数组,MultiPolygon是双层数组,需递归处理所有嵌套坐标。

步骤4:创建Shape与省份Mesh

4.1 核心代码
// 在loadAndDrawMap函数内,边界计算后执行:
geojson.features.forEach(feature => {
  const shapes = [];
  const provinceName = feature.properties.name;
  const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

  // 处理单个Polygon(如大多数省份)
  if (feature.geometry.type === 'Polygon') {
    const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
    if (shape) shapes.push(shape);
  } 
  // 处理MultiPolygon(如包含岛屿的省份:海南、浙江等)
  else if (feature.geometry.type === 'MultiPolygon') {
    feature.geometry.coordinates.forEach(polygon => {
      const shape = createShape(polygon[0], centerX, centerY, scale, offset);
      if (shape) shapes.push(shape);
    });
  }

  // 为每个Shape创建Mesh(填充)和Line(边框)
  shapes.forEach(shape => {
    // 1. 创建填充几何体
    const geometry = new THREE.ShapeGeometry(shape);
    // 2. 填充材质(蓝色,双面渲染)
    const material = new THREE.MeshBasicMaterial({
      color: 0x3b82f6,
      side: THREE.DoubleSide
    });
    // 3. 创建省份Mesh
    const mesh = new THREE.Mesh(geometry, material);
    
    // 关键:绑定省份信息到userData(交互时使用)
    mesh.userData = {
      provinceName: feature.properties.name,
      originalColor: 0x3b82f6,
      isHighlighted: false
    };

    scene.add(mesh);
    provinceMeshes.push(mesh); // 加入数组,用于射线检测

    // 4. 创建白色边框
    const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
    const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
    const border = new THREE.Line(borderGeo, borderMat);
    mesh.border = border; // 边框绑定到Mesh,方便后续修改颜色
    scene.add(border);
  });
});

// 创建Shape:将GeoJSON多边形转换为Three.js Shape
function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
  if (ring.length < 3) return null; // 少于3个点无法构成多边形
  const shape = new THREE.Shape();
  // 转换所有点为平面坐标,并应用居中+缩放+偏移
  const points = ring.map(([lon, lat]) => {
    const { x, y } = mercator(lon, lat);
    const shiftedX = x + offset.dx;
    const shiftedY = y + offset.dy;
    return {
      x: (shiftedX - centerX) * scale, // 居中后缩放
      y: (shiftedY - centerY) * scale
    };
  });
  // 绘制Shape:移动到第一个点,依次连线
  shape.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) {
    shape.lineTo(points[i].x, points[i].y);
  }
  return shape;
}
4.2 关键逻辑解析
  • Shape创建THREE.Shape是Three.js的2D形状对象,通过moveTo+lineTo绘制多边形;
  • MultiPolygon处理:包含多个多边形的省份(如海南=海南岛+南海诸岛),需为每个多边形创建独立Shape;
  • userData绑定:Three.js的Object3D对象可通过userData存储自定义数据,这里绑定省份名称/原始颜色,是交互的核心;
  • 边框创建:通过shape.getPoints()获取Shape的顶点,创建Line实现边框效果。

步骤5:Raycaster射线检测(鼠标点击高亮)

5.1 核心代码
let highlightedProvince = null; // 记录当前高亮的省份

// 鼠标点击事件处理
window.addEventListener('click', onDocumentMouseDown, false);

function onDocumentMouseDown(event) {
  // 1. 将鼠标屏幕坐标转换为NDC(归一化设备坐标,范围-1~1)
  const mouse = new THREE.Vector2();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 2. 创建Raycaster(射线检测)
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera); // 设置射线:从相机到鼠标位置

  // 3. 检测射线与所有省份Mesh的相交
  const intersects = raycaster.intersectObjects(provinceMeshes);

  if (intersects.length > 0) {
    // 点击到省份:切换高亮
    const clickedMesh = intersects[0].object;

    // 取消之前的高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
    }

    // 高亮当前点击的省份
    clickedMesh.material.color.set(0xffd700); // 金色填充
    if (clickedMesh.border) {
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
    }
    clickedMesh.userData.isHighlighted = true;
    highlightedProvince = clickedMesh;
    console.log('点击了:', clickedMesh.userData.provinceName);
  } else {
    // 点击空白处:取消所有高亮
    if (highlightedProvince) {
      highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
      if (highlightedProvince.border) {
        highlightedProvince.border.material.color.set(0xffffff);
      }
      highlightedProvince.userData.isHighlighted = false;
      highlightedProvince = null;
    }
  }
}
5.2 射线检测核心原理
  • NDC坐标转换:屏幕坐标(clientX/clientY)转换为归一化设备坐标(-1~1),是Raycaster的标准输入;
  • 射线创建raycaster.setFromCamera(mouse, camera) 生成从相机位置指向鼠标位置的射线;
  • 相交检测raycaster.intersectObjects(provinceMeshes) 返回射线与Mesh的相交结果,优先返回最近的Mesh;
  • 高亮逻辑:通过修改材质颜色实现高亮,利用userData存储原始颜色,保证切换回退。

步骤6:窗口适配与渲染循环

6.1 核心代码
// 窗口大小适配
window.addEventListener('resize', () => {
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800;
  // 更新正交相机边界
  camera.left = -frustumSize * aspect / 2;
  camera.right = frustumSize * aspect / 2;
  camera.top = frustumSize / 2;
  camera.bottom = -frustumSize / 2;
  camera.updateProjectionMatrix(); // 必须更新投影矩阵
  renderer.setSize(window.innerWidth, window.innerHeight); // 更新渲染器尺寸
});

// 渲染循环(持续渲染场景)
function animate() {
  requestAnimationFrame(animate); // 浏览器刷新率同步
  controls.update(); // 更新控制器(阻尼/平移/缩放)
  renderer.render(scene, camera); // 渲染场景
}
animate();

// 启动地图加载
loadAndDrawMap().catch(err => console.error('加载失败:', err));
6.2 关键注意点
  • 投影矩阵更新:正交相机参数修改后,必须调用camera.updateProjectionMatrix()使修改生效;
  • 渲染循环:Three.js需要持续调用renderer.render()才能显示画面,requestAnimationFrame保证帧率稳定。

核心方法/参数速查表

1. 核心类/函数参数

类/函数 关键参数 说明
OrthographicCamera left/right/top/bottom/near/far 正交相机边界,near/far控制可见距离
mercator(lon, lat) lon(经度)、lat(纬度) 返回{x,y}平面坐标,R=6378137(地球半径)
traverseCoordinates(coords, callback) coords(GeoJSON坐标)、callback(遍历回调) 递归处理嵌套坐标,回调参数为lon/lat
createShape(ring, centerX, centerY, scale, offset) ring(多边形坐标环)、centerX/Y(地图中心)、scale(缩放)、offset(偏移) 返回THREE.Shape,实现坐标居中+缩放
Raycaster.setFromCamera(mouse, camera) mouse(NDC坐标)、camera(相机) 创建从相机到鼠标的射线

2. 交互核心参数

参数 作用
mesh.userData 存储省份名称/原始颜色/高亮状态,交互时读取
intersects[0].object 射线检测命中的第一个Mesh(最近的省份)
controls.enableRotate 禁用旋转(false),保证2D地图体验

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 中国地图 - 2D矢量版</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      background: #f8fafc;
    }
    /* 可选:添加加载提示 */
    .loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 18px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="loading">加载地图中...</div>
<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // 全局变量:存储所有省份Mesh(用于射线检测)
  const provinceMeshes = [];
  // 全局变量:记录当前高亮的省份
  let highlightedProvince = null;
  // 全局变量:地图边界(用于居中缩放)
  let bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };

  // ========== 1. 初始化基础环境 ==========
  // 场景:所有元素的容器
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf8fafc); // 浅灰背景

  // 正交相机:2D地图核心(无透视)
  const aspect = window.innerWidth / window.innerHeight;
  const frustumSize = 800; // 相机视口高度(控制初始显示范围)
  const camera = new THREE.OrthographicCamera(
    -frustumSize * aspect / 2,
    frustumSize * aspect / 2,
    frustumSize / 2,
    -frustumSize / 2,
    1, 1000
  );
  camera.position.set(0, 0, 10); // 相机在Z轴,看向场景中心
  camera.lookAt(0, 0, 0);

  // 渲染器:抗锯齿+高清适配
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:自定义2D交互规则
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableRotate = false; // 禁用旋转(纯2D)
  controls.enablePan = true;     // 启用平移(拖拽)
  controls.enableZoom = true;    // 启用缩放(滚轮)
  controls.zoomSpeed = 1.5;      // 缩放速度适配

  // ========== 2. 墨卡托投影函数 ==========
  /**
   * 墨卡托投影:经纬度转平面坐标
   * @param {Number} lon - 经度(-180~180)
   * @param {Number} lat - 纬度(-90~90)
   * @returns {Object} {x, y} 平面坐标(米)
   */
  function mercator(lon, lat) {
    const R = 6378137; // WGS84地球半径
    const x = (lon * Math.PI / 180) * R;
    const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) * R;
    return { x, y };
  }

  // ========== 3. GeoJSON加载与绘制 ==========
  /**
   * 递归遍历GeoJSON坐标(处理Polygon/MultiPolygon嵌套)
   * @param {Array} coords - GeoJSON坐标数组
   * @param {Function} callback - 遍历回调(lon, lat)
   */
  function traverseCoordinates(coords, callback) {
    if (typeof coords[0] === 'number') {
      callback(coords[0], coords[1]);
    } else {
      coords.forEach(c => traverseCoordinates(c, callback));
    }
  }

  /**
   * 创建Three.js Shape(多边形)
   * @param {Array} ring - 单个多边形坐标环
   * @param {Number} centerX - 地图中心X
   * @param {Number} centerY - 地图中心Y
   * @param {Number} scale - 缩放比例
   * @param {Object} offset - 位置偏移(dx, dy)
   * @returns {THREE.Shape|null} 形状对象
   */
  function createShape(ring, centerX, centerY, scale, offset = { dx: 0, dy: 0 }) {
    if (ring.length < 3) return null; // 最少3个点构成多边形
    const shape = new THREE.Shape();
    const points = ring.map(([lon, lat]) => {
      const { x, y } = mercator(lon, lat);
      const shiftedX = x + offset.dx;
      const shiftedY = y + offset.dy;
      // 居中+缩放:转换为相机视口坐标
      return {
        x: (shiftedX - centerX) * scale,
        y: (shiftedY - centerY) * scale
      };
    });
    // 绘制Shape
    shape.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      shape.lineTo(points[i].x, points[i].y);
    }
    return shape;
  }

  /**
   * 加载GeoJSON并绘制地图
   */
  async function loadAndDrawMap() {
    try {
      // 1. 加载GeoJSON数据
      const response = await fetch('./china.json');
      const geojson = await response.json();

      // 2. 港澳位置偏移(校正投影偏差)
      const SPECIAL_REGION_OFFSETS = {
        '香港特别行政区': { dx: 80000, dy: -60000 },
        '澳门特别行政区': { dx: 100000, dy: -80000 }
      };

      // 3. 遍历所有坐标,计算全局边界
      geojson.features.forEach(feature => {
        traverseCoordinates(feature.geometry.coordinates, (lon, lat) => {
          const { x, y } = mercator(lon, lat);
          bounds.minX = Math.min(bounds.minX, x);
          bounds.maxX = Math.max(bounds.maxX, x);
          bounds.minY = Math.min(bounds.minY, y);
          bounds.maxY = Math.max(bounds.maxY, y);
        });
      });

      // 4. 计算地图中心和缩放比例
      const centerX = (bounds.minX + bounds.maxX) / 2;
      const centerY = (bounds.minY + bounds.maxY) / 2;
      const width = bounds.maxX - bounds.minX;
      const height = bounds.maxY - bounds.minY;
      const scale = 700 / Math.max(width, height); // 适配视口

      // 5. 遍历每个省份,创建Shape和Mesh
      geojson.features.forEach(feature => {
        const shapes = [];
        const provinceName = feature.properties.name;
        const offset = SPECIAL_REGION_OFFSETS[provinceName] || { dx: 0, dy: 0 };

        // 处理Polygon(单多边形)
        if (feature.geometry.type === 'Polygon') {
          const shape = createShape(feature.geometry.coordinates[0], centerX, centerY, scale, offset);
          if (shape) shapes.push(shape);
        }
        // 处理MultiPolygon(多多边形,如海南)
        else if (feature.geometry.type === 'MultiPolygon') {
          feature.geometry.coordinates.forEach(polygon => {
            const shape = createShape(polygon[0], centerX, centerY, scale, offset);
            if (shape) shapes.push(shape);
          });
        }

        // 为每个Shape创建填充和边框
        shapes.forEach(shape => {
          // 填充Mesh
          const geometry = new THREE.ShapeGeometry(shape);
          const material = new THREE.MeshBasicMaterial({
            color: 0x3b82f6,
            side: THREE.DoubleSide
          });
          const mesh = new THREE.Mesh(geometry, material);

          // 绑定自定义数据(交互用)
          mesh.userData = {
            provinceName: provinceName,
            originalColor: 0x3b82f6,
            isHighlighted: false
          };

          scene.add(mesh);
          provinceMeshes.push(mesh);

          // 白色边框
          const borderGeo = new THREE.BufferGeometry().setFromPoints(shape.getPoints());
          const borderMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
          const border = new THREE.Line(borderGeo, borderMat);
          mesh.border = border;
          scene.add(border);
        });
      });

      // 隐藏加载提示
      document.querySelector('.loading').style.display = 'none';
    } catch (err) {
      console.error('地图加载失败:', err);
      document.querySelector('.loading').textContent = '加载失败,请检查GeoJSON文件';
    }
  }

  // ========== 4. 鼠标点击交互(Raycaster) ==========
  /**
   * 鼠标点击事件处理:省份高亮
   * @param {MouseEvent} event - 鼠标事件
   */
  function onDocumentMouseDown(event) {
    // 1. 转换鼠标坐标为NDC(-1~1)
    const mouse = new THREE.Vector2();
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    // 2. 创建射线检测
    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(mouse, camera);

    // 3. 检测与省份Mesh的相交
    const intersects = raycaster.intersectObjects(provinceMeshes);

    if (intersects.length > 0) {
      // 点击到省份:切换高亮
      const clickedMesh = intersects[0].object;

      // 取消之前的高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
      }

      // 高亮当前省份
      clickedMesh.material.color.set(0xffd700); // 金色填充
      clickedMesh.border.material.color.set(0xff0000); // 红色边框
      clickedMesh.userData.isHighlighted = true;
      highlightedProvince = clickedMesh;
      console.log('点击省份:', clickedMesh.userData.provinceName);
    } else {
      // 点击空白处:取消所有高亮
      if (highlightedProvince) {
        highlightedProvince.material.color.set(highlightedProvince.userData.originalColor);
        highlightedProvince.border.material.color.set(0xffffff);
        highlightedProvince.userData.isHighlighted = false;
        highlightedProvince = null;
      }
    }
  }
  window.addEventListener('click', onDocumentMouseDown, false);

  // ========== 5. 窗口适配与渲染循环 ==========
  // 窗口大小变化适配
  window.addEventListener('resize', () => {
    const aspect = window.innerWidth / window.innerHeight;
    camera.left = -frustumSize * aspect / 2;
    camera.right = frustumSize * aspect / 2;
    camera.top = frustumSize / 2;
    camera.bottom = -frustumSize / 2;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  // 渲染循环
  function animate() {
    requestAnimationFrame(animate);
    controls.update(); // 更新控制器
    renderer.render(scene, camera); // 渲染场景
  }
  animate();

  // 启动地图加载
  loadAndDrawMap();
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 2D地图核心:正交相机(OrthographicCamera)是实现无透视2D效果的关键,区别于透视相机;
  2. 坐标转换:墨卡托投影是地理数据可视化的基础,需掌握经纬度→平面坐标的转换逻辑;
  3. GeoJSON处理:递归遍历嵌套坐标、计算全局边界是地图居中缩放的核心;
  4. 交互实现Raycaster射线检测是Three.js实现鼠标点击交互的标准方式,userData是存储自定义数据的最佳实践;
  5. 性能优化:复用几何体/材质、减少不必要的顶点数,可提升大地图的渲染性能。

扩展建议

  1. 添加省份标签:基于省份中心坐标创建CSS2DLabel,显示省份名称;
  2. hover高亮:监听mousemove事件,实现鼠标悬浮高亮;
  3. 数据可视化:根据省份数据(如GDP、人口)动态修改填充颜色;
  4. 层级优化:为南海诸岛等小区域单独缩放,提升显示效果;
  5. 性能优化:使用BufferGeometry替代ShapeGeometry,减少内存占用;
  6. 地图交互增强:添加缩放限制(最小/最大缩放)、地图复位按钮。

CSS-深度解析伪类与伪元素

前言

在 CSS 的世界里,单冒号 : 和双冒号 :: 并不是随心所欲使用的。它们分别代表了伪类伪元素。理解它们的区别,不仅能帮你写出更优雅的选择器,还能在面试中展示扎实的基础。

一、 核心区别:它们到底是什么?

  • 伪类 (Pseudo-classes)

    • 本质:代表一种状态
    • 作用:当元素处于某种特定状态时(如鼠标悬停、获得焦点、被选中),为其添加样式。它选择的是已存在的元素
    • 语法:使用单冒号 :(如 :hover)。
  • 伪元素 (Pseudo-elements)

    • 本质:代表一种虚拟内容
    • 作用:创建一个不在 DOM 树中的“虚拟元素”。例如,为一个段落的开头添加一个字母,或者在元素前后插入修饰性的内容。
    • 语法:CSS3 规范建议使用双冒号 ::(如 ::after),以区分伪类。

二、 常用伪类:状态与结构选择

伪类可以分为行为伪类结构伪类表单伪类

1. 行为与状态伪类

选择器 含义
E:link 匹配未访问的链接
E:visited 匹配已访问的链接
E:hover 鼠标悬停在元素上
E:active 鼠标按下尚未释放(激活状态)
E:focus 元素获得焦点(如输入框点击后)

2. 结构伪类

选择器 含义
E:first-child 匹配父元素的第一个子元素,且该子元素必须是 E
E:last-child 匹配父元素的最后一个子元素,且该子元素必须是 E
E:nth-child(n) 匹配父元素中第 n 个子元素(n 可以是数字、关键字或公式)
E:first-of-type 匹配同类型兄弟元素中的第一个 E
E:only-child 匹配父元素中唯一的子元素

3. 表单相关伪类

选择器 含义
E:enabled 匹配可用的表单元素
E:disabled 匹配被禁用的表单元素
E:checked 匹配单选框或复选框被选中的状态

三、 常用伪元素:内容与样式扩展

伪元素在 CSS3 规范中统一使用双冒号 ::

选择器 含义 场景
::before 在 E 元素内容的最前面插入内容 字体图标、修饰性装饰
::after 在 E 元素内容的最后面插入内容 清除浮动、对话框小箭头
::first-line 匹配 E 元素的第一行文字 杂志风格的排版
::first-letter 匹配 E 元素的第一个字母 首字下沉效果
::selection 匹配用户在页面上选中的文本 自定义选中文本的颜色

四、 深度辨析:nth-child vs nth-of-type

这是面试中最常被问到的细节:

  • p:nth-child(2) :选择父元素的第二个子元素,如果这个子元素是 <p>,则应用样式。如果第二个子元素是 <div>,则选择失败。
  • p:nth-of-type(2) :选择父元素下第二个 <p> 类型的子元素,不考虑非 <p> 标签。

💡 总结

  • 伪类是用来增强选择器的,描述的是状态(如被点击、被移动)。
  • 伪元素是用来创建新内容的,虽然在 HTML 里看不到,但在 CSS 中可以操作。
  • 写代码时,尽量遵循 CSS3 规范:状态用单冒号,内容用双冒号

马化腾:腾讯唯一花钱投入比较多的就是AI

在今日召开的腾讯年会上,腾讯董事会主席马化腾表示,2025年是AI大年。今年业内竞争激烈,除了AI还有社区团购、外卖,腾讯则稳扎稳打,唯一花钱投入比较多的就是AI。混元过去一年进行了结构调整,发力吸引人才,包括博士毕业生,接下来还要吸引AI原生人才重构AI团队。(第一财经)

CSS属性 - 文本属性

CSS属性 - 文本属性

文本装饰属性

  • 格式:text-decoration:underline;

  • 取值:

    • underline:下划线
    • line-through:删除线
    • overline:上划线
    • none:什么都没有,最常见的用途就是用于去掉超链接的下划线。
  • 快捷键:

    • 输入td + 按tab键:text-decoration: none;
    • 输入tdu + 按tab键 : text-decoration: underline;
    • 输入tdl + 按tab键 : text-decoration: line-through;
    • 输入tdo + 按tab键 : text-decoration: overline;
  • 样式效果:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>文本属性练习</title>
            <style type="text/css">
                .setUnderline {
                    text-decoration: underline;
                }
                .setOverline {
                    text-decoration: overline;
                }
                .setLineThrought {
                    text-decoration: line-through;
                }
            </style>
        </head>
        <body>
            <p class="non">New York</p>
            <p class="setUnderline">New York</p>
            <p class="setLineThrought">New York</p>
            <p class="setOverline">New York</p>
        </body>
    </html>
    

    text-decoration属性

二、文本水平对齐的属性

  • 格式:text-align: center;

  • 取值:如果文本方向是从左到右,则默认为左对齐;如果文本方向是从右到左,则默认是右对齐。

    • left:左。
    • right:右。
    • center:中。
  • 快捷键:

    • 输入ta + 按tab 键:text-align: left;
    • 输入tar+ 按tab 键: text-align: right;
    • 输入tac+ 按tab键: text-align: center;
  • 样式效果:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>文本属性练习</title>
            <style type="text/css">
                .setCenter {
                    text-align: center;
                }
                .setRight {
                    text-align: right;
                }
            </style>
        </head>
        <body>
            <h1>文本左对齐</h1>
            <p class="non">New York</p>
            <hr>
            <h1>文本居中</h1>
            <p class="setCenter">New York</p>
            <hr>
            <h1>文本右对齐</h1>
            <p class="setRight">New York</p>
        </body>
    </html>
    

    text-align属性

文本缩进的属性

  • 格式:text-indent: 2em;

  • 取值:(均为具体数值 + 单位)

    • 可以以em为单位,一个em代表缩进一个文字的宽度。
    • 可以以px为单位。
  • 快捷键:

    • 输入ti + 按tab键:text-indent: ;
    • 输入ti2e + 按tab键: text-indent: 2em;
  • 样式效果:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>文本属性练习</title>
            <style type="text/css">
                .setTextIndent {
                    text-indent: 2em;
                }
            </style>
        </head>
        <body>
            <p class="non">New York</p>
            <p class="setTextIndent">New York</p>
        </body>
    </html>
    

    text-indent属性

修改文本颜色属性

  • 格式:color: #ff0000;

  • 取值:

    • 可以通过英文单词赋值

      • 一般情况下,常见的颜色都有对应的英文单词,英文单词能够表达的颜色是有限制的,也就是说不是所有的颜色都能通过英文单词来做表达。
    • 可以通过rgb赋值

      • rgb其实就是三原色,r-redg-greenb-blue
      • 格式:rgb(红色, 绿色, 蓝色)。那么这个格式中的第一个数字就是用来设置三原色的光源元件红色显示的亮度;第二个数字设置三原色的光源元件绿色显示的亮度;第三个数字设置三原色的光源元件蓝色显示的亮度。
      • 这其中的每一个数字的取值是0-255之间,0代表不发光,255代表发光,值越大就越亮。
      • 只要让红色、绿色、蓝色的值都一样就是灰色,而且这个值越小越偏向黑色,值越大越偏向白色。
    • 可以通过rgba赋值(CSS3推出)

      • rgba中的rgb和前面讲解的一样,只不过多了一个a
      • 那么这个a代表透明度,取值0-1,取值越小越透明。
    • 可以通过十六进制赋值

      • 在前端开发中,通过16进制来表示颜色, 其实本质就是rgb,十六进制中是通过每两位表示一个颜色。
      • 例如:#FFEE00 FF表示r, EE表示g,00表示b
    • 可以通过十六进制缩写赋值

      • 在CSS中只要十六进制的颜色每两位的值都是一样的就可以简写为一位。
      • 例如:#FFEE00 ==> #FE0
      • 这里需要注意:如果当前颜色对应的两位数字不一样,就不能简写;如果两位相同的数字不是属于同一个颜色的,也不能简写。

参考链接:

W3School官方文档:www.w3school.com.cn

特朗普政府拟16亿美元入股美国稀土公司

1月26日消息,据报道,特朗普政府将以16亿美元债务与股权投资组合的形式,收购美国稀土公司USA Rare Earth的10%股权,旨在协助该公司开发美国国内矿山及磁铁生产设施。(界面)

东风华神汽车公司增资至约11.5亿元

36氪获悉,爱企查App显示,近日,东风华神汽车有限公司发生工商变更,注册资本由约10.4亿元人民币增至约11.5亿元人民币,增幅约10%,同时,孙振义卸任董事长,由金谋志接任。该公司成立于1998年3月,法定代表人为金谋志,经营范围包括汽车零部件及配件制造、汽车销售、汽车零配件批发等,由东风特种商用车有限公司全资持股。

# 手把手教你实现“左右拖动布局”:打造丝滑的 Vue 分屏体验

在开发像考试系统、代码编辑器或者对比工具这类 Web 应用时,左右分屏且可自由拖动调整宽度的布局是一种非常常见且高频的需求。

效果演示

我们需要实现的效果如下:

  1. 页面分为“左侧内容区”、“中间拖动条”、“右侧内容区”。
  2. 用户按住中间的拖动条(Resizer)左右拖动,实时改变左右两边的宽度比例。
  3. 拖动过程中禁止文字选中,避免体验跳脱。
  4. 设置最小/最大宽度限制,防止某一边被挤压得看不见。

核心思路

我们的实现核心在于 Flex 布局 配合 百分比宽度

  • 布局:父容器使用 display: flex

  • 宽度控制:使用一个响应式变量 leftPercentage 来控制左侧容器的宽度。右侧容器的宽度就是 100% - leftPercentage

  • 交互逻辑

    1. mousedown:在拖动条上按下鼠标,标记开始拖动,并注册全局 mousemove 和 mouseup 事件。
    2. mousemove:计算鼠标当前位置相对于父容器的百分比,动态更新 leftPercentage
    3. mouseup:松开鼠标,移除事件监听,结束拖动。

代码实现

以下以 Vue 2 为例(Vue 3 原理完全相同,只是语法稍有区别)。

1. HTML 结构

结构非常简单,经典的“三明治”夹心结构。

<template>
  <!-- 父容器 -->
  <div ref="splitPane" class="split-pane-container">
    
    <!-- 左侧面板 -->
    <div class="left-pane" :style="{ width: leftPercentage + '%' }">
      <div class="content">
        <!-- 插槽或具体内容 -->
        <slot name="left">Left Content</slot>
      </div>
    </div>
    <!-- 拖动条 (Resizer) -->
    <div class="resizer" @mousedown="startResize">
      <!-- 可以放一个拖拽图标,增加可识别性 -->
      <img src="@/assets/icon_handler.png" class="icon-handler" />
    </div>
    <!-- 右侧面板 -->
    <div class="right-pane" :style="{ width: (100 - leftPercentage) + '%' }">
      <div class="content">
         <slot name="right">Right Content</slot>
      </div>
    </div>
  </div>
</template>

2. CSS 样式

关键点在于 resize 的样式设置,以及 cursor: col-resize 提示用户可以左右拖动。

.split-pane-container {
  display: flex;
  height: 100vh; /* 或指定高度 */
  overflow: hidden;
}
.left-pane, .right-pane {
  overflow-y: auto; /* 内容溢出滚动 */
  height: 100%;
}
/* 拖动条样式 */
.resizer {
  width: 14px;  /* 拖动条宽度 */
  cursor: col-resize; /* 鼠标样式变为左右拖动箭头 */
  background-color: #f5f7fa;
  border-left: 1px solid #e4e7ed;
  border-right: 1px solid #e4e7ed;
  
  /* 居中内部图标 */
  display: flex;
  justify-content: center;
  align-items: center;
  
  /* 防止 Flex 压缩拖动条宽度 */
  flex-shrink: 0; 
  
  transition: background-color 0.3s;
}
.resizer:hover {
  background-color: #e6e8eb; /* 悬停高亮 */
}
.icon-handler {
  width: 20px;
  pointer-events: none; /* 防止拖动图片本身 */
  user-select: none;
}

3. JavaScript 核心逻辑

这里是灵魂所在。特别需要注意的是事件监听必须绑定在 document 上,而不是 resizer 上。因为用户拖动过快时,鼠标可能会移出拖动条范围,如果绑定在 resizer 上会导致拖动断触。

export default {
  data() {
    return {
      leftPercentage: 50, // 初始左侧宽度占比 50%
      isResizing: false,  // 是否正在拖动标志位
    }
  },
  methods: {
    // 1. 开始拖动
    startResize() {
      this.isResizing = true
      
      // 添加全局事件监听
      document.addEventListener('mousemove', this.doResize)
      document.addEventListener('mouseup', this.stopResize)
      
      // 关键优化:拖动时禁止选中文字,避免变蓝
      document.body.style.userSelect = 'none'
      document.body.style.cursor = 'col-resize' // 强制全局鼠标样式
    },
    // 2. 执行拖动
    doResize(e) {
      if (!this.isResizing) return
      const splitPane = this.$refs.splitPane
      if (!splitPane) return
      // 获取父容器的位置信息
      const containerRect = splitPane.getBoundingClientRect()
      const containerWidth = containerRect.width
      const containerLeft = containerRect.left
      // 计算鼠标相对于父容器左侧的距离 (X轴)
      const mouseX = e.clientX - containerLeft
      // 转换为百分比
      let newLeftPercentage = (mouseX / containerWidth) * 100
      // 边界限制:建议设置 20% ~ 80%,防止某一边被完全遮挡
      if (newLeftPercentage < 20) newLeftPercentage = 20
      if (newLeftPercentage > 80) newLeftPercentage = 80
      this.leftPercentage = newLeftPercentage
    },
    // 3. 结束拖动
    stopResize() {
      this.isResizing = false
      
      // 移除事件监听
      document.removeEventListener('mousemove', this.doResize)
      document.removeEventListener('mouseup', this.stopResize)
      
      // 恢复样式
      document.body.style.userSelect = ''
      document.body.style.cursor = ''
    }
  }
}

遇到的“坑”与优化点

1. 拖动卡顿与丢帧

问题:如果在 

doResize 中做复杂的 DOM 操作,会导致拖动卡顿。 

解决方案:我们只改变了一个响应式变量 leftPercentage,Vue 的 Diff 算法足够快。如果依然卡顿,可以使用 requestAnimationFrame 进行节流。

2. 鼠标移出拖动条失效

问题:鼠标拖得太快,离开了 .resizer 元素,拖动就停止了。 

解决方案:如上代码所示,使用 document.addEventListener 监听 mousemove,确保鼠标在页面任何位置都能响应。

3. 文字选中干扰

问题:拖动时如果不小心选中了左右两边的文字,体验非常差,甚至会自动触发浏览器的原生拖拽。 

解决方案:在 startResize 中设置 document.body.style.userSelect = 'none',拖动结束后恢复。

4. Iframe 遮挡问题(进阶)

问题:如果你的左右面板里嵌入了 <iframe>(例如显示 PDF 或外部网页),鼠标滑过 iframe 时,mousemove 事件会被 iframe 吞掉,导致拖动失效。 

解决方案:在 startResize 时,给所有的 iframe 上面覆盖一层透明的 div,或者设置 pointer-events: none,拖动结束后恢复。

总结

通过简单的 Flex 布局和数十行 JS 代码,我们就能实现一个高性能、兼容性好的分屏组件。这个方案不依赖任何重型第三方库(如 split.js),非常适合不仅需要轻量,又需要高度定制 UI 的场景。

同比增长16.2%,2025年国内居民出游人次超65亿

根据国内居民出游抽样调查统计结果,2025年,国内居民出游人次65.22亿,比上年同期增加9.07亿,同比增长16.2%。其中,城镇居民国内出游人次49.96亿,同比增长14.3%;农村居民国内出游15.26亿,同比增长22.6%。(央视新闻)

“吉泰智能”获7500万元新一轮融资,拟于2029年前正式冲击IPO

36氪获悉,近日,国内能源行业高空高危场景爬壁作业机器人企业“吉泰智能”宣布完成新一轮融资。本轮融资金额合计7500万元。本轮融资完成后,吉泰智能的投后估值达到7.75亿元 。本轮投资方主要由绍兴滨海新区长浙创业投资合伙企业(有限合伙)(长浙资本)和绍兴市国鼎多策略股权投资合伙企业(有限合伙)组成 。吉泰智能创始人徐光平表示,根据规划,公司计划在产线达产及业务持续扩张的基础上,于2029年前正式冲击IPO 。

中国光伏组件、精细陶瓷等23项团体标准被确认为国际标准提案

记者今天了解到,2025年市场监管总局持续推动团体标准高质量发展,指导将先进团体标准转化为国际标准,取得积极进展。中国光伏行业协会、中关村材料试验技术联盟、中国细胞生物学学会等社会团体,组织产业领军企业、科研机构、高等院校等单位,充分发挥我国在相关技术领域的领先优势,制定并组织实施一批先进的团体标准,其中光伏组件、精细陶瓷、生物技术等领域的23项团体标准已经被国际标准化组织、国际电工委员会确认为国际标准提案,正在按程序制定成为国际标准。(央视新闻)

花了两天,让Trae,给我用魔珐星云数字人写了个项目!

最近参加了 pre.xingyun3d.com/hackathon20… 活动,这个由魔珐星云组织的数字人开发活动!

菜鸟感觉我这个想法的商业价值还是挺大的!

下面是背景、需求分析!

二、 项目背景与痛点

2.1 行业与社会背景

随着人工智能、大模型与数字人技术的快速发展,人机交互正从“屏幕点击式”向“自然语言式、具身交互式”演进。语音识别、语义理解与数字人形象的成熟,使 AI 不再只是信息工具,而逐步成为能够融入现实场景、主动提供服务的“智能伴随者”。

与此同时,大型公共空间与商业空间正变得愈发复杂。以医院、综合商场为代表的场所,普遍存在空间结构复杂、信息分散、服务节点多等特征。用户在进入这些场所后,往往面临“找不到路、问不到人、不知道下一步该做什么”的问题。

从社会结构来看,老龄化趋势明显。大量老年人对智能设备操作不熟悉,在医院等高频公共场景中,仍高度依赖人工指引甚至医陪服务,增加了家庭与社会成本。

在消费层面,用户对效率、体验与个性化服务的期待持续提升。传统依赖指示牌、人工咨询、被动检索的服务方式,已难以满足用户对“即时、准确、自然交互”的需求。

在此背景下,一个能够感知用户位置、理解用户意图,并以自然方式进行引导与服务的 AI 数字人交互系统,具备明确的现实价值与应用空间。

2.2 现有场景的核心痛点分析

(一)医院场景:信息复杂,对弱势群体不友好

医院是信息密度极高的公共空间,但其服务体系长期以“懂规则的人”为默认用户。

挂号流程复杂:科室划分专业性强,普通患者尤其是老年人,很难根据症状准确判断应挂哪个科室。

挂错号成本高:一旦挂错科室,往往需要重新排队、重新缴费,甚至改期就诊,造成时间与精力的双重浪费。

老年人操作困难:大量医院已转向自助机、App 挂号,但对老年人并不友好,催生了高成本的“医陪”需求。

人工咨询压力大:导医台与人工窗口长期处于高负荷状态,服务质量难以保证。

痛点本质:医院缺少一个能够“用人话解释规则、替用户思考路径”的智能引导角色。

(二)大型商场场景:空间大、选择多、决策成本高

现代商业综合体体量不断扩大,但用户体验并未同步提升。

门店分布复杂:楼层多、动线绕,用户往往需要反复查看地图或询问工作人员。

找店效率低:想找某一家店,常常需要“走错—返回—再确认”,体验割裂。

消费决策困难:面对大量餐饮与品牌,用户不知道“哪家好吃”“哪家适合自己”。

用户粘性不足:商场难以持续与用户产生互动,只能依赖静态导览与被动推荐。

痛点本质:商场缺乏一个“能随时被询问、能结合场景做推荐”的智能交互入口。

(三)试衣与消费体验:流程繁琐,心理与时间成本高

在服装消费场景中,试衣环节长期存在明显痛点:

试衣过程麻烦:反复穿脱衣物耗时耗力,尤其在客流高峰期体验较差。

心理压力存在:部分用户对在公共试衣间反复试穿存在心理负担。

体验割裂:线下试衣与线上浏览、搭配之间缺乏连续性。

转化率受限:用户因试衣不便而放弃购买的情况普遍存在。

痛点本质:传统试衣方式效率低、体验重,不符合当前“便捷化、数字化”的消费趋势。

(四)公共空间中的“不知道该做什么”

在医院或商场中,用户经常并非目标明确,而是处于一种“被动探索”状态:

  1. 不知道下一步该去哪
  2. 不清楚当前有哪些服务或活动
  3. 不确定自己的选择是否最优

现有系统只能提供静态信息展示,无法主动理解用户需求并给出引导。

痛点本质:场所与用户之间缺乏持续、自然的互动连接。

2.3 项目切入价值总结

综合以上痛点可以看出,无论是医院还是商场,本质问题都不在于“信息不存在”,而在于:

  1. 信息分散、表达方式不友好
  2. 服务依赖人工,成本高且不可规模化
  3. 用户需要的是“被理解、被引导”,而非“自己去找答案”

AI 伴你「衣食行」项目,通过引入具备语音交互能力的 AI 数字人,将复杂的空间信息与服务流程,转化为自然对话式的引导体验,为用户提供:

  1. 随问随答的空间指引
  2. 基于语义理解的决策辅助
  3. 贴近真实人的交互方式
  4. 覆盖“衣、食、行、医”等高频生活场景的一体化服务

从而有效降低公共空间的使用门槛,提升服务效率与用户体验,同时为场地方构建新的智能服务入口与商业延展可能。

三、 产品核心功能

3.1 语音驱动的自然交互数字人

产品以 AI 数字人为核心交互载体,采用语音作为主要交互方式,用户无需复杂操作,仅通过自然语言即可完成信息获取与服务请求。

  1. 支持实时语音唤醒与连续对话
  2. 能理解口语化、非标准表达的用户提问
  3. 通过拟人化数字人形象进行反馈,降低技术使用门槛
  4. 特别适配老年人等对智能设备不熟悉的群体

功能价值
将传统“点按钮、看说明”的操作模式,转变为“像问一个人一样问系统”,显著提升公共空间中的服务可达性与亲和力。

3.2 基于位置感知的智能导航与指路服务(行)

产品可结合用户当前位置,为其提供直观、可理解的空间引导服务。

  1. 自动识别或确认用户所处位置
  2. 支持语音查询目的地(如科室、门店、服务点)
  3. 提供清晰的路线指引与方向说明
  4. 可在医院、商场等复杂空间中使用

应用场景

  1. 医院内寻找挂号窗口、科室、检查区域
  2. 商场内寻找具体门店、餐饮区域、服务设施

功能价值
帮助用户在复杂空间中快速建立方向感,减少迷路、反复确认和对人工咨询的依赖。

3.3 智能科室引导与就医辅助(医)

针对医院高频痛点,产品提供基于症状描述的科室引导能力。

  1. 用户可通过语音描述自身症状或就诊目的
  2. 系统对语义进行解析,并匹配对应科室类型
  3. 以“引导建议”的形式告知推荐科室方向
  4. 明确提示结果为辅助参考,避免误导性医疗判断

功能价值
降低因不了解医学专业划分而造成的挂错号概率,减少患者时间成本,缓解医院导医压力,尤其对老年患者具有显著帮助。

3.4 商场智能推荐与即时问答(食)

在商业场景中,产品可作为“随行导购式数字人”,提供即时咨询与推荐服务。

  1. 支持询问商场内“吃什么”“去哪逛”等问题
  2. 可根据餐饮类别、位置等进行推荐
  3. 通过对话方式帮助用户快速做出选择
  4. 减少用户在商场内的决策成本与犹豫时间

功能价值
让用户在商场中“随时有人可问”,提升消费体验,同时为商场构建更具互动性的服务入口。

3.5 虚拟试衣与数字分身体验(衣)

产品支持通过数字人技术,构建用户的虚拟形象,实现虚拟试衣体验。

  1. 可生成与用户外形相近的数字分身
  2. 在无需实际更换衣物的情况下进行服装展示
  3. 支持多款式、多颜色的快速切换对比
  4. 可应用于服装零售、品牌展示等场景

功能价值
减少试衣流程带来的时间与心理成本,提升服装消费效率,为线下商业场景引入更具吸引力的数字化体验。

3.6 多场景融合的一体化服务能力(衣 · 食 · 行)

“AI 伴你『衣食行』”并非单一功能工具,而是围绕用户在公共与商业空间中的完整行动路径,提供连续性的服务体验。

  1. 同一交互入口,覆盖导航、咨询、推荐与体验
  2. 根据场景不同切换服务重点
  3. 数字人形象统一,降低用户学习成本
  4. 可根据不同场所进行定制化部署

功能价值
实现从“被动信息查询”到“主动智能陪伴”的转变,打造可持续扩展的场景级智能交互平台。

3.7 核心功能总结

通过将语音交互、位置感知、语义理解与数字人形态进行融合,产品构建了一个面向真实空间的智能服务系统,使用户在医院与商场等高频场景中:

  1. 找得到路
  2. 问得到人
  3. 做得出决定
  4. 得到更好的体验

开发

直接按照官网 xingyun3d.com/developers/… ,下载一个demo示例,解压后运行即可!

菜鸟本来是想接入大模型的,结果发现需要money就算了,还想着魔珐星云有AI大模型可以直接使用,结果这个活动原来只是提供一个数字人,要用大模型一样要花钱,所以就没搞了。

还有语音识别也是一样。

菜鸟做的第一件事就是试试水,看Trae能否帮我完成这个艰难的任务!

image.png

准备是看看能不能直接把Trae接入的,结果只是多了个下拉选项 (ˉ▽ˉ;)...

但是确实完成了任务,说明这种小问题还是可以解决,接下来就是开发了!

后续的一些需求沟通

image.png

image.png

image.png

image.png

image.png

菜鸟后续还提出了很多需求,基本上都能完成,有些需要人为判断一下是否正确,不正确的时候git撤销掉,然后重新描述一下,这样就能保证每一个需求都能准确完成

代码,大家可以直接访问获取,可以的话给个Star,感谢大家:

github.com/pbw-langwan…

视频演示见:

花了两天,让Trae,给我用星云AI数字人写了个项目-哔哩哔哩

❌