feat: project scaffold + bridge + maplibre engine

- Vite + Vue 3 + TypeScript strict
- @yuto-water/js-bridge (types, detector, browser provider)
- MapLibre engine (MapManager singleton, MapFactory, LayerFactory)
- Map composables (useMap, useLayer, usePopup)
- Tianditu tile source, 6 layer type factory
- SCSS design tokens (water-blue theme, dark mode)
- Vant 4, maplibre-gl, axios, pinia, echarts deps
- Build passes (vue-tsc + vite)
This commit is contained in:
2026-06-15 20:46:11 +08:00
commit 74cc0df2b8
33 changed files with 9720 additions and 0 deletions

87
src/bridge/detector.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* 环境检测器
*
* 检测当前运行环境类型,返回统一的环境标识,
* 供上层使用以决定加载哪个 Bridge 提供者。
*
* @module bridge/detector
*/
/** 可识别的运行环境 */
export type Environment = 'browser' | 'wechat-mp' | 'uniapp-webview'
/** wechat 全局声明扩展 */
interface WechatWindow extends Window {
/** 微信 JS-SDK 桥接对象 */
WeixinJSBridge?: Record<string, unknown>
/** 微信环境标识 */
__wxjs_environment?: string
}
/**
* 全局 uni 对象uni-app WebView 中存在)
* 声明为 any 以避免对 @dcloudio 类型包的硬依赖
*/
declare const uni: Record<string, unknown> | undefined
/**
* 检测当前运行环境
*
* 判断优先级:
* 1. 微信小程序 WebView —— userAgent 含 MicroMessenger 且标记为 miniprogram
* 2. uni-app WebView —— 全局存在 uni 对象(且非小程序场景)
* 3. 普通浏览器 —— 其余情况降级
*
* @returns 环境标识字符串
*/
export function detectEnvironment(): Environment {
const win = window as WechatWindow
// ── 检测微信环境 ──
const ua = navigator.userAgent
const isWechat = /MicroMessenger/i.test(ua)
if (isWechat) {
// 检查是否为小程序 WebView
// 方式一WeixinJSBridge 注入的环境标识
// 方式二:页面 URL 参数 __wxjs_environment少数场景
if (win.__wxjs_environment === 'miniprogram') {
return 'wechat-mp'
}
// 微信 JS-SDK ready 后可通过 WeixinJSBridge 进一步确认,
// 但此处仅做同步检测,保守返回 wechat-mp若 UA 匹配且环境变量已注入)
// 否则仍可能为普通微信内置浏览器,暂归为 wechat-mp
return 'wechat-mp'
}
// ── 检测 uni-app WebView ──
// uni 对象在 uni-app 的 WebView 环境中由框架注入
if (typeof uni !== 'undefined' && uni !== null) {
return 'uniapp-webview'
}
// ── 默认:普通浏览器 ──
return 'browser'
}
/**
* 判断当前是否在微信小程序 WebView 中
*/
export function isWechatMiniProgram(): boolean {
return detectEnvironment() === 'wechat-mp'
}
/**
* 判断当前是否在 uni-app WebView 中
*/
export function isUniappWebView(): boolean {
return detectEnvironment() === 'uniapp-webview'
}
/**
* 判断当前是否为普通浏览器环境
*/
export function isBrowser(): boolean {
return detectEnvironment() === 'browser'
}

67
src/bridge/index.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* 桥接层统一入口
*
* 导出类型定义、环境检测器以及各环境提供者,
* 并提供 createBridge() 工厂函数自动选择合适的提供者实例。
*
* @module bridge
*/
// ── 类型定义 ──
export type {
IBridge,
GetLocationOptions,
LocationResult,
ScanType,
ScanCodeOptions,
ScanCodeResult,
OpenMapParams,
ImageSourceType,
ChooseImageOptions,
ChooseImageResult,
DeviceInfo,
} from './types'
// ── 环境检测 ──
export {
detectEnvironment,
isWechatMiniProgram,
isUniappWebView,
isBrowser,
} from './detector'
export type { Environment } from './detector'
// ── 提供者 ──
export { default as browserProvider } from './providers/browser'
// ── 工厂函数 ──
import type { IBridge } from './types'
import { detectEnvironment } from './detector'
import browserProvider from './providers/browser'
/**
* 根据当前运行环境自动创建对应的 Bridge 实例
*
* - 普通浏览器:返回 browserProvider基于 Web API 降级实现)
* - 微信小程序 WebView待实现
* - uni-app WebView待实现
*
* @returns 符合 IBridge 接口的提供者实例
*/
export function createBridge(): IBridge {
const env = detectEnvironment()
switch (env) {
case 'wechat-mp':
// TODO: 引入 WechatProvider 后替换
throw new Error('微信小程序 WebView Bridge 尚未实现')
case 'uniapp-webview':
// TODO: 引入 UniappProvider 后替换
throw new Error('uni-app WebView Bridge 尚未实现')
case 'browser':
default:
return browserProvider
}
}

