Docker 部署后 MySQL 中文变问号排查实录

环境:MySQL 8.0 容器 + Flask + docker-compose,种子数据来自 db.sql

1. 现象

示例

SYS_SITE_CONFIG 等表中文全变 ?,但 © 正常:

1
SELECT * FROM SYS_SITE_CONFIG LIMIT 3;
1
2
3
4
5
6
7
+---------------------+---------------------------------------------+---------------+
| config_key | config_value | description |
+---------------------+---------------------------------------------+---------------+
| sys.site.copyright | Copyright © 2026 ???? All Rights Reserved. | ????????? |
| sys.site.loginDesc | ????????????????????????????????????? | ????????? |
| sys.site.loginTitle | ?????? | ????????????? |
+---------------------+---------------------------------------------+---------------+

2. 初步怀疑:表/库字符集

1
2
3
SHOW CREATE TABLE SYS_SITE_CONFIG\G
SELECT HEX(LEFT(config_value,12)) AS hex_head FROM SYS_SITE_CONFIG WHERE config_key='sys.site.loginDesc'\G
SELECT @@character_set_database;
1
2
3
4
5
6
7
Create Table: CREATE TABLE `SYS_SITE_CONFIG` (...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
hex_head: 3F3F3F3F3F3F3F3F3F3F3F3F
+--------------------------+
| @@character_set_database |
+--------------------------+
| utf8mb4 |
+--------------------------+

结论:表和库都是 utf8mb4,但 HEX 全是 3F(ASCII ?),说明中文在写入时就被物理替换,不是显示问题。

db.sql 本身没问题:

1
2
3
4
file -i db.sql
# db.sql: text/plain; charset=utf-8
grep -c "集打卡" db.sql
# 1

3. 文件字节是否正确

1
2
3
4
5
6
grep "sys.site.loginDesc" db.sql | od -An -tx1 | head
# 28 27 73 79 73 2e ... e9 9b 86 e6 89 93 e5 8d a1 e3 80 81 ...
# e9 9b 86 e6 89 93 e5 8d a1 = "集打卡" 的正确 UTF-8 字节

docker compose exec mysql bash -c "grep \"sys.site.loginDesc\" /docker-entrypoint-initdb.d/01-init.sql | od -An -tx1 | head"
# 容器内同一段字节完全一致 → 挂载没坏

再查库里同一段:

1
2
3
docker compose exec mysql mysql -uroot -p'***' --default-character-set=utf8mb4 your_db \
-e "SELECT HEX(config_value) FROM SYS_SITE_CONFIG WHERE config_key='sys.site.loginDesc';"
# 3F3F3F... → 库里已是问号

=> 文件进容器是好的,但 mysql < db.sql 这一步把有效 UTF-8 写成了 ?

4. 连接字符集是否正常

1
2
docker compose exec mysql mysql -uroot -p'***' --default-character-set=utf8mb4 your_db \
-e "SHOW VARIABLES LIKE 'character_set%';"
1
2
3
4
5
6
+--------------------------+--------------------------------+
| character_set_client | utf8mb4 |
| character_set_connection | utf8mb4 |
| character_set_database | utf8mb4 |
| character_set_server | utf8mb4 |
+--------------------------+

服务端全是 utf8mb4,但最小化写入却正常:

1
docker compose exec mysql bash -c "mysql -uroot -p'***' --default-character-set=utf8mb4 your_db -e \"SET NAMES utf8mb4; INSERT INTO SYS_SITE_CONFIG (config_key, config_value) VALUES ('test.chinese','集打卡') ON DUPLICATE KEY UPDATE config_value='集打卡'; SELECT config_value, HEX(config_value) FROM SYS_SITE_CONFIG WHERE config_key='test.chinese';\""
1
2
3
4
5
+--------------+--------------------+
| config_value | HEX(config_value) |
+--------------+--------------------+
| 集打卡 | E99B86E68993E58DA1 |
+--------------+--------------------+

结论:服务端能存中文,问题只在 docker compose exec -T mysql ... < db.sql 这种宿主机管道

5. 根因

docker compose exec -T 的 stdin 在当前环境下会对中文做文本转码,导致有效 UTF-8 进到 mysql 客户端前就被替换为 ?。错误路径:

1
2
3
4
主机 db.sql (UTF-8 正确)
→ docker exec -T stdin 管道(转码)
→ mysql 客户端(收到已是 3F)
→ 存库 3F

而直接字符串 INSERT '集打卡' 不走管道,所以正常。

6. 修复

拷进容器再导,绕开管道:

1
2
3
4
5
docker compose exec mysql mysql -uroot -p'***' -e "DROP DATABASE your_db; CREATE DATABASE your_db CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
docker cp db.sql $(docker compose ps -q mysql):/tmp/db.sql
docker compose exec mysql bash -c "mysql -uroot -p'***' --default-character-set=utf8mb4 your_db < /tmp/db.sql"
docker compose exec mysql mysql -uroot -p'***' --default-character-set=utf8mb4 your_db \
-e "SELECT config_value FROM SYS_SITE_CONFIG WHERE config_key='sys.site.loginDesc';"
1
2
3
+-----------------------------------------------------------------------------------------------------------------+
| 集打卡、考勤、业绩统计于一体的现代化直播运营平台,简洁高效,助力团队协作。 |
+-----------------------------------------------------------------------------------------------------------------+

7. 自动化避免复发

docker-compose.yml 改为首次建库时容器内导入:

1
2
3
4
volumes:
- mysql-data:/var/lib/mysql
- ./db.sql:/tmp/db.sql:ro
- ./docker/mysql-init.sh:/docker-entrypoint-initdb.d/01-init.sh:ro

docker/mysql-init.sh

1
2
3
#!/bin/bash
set -e
mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < /tmp/db.sql

新部署 docker compose down -v && docker compose up -d 自动正确;已有库的重导仍用上面的 docker cp 方式,并始终加 --default-character-set=utf8mb4

8. 教训

  1. 3F 的 HEX 且 © 正常,是写入时转码的典型特征(中文映射不到目标字符集)。
  2. SHOW CREATE TABLEcharacter_set% 都正常时,重点查导入管道,而非表结构。
  3. db.sql 保持 UTF-8 无 BOM,用 scp/git 传输,别用会转码的在线编辑器。

最近接了个活,客户那边的服务器是完全断网的(内网隔离,出于安全考虑),又是windows电脑,所以考虑使用docker,平时都是联网直接 docker compose up 完事。头一次遇到要把整套 MySQL + 后端 + 前端服务打包带到一台”与世隔绝”的 Windows 机器上跑起来,中间踩了几个坑,记录一下完整流程,给同样场景的朋友们省点时间。

场景说明

项目是标准的三件套:

  • mysql —— 数据库
  • backend —— Python/Flask 后端,跑在 gunicorn 里
  • web —— Nginx 托管前端页面,反向代理 API

平时开发联网状态下,一条 docker compose up -d 就能起。但目标机器完全没有网络,连 aptpipnpm 的源都碰不到,更别说 docker pull 了。思路很简单:在有网的电脑上把所有东西准备好,打包成文件,U 盘拷过去,离线机上直接加载。

第一步:有网电脑上把镜像打好包

先确保代码是最新的,然后照常构建:

1
2
3
cd D:\你的项目目录
git pull
docker compose build

构建完了以后,把用到的几个镜像导出成一个 tar 包:

1
docker save -o project-images.tar mysql:8.0 project-backend project-web

这里有个小提醒:docker save 支持一次导出多个镜像到同一个 tar 里,不用一个个来。导出的文件通常几百 MB 到一两个 G,看你项目依赖装了多少东西。

第二步:把需要的文件都收集到一起

除了刚才导出的镜像包,U 盘里还得放这几样:

文件 用途
项目目录(含 docker-compose.yml、初始化 SQL 等) Compose 编排配置和数据库初始化脚本
project-images.tar 打包好的镜像
WSL2 安装包(.msi Docker Desktop 在 Windows 上跑的底层依赖
Docker Desktop 安装包(.exe 目标机上运行容器的工具

WSL2 安装包可以去 GitHub 的 WSL 发布页 下,注意看清楚机器架构,一般台式机/笔记本都是选 x64 那个。

如果目标机已经装过 WSL 和 Docker 了,后续再更新项目,就不用重新拷安装包了,只需要拷项目目录和新的镜像 tar。

第三步:离线机上装环境

这一步跟正常装 Docker Desktop 没什么区别,就是把在线下载的步骤换成了运行本地安装包:

  1. 双击装 WSL 的 .msi,装完重启一下电脑
  2. 装 Docker Desktop,安装过程中记得勾上 Use the WSL 2 based engine(不然容器跑不起来)
  3. 打开 Docker Desktop,确认它显示正常运行(左下角是绿色的)

如果项目里有挂载宿主机某个目录的配置(比如存图片、存文件的路径),记得提前把这个目录建好,不然容器起来的时候会因为挂载路径不存在报错。

第四步:导入镜像,启动项目

把项目目录和 tar 包拷到离线机上以后:

1
2
3
cd D:\项目目录
docker load -i project-images.tar
docker compose up -d --no-build

注意这里加 --no-build,因为离线机没法联网拉依赖,docker compose up 默认如果发现镜像不存在会尝试构建,这在离线环境下必然失败。加了这个参数就是明确告诉它:别 build,直接用我导入的镜像跑。

跑起来以后用这条命令看看容器状态:

1
docker compose ps

都是 Up 状态就说明没问题,浏览器打开对应地址验证一下页面能不能访问。

后续更新怎么办

项目难免要迭代,更新流程也不复杂。有网的机器上重新 build 一遍,重新 save 出新的 tar,拷过去以后:

1
2
docker load -i project-images.tar
docker compose up -d --no-build

docker load 会覆盖同名的旧镜像,up -d 会用新镜像重建容器。这里有两个坑要注意:

  • 不要手动删旧镜像再导入,一般没必要,load 本身会处理好覆盖逻辑
  • 千万不要docker compose down -v 去”清理一下”,这个 -v 会把数据卷一起删掉,数据库里的数据也会跟着没了。正常更新用不到删卷这个操作。

踩坑一:Docker Desktop 一直卡在 “Starting the Docker Engine”

这个坑基本每台离线机第一次装的时候都会遇到一次:WSL 装完、Docker Desktop 也装完了,打开之后界面一直转圈,提示 “Starting the Docker Engine”,等再久也不会变绿。

排查过一圈发现跟网络、镜像都没关系,纯粹是 WSL 这层的虚拟化环境没起来或者卡住了。解法很固定,照着下面顺序来一遍基本都能好:

  1. 先把 Docker Desktop 完全退出(右下角托盘图标右键 Quit Docker Desktop,别只是关窗口)

  2. 打开 PowerShell,执行:

    1
    wsl --shutdown
  3. 重启电脑(这一步不要跳过,单纯 wsl --shutdown 有时候不够彻底)

  4. 重启后重新打开 Docker Desktop,耐心等几分钟,让它把 WSL2 的虚拟机重新拉起来

如果重启电脑之后还是老样子,八成是 BIOS 里的虚拟化功能(Intel VT-x / AMD-V)没开,或者 Windows 的”虚拟机平台”、”适用于 Linux 的 Windows 子系统”这两个可选功能没勾上,去”启用或关闭 Windows 功能”里确认一下。

踩坑二:镜像莫名其妙很大

这个坑值得单独说一下。有一次发现后端镜像居然有 3 个多 G,用 docker history 镜像名 查了一下每一层的大小:

1
2
3
COPY . .                          1.04GB
RUN pip install ... 444MB
RUN apt-get install ... 365MB

COPY . . 这一层正常情况下应该就是项目代码,几 MB 到几十 MB 都算合理,但它跑出了 1 个多 G,明显不对劲。查下来发现是 .dockerignore 没配全,构建的时候把一些不该打进镜像的大文件也一起 COPY 进去了。

排查思路很简单:先看 .dockerignore 里排除了哪些目录,再实际去项目目录里翻一遍,看排除规则之外还有没有大文件残留(比如临时生成的日志、缓存的第三方素材、之前调试留下的大数据文件)。把这些也加进 .dockerignore,重新 build 一次,这一层直接从 1G 掉到几 MB。

这里给个通用建议:如果你的镜像体积让你觉得”不对劲”,第一反应应该是跑一下 docker history 镜像名,一层一层看是哪个步骤占的空间大,而不是瞎猜。定位到具体层之后,问题基本就很好查了。

最近在项目里做了个小玩法:屏幕上方不断掉字符,玩家在字符落到键位附近时按对应按键,命中就加分,漏掉就掉血。

我一开始没有先抠视觉,而是先把几件事定住:

  • 一个下落字符要有哪些字段(出现时间、目标键位、当前位置、是否命中)
  • 命中后特效怎么记录(位置、开始时间、类型)
  • 难度怎么控制(掉落速度、同屏数量、容错)

这些先定好,后面就不容易乱。

先说最核心的一点:别用“每帧位移”,用“时间算位置”

我最开始就避开了“每帧 y + 速度”这套。
因为帧率一抖,手感就会变,低性能机器和高性能机器会像两个游戏。

所以我直接按时间算:

  • 音符什么时候生成
  • 当前时间离生成时间过去了多久
  • 再换算成当前位置

这样即使偶尔掉帧,整体节奏也不会散。

1
2
3
4
5
6
7
8
const getNoteY = useCallback((note: FallingNote, currentMs: number, keyPositions: Map<string, any>) => {
const targetPos = keyPositions.get(note.keyId)
if (!targetPos) return -100
const top = -NOTE_SIZE
const targetY = targetPos.y
const progress = (currentMs - note.spawnAtMs) / settings.dropMs
return top + progress * (targetY - top)
}, [settings.dropMs])

位置计算逻辑是线性的插值:

  • 起点:top = -NOTE_SIZE(音符刚出现时在屏幕上方)
  • 终点:targetY = 对应键位的 y
  • 进度:progress = (currentMs - spawnAtMs) / dropMs
  • 最终:y = top + progress * (targetY - top)

也就是说:

  • progress = 0 时,音符在起点
  • progress = 1 时,音符到达键位
  • 中间按比例移动

所以你掉帧时,下一帧会直接按“真实经过的时间”算到正确位置,不会因为少渲染几帧就整体变慢。

输入判定也要提前设计,不然会很难补

键盘事件和字符不是天然一一对应的,尤其是大小写、符号、空格这些细节。
我的做法是先把“字符 -> 可触发按键”这层映射建好,按下去时只做筛选:

  1. 先找这次按键可能命中的目标
  2. 再从场上挑“在判定区里、并且最近的那个”
  3. 命中就给 Perfect/Good,没打中就 Miss

这样做的好处是:同屏多个字符时,不会乱判。

真正踩坑的是暂停恢复

这个坑我中招过一次:
暂停时看起来停住了,但系统时间没停。恢复那一瞬间,很多字符会“直接穿越到底部”,然后连着触发 Miss。

后来我的修法很直接:恢复时统一补时间,把暂停这段加回去。

为什么我最后还是选 Canvas

这类玩法如果用 DOM 也能做,但对象一多、运动一复杂,Canvas 会更稳一些。
我现在的拆法是:

  • 运动对象(下落字符、特效)走 Canvas
  • UI(分数、血条、按钮)继续放在 React/DOM

这样调试和维护都比较顺手,性能也更可控。

概述

用 uniapp 开发APP,版本 vue3,HBuildX 4.75
map组件在 APP 平台(vue版)上,地图组件的点击事件无法直接返回经纬度。我们通过屏幕坐标和地图视野范围,计算出点击位置的经纬度。

示例

核心思路

将屏幕上的点击位置,按照比例映射到地图的地理坐标上。

实现步骤

第一步:获取屏幕点击坐标

当用户点击地图时,我们从触摸事件中获取点击位置在屏幕上的坐标:

  • clickX:点击位置距离屏幕左边的像素距离
  • clickY:点击位置距离屏幕顶部的像素距离

第二步:转换为地图容器内的相对坐标

地图组件在屏幕上有一个固定的位置和大小。我们需要计算点击位置相对于地图容器的坐标:

1
2
相对X = 屏幕X - 地图容器左边距离
相对Y = 屏幕Y - 地图容器顶部距离

第三步:计算比例

将相对坐标转换为比例值(0 到 1 之间):

  • ratioX = 相对X / 地图容器宽度(0 = 最左边,1 = 最右边)
  • ratioY = 相对Y / 地图容器高度(0 = 最上边,1 = 最下边)

第四步:获取地图视野范围

通过地图 API 获取当前地图显示的地理范围:

  • 西南角(左下角):{ latitude: 39.91, longitude: 116.39 }
  • 东北角(右上角):{ latitude: 39.92, longitude: 116.41 }

第五步:按比例计算经纬度

使用线性插值,根据点击位置的比例,在地图视野范围内计算对应的经纬度:

经度计算(从左到右):

1
经度 = 西边经度 + (东边经度 - 西边经度) × ratioX

纬度计算(从下到上,注意屏幕Y轴和纬度方向相反):

1
纬度 = 南边纬度 + (北边纬度 - 南边纬度) × (1 - ratioY)

举例说明

假设:

  • 屏幕点击位置:(375, 500) 像素
  • 地图容器位置:左边 32px,顶部 128px,宽度 686px,高度 822px
  • 地图视野:西南角 (39.91, 116.39),东北角 (39.92, 116.41)

计算过程:

  1. 相对坐标

    • relativeX = 375 - 32 = 343
    • relativeY = 500 - 128 = 372
  2. 比例

    • ratioX = 343 / 686 = 0.5(水平中间)
    • ratioY = 372 / 822 = 0.453(垂直中偏上)
  3. 经纬度

    • 经度 = 116.39 + (116.41 - 116.39) × 0.5 = 116.40
    • 纬度 = 39.91 + (39.92 - 39.91) × (1 - 0.453) = 39.91547

最终结果:点击位置的经纬度是 (39.91547, 116.40)

注意事项

  1. 精度:这种方法在小范围视野内(几公里)精度较好,误差通常在 10-50 米左右,对于围栏设置等场景足够使用。

  2. 纬度反转:屏幕坐标系 Y 轴向下增大,但地图纬度向上增大,所以计算纬度时需要使用 (1 - ratioY) 来反转。

  3. 线性插值:我们假设地图上两点之间的坐标变化是均匀的(线性),这在较小视野范围内是合理的近似。

总结

通过”屏幕坐标 → 相对坐标 → 比例 → 经纬度”的转换过程,我们实现了在 APP 平台上获取地图点击位置经纬度的功能。核心思想就是按比例映射:点击位置在地图上的相对位置,对应到地理坐标上的相对位置。

1
2
3
4
5
6
7
8
9
<view 
class="outgoing-map-container"
@tap="handleContainerTap"
>
<map :longitude="currentLongitude" :latitude="currentLatitude" :scale="currentScale" :markers="markers"
:show-location="showLocation" :include-points="includePoints" :circles="circles" :polyline="polyline"
:polygons="polygons" class="map" @tap="handleMapTap"
@markertap="handleMarkerTap" @poitap="handleMapTap" :enable-zoom="true" :enable-scroll="true" id="map"></map>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
handleContainerTap(e) {
console.log('xxxx',e)
// 如果未启用该功能,直接返回
if (!this.enableAppTapLocation) return

if (!this.mapContext) {
console.warn('地图上下文未初始化')
return
}

// 尝试从事件对象获取坐标
// 注意:APP 平台的 @tap 事件可能没有坐标信息,需要从 touches/changedTouches 获取
let clickX, clickY

// 方法1:尝试从 detail 中获取(小程序可能有)
if (e.detail && typeof e.detail.x === 'number' && typeof e.detail.y === 'number') {
clickX = e.detail.x
clickY = e.detail.y
}
// 方法2:尝试从 changedTouches 获取(触摸结束时可能有)
else if (e.changedTouches && e.changedTouches.length > 0) {
const touch = e.changedTouches[0]
clickX = touch.clientX || touch.pageX
clickY = touch.clientY || touch.pageY
}

// 如果成功获取坐标,处理点击
if (typeof clickX === 'number' && typeof clickY === 'number') {
this.processTapLocation(clickX, clickY)
} else {
console.warn('无法获取点击坐标,@tap 事件对象:', e)
}
},
// 处理点击位置(计算经纬度并触发事件)
processTapLocation(clickX, clickY) {
// 获取地图视野范围
this.mapContext.getRegion({
success: async (res) => {
try {
// 计算点击位置的经纬度
const coords = await this.screenToLatLng(
clickX,
clickY,
res
)

if (coords) {
console.log('=== 地图点击坐标计算 ===')
console.log('屏幕坐标:', { x: clickX, y: clickY })
console.log('地图视野范围:', res)
console.log('计算的经纬度:', coords)

// 触发点击事件,格式与小程序保持一致
this.$emit('map-tap', {
detail: {
latitude: coords.latitude,
longitude: coords.longitude
}
})
} else {
console.warn('坐标转换失败')
}
} catch (err) {
console.error('处理地图点击失败', err)
}
},
fail: (err) => {
console.error('获取地图视野范围失败', err)
}
})
},
// 将屏幕坐标转换为经纬度
screenToLatLng(screenX, screenY, region) {
return new Promise((resolve) => {
if (!region || !region.northeast || !region.southwest) {
console.warn('地图视野范围数据无效', region)
resolve(null)
return
}

// 获取地图容器的位置和尺寸
const query = uni.createSelectorQuery().in(this)
query.select('.map').boundingClientRect((rect) => {
if (!rect) {
console.warn('获取地图容器位置失败')
resolve(null)
return
}

// 计算相对于地图容器的坐标
const relativeX = screenX - rect.left
const relativeY = screenY - rect.top

// 验证坐标是否在地图范围内
if (relativeX < 0 || relativeX > rect.width || relativeY < 0 || relativeY > rect.height) {
console.warn('点击坐标超出地图范围', { relativeX, relativeY, width: rect.width, height: rect.height })
resolve(null)
return
}

// 计算相对比例(0-1)
const ratioX = relativeX / rect.width
const ratioY = relativeY / rect.height

// 获取边界坐标
const sw = region.southwest // 西南角(左下角)
const ne = region.northeast // 东北角(右上角)

// 线性插值计算经纬度
// 纬度:从下往上(sw.latitude -> ne.latitude),所以是 1 - ratioY
const lat = sw.latitude + (ne.latitude - sw.latitude) * (1 - ratioY)

// 经度:从左往右(sw.longitude -> ne.longitude),所以是 ratioX
const lng = sw.longitude + (ne.longitude - sw.longitude) * ratioX

// 验证坐标有效性
if (typeof lat !== 'number' || typeof lng !== 'number' ||
isNaN(lat) || isNaN(lng) ||
lat < -90 || lat > 90 || lng < -180 || lng > 180) {
console.warn('计算的坐标无效', { lat, lng })
resolve(null)
return
}

resolve({ latitude: lat, longitude: lng })
}).exec()
})
},

uniapp 调用第三方地图导航

在 uniapp 中调用第三方地图导航,主要用 URL Scheme。

基本思路

通过 plus.runtime.openURL() 打开地图应用的 URL Scheme,传入目标经纬度,应用会自动打开并导航。

常用地图的 URL Scheme

高德地图

  • iOS: iosamap://path?dname=目的地&dlat=纬度&dlon=经度&dev=0&t=0
  • Android: androidamap://route?dname=目的地&dlat=纬度&dlon=经度&dev=0&t=0

百度地图

  • 通用: baidumap://map/direction?destination=latlng:纬度,经度|name:目的地&mode=driving

腾讯地图

  • 通用: qqmap://map/routeplan?type=drive&to=目的地&tocoord=纬度,经度

苹果地图(仅 iOS)

  • maps://maps.apple.com/?daddr=纬度,经度&dirflg=d

检测应用是否安装

使用 plus.runtime.isApplicationExist() 检测:

1
2
3
4
5
// Android 用包名,iOS 用 Scheme
const isInstalled = plus.runtime.isApplicationExist({
pname: 'com.autonavi.minimap', // Android 包名
action: 'iosamap://' // iOS Scheme
});

注意:iOS 9+ 需要在 manifest.json 的 plus.distribute.apple.urlschemewhitelist 中添加 Scheme 白名单,否则无法检测。

实现流程

  1. 获取目标经纬度
  2. 检测已安装的地图应用
  3. 弹出选择列表让用户选择
  4. 根据选择打开对应的 URL Scheme

注意事项

基本就是这样,实现起来不难,主要是整理好各个地图的 URL 格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
/**
* 地图导航工具函数
* 支持高德地图、百度地图、腾讯地图、苹果地图、谷歌地图
*/

/**
* 获取系统信息
*/
function getSystemInfo() {
return new Promise((resolve) => {
uni.getSystemInfo({
success: (res) => {
resolve(res);
},
fail: () => {
resolve({});
}
});
});
}

/**
* 检测应用是否安装
* @param {String} pname - Android平台的应用包名
* @param {String} action - iOS平台的应用Scheme
* @returns {Boolean}
*/
function checkAppInstalled(pname, action) {
try {
return plus.runtime.isApplicationExist({
pname: pname || '',
action: action || ''
});
} catch (error) {
console.error('检测应用安装失败:', error);
return false;
}
}

/**
* 获取所有可能的地图应用列表(不检测是否安装)
* @param {Number} latitude - 纬度
* @param {Number} longitude - 经度
* @param {String} name - 地点名称(可选)
* @returns {Promise<Array>}
*/
async function getAllMaps(latitude, longitude, name = '') {
const systemInfo = await getSystemInfo();
const platform = systemInfo.platform || '';
const isIOS = platform === 'ios';
const isAndroid = platform === 'android';

const maps = [];

// 高德地图
// Android包名: com.autonavi.minimap
// iOS Scheme: iosamap://
maps.push({
name: '高德地图',
pname: 'com.autonavi.minimap', // Android包名
action: 'iosamap://', // iOS Scheme
scheme: isIOS ? 'iosamap://' : 'androidamap://',
url: isIOS
? `iosamap://path?sourceApplication=applicationName&dname=${encodeURIComponent(name || '目的地')}&dlat=${latitude}&dlon=${longitude}&dev=0&t=0`
: `androidamap://route?sourceApplication=amap&dname=${encodeURIComponent(name || '目的地')}&dlat=${latitude}&dlon=${longitude}&dev=0&t=0`
});

// 百度地图
// Android包名: com.baidu.BaiduMap
// iOS Scheme: baidumap://
maps.push({
name: '百度地图',
pname: 'com.baidu.BaiduMap', // Android包名
action: 'baidumap://', // iOS Scheme
scheme: 'baidumap://',
url: `baidumap://map/direction?origin=我的位置&destination=latlng:${latitude},${longitude}|name:${encodeURIComponent(name || '目的地')}&mode=driving&coord_type=gcj02`
});

// 腾讯地图
// Android包名: com.tencent.map
// iOS Scheme: qqmap://
maps.push({
name: '腾讯地图',
pname: 'com.tencent.map', // Android包名
action: 'qqmap://', // iOS Scheme
scheme: 'qqmap://',
url: `qqmap://map/routeplan?type=drive&from=我的位置&to=${encodeURIComponent(name || '目的地')}&tocoord=${latitude},${longitude}`
});

// 苹果地图(仅iOS)
if (isIOS) {
maps.push({
name: '苹果地图',
pname: '', // iOS不需要包名
action: 'maps://', // iOS Scheme
scheme: 'maps://',
url: `maps://maps.apple.com/?daddr=${latitude},${longitude}&dirflg=d`
});
}

// 谷歌地图(主要Android)
if (isAndroid) {
maps.push({
name: '谷歌地图',
pname: 'com.google.android.apps.maps', // Android包名
action: 'google.navigation:', // Android也可以使用
scheme: 'google.navigation:',
url: `google.navigation:q=${latitude},${longitude}`
});
}

return maps;
}

/**
* 获取已安装的地图应用列表
* @param {Number} latitude - 纬度
* @param {Number} longitude - 经度
* @param {String} name - 地点名称(可选)
* @returns {Promise<Array>}
*/
async function getAvailableMaps(latitude, longitude, name = '') {
const allMaps = await getAllMaps(latitude, longitude, name);
const installedMaps = [];

// 检测每个地图应用是否已安装
for (const map of allMaps) {
const isInstalled = checkAppInstalled(map.pname, map.action);
if (isInstalled) {
installedMaps.push(map);
}
}

return installedMaps;
}

/**
* 打开地图应用
* @param {String} url - 地图应用的URL
* @param {String} mapName - 地图应用名称
*/
function openMapApp(url, mapName) {
plus.runtime.openURL(url, (error) => {
if (error) {
uni.showToast({
title: `未安装${mapName}`,
icon: 'none'
});
}
});
}

/**
* 显示地图选择列表并跳转
* @param {Number} latitude - 纬度
* @param {Number} longitude - 经度
* @param {String} name - 地点名称(可选)
* @param {Object} options - 配置选项
* @param {String} options.title - 选择列表标题
* @param {String} options.cancelText - 取消按钮文字
*/
export async function openNavigation(latitude, longitude, name = '', options = {}) {
if (!latitude || !longitude) {
uni.showToast({
title: '经纬度不能为空',
icon: 'none'
});
return;
}

try {
const maps = await getAvailableMaps(latitude, longitude, name);

if (maps.length === 0) {
uni.showModal({
title: '提示',
content: '请安装地图应用(高德地图、百度地图、腾讯地图等)',
showCancel: false,
confirmText: '知道了'
});
return;
}

const itemList = maps.map(map => map.name);

uni.showActionSheet({
title: options.title || '选择地图应用',
itemList: itemList,
cancelText: options.cancelText || '取消',
success: (res) => {
const selectedMap = maps[res.tapIndex];
openMapApp(selectedMap.url, selectedMap.name);
},
fail: (err) => {
// 用户取消选择
if (err.errMsg !== 'showActionSheet:cancel') {
console.error('显示地图选择失败:', err);
}
}
});
} catch (error) {
console.error('打开导航失败:', error);
uni.showToast({
title: '打开导航失败',
icon: 'none'
});
}
}

魔鬼往往藏在细节中

简介

由于两个应用基本相似,所以使用uniappX的一套代码,开发了两个应用A和B,但是在安装到手机时却报错。
虽然是一套代码,但是有关的配置都以进行了修改,包括APPID、证书、包名等等。
起初我尝试用不同的手机进行安装,结论都是不行。

  • oppo手机提示:”已安装了签名冲突的应用”
  • 小米手机提示:”与己安装应用内容提供者冲突”

尝试

报错后就开始从网上查找,按照网上教程尝试过许多办法,包括但不限于

  1. 修改开发账号,重新获取APPID
  2. 更换设备进行打包
  3. 离线打包,使用自己生成的证书
  4. 多次更换包名

尝试很多方法,都不行。

解决

最终从小米手机的提示中获得灵感,“提供者冲突”。于是顺着这个思路开始查找,了解到安卓有android:authorities这么一个属性。但是我在uniappx配置中并未找到此配置。
然后开始去搜索一些安卓有关的内容,发现安卓有一个 AndroidManifest.xml 文件,此文件是 Android 应用的“身份证”和“总说明书”。
但是我并没有这个配置文件,于是我尝试全局搜索,在uni_modules中的worry-clipboard 插件中找到了这样的配置文件,worry-clipboard 是用来复制粘贴文字、图片。

于是就修改了里面的package和authorities,重新打包,完美解决~

示例

如果我对Android原生开发有足够了解,看到”内容提供者冲突”这个提示,估计很快就能定位到android:authorities配置问题。结果因为开发APP只会uniapp这一套,遇到没见过的问题就抓瞎了。。

实现一个带S曲线的标签页切换效果

最近做项目遇到一个设计稿,标签页要做成这种带曲线的效果,折腾了半天终于搞定了,记录一下。

效果图

效果说明

就是那种选中的标签页会凸起来,边缘是S形曲线,看起来像是从背景里”挤”出来的感觉。

实现思路

核心就是用多层背景 + 伪元素来模拟这个效果。

HTML结构

1
2
3
4
5
<view class="tab-background"></view>
<view class="tab-active-bg" :class="activeTab === 'service' ? 'right' : 'left'"></view>
<view class="tab-no-active-bg-wrapper">
<view class="tab-no-active-bg" :class="activeTab === 'service' ? 'left' : 'right'"></view>
</view>

关键点1:选中标签比背景高

1
2
3
4
.tab-active-bg {
height: calc(100% + 16rpx);
top: -16rpx; /* 向上偏移16rpx,看起来就凸起了 */
}

关键点2:S曲线用伪元素 + 倾斜实现

1
2
3
4
5
.tab-active-bg.left::after {
content: '';
transform: skewX(15deg); /* 关键:15度倾斜 */
border-top-right-radius: 50rpx;
}

左右两边的曲线方向相反:

  • 左边用skewX(15deg)
  • 右边用skewX(-15deg)

关键点3:用wrapper防止伪元素溢出

1
2
3
4
.tab-no-active-bg-wrapper {
overflow: hidden; /* 这个很重要,不然伪元素会跑到外面去 */
border-radius: 40rpx 40rpx 0 0;
}

没有这个wrapper的话,那些倾斜的伪元素会超出边界,看起来很奇怪。

关键点4:box-shadow填补空隙

1
2
3
4
5
6
.tab-active-bg.left {
box-shadow: 65px 22px 0 #ffffff; /* 填补右边的空隙 */
}
.tab-active-bg.right {
box-shadow: -65px 20px 0 #ffffff; /* 填补左边的空隙 */
}

这个是最难调的,需要反复调整偏移值才能让曲线看起来自然。

踩过的坑

  1. 伪元素跑偏:一开始没用wrapper,伪元素到处乱跑
  2. 曲线不自然:box-shadow的偏移值调了好久
  3. 层级问题:忘记设置z-index,效果出不来

完整代码

就是开头贴的那些CSS,主要就是这几个要点:

  • 选中状态向上偏移16rpx
  • 用skewX(±15deg)做S曲线
  • wrapper设置overflow:hidden
  • box-shadow填补空隙
1
2
3
4
5
6
7
8
9
<!-- 标签背景 -->
<view class="tab-background"></view>
<!-- 活动标签背景 -->
<view class="tab-active-bg" :class="activeTab === 'service' ? 'right' : 'left'">
</view>
<view class="tab-no-active-bg-wrapper">
<view class="tab-no-active-bg" :class="activeTab === 'service' ? 'left' : 'right'">
</view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111

.tab-background {
position: absolute;
width: 100%;
height: 100%;
background: #f5f5f5;
border-radius: 40rpx 40rpx 0 0;
}

.tab-active-bg{
position: absolute;
width: 42%;
height: calc(100% + 16rpx);
background: white;
top: -16rpx;
left: 0;
border-radius: 40rpx 0 0 0;
/* box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.1); */
/* transition: all 0.4s cubic-bezier(0.4, 0.0, 0.2, 1); */
z-index: 1;
opacity: 1;
background: #ffffff;
}

.tab-active-bg.left {
box-shadow: 65px 22px 0 #ffffff;
border-radius: 40rpx 0 0 0;

}

.tab-active-bg.right {
left: 58%;
border-radius: 0 40rpx 0 0;
box-shadow: -65px 20px 0 #ffffff;
}

.tab-no-active-bg-wrapper {
height: 100%;
width: 100%;
background-color: #f5f5f5;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
border-radius: 40rpx 40rpx 0 0;
}

.tab-no-active-bg{
position: absolute;
width: 42%;
height: calc(100% + 16rpx);
background: #f5f5f5;
top: -16rpx;
border-radius: 0 40rpx 0 0;
/* box-shadow: 0 -8rpx 40rpx rgba(0, 0, 0, 0.1); */
/* transition: all 0.4s cubic-bezier(0.4, 0.0, 0.2, 1); */
z-index: 1;
}
.tab-no-active-bg.right {
left: 58%;
}
.tab-no-active-bg.left{
left: 0;
}

.tab-active-bg.left::after {
content: '';
position: absolute;
right: -60rpx;
bottom: 0;
width: 70rpx;
height: 100%;
background-color: #ffffff;
transform: skewX(15deg);
border-top-right-radius: 50rpx;
}

.tab-active-bg.right::before {
content: '';
position: absolute;
left: -60rpx;
bottom: 0;
width: 70rpx;
height: 100%;
background-color: #ffffff;
transform: skewX(-15deg);
border-top-left-radius: 50rpx;
}

.tab-no-active-bg::before {
content: '';
position: absolute;
left: -55rpx;
bottom: 0;
width: 70rpx;
height: 100%;
background-color: #f5f5f5;
transform: skewX(15deg);
border-bottom-left-radius: 50rpx;
}
.tab-no-active-bg.left::after {
content: '';
position: absolute;
right: -56rpx;
bottom: 0;
width: 70rpx;
height: 100%;
background-color: #f5f5f5;
transform: skewX(-15deg);
border-bottom-right-radius: 50rpx;
}

调好参数后效果还挺不错的,分享给有需要的同学。

📋 完整流程概览

1
2
3
4
5
6
1. 预处理:扫描所有Vue组件文件 (asyncModules.ts)
2. 获取数据:从后端API获取路由配置 (getUserRoute)
3. 数据转换:将后端数据转换为Vue Router格式 (formatAsyncRoutes)
4. 组件映射:将字符串路径映射为实际组件 (transformComponentView)
5. 路由处理:多级路由扁平化处理 (flatMultiLevelRoutes)
6. 动态挂载:将路由添加到Vue Router实例 (router.addRoute)

🔧 第一步:组件预处理 (asyncModules.ts)

1
2
3
4
5
6
// src/router/asyncModules.ts
type ImportVueFileType = typeof import('*.vue')
type ImportVueFileFnType = () => Promise<ImportVueFileType>

// 🔍 使用 Vite 的 import.meta.glob 扫描所有 Vue 文件
const moduleFiles = import.meta.glob<ImportVueFileType>('@/views/**/*.vue')

这一步在做什么?

  • 扫描 src/views/ 目录下的所有 .vue 文件
  • 生成一个文件路径到动态导入函数的映射

生成的 moduleFiles 对象示例:

1
2
3
4
5
6
{
'/src/views/system/user/index.vue': () => import('/src/views/system/user/index.vue'),
'/src/views/system/role/index.vue': () => import('/src/views/system/role/index.vue'),
'/src/views/dashboard/workplace/index.vue': () => import('/src/views/dashboard/workplace/index.vue'),
// ... 更多文件
}

转换为 asyncRouteModules:

1
2
3
4
5
6
7
8
9
export const asyncRouteModules = Object.entries(moduleFiles).reduce((routes, [url, importFn]) => {
// 排除登录页和组件文件夹
if (!/\/(views\/login|components)\//.test(url)) {
// 转换路径:'/src/views/system/user/index.vue' → 'system/user/index'
const path = url.replace('/src/views/', '').replace('.vue', '')
routes[path] = importFn
}
return routes
}, {} as Recordable<ImportVueFileFnType>)

最终的 asyncRouteModules 对象:

1
2
3
4
5
6
{
'system/user/index': () => import('/src/views/system/user/index.vue'),
'system/role/index': () => import('/src/views/system/role/index.vue'),
'dashboard/workplace/index': () => import('/src/views/dashboard/workplace/index.vue'),
// ... 更多组件
}

🌐 第二步:获取后端数据

1
2
3
4
5
6
// src/stores/modules/route.ts
const generateRoutes = async (): Promise<RouteRecordRaw[]> => {
// 🔍 从后端API获取路由配置
const { data } = await getUserRoute()
// ...
}

后端返回的数据格式示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[
{
"id": "1",
"title": "系统管理",
"parentId": "0",
"type": 1, // 1=目录
"path": "/system",
"component": "Layout", // 🔑 关键:使用Layout作为父组件
"icon": "settings",
"isHidden": false,
"sort": 1,
"children": [
{
"id": "2",
"title": "用户管理",
"parentId": "1",
"type": 2, // 2=菜单
"path": "/system/user",
"component": "system/user/index", // 🔑 关键:对应实际组件路径
"icon": "user",
"isHidden": false,
"sort": 1
},
{
"id": "3",
"title": "角色管理",
"parentId": "1",
"type": 2,
"path": "/system/role",
"component": "system/role/index",
"icon": "role",
"isHidden": false,
"sort": 2
}
]
}
]

🔄 第三步:组件映射机制

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/stores/modules/route.ts
const layoutComponentMap = {
Layout: () => import('@/layout/index.vue'), // 布局组件
ParentView: () => import('@/components/ParentView/index.vue'), // 父级视图
}

/** 将component字符串转成真正的组件模块 */
const transformComponentView = (component: string) => {
// 🔍 首先检查是否是布局组件
return layoutComponentMap[component as keyof typeof layoutComponentMap]
// 🔍 否则从预扫描的组件中查找
|| asyncRouteModules[component]
}

映射过程示例:

1
2
3
4
// 映射示例:
transformComponentView('Layout') // → () => import('@/layout/index.vue')
transformComponentView('system/user/index') // → () => import('/src/views/system/user/index.vue')
transformComponentView('system/role/index') // → () => import('/src/views/system/role/index.vue')

🏗️ 第四步:数据格式化转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const formatAsyncRoutes = (menus: RouteItem[]) => {
if (!menus.length) return []

const pathMap = new Map()
return mapTree(menus, (item) => {
pathMap.set(item.id, item.path)

// 🔍 子菜单排序
if (item.children?.length) {
item.children.sort((a, b) => (a?.sort ?? 0) - (b?.sort ?? 0))
}

// 🔍 处理激活菜单逻辑
if (item.parentId && item.type === 2 && item.permission) {
item.activeMenu = pathMap.get(item.parentId)
}

// 🔑 核心:转换为Vue Router格式
return {
path: item.path,
name: item.name ?? transformPathToName(item.path),
component: transformComponentView(item.component), // 🔍 组件映射
redirect: item.redirect,
meta: {
title: item.title,
hidden: item.isHidden,
keepAlive: item.isCache,
icon: item.icon,
showInTabs: item.showInTabs,
activeMenu: item.activeMenu,
},
}
}) as RouteRecordRaw[]
}

转换后的路由结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[
{
path: '/system',
name: 'System',
component: () => import('@/layout/index.vue'), // 🔑 Layout作为父组件
meta: { title: '系统管理', icon: 'settings' },
children: [
{
path: '/system/user',
name: 'SystemUser',
component: () => import('/src/views/system/user/index.vue'), // 🔑 具体页面组件
meta: { title: '用户管理', icon: 'user' }
},
{
path: '/system/role',
name: 'SystemRole',
component: () => import('/src/views/system/role/index.vue'),
meta: { title: '角色管理', icon: 'role' }
}
]
}
]

📊 第五步:路由扁平化处理

1
2
3
4
5
6
7
8
9
10
11
12
/** 路由降级(把三级及其以上的路由转化为二级路由) */
export const flatMultiLevelRoutes = (routes: RouteRecordRaw[]) => {
return cloneDeep(routes).map((route) => {
if (!isMultipleRoute(route)) return route

return {
...route,
// 🔍 将多级嵌套转为扁平结构
children: toTreeArray(route.children).map((item) => omit(item, 'children')) as RouteRecordRaw[],
}
})
}

为什么要扁平化?

  • Vue Router 推荐使用二级路由结构
  • 避免过深的嵌套导致的性能问题
  • 简化路由匹配逻辑

🚀 第六步:路由动态挂载

1
2
3
4
5
6
7
// src/router/guard.ts
const accessRoutes = await routeStore.generateRoutes()
accessRoutes.forEach((route) => {
if (!isHttp(route.path)) {
router.addRoute(route) // 🔍 动态添加到路由器
}
})

🎯 整个流程的关键优势

1. 自动化组件发现

  • 无需手动维护组件映射表
  • 新增页面只需放在正确目录即可

2. 类型安全

  • TypeScript 类型定义确保数据结构正确
  • 编译时就能发现路径错误

3. 权限控制

  • 后端根据用户角色返回不同路由
  • 前端只渲染有权限的页面

4. 按需加载

  • 使用动态导入实现代码分割
  • 只有访问时才加载对应组件

5. 灵活配置

  • 路由结构可在数据库中配置
  • 支持动态调整菜单结构和权限

Vue3动态路由核心实现

1. 动态导入组件

使用 import.meta.glob 批量导入组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// utils/loadComponent.js
const modules = import.meta.glob('../views/modules/**/index.vue')

export function loadComponent(componentPath) {
// Layout组件
if (componentPath === 'Layout') {
return () => import('@/views/layout/index.vue')
}

// 动态组件
const component = modules[`../views/modules${componentPath}/index.vue`]
if (component) {
return component
}

// 404组件
return () => import('@/views/404/index.vue')
}

2. 路由数据转换

将后端返回的菜单数据转换为路由配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// utils/routeHelper.js
import { loadComponent } from './loadComponent'

export function transformRoutes(menuList) {
const routes = []

menuList.forEach(menu => {
const route = {
path: menu.path,
name: menu.name,
component: loadComponent(menu.component),
meta: {
title: menu.title,
icon: menu.icon,
hidden: menu.hidden,
keepAlive: menu.keepAlive
}
}

// 处理子路由
if (menu.children && menu.children.length) {
route.children = transformRoutes(menu.children)
}

routes.push(route)
})

return routes
}

3. 权限Store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// store/permission.js
import { defineStore } from 'pinia'
import { getMenuList } from '@/api/menu'
import { transformRoutes } from '@/utils/routeHelper'

export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [],
menuList: []
}),

actions: {
async generateRoutes() {
try {
// 获取菜单数据
const { data } = await getMenuList()
this.menuList = data

// 转换为路由配置
const dynamicRoutes = transformRoutes(data)
this.routes = dynamicRoutes

return dynamicRoutes
} catch (error) {
console.error('生成路由失败:', error)
return []
}
}
}
})

