1. 新增图上量测与拾取、坐标展点两个地图工具页面 2. 安装leaflet依赖并配置vite开发服务器允许局域网访问 3. 更新首页工具列表,新增两个工具入口 4. 优化坐标转换器默认参数和文件读取方式 5. 调整面积计算精度,删除无用的HelloWorld组件
710 lines
30 KiB
Vue
710 lines
30 KiB
Vue
<template>
|
||
<div class="container pb-2xl">
|
||
<div class="tool-header">
|
||
<router-link to="/" class="back-link">
|
||
<ChevronLeft class="icon-sm" /> 返回首页
|
||
</router-link>
|
||
<h2>
|
||
<Globe class="icon-lg" /> 坐标系统与格式转换
|
||
</h2>
|
||
<p>支持经纬度格式转换、常用地图坐标系互转及 CGCS2000 高斯平面投影与批量转换</p>
|
||
</div>
|
||
|
||
<div class="tabs-wrapper">
|
||
<!-- Tab Navigation -->
|
||
<div class="tabs-header">
|
||
<button
|
||
v-for="tab in tabsList"
|
||
:key="tab.id"
|
||
@click="activeTab = tab.id"
|
||
:class="['tab-btn', { active: activeTab === tab.id }]"
|
||
>
|
||
<component :is="tab.icon" class="icon-sm mr-xs" />
|
||
{{ tab.name }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tab Content -->
|
||
<div class="tab-content card">
|
||
|
||
<!-- 1. Format Conversion -->
|
||
<div v-show="activeTab === 'format'">
|
||
<h3 class="card-title text-center mb-xl">度分秒与十进制度数互转</h3>
|
||
<div class="grid grid-2 gap-xl">
|
||
<div class="flex-col">
|
||
<div class="input-group flex-1">
|
||
<label class="input-label">度分秒 → 十进制</label>
|
||
<div class="dms-input mb-sm">
|
||
<input type="number" v-model.number="dms.lat.degrees" class="input" placeholder="度">
|
||
<input type="number" v-model.number="dms.lat.minutes" class="input" placeholder="分">
|
||
<input type="number" v-model.number="dms.lat.seconds" class="input" placeholder="秒">
|
||
<select v-model="dms.lat.direction" class="input px-xs">
|
||
<option value="N">N</option>
|
||
<option value="S">S</option>
|
||
</select>
|
||
</div>
|
||
<div class="dms-input">
|
||
<input type="number" v-model.number="dms.lon.degrees" class="input" placeholder="度">
|
||
<input type="number" v-model.number="dms.lon.minutes" class="input" placeholder="分">
|
||
<input type="number" v-model.number="dms.lon.seconds" class="input" placeholder="秒">
|
||
<select v-model="dms.lon.direction" class="input px-xs">
|
||
<option value="E">E</option>
|
||
<option value="W">W</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button @click="convertToDecimal" class="btn btn-primary btn-full mt-md">转换为十进制</button>
|
||
</div>
|
||
|
||
<div class="flex-col">
|
||
<div class="input-group flex-1">
|
||
<label class="input-label">十进制 → 度分秒</label>
|
||
<div class="flex flex-col gap-sm">
|
||
<input type="number" v-model.number="decimal.lat" class="input" placeholder="纬度 (Lat)">
|
||
<input type="number" v-model.number="decimal.lon" class="input" placeholder="经度 (Lon)">
|
||
</div>
|
||
</div>
|
||
<button @click="convertToDms" class="btn btn-primary btn-full mt-md">转换为度分秒</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. CRS Transformation -->
|
||
<div v-show="activeTab === 'crs'">
|
||
<h3 class="card-title text-center mb-xl">常用地图坐标系互转</h3>
|
||
<div class="max-w-3xl mx-auto">
|
||
<div class="input-group">
|
||
<label class="input-label">输入坐标 (经度, 纬度)</label>
|
||
<input type="text" v-model="crsInput" class="input" placeholder="例: 116.397, 39.908">
|
||
</div>
|
||
|
||
<div class="grid grid-2 gap-md mt-md">
|
||
<div class="input-group">
|
||
<label class="input-label">来源坐标系</label>
|
||
<select v-model="sourceCrs" class="input">
|
||
<option value="WGS84">WGS84 (大地坐标/GPS)</option>
|
||
<option value="GCJ02">GCJ02 (火星坐标/高德/腾讯)</option>
|
||
<option value="BD09">BD09 (百度坐标)</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group">
|
||
<label class="input-label">目标坐标系</label>
|
||
<select v-model="targetCrs" class="input">
|
||
<option value="GCJ02">GCJ02 (火星坐标/高德/腾讯)</option>
|
||
<option value="WGS84">WGS84 (大地坐标/GPS)</option>
|
||
<option value="BD09">BD09 (百度坐标)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<button @click="transformCrs" class="btn btn-primary btn-full mt-lg">执行地图坐标转换</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. CGCS2000 Projection -->
|
||
<div v-show="activeTab === 'proj'">
|
||
<h3 class="card-title text-center mb-xl">高斯-克吕格平面投影计算</h3>
|
||
<div class="max-w-3xl mx-auto">
|
||
<div class="grid grid-2 gap-md mb-md">
|
||
<div class="input-group">
|
||
<label class="input-label">转换方向</label>
|
||
<select v-model="projConfig.direction" class="input">
|
||
<option value="toPlane">经纬度 → 平面坐标</option>
|
||
<option value="toLngLat">平面坐标 → 经纬度</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group">
|
||
<label class="input-label">分带方式 (CGCS2000)</label>
|
||
<select v-model="projConfig.zoneType" class="input">
|
||
<option :value="3">3 度带</option>
|
||
<option :value="6">6 度带</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="projConfig.direction === 'toPlane'" class="flex-col pb-md">
|
||
<div class="grid grid-2 gap-md mb-md">
|
||
<div class="input-group">
|
||
<label class="input-label">经度 (Lon)</label>
|
||
<input type="number" v-model.number="projConfig.lng" class="input" placeholder="输入经度">
|
||
</div>
|
||
<div class="input-group">
|
||
<label class="input-label">纬度 (Lat)</label>
|
||
<input type="number" v-model.number="projConfig.lat" class="input" placeholder="输入纬度">
|
||
</div>
|
||
</div>
|
||
<label class="checkbox-label mb-md">
|
||
<input type="checkbox" v-model="projConfig.withZone">
|
||
投影后的 X 坐标 (北移/纬距对应国内Y) 包含带号
|
||
</label>
|
||
<button @click="doProjection" class="btn btn-primary btn-full">投影到平面</button>
|
||
</div>
|
||
|
||
<div v-else class="flex-col pb-md">
|
||
<div class="grid grid-2 gap-md mb-md">
|
||
<div class="input-group">
|
||
<label class="input-label">北移坐标 (国内X)</label>
|
||
<input type="number" v-model.number="projConfig.y" class="input" placeholder="对应测量的 X 坐标 (Northing)">
|
||
</div>
|
||
<div class="input-group">
|
||
<label class="input-label">东移坐标 (国内Y)</label>
|
||
<input type="number" v-model.number="projConfig.x" class="input" placeholder="对应测量的 Y 坐标 (Easting)">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2 gap-md mb-md align-center">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" v-model="projConfig.withZone"> 东移坐标包含带号
|
||
</label>
|
||
<div class="input-group m-0" v-if="!projConfig.withZone">
|
||
<input type="number" v-model.number="projConfig.manualZone" class="input" placeholder="请手动输入区域带号">
|
||
</div>
|
||
</div>
|
||
<button @click="doProjection" class="btn btn-primary btn-full">反投到经纬度</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 4. Execute Bulk Conversion -->
|
||
<div v-show="activeTab === 'batch'">
|
||
<h3 class="card-title text-center mb-md">通过 Excel 批量互转平面与地理坐标</h3>
|
||
<p class="text-secondary text-sm text-center mb-xl">
|
||
请先下载对应的格式模板,填写数据后上传即可自动解析、计算并导出。所有计算均在浏览器本地完成。
|
||
</p>
|
||
|
||
<div class="max-w-3xl mx-auto flex-col">
|
||
<div class="grid grid-2 gap-md mb-xl">
|
||
<button @click="downloadTemplate('toPlane')" class="btn btn-secondary flex-center gap-xs">
|
||
<DownloadCloud class="icon-sm" /> 下载 经纬度转平面 模板
|
||
</button>
|
||
<button @click="downloadTemplate('toLngLat')" class="btn btn-secondary flex-center gap-xs">
|
||
<DownloadCloud class="icon-sm" /> 下载 平面转经纬度 模板
|
||
</button>
|
||
<button @click="downloadTemplate('toDecimal')" class="btn btn-secondary flex-center gap-xs">
|
||
<DownloadCloud class="icon-sm" /> 下载 度分秒转十进制 模板
|
||
</button>
|
||
<button @click="downloadTemplate('toDms')" class="btn btn-secondary flex-center gap-xs">
|
||
<DownloadCloud class="icon-sm" /> 下载 十进制转度分秒 模板
|
||
</button>
|
||
</div>
|
||
|
||
<div class="upload-area">
|
||
<input type="file" id="excel-upload" accept=".xlsx, .xls" class="hidden" @change="handleFileUpload">
|
||
<label for="excel-upload" class="upload-label">
|
||
<UploadCloud class="icon-xl mb-md text-primary" />
|
||
<span class="text-lg font-600 mb-xs">点击此处选择 Excel 文件</span>
|
||
<span class="text-tertiary text-sm">支持 .xlsx, .xls 格式</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Single Result Area -->
|
||
<div v-if="result && activeTab !== 'batch'" class="card mt-xl">
|
||
<div class="flex justify-between items-center mb-md">
|
||
<h4 class="m-0 flex items-center gap-xs">
|
||
<CheckCircle2 class="icon-md text-success" /> 单点计算结果
|
||
</h4>
|
||
<button @click="copyResult" class="btn btn-secondary btn-sm flex-center gap-xs">
|
||
<Copy class="icon-sm" /> 复制
|
||
</button>
|
||
</div>
|
||
|
||
<div class="result-box">
|
||
<div v-if="result.type === 'decimal'">
|
||
<p><strong>纬度 (Lat):</strong> {{ result.lat }}</p>
|
||
<p><strong>经度 (Lon):</strong> {{ result.lon }}</p>
|
||
</div>
|
||
<div v-else-if="result.type === 'dms'">
|
||
<p><strong>纬度:</strong> {{ result.lat }}</p>
|
||
<p><strong>经度:</strong> {{ result.lon }}</p>
|
||
</div>
|
||
<div v-else-if="result.type === 'crs'">
|
||
<p><strong>经度 (Lon):</strong> {{ result.lon.toFixed(8) }}°</p>
|
||
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}°</p>
|
||
<small class="text-tertiary">由 {{ sourceCrs }} 转换为 {{ targetCrs }}</small>
|
||
</div>
|
||
<div v-else-if="result.type === 'proj_plane'">
|
||
<p><strong>北移坐标 (国内X):</strong> {{ result.northing.toFixed(4) }} m</p>
|
||
<p><strong>东移坐标 (国内Y):</strong> {{ result.easting.toFixed(4) }} m</p>
|
||
<small class="text-tertiary">基于 CGCS2000 {{ projConfig.zoneType }}度带高斯投影 (带号: {{ result.zone }}, 中央子午线: {{ result.meridian }}°)</small>
|
||
</div>
|
||
<div v-else-if="result.type === 'proj_lnglat'">
|
||
<p><strong>经度 (Lon):</strong> {{ result.lng.toFixed(8) }}°</p>
|
||
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}°</p>
|
||
<small class="text-tertiary">基于 CGCS2000 {{ projConfig.zoneType }}度带高斯投影 (带号: {{ result.zone }}, 中央子午线: {{ result.meridian }}°)</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import gcoord from 'gcoord'
|
||
import * as xlsx from 'xlsx'
|
||
import { ChevronLeft, Globe, RefreshCw, MapPin, Copy, CheckCircle2, Layers, FileSpreadsheet, DownloadCloud, UploadCloud } from 'lucide-vue-next'
|
||
import { dmsToDecimal, formatCoordinate } from '../utils/coordinate'
|
||
import { projectToPlane, unprojectToLngLat } from '../utils/proj'
|
||
|
||
// === Tabs UI State ===
|
||
const activeTab = ref('format')
|
||
const tabsList = [
|
||
{ id: 'format', name: '格式转换', icon: RefreshCw },
|
||
{ id: 'crs', name: '地图坐标转换', icon: MapPin },
|
||
{ id: 'proj', name: 'CGCS2000投影转换', icon: Layers },
|
||
{ id: 'batch', name: 'Excel 批量互转', icon: FileSpreadsheet }
|
||
]
|
||
|
||
// === DMS Conversion State ===
|
||
const dms = ref({
|
||
lat: { degrees: 22, minutes: 49, seconds: 16.8640, direction: 'N' },
|
||
lon: { degrees: 108, minutes: 18, seconds: 37.4184, direction: 'E' }
|
||
})
|
||
const decimal = ref({ lat: 22.8213511, lon: 108.3103940 })
|
||
|
||
// === CRS Transformation State ===
|
||
const crsInput = ref('116.397451, 39.909235')
|
||
const sourceCrs = ref('WGS84')
|
||
const targetCrs = ref('GCJ02')
|
||
|
||
// === Projection State ===
|
||
const projConfig = ref({
|
||
direction: 'toPlane', // or 'toLngLat'
|
||
zoneType: 3, // 3 or 6
|
||
withZone: true,
|
||
lng: 108.55972199,
|
||
lat: 22.85808956,
|
||
x: 36557444.5725, // Easting (Y)
|
||
y: 2528930.9851, // Northing (X)
|
||
manualZone: null
|
||
})
|
||
|
||
const result = ref(null)
|
||
|
||
// --- Methods ---
|
||
|
||
const convertToDecimal = () => {
|
||
const latDecimal = dmsToDecimal(dms.value.lat.degrees, dms.value.lat.minutes, dms.value.lat.seconds)
|
||
const lonDecimal = dmsToDecimal(dms.value.lon.degrees, dms.value.lon.minutes, dms.value.lon.seconds)
|
||
|
||
result.value = {
|
||
type: 'decimal',
|
||
lat: (dms.value.lat.direction === 'S' ? -latDecimal : latDecimal).toFixed(8),
|
||
lon: (dms.value.lon.direction === 'W' ? -lonDecimal : lonDecimal).toFixed(8)
|
||
}
|
||
}
|
||
|
||
const convertToDms = () => {
|
||
result.value = {
|
||
type: 'dms',
|
||
lat: formatCoordinate(decimal.value.lat, 'lat'),
|
||
lon: formatCoordinate(decimal.value.lon, 'lon')
|
||
}
|
||
}
|
||
|
||
const transformCrs = () => {
|
||
const parts = crsInput.value.split(/[,,\s]+/).map(p => parseFloat(p.trim())).filter(p => !isNaN(p))
|
||
if (parts.length < 2) {
|
||
alert('请输入有效的坐标 (经度, 纬度)')
|
||
return
|
||
}
|
||
|
||
const [lon, lat] = parts
|
||
const transformed = gcoord.transform(
|
||
[lon, lat],
|
||
gcoord[sourceCrs.value],
|
||
gcoord[targetCrs.value]
|
||
)
|
||
|
||
result.value = {
|
||
type: 'crs',
|
||
lon: transformed[0],
|
||
lat: transformed[1]
|
||
}
|
||
}
|
||
|
||
const doProjection = () => {
|
||
try {
|
||
if (projConfig.value.direction === 'toPlane') {
|
||
const { lng, lat, zoneType, withZone } = projConfig.value
|
||
if (!lng || !lat) return alert('请输入经纬度')
|
||
const res = projectToPlane(lng, lat, zoneType, withZone)
|
||
result.value = {
|
||
type: 'proj_plane',
|
||
easting: res.easting,
|
||
northing: res.northing,
|
||
zone: res.zone,
|
||
meridian: res.meridian
|
||
}
|
||
} else {
|
||
const { x, y, zoneType, withZone, manualZone } = projConfig.value
|
||
if (!x || !y) return alert('请输入平面坐标')
|
||
const res = unprojectToLngLat(x, y, zoneType, withZone, manualZone)
|
||
result.value = {
|
||
type: 'proj_lnglat',
|
||
lng: res.lng,
|
||
lat: res.lat,
|
||
zone: res.zone,
|
||
meridian: res.meridian
|
||
}
|
||
}
|
||
} catch (err) {
|
||
alert('转换失败: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const copyResult = () => {
|
||
let text = ''
|
||
if (result.value.type === 'decimal') {
|
||
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
||
} else if (result.value.type === 'dms') {
|
||
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
||
} else if (result.value.type === 'crs') {
|
||
text = `${result.value.lon.toFixed(8)}, ${result.value.lat.toFixed(8)}`
|
||
} else if (result.value.type === 'proj_plane') {
|
||
text = `X (北移): ${result.value.northing.toFixed(4)}, Y (东移): ${result.value.easting.toFixed(4)}`
|
||
} else {
|
||
text = `${result.value.lng.toFixed(8)}, ${result.value.lat.toFixed(8)}`
|
||
}
|
||
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
alert('已复制到剪贴板!')
|
||
})
|
||
}
|
||
|
||
// === Bulk Excel Conversion ===
|
||
|
||
const downloadTemplate = (type) => {
|
||
let wsData = []
|
||
let filename = ''
|
||
if (type === 'toPlane') {
|
||
wsData = [['点名', '经度', '纬度', '分带类型(3/6)', 'X包含带号(1/0)'], ['pt1', 116.397451, 39.909235, 3, 1]]
|
||
filename = '经纬度转平面模板.xlsx'
|
||
} else if (type === 'toLngLat') {
|
||
wsData = [['点名', 'X坐标(北移)', 'Y坐标(东移)', '分带类型(3/6)', '手动带号'], ['pt1', 4419519.82, 39533966.82, 3, '']]
|
||
filename = '平面转经纬度模板.xlsx'
|
||
} else if (type === 'toDecimal') {
|
||
wsData = [['点名', '纬度度', '纬度分', '纬度秒', '纬度方向(N/S)', '经度度', '经度分', '经度秒', '经度方向(E/W)'], ['pt1', 39, 54, 27, 'N', 116, 23, 50, 'E']]
|
||
filename = '度分秒转十进制模板.xlsx'
|
||
} else if (type === 'toDms') {
|
||
wsData = [['点名', '纬度(十进制)', '经度(十进制)'], ['pt1', 39.9075, 116.3972]]
|
||
filename = '十进制转度分秒模板.xlsx'
|
||
}
|
||
|
||
const ws = xlsx.utils.aoa_to_sheet(wsData)
|
||
const wb = xlsx.utils.book_new()
|
||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1")
|
||
xlsx.writeFile(wb, filename)
|
||
}
|
||
|
||
const handleFileUpload = (e) => {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (evt) => {
|
||
try {
|
||
const data = evt.target.result
|
||
const wb = xlsx.read(data, { type: 'binary' })
|
||
const wsName = wb.SheetNames[0]
|
||
const ws = wb.Sheets[wsName]
|
||
const json = xlsx.utils.sheet_to_json(ws)
|
||
if (json.length === 0) return alert('Excel无数据')
|
||
|
||
// 识别是哪种模式 (通过列名)
|
||
const first = json[0]
|
||
const isToDecimal = '纬度度' in first
|
||
const isToDms = '纬度(十进制)' in first || '经度(十进制)' in first
|
||
const isToPlane = !isToDecimal && !isToDms && ('经度' in first || '经度(Lon)' in first)
|
||
const isToLngLat = 'X坐标(北移)' in first || 'X坐标' in first
|
||
|
||
const resultData = []
|
||
|
||
for (let i = 0; i < json.length; i++) {
|
||
const row = json[i]
|
||
const name = row['点名'] || `Point${i+1}`
|
||
const zoneType = parseInt(row['分带类型(3/6)'] || projConfig.value.zoneType)
|
||
|
||
if (isToDecimal) {
|
||
const latDeg = parseFloat(row['纬度度']) || 0
|
||
const latMin = parseFloat(row['纬度分']) || 0
|
||
const latSec = parseFloat(row['纬度秒']) || 0
|
||
const latDir = row['纬度方向(N/S)'] || 'N'
|
||
|
||
const lonDeg = parseFloat(row['经度度']) || 0
|
||
const lonMin = parseFloat(row['经度分']) || 0
|
||
const lonSec = parseFloat(row['经度秒']) || 0
|
||
const lonDir = row['经度方向(E/W)'] || 'E'
|
||
|
||
try {
|
||
let latDec = dmsToDecimal(latDeg, latMin, latSec)
|
||
if (latDir.toUpperCase() === 'S') latDec = -latDec
|
||
let lonDec = dmsToDecimal(lonDeg, lonMin, lonSec)
|
||
if (lonDir.toUpperCase() === 'W') lonDec = -lonDec
|
||
|
||
resultData.push({
|
||
...row,
|
||
'点名': name,
|
||
'纬度(十进制)': latDec.toFixed(8),
|
||
'经度(十进制)': lonDec.toFixed(8)
|
||
})
|
||
} catch (err) {
|
||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||
}
|
||
} else if (isToDms) {
|
||
const latDec = parseFloat(row['纬度(十进制)'])
|
||
const lonDec = parseFloat(row['经度(十进制)'])
|
||
|
||
if (!isNaN(latDec) && !isNaN(lonDec)) {
|
||
try {
|
||
resultData.push({
|
||
...row,
|
||
'点名': name,
|
||
'纬度(度分秒)': formatCoordinate(latDec, 'lat'),
|
||
'经度(度分秒)': formatCoordinate(lonDec, 'lon')
|
||
})
|
||
} catch (err) {
|
||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||
}
|
||
}
|
||
} else if (isToPlane) {
|
||
const lng = parseFloat(row['经度']) || parseFloat(row['经度(Lon)'])
|
||
const lat = parseFloat(row['纬度']) || parseFloat(row['纬度(Lat)'])
|
||
const withZone = (row['X包含带号(1/0)'] !== undefined) ? (parseInt(row['X包含带号(1/0)']) === 1) : true
|
||
|
||
if (lng && lat) {
|
||
try {
|
||
const res = projectToPlane(lng, lat, zoneType, withZone)
|
||
resultData.push({
|
||
...row,
|
||
'点名': name,
|
||
'经度': lng,
|
||
'纬度': lat,
|
||
'X坐标(北移/Northing)': res.northing.toFixed(4),
|
||
'Y坐标(东移/Easting)': res.easting.toFixed(4),
|
||
'带号': res.zone,
|
||
'中央子午线': res.meridian
|
||
})
|
||
} catch (err) {
|
||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||
}
|
||
}
|
||
} else if (isToLngLat) {
|
||
const northing = parseFloat(row['X坐标(北移)']) || parseFloat(row['X坐标'])
|
||
const easting = parseFloat(row['Y坐标(东移)']) || parseFloat(row['Y坐标'])
|
||
const manualZone = row['手动带号'] ? parseInt(row['手动带号']) : null
|
||
|
||
const withZone = easting > 1000000
|
||
|
||
if (northing && easting) {
|
||
try {
|
||
const res = unprojectToLngLat(easting, northing, zoneType, withZone, manualZone)
|
||
resultData.push({
|
||
...row,
|
||
'点名': name,
|
||
'X坐标(北)': northing,
|
||
'Y坐标(东)': easting,
|
||
'经度': res.lng.toFixed(8),
|
||
'纬度': res.lat.toFixed(8),
|
||
'带号': res.zone,
|
||
'中央子午线': res.meridian
|
||
})
|
||
} catch (err) {
|
||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const newWs = xlsx.utils.json_to_sheet(resultData)
|
||
const newWb = xlsx.utils.book_new()
|
||
xlsx.utils.book_append_sheet(newWb, newWs, "转换结果")
|
||
xlsx.writeFile(newWb, `坐标转换结果_${Date.now()}.xlsx`)
|
||
|
||
// clear input
|
||
e.target.value = ''
|
||
|
||
} catch (err) {
|
||
alert('文件解析失败: ' + err.message)
|
||
}
|
||
}
|
||
reader.readAsArrayBuffer(file)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.container.pb-2xl {
|
||
padding-bottom: 4rem;
|
||
}
|
||
|
||
.tool-header {
|
||
text-align: center;
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.back-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--text-secondary);
|
||
text-decoration: none;
|
||
margin-bottom: var(--spacing-md);
|
||
transition: color var(--transition-base);
|
||
}
|
||
|
||
.back-link:hover {
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* --- Tabs Styling --- */
|
||
.tabs-wrapper {
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.tabs-header {
|
||
display: flex;
|
||
overflow-x: auto;
|
||
border-bottom: 2px solid var(--border-color);
|
||
margin-bottom: var(--spacing-lg);
|
||
scrollbar-width: none; /* Firefox */
|
||
}
|
||
.tabs-header::-webkit-scrollbar {
|
||
display: none; /* Chrome/Safari */
|
||
}
|
||
|
||
.tab-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
background: transparent;
|
||
border: none;
|
||
border-bottom: 3px solid transparent;
|
||
color: var(--text-secondary);
|
||
font-size: var(--font-size-md);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: all var(--transition-base);
|
||
margin-bottom: -2px;
|
||
}
|
||
|
||
.tab-btn:hover {
|
||
color: var(--primary);
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.tab-btn.active {
|
||
color: var(--primary);
|
||
border-bottom-color: var(--primary);
|
||
}
|
||
|
||
.mr-xs {
|
||
margin-right: 4px;
|
||
}
|
||
.mb-lg {
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
.max-w-3xl {
|
||
max-width: 768px;
|
||
}
|
||
.mx-auto {
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
}
|
||
.text-center {
|
||
text-align: center;
|
||
}
|
||
|
||
.flex-center {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.flex-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.flex-1 {
|
||
flex: 1;
|
||
}
|
||
|
||
.dms-input {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.dms-input .input {
|
||
width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.px-xs {
|
||
padding-left: var(--spacing-xs);
|
||
padding-right: var(--spacing-xs);
|
||
}
|
||
|
||
.icon-sm { width: 16px; height: 16px; }
|
||
.icon-md { width: 20px; height: 20px; }
|
||
.icon-lg { width: 24px; height: 24px; vertical-align: bottom; }
|
||
.icon-xl { width: 48px; height: 48px; }
|
||
|
||
.checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.result-box {
|
||
background: var(--bg-secondary);
|
||
padding: var(--spacing-md);
|
||
border-radius: var(--border-radius-sm);
|
||
border-left: 4px solid var(--primary);
|
||
}
|
||
|
||
.result-box p {
|
||
margin: var(--spacing-xs) 0;
|
||
font-family: monospace;
|
||
font-size: var(--font-size-md);
|
||
}
|
||
|
||
.btn-full { width: 100%; }
|
||
.text-success { color: #10b981; }
|
||
|
||
.upload-area {
|
||
margin-top: var(--spacing-lg);
|
||
}
|
||
|
||
.upload-label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 3rem 1rem;
|
||
background: var(--bg-tertiary);
|
||
border: 2px dashed var(--border-color);
|
||
border-radius: var(--border-radius);
|
||
cursor: pointer;
|
||
transition: all var(--transition-base);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.upload-label:hover {
|
||
border-color: var(--primary);
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
|
||
</style>
|