View File

@@ -0,0 +1,327 @@
/**
* 浏览器环境 Bridge 提供者
*
* 作为普通浏览器中的降级实现,利用 Web API 尽可能模拟原生能力。
* 当 navigator.geolocation 不可用时getLocation 会抛出清晰的错误。
*
* @module bridge/providers/browser
*/
import type {
IBridge,
GetLocationOptions,
LocationResult,
ScanCodeOptions,
ScanCodeResult,
OpenMapParams,
ChooseImageOptions,
ChooseImageResult,
DeviceInfo,
} from '../types'
/**
* 浏览器降级提供者
*
* 实现了 IBridge 接口的六个方法:
* - getLocation —— 依赖 navigator.geolocation
* - scanCode —— 浏览器无法扫码,直接抛出错误
* - openMap —— 通过 URL Scheme 跳转高德/百度地图
* - chooseImage —— 通过 <input type="file"> 选择图片
* - getDeviceInfo —— 通过 navigator / screen 收集设备信息
* - setTitle —— 通过 document.title 设置标题
*/
const browserProvider: IBridge = {
// ────────────────────────────────────────────
// 获取地理位置
// ────────────────────────────────────────────
async getLocation(options: GetLocationOptions = {}): Promise<LocationResult> {
const geolocation = navigator.geolocation
if (!geolocation) {
throw new Error('浏览器不支持定位功能geolocation API 不可用)')
}
const {
enableHighAccuracy = false,
timeout = 10000,
needAddress = false,
} = options
return new Promise<LocationResult>((resolve, reject) => {
geolocation.getCurrentPosition(
(position) => {
const result: LocationResult = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
speed: position.coords.speed ?? undefined,
accuracy: position.coords.accuracy,
}
if (needAddress) {
// 浏览器原生 geolocation 不支持逆地理编码,
// address 留空,由调用方自行调用第三方服务补齐
result.address = undefined
}
resolve(result)
},
(error) => {
let message: string
switch (error.code) {
case error.PERMISSION_DENIED:
message = '用户拒绝定位授权'
break
case error.POSITION_UNAVAILABLE:
message = '无法获取位置信息'
break
case error.TIMEOUT:
message = '定位请求超时'
break
default:
message = `定位失败: ${error.message}`
}
reject(new Error(message))
},
{
enableHighAccuracy,
timeout,
maximumAge: 0,
},
)
})
},
// ────────────────────────────────────────────
// 扫码
// ────────────────────────────────────────────
async scanCode(_options?: ScanCodeOptions): Promise<ScanCodeResult> {
throw new Error('浏览器不支持扫码功能,请使用移动端打开')
},
// ────────────────────────────────────────────
// 打开地图
// ────────────────────────────────────────────
async openMap(params: OpenMapParams): Promise<boolean> {
const { latitude, longitude, name, address, scale = 16 } = params
const label = encodeURIComponent(name || address || '目标位置')
// 优先尝试高德地图 URI
// 格式参考: https://lbs.amap.com/api/uri-api/guide/mobile-web/open
const amapUrl = `https://uri.amap.com/marker?position=${longitude},${latitude}&name=${label}&callnative=1`
// 同时准备通用经纬度链接作为降级(打开系统默认地图)
const fallbackUrl = `https://maps.google.com/maps?q=${latitude},${longitude}(${label})&z=${scale}`
// 检测当前环境
const ua = navigator.userAgent
let mapUrl: string
if (/iPhone|iPad|iPod/i.test(ua)) {
// iOS使用 Apple Maps URL Scheme
mapUrl = `https://maps.apple.com/?q=${label}&ll=${latitude},${longitude}&z=${scale}`
} else if (/Android/i.test(ua)) {
// Android优先使用高德浏览器会自动唤起
mapUrl = amapUrl
} else {
// 桌面端:打开 Google Maps
mapUrl = fallbackUrl
}
try {
window.open(mapUrl, '_blank')
return true
} catch {
return false
}
},
// ────────────────────────────────────────────
// 选择图片
// ────────────────────────────────────────────
async chooseImage(options: ChooseImageOptions = {}): Promise<ChooseImageResult> {
const { count = 1, sourceType = ['album', 'camera'], compress = true } = options
return new Promise<ChooseImageResult>((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
// 根据 sourceType 设置 capture 属性
if (sourceType.length === 1 && sourceType[0] === 'camera') {
input.capture = 'environment'
}
// 支持多选
if (count > 1) {
input.multiple = true
}
// 压缩由 accept / 浏览器默认行为处理,
// 真实压缩需后续通过 Canvas 实现,此处仅标记
// 超时兜底30s 后自动 reject
const timeoutId = setTimeout(() => {
reject(new Error('选择图片超时,请重试'))
cleanup()
}, 30000)
const cleanup = () => {
clearTimeout(timeoutId)
input.removeEventListener('change', onChange)
input.removeEventListener('cancel', onCancel)
input.remove()
}
const onChange = () => {
clearTimeout(timeoutId)
const files = input.files
if (!files || files.length === 0) {
reject(new Error('未选择任何图片'))
cleanup()
return
}
const tempFilePaths: string[] = []
const tempFiles: Array<{ path: string; size: number }> = []
const validFiles: File[] = []
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (file) {
validFiles.push(file)
}
}
for (const file of validFiles) {
// 通过 URL.createObjectURL 创建临时可访问路径
const objectUrl = URL.createObjectURL(file)
tempFilePaths.push(objectUrl)
tempFiles.push({ path: objectUrl, size: file.size })
}
resolve({
tempFilePaths,
tempFiles,
})
cleanup()
}
// 用户取消选择(部分浏览器支持)
const onCancel = () => {
clearTimeout(timeoutId)
reject(new Error('用户取消选择图片'))
cleanup()
}
input.addEventListener('change', onChange)
// 监听 cancel 事件(部分浏览器)
input.addEventListener('cancel', onCancel)
// 兼容移动端 webview 的取消操作:
// 通过 focus 丢失检测取消操作
let focusLost = false
const onFocus = () => {
focusLost = false
}
const onBlur = () => {
focusLost = true
// 延迟检测:如果 blur 后 change 未触发,视为取消
setTimeout(() => {
if (focusLost && input.files && input.files.length === 0) {
clearTimeout(timeoutId)
reject(new Error('用户取消选择图片'))
cleanup()
}
}, 500)
}
input.addEventListener('focus', onFocus)
input.addEventListener('blur', onBlur)
// 触发文件选择
input.click()
})
},
// ────────────────────────────────────────────
// 获取设备信息
// ────────────────────────────────────────────
async getDeviceInfo(): Promise<DeviceInfo> {
const ua = navigator.userAgent
// 解析平台
let platform: string
if (/iPhone|iPad|iPod/i.test(ua)) {
platform = 'ios'
} else if (/Android/i.test(ua)) {
platform = 'android'
} else if (/Windows/i.test(ua)) {
platform = 'windows'
} else if (/Macintosh|Mac OS/i.test(ua)) {
platform = 'mac'
} else if (/Linux/i.test(ua)) {
platform = 'linux'
} else {
platform = 'unknown'
}
// 尝试解析设备型号(粗糙方式)
let model = 'unknown'
const deviceMatch = ua.match(/\(([^)]+)\)/)
if (deviceMatch && deviceMatch[1]) {
// 从 UA 括号中取设备信息
const parts = deviceMatch[1].split(';')
// 最后一个不含版本号的 token 通常是设备型号
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i].trim()
if (
part &&
!/AppleWebKit|KHTML|Gecko|Chrome|Safari|Firefox|Edge|Version|Mobile/i.test(part) &&
!/^[0-9._]+$/.test(part) &&
!/U;|CPU|iPhone|iPad|iPod|Mac OS|Windows|Android|Linux/i.test(part)
) {
model = part
break
}
}
}
// 解析版本
let version = 'unknown'
if (platform === 'android') {
const match = ua.match(/Android\s+([0-9.]+)/i)
if (match && match[1]) {
version = match[1]
}
} else if (platform === 'ios') {
const match = ua.match(/OS\s+([0-9_]+)/i)
if (match && match[1]) {
version = match[1].replace(/_/g, '.')
}
}
return {
platform,
model,
version,
language: navigator.language,
pixelRatio: window.devicePixelRatio || 1,
screenWidth: screen.width,
screenHeight: screen.height,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
}
},
// ────────────────────────────────────────────
// 设置标题
// ────────────────────────────────────────────
async setTitle(title: string): Promise<boolean> {
document.title = title
return true
},
}
export default browserProvider