4. 动态添加路由

在路由守卫中添加动态路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/store/user'
import { usePermissionStore } from '@/store/permission'

const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/views/login/index.vue')
}
]
})

router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const permissionStore = usePermissionStore()

if (userStore.token) {
if (permissionStore.routes.length === 0) {
try {
// 生成动态路由
const dynamicRoutes = await permissionStore.generateRoutes()

// 添加到router实例
dynamicRoutes.forEach(route => {
router.addRoute(route)
})

// 重新跳转,确保路由已加载
next({ ...to, replace: true })
} catch (error) {
userStore.logout()
next('/login')
}
} else {
next()
}
} else {
next('/login')
}
})

export default router

5. 后端数据格式

后端返回的菜单数据格式示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[
{
"id": 1,
"path": "/system",
"name": "System",
"component": "Layout",
"title": "系统管理",
"icon": "system",
"hidden": false,
"children": [
{
"id": 11,
"path": "user",
"name": "SystemUser",
"component": "/system/user",
"title": "用户管理",
"icon": "user",
"hidden": false
}
]
}
]

6. 文件目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
src/
├── views/
│ ├── layout/
│ │ └── index.vue
│ └── modules/
│ ├── system/
│ │ ├── user/
│ │ │ └── index.vue
│ │ └── role/
│ │ └── index.vue
│ └── content/
│ └── article/
│ └── index.vue