184
src/bridge/types.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* 桥接层类型定义
*
* 定义统一的能力接口 IBridge屏蔽不同运行环境浏览器、微信小程序、uni-app WebView的差异
* 让上层业务代码只需依赖此接口即可调用原生能力。
*
* @module bridge/types
*/
// ──────────────────────────────────────────────
// 地理位置
// ──────────────────────────────────────────────
/** 获取地理位置时的配置选项 */
export interface GetLocationOptions {
/** 是否启用高精度模式GPS默认 false */
enableHighAccuracy?: boolean
/** 超时时间(毫秒),默认 10000 */
timeout?: number
/** 是否需要返回逆地理编码后的地址描述 */
needAddress?: boolean
}
/** 地理位置坐标 */
export interface LocationResult {
/** 纬度,范围 -90 ~ 90 */
latitude: number
/** 经度,范围 -180 ~ 180 */
longitude: number
/** 速度m/s */
speed?: number
/** 精度(米) */
accuracy?: number
/** 逆地理编码后的地址描述(需要 needAddress 选项) */
address?: string
}
// ──────────────────────────────────────────────
// 扫码
// ──────────────────────────────────────────────
/** 扫码类型 */
export type ScanType = 'qr' | 'barcode' | 'all'
/** 扫码配置选项 */
export interface ScanCodeOptions {
/** 扫码类型,默认 'all' */
type?: ScanType
}
/** 扫码结果 */
export interface ScanCodeResult {
/** 扫码得到的字符串内容 */
result: string
/** 码的类型,如 'QR_CODE'、'EAN_13' 等 */
format?: string
/** 字符集 */
charset?: string
}
// ──────────────────────────────────────────────
// 地图
// ──────────────────────────────────────────────
/** 打开地图查看位置所需的参数 */
export interface OpenMapParams {
/** 目标纬度 */
latitude: number
/** 目标经度 */
longitude: number
/** 位置名称 */
name?: string
/** 地址详情 */
address?: string
/** 缩放级别,默认 16 */
scale?: number
}
// ──────────────────────────────────────────────
// 图片选择
// ──────────────────────────────────────────────
/** 图片来源 */
export type ImageSourceType = 'album' | 'camera'
/** 选择图片的配置选项 */
export interface ChooseImageOptions {
/** 最多可选图片数量,默认 1 */
count?: number
/** 图片来源,默认 ['album', 'camera'] */
sourceType?: ImageSourceType[]
/** 是否允许压缩,默认 true */
compress?: boolean
}
/** 选择图片的返回结果 */
export interface ChooseImageResult {
/** 选中图片的临时文件路径列表 */
tempFilePaths: string[]
/** 选中图片的临时文件对象列表 */
tempFiles?: Array<{
path: string
size: number
}>
}
// ──────────────────────────────────────────────
// 设备信息
// ──────────────────────────────────────────────
/** 设备信息 */
export interface DeviceInfo {
/** 操作系统平台,如 'ios'、'android'、'windows'、'mac' */
platform: string
/** 设备型号 */
model: string
/** 操作系统版本号 */
version: string
/** 系统语言 */
language?: string
/** 设备像素比 */
pixelRatio?: number
/** 屏幕宽度px */
screenWidth?: number
/** 屏幕高度px */
screenHeight?: number
/** 窗口宽度px */
windowWidth?: number
/** 窗口高度px */
windowHeight?: number
}
// ──────────────────────────────────────────────
// Bridge 接口 —— 所有提供者需实现此接口
// ──────────────────────────────────────────────
/**
* 统一桥接接口
*
* 所有环境提供者(浏览器、微信小程序 WebView、uni-app WebView
* 都必须实现此接口,确保上层业务代码不感知底层差异。
*/
export interface IBridge {
/**
* 获取当前地理位置
* @param options - 配置选项
* @returns Promise 返回位置信息
*/
getLocation(options?: GetLocationOptions): Promise<LocationResult>
/**
* 调起扫码
* @param options - 扫码配置
* @returns Promise 返回扫码结果
*/
scanCode(options?: ScanCodeOptions): Promise<ScanCodeResult>
/**
* 打开地图查看指定位置
* @param params - 目标位置参数
* @returns Promise 返回是否成功打开
*/
openMap(params: OpenMapParams): Promise<boolean>
/**
* 从相册或相机选择图片
* @param options - 选择配置
* @returns Promise 返回选中图片信息
*/
chooseImage(options?: ChooseImageOptions): Promise<ChooseImageResult>
/**
* 获取设备信息
* @returns Promise 返回设备信息
*/
getDeviceInfo(): Promise<DeviceInfo>
/**
* 设置页面标题
* @param title - 标题文本
* @returns Promise 返回是否设置成功
*/
setTitle(title: string): Promise<boolean>
}