核心要点

  1. import.meta.glob: 批量导入组件,避免手动维护组件映射
  2. router.addRoute(): 动态添加路由到router实例
  3. next({ ...to, replace: true }): 确保动态路由添加后重新导航
  4. 组件路径映射: 后端返回的component字段对应views目录结构

编程中的分包概念

分包(Package Splitting/Code Splitting)是现代软件开发中的一种重要优化技术,主要目的是将大型应用程序拆分成更小、更易管理的模块或包。

分包的核心优势

性能优化:通过将代码分割成多个包,可以实现按需加载,减少初始加载时间。用户只需要下载当前需要的代码,而不是整个应用程序。

缓存效率:当应用更新时,只有修改过的包需要重新下载,未修改的包可以继续使用缓存版本,提高加载速度。

并行加载:多个包可以同时下载,充分利用网络带宽,缩短总体加载时间。

代码组织:将功能相关的代码组织在一起,提高代码的可维护性和可读性。

微信小程序分包详解

微信小程序的分包是一个特别重要的功能,因为小程序有严格的体积限制。

基本概念

微信小程序分包将小程序划分为主包和若干个分包:

  • 主包:包含默认启动页面和公共资源
  • 分包:根据功能模块划分的独立包

体积限制

  • 整个小程序所有分包大小不超过 20MB
  • 单个分包/主包大小不超过 2MB
  • 主包通常建议控制在 1MB 以内

分包配置

app.json 中配置分包结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"pages": [
"pages/index/index",
"pages/logs/logs"
],
"subPackages": [
{
"root": "packageA",
"name": "pack1",
"pages": [
"pages/cat/cat",
"pages/dog/dog"
]
},
{
"root": "packageB",
"name": "pack2",
"pages": [
"pages/apple/apple",
"pages/banana/banana"
]
}
]
}

目录结构示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
├── app.js
├── app.json
├── app.wxss
├── pages/
│ ├── index/
│ └── logs/
├── packageA/
│ └── pages/
│ ├── cat/
│ └── dog/
└── packageB/
└── pages/
├── apple/
└── banana/

分包预下载

可以配置在进入某个页面时预下载分包:

1
2
3
4
5
6
7
8
{
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["pack1"]
}
}
}

独立分包

独立分包可以独立于主包运行:

1
2
3
4
5
6
7
8
9
10
11
{
"subPackages": [
{
"root": "moduleA",
"pages": [
"pages/rabbit/rabbit"
],
"independent": true
}
]
}

使用注意事项

跳转限制:分包内页面可以访问主包页面,但不能直接访问其他分包页面,需要先跳转到主包页面再跳转到目标分包。

资源引用:分包可以引用主包的公共资源,但不能引用其他分包的资源。

tabBar限制:tabBar页面必须在主包中。

插件限制:使用插件的页面必须在主包中。

0%