初次提交

This commit is contained in:
2026-01-28 23:39:11 +08:00
commit 5ee9aef865
27 changed files with 4449 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

23
index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
<title>测绘工具箱 - 专业测绘计算工具集</title>
<meta name="description" content="专为测绘工作者打造的在线工具集,包含坐标转换、距离计算、面积计算、角度转换、方位角计算、高程计算等实用工具。">
<meta name="keywords" content="测绘工具,坐标转换,距离计算,面积计算,方位角,高程计算,测量工具">
<meta name="author" content="测绘工具箱">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1359
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "geo-tools",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

143
src/App.vue Normal file
View File

@@ -0,0 +1,143 @@
<template>
<div id="app">
<nav class="navbar">
<div class="container">
<div class="nav-content">
<router-link to="/" class="logo">
<span class="logo-icon">📐</span>
<span class="logo-text">测绘工具箱</span>
</router-link>
<button @click="toggleTheme" class="theme-toggle" aria-label="切换主题">
{{ isDark ? '🌞' : '🌙' }}
</button>
</div>
</div>
</nav>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2026 测绘工具箱 - 为测绘工作者打造</p>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const isDark = ref(false)
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
isDark.value = savedTheme === 'dark'
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme)
}
})
</script>
<style scoped>
.navbar {
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-md) 0;
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: var(--spacing-sm);
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: var(--font-size-xl);
transition: all var(--transition-base);
}
.logo:hover {
transform: translateY(-2px);
}
.logo-icon {
font-size: var(--font-size-2xl);
}
.logo-text {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.theme-toggle {
background: var(--bg-tertiary);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-xl);
cursor: pointer;
transition: all var(--transition-base);
}
.theme-toggle:hover {
transform: scale(1.1);
border-color: var(--primary);
}
.main-content {
flex: 1;
padding: var(--spacing-2xl) 0;
min-height: calc(100vh - 200px);
}
.footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: var(--spacing-lg) 0;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-base), transform var(--transition-base);
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

431
src/assets/css/index.css Normal file
View File

@@ -0,0 +1,431 @@
:root {
/* Colors - Modern Gradient Palette */
--primary: #6366f1;
--primary-dark: #4f46e5;
--primary-light: #818cf8;
--secondary: #06b6d4;
--accent: #f59e0b;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-success: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-warm: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
/* Background Colors */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-card: #ffffff;
/* Text Colors */
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
/* Border */
--border-color: #e2e8f0;
--border-radius: 12px;
--border-radius-sm: 8px;
--border-radius-lg: 16px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Spacing */
--spacing-xs: 0.3rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--spacing-xl: 1rem;
--spacing-2xl: 2rem;
/* Typography */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
/* Dark Theme */
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-card: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-tertiary: #64748b;
--border-color: #334155;
}
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
transition: background-color var(--transition-base), color var(--transition-base);
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
line-height: 1.2;
margin-bottom: var(--spacing-md);
}
h1 {
font-size: var(--font-size-4xl);
}
h2 {
font-size: var(--font-size-3xl);
}
h3 {
font-size: var(--font-size-2xl);
}
h4 {
font-size: var(--font-size-xl);
}
h5 {
font-size: var(--font-size-lg);
}
h6 {
font-size: var(--font-size-base);
}
p {
margin-bottom: var(--spacing-md);
}
/* Container */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
/* Card */
.card {
background: var(--bg-card);
border-radius: var(--border-radius);
padding: var(--spacing-xl);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
transition: all var(--transition-base);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: 600;
margin-bottom: var(--spacing-md);
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-base);
font-weight: 500;
border-radius: var(--border-radius-sm);
border: none;
cursor: pointer;
transition: all var(--transition-base);
text-decoration: none;
gap: var(--spacing-xs);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--gradient-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
}
.btn-outline {
background: transparent;
border: 2px solid var(--border-color);
color: var(--text-primary);
}
.btn-outline:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
/* Input */
.input-group {
margin-bottom: var(--spacing-lg);
}
.input-label {
display: block;
font-size: var(--font-size-sm);
font-weight: 500;
margin-bottom: var(--spacing-xs);
color: var(--text-secondary);
}
.input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-base);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
transition: all var(--transition-base);
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.input::placeholder {
color: var(--text-tertiary);
}
/* Grid */
.grid {
display: grid;
gap: var(--spacing-lg);
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* Result Display */
.result {
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
padding: var(--spacing-lg);
margin-top: var(--spacing-lg);
}
.result-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.result-value {
font-size: var(--font-size-2xl);
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn var(--transition-slow) ease;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Utility Classes */
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-left {
text-align: left;
}
.mt-0 {
margin-top: 0;
}
.mt-sm {
margin-top: var(--spacing-sm);
}
.mt-md {
margin-top: var(--spacing-md);
}
.mt-lg {
margin-top: var(--spacing-lg);
}
.mt-xl {
margin-top: var(--spacing-xl);
}
.mb-0 {
margin-bottom: 0;
}
.mb-sm {
margin-bottom: var(--spacing-sm);
}
.mb-md {
margin-bottom: var(--spacing-md);
}
.mb-lg {
margin-bottom: var(--spacing-lg);
}
.mb-xl {
margin-bottom: var(--spacing-xl);
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: var(--spacing-sm);
}
.gap-md {
gap: var(--spacing-md);
}
.gap-lg {
gap: var(--spacing-lg);
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0 var(--spacing-md);
}
.card {
padding: var(--spacing-lg);
}
h1 {
font-size: var(--font-size-3xl);
}
h2 {
font-size: var(--font-size-2xl);
}
h3 {
font-size: var(--font-size-xl);
}
}

79
src/assets/css/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,648 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>🗺 高德地名搜索工具</h2>
<p>通过高德API搜索地名获取经纬度坐标支持批量导出</p>
</div>
<div class="card">
<!-- API Key Section -->
<div class="api-section">
<h3 class="card-title">第一步设置高德API密钥</h3>
<div class="api-input-group">
<div class="input-group">
<label class="input-label">高德API密钥</label>
<input type="text" v-model="apiKey" class="input" placeholder="请输入您的高德API密钥">
</div>
<button @click="saveApiKey" class="btn btn-primary">
💾 保存密钥
</button>
</div>
<p class="hint">
密钥将保存在浏览器本地您可以在
<a href="https://console.amap.com/dev/key/app" target="_blank">高德控制台</a>
获取API密钥
</p>
</div>
<!-- Search Section -->
<div class="search-section">
<h3 class="card-title">第二步搜索位置信息</h3>
<form @submit.prevent="performSearch" class="search-form">
<div class="input-group">
<label class="input-label">搜索关键词</label>
<input type="text" v-model="keyword" class="input" placeholder="例如:人民政府、学校、医院等" required>
</div>
<div class="input-group">
<label class="input-label">限定城市可选</label>
<input type="text" v-model="city" class="input" placeholder="例如:楚雄、昆明、北京等">
</div>
<div class="input-group">
<label class="input-label">结果数量</label>
<select v-model="pageSize" class="input">
<option value="20">20条结果</option>
<option value="40">40条结果</option>
<option value="60">60条结果</option>
<option value="100">100条结果</option>
</select>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary" :disabled="loading">
🔍 {{ loading ? '搜索中...' : '开始搜索' }}
</button>
<button type="button" @click="reset" class="btn btn-secondary">
🔄 重置
</button>
</div>
</form>
<div class="note">
<p><strong>💡 搜索提示</strong></p>
<ul>
<li>搜索"人民政府"可获取政府机构位置</li>
<li>搜索"医院"可获取医疗机构位置</li>
<li>限定城市可提高搜索准确性</li>
</ul>
</div>
</div>
<!-- Results Section -->
<div v-if="searchResults.length > 0" class="results-section">
<div class="results-header">
<div class="results-count">
📍 {{ searchResults.length }} 条结果
</div>
<div class="export-buttons">
<button @click="exportToTxt" class="btn btn-export">
📄 导出TXT
</button>
<button @click="exportToExcel" class="btn btn-export">
📊 导出Excel
</button>
</div>
</div>
<div class="results-container">
<table class="results-table">
<thead>
<tr>
<th width="50">序号</th>
<th width="200">名称</th>
<th width="300">地址</th>
<th width="120">WGS-84经度</th>
<th width="120">WGS-84纬度</th>
<th width="80">省份</th>
<th width="80">城市</th>
<th width="80">区域</th>
</tr>
</thead>
<tbody>
<tr v-for="(result, index) in searchResults" :key="result.id">
<td>{{ index + 1 }}</td>
<td class="location-cell">{{ result.name }}</td>
<td class="address-cell">{{ result.address }}</td>
<td class="coordinate-cell">
<div>{{ result.wgsLongitude }}</div>
<div class="gcj-coord">GCJ: {{ result.longitude }}</div>
</td>
<td class="coordinate-cell">
<div>{{ result.wgsLatitude }}</div>
<div class="gcj-coord">GCJ: {{ result.latitude }}</div>
</td>
<td>{{ result.pname }}</td>
<td>{{ result.cityname }}</td>
<td>{{ result.adname }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div v-else-if="!loading" class="empty-state">
<div class="empty-icon">🗺</div>
<h3>暂无搜索结果</h3>
<p>请输入搜索关键词并点击"开始搜索"按钮</p>
</div>
<!-- Loading -->
<div v-if="loading" class="loading">
正在搜索并获取位置数据请稍候...
</div>
<!-- Messages -->
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="success-message">
{{ successMessage }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { gcj02ToWgs84 } from '../utils/amap'
const apiKey = ref('')
const keyword = ref('人民政府')
const city = ref('楚雄')
const pageSize = ref(40)
const searchResults = ref([])
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
// 延迟函数
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
// 显示消息
const showMessage = (message, isError = false) => {
if (isError) {
errorMessage.value = message
setTimeout(() => { errorMessage.value = '' }, 5000)
} else {
successMessage.value = message
setTimeout(() => { successMessage.value = '' }, 5000)
}
}
// 保存API密钥
const saveApiKey = () => {
const trimmedKey = apiKey.value.trim()
if (!trimmedKey) {
showMessage('请输入高德API密钥', true)
return
}
if (trimmedKey.length < 10) {
showMessage('API密钥格式不正确长度应至少为10个字符', true)
return
}
try {
localStorage.setItem('amap_api_key', trimmedKey)
// 验证是否保存成功
const saved = localStorage.getItem('amap_api_key')
if (saved === trimmedKey) {
console.log('API密钥已成功保存到localStorage')
showMessage('API密钥保存成功')
} else {
throw new Error('保存验证失败')
}
} catch (error) {
console.error('保存API密钥失败:', error)
showMessage('保存失败请检查浏览器是否允许使用localStorage', true)
}
}
// 加载API密钥
const loadApiKey = () => {
try {
const savedKey = localStorage.getItem('amap_api_key')
if (savedKey) {
apiKey.value = savedKey
console.log('已从localStorage加载API密钥长度:', savedKey.length)
} else {
console.log('localStorage中没有保存的API密钥')
}
} catch (error) {
console.error('加载API密钥失败:', error)
showMessage('加载密钥失败,请检查浏览器设置', true)
}
}
// 搜索地点
const searchPlace = async (kw, ct, page) => {
let url = `https://restapi.amap.com/v3/place/text?key=${apiKey.value}&keywords=${encodeURIComponent(kw)}&page=${page}&offset=25&extensions=base`
if (ct) {
url += `&city=${encodeURIComponent(ct)}`
}
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`)
}
const data = await response.json()
if (data.status === '1' && data.pois) {
return data.pois.map(poi => {
// 解析原始坐标
let originalLng = 0
let originalLat = 0
if (poi.location) {
const coords = poi.location.split(',')
originalLng = parseFloat(coords[0])
originalLat = parseFloat(coords[1])
}
// 转换为WGS-84坐标
let wgsLng = originalLng
let wgsLat = originalLat
if (originalLng !== 0 && originalLat !== 0) {
const wgsCoords = gcj02ToWgs84(originalLng, originalLat)
wgsLng = wgsCoords[0]
wgsLat = wgsCoords[1]
}
return {
id: poi.id,
name: poi.name,
address: poi.address || '地址未提供',
location: poi.location,
longitude: originalLng.toFixed(6),
latitude: originalLat.toFixed(6),
wgsLongitude: wgsLng,
wgsLatitude: wgsLat,
pname: poi.pname || '',
cityname: poi.cityname || '',
adname: poi.adname || '',
type: poi.type || ''
}
})
} else {
throw new Error(data.info || '搜索失败')
}
}
// 执行搜索
const performSearch = async () => {
if (!apiKey.value) {
showMessage('请先输入并保存高德API密钥', true)
return
}
if (!keyword.value.trim()) {
showMessage('请输入搜索关键词', true)
return
}
loading.value = true
searchResults.value = []
errorMessage.value = ''
successMessage.value = ''
try {
const results = []
const pages = Math.ceil(pageSize.value / 25)
for (let page = 1; page <= pages; page++) {
const pageResults = await searchPlace(keyword.value, city.value, page)
results.push(...pageResults)
if (results.length >= pageSize.value || pageResults.length < 25) {
break
}
if (page < pages) {
await delay(300) // 避免API频率限制
}
}
searchResults.value = results.slice(0, pageSize.value)
showMessage(`成功获取 ${searchResults.value.length} 条位置信息`)
} catch (error) {
console.error('搜索出错:', error)
showMessage('搜索失败: ' + error.message, true)
} finally {
loading.value = false
}
}
// 重置
const reset = () => {
keyword.value = ''
city.value = ''
pageSize.value = 40
searchResults.value = []
showMessage('搜索条件已重置')
}
// 导出为TXT
const exportToTxt = () => {
if (searchResults.value.length === 0) {
showMessage('没有可导出的数据', true)
return
}
let txtContent = '高德API地名搜索结果WGS-84坐标\n'
txtContent += '='.repeat(80) + '\n'
txtContent += `搜索关键词: ${keyword.value}\n`
txtContent += `限定城市: ${city.value || '无'}\n`
txtContent += `搜索时间: ${new Date().toLocaleString()}\n`
txtContent += `结果数量: ${searchResults.value.length}\n`
txtContent += `坐标系统: WGS-84 (由高德GCJ-02转换)\n`
txtContent += '='.repeat(80) + '\n\n'
txtContent += '序号\t名称\t地址\tWGS-84经度\tWGS-84纬度\tGCJ-02经度\tGCJ-02纬度\t省份\t城市\t区域\n'
txtContent += '-'.repeat(120) + '\n'
searchResults.value.forEach((result, index) => {
txtContent += `${index + 1}\t${result.name}\t${result.address}\t${result.wgsLongitude}\t${result.wgsLatitude}\t${result.longitude}\t${result.latitude}\t${result.pname}\t${result.cityname}\t${result.adname}\n`
})
// 创建安全的文件名
const safeKeyword = keyword.value.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
const filename = `高德搜索_${safeKeyword}_${timestamp}.txt`
const blob = new Blob(['\uFEFF' + txtContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = filename
a.setAttribute('download', filename) // 明确设置download属性
document.body.appendChild(a)
a.click()
// 延迟清理
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 100)
showMessage(`已导出 ${searchResults.value.length} 条数据到TXT文件`)
}
// 导出为Excel (简化版 - 使用CSV格式)
const exportToExcel = () => {
if (searchResults.value.length === 0) {
showMessage('没有可导出的数据', true)
return
}
let csvContent = '\uFEFF' // BOM for UTF-8
csvContent += '序号,名称,地址,WGS-84经度,WGS-84纬度,GCJ-02经度,GCJ-02纬度,省份,城市,区域\n'
searchResults.value.forEach((result, index) => {
csvContent += `${index + 1},"${result.name}","${result.address}",${result.wgsLongitude},${result.wgsLatitude},${result.longitude},${result.latitude},"${result.pname}","${result.cityname}","${result.adname}"\n`
})
// 创建安全的文件名
const safeKeyword = keyword.value.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
const filename = `高德搜索_${safeKeyword}_${timestamp}.csv`
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = filename
a.setAttribute('download', filename) // 明确设置download属性
document.body.appendChild(a)
a.click()
// 延迟清理
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 100)
showMessage(`已导出 ${searchResults.value.length} 条数据到CSV文件`)
}
onMounted(() => {
loadApiKey()
})
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.api-section {
background: var(--bg-secondary);
padding: var(--spacing-lg);
border-radius: var(--border-radius-sm);
margin-bottom: var(--spacing-xl);
}
.api-input-group {
display: flex;
gap: var(--spacing-md);
align-items: flex-end;
margin-bottom: var(--spacing-sm);
}
.api-input-group .input-group {
flex: 1;
}
.hint {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-top: var(--spacing-sm);
}
.hint a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.hint a:hover {
text-decoration: underline;
}
.search-section {
margin-bottom: var(--spacing-xl);
}
.search-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.button-group {
grid-column: 1 / -1;
display: flex;
gap: var(--spacing-md);
}
.note {
background: #fff8e1;
padding: var(--spacing-md);
border-radius: var(--border-radius-sm);
border-left: 4px solid #ffc107;
font-size: var(--font-size-sm);
}
.note ul {
margin-left: var(--spacing-lg);
margin-top: var(--spacing-xs);
}
.note li {
margin-bottom: var(--spacing-xs);
}
.results-section {
margin-top: var(--spacing-xl);
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.results-count {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--primary);
}
.export-buttons {
display: flex;
gap: var(--spacing-sm);
}
.btn-export {
background: var(--success);
}
.btn-export:hover {
background: #0d9b5f;
}
.results-container {
max-height: 500px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
}
.results-table {
width: 100%;
border-collapse: collapse;
}
.results-table th {
background: var(--bg-tertiary);
padding: var(--spacing-sm);
text-align: left;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 2px solid var(--border-color);
font-size: var(--font-size-sm);
}
.results-table tr:nth-child(even) {
background: var(--bg-secondary);
}
.results-table tr:hover {
background: #e8f4ff;
}
.results-table td {
padding: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
font-size: var(--font-size-sm);
}
.coordinate-cell {
font-family: monospace;
font-weight: 600;
color: var(--primary);
}
.gcj-coord {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
font-weight: 400;
}
.location-cell {
font-weight: 600;
color: var(--text-primary);
}
.address-cell {
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-tertiary);
}
.empty-icon {
font-size: 4rem;
margin-bottom: var(--spacing-md);
}
.loading {
text-align: center;
padding: var(--spacing-xl);
color: var(--primary);
font-weight: 600;
}
.error-message,
.success-message {
padding: var(--spacing-md);
border-radius: var(--border-radius-sm);
margin-top: var(--spacing-md);
font-size: var(--font-size-sm);
}
.error-message {
background: #fee;
color: #c33;
border-left: 4px solid #c33;
}
.success-message {
background: #d4edda;
color: #155724;
border-left: 4px solid #28a745;
}
@media (max-width: 768px) {
.api-input-group {
flex-direction: column;
align-items: stretch;
}
.results-container {
max-height: 400px;
}
.results-table {
font-size: var(--font-size-xs);
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>📊 角度转换工具</h2>
<p>度分秒十进制度数弧度之间的转换</p>
</div>
<div class="card">
<div class="grid grid-3">
<!-- DMS Input -->
<div>
<h3 class="card-title">度分秒</h3>
<div class="input-group">
<label class="input-label"></label>
<input type="number" v-model.number="dms.degrees" class="input" @input="fromDms">
</div>
<div class="input-group">
<label class="input-label"></label>
<input type="number" v-model.number="dms.minutes" class="input" @input="fromDms" min="0"
max="59">
</div>
<div class="input-group">
<label class="input-label"></label>
<input type="number" v-model.number="dms.seconds" class="input" @input="fromDms" min="0"
max="59" step="0.01">
</div>
</div>
<!-- Decimal Input -->
<div>
<h3 class="card-title">十进制度数</h3>
<div class="input-group">
<label class="input-label">度数</label>
<input type="number" v-model.number="decimal" class="input" @input="fromDecimal" step="0.000001"
placeholder="例45.5">
</div>
<div class="conversion-info">
<p class="hint">当前角度的其他表示形式会自动同步</p>
</div>
</div>
<!-- Radian Input -->
<div>
<h3 class="card-title">弧度</h3>
<div class="input-group">
<label class="input-label">弧度值</label>
<input type="number" v-model.number="radian" class="input" @input="fromRadian" step="0.000001"
placeholder="例0.7854">
</div>
<div class="conversion-info">
<p class="hint">π 3.14159</p>
<p class="hint">180° = π 弧度</p>
</div>
</div>
</div>
<div class="result mt-xl">
<div class="result-label">完整转换结果</div>
<div class="conversion-results">
<div class="conversion-item">
<strong>度分秒</strong>
<span>{{ dms.degrees }}° {{ dms.minutes }}' {{ dms.seconds.toFixed(2) }}"</span>
</div>
<div class="conversion-item">
<strong>十进制:</strong>
<span>{{ decimal.toFixed(6) }}°</span>
</div>
<div class="conversion-item">
<strong>弧度:</strong>
<span>{{ radian.toFixed(6) }} rad</span>
</div>
<div class="conversion-item">
<strong>π表示:</strong>
<span>{{ (radian / Math.PI).toFixed(4) }}π</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { dmsAngleToDecimal, decimalAngleToDms } from '../utils/geometry'
const dms = ref({ degrees: 45, minutes: 30, seconds: 0 })
const decimal = ref(45.5)
const radian = ref(0.7941)
const fromDms = () => {
decimal.value = dmsAngleToDecimal(dms.value.degrees, dms.value.minutes, dms.value.seconds)
radian.value = decimal.value * Math.PI / 180
}
const fromDecimal = () => {
const converted = decimalAngleToDms(decimal.value)
dms.value = converted
radian.value = decimal.value * Math.PI / 180
}
const fromRadian = () => {
decimal.value = radian.value * 180 / Math.PI
const converted = decimalAngleToDms(decimal.value)
dms.value = converted
}
// Initialize
fromDms()
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.conversion-info {
margin-top: var(--spacing-lg);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
}
.hint {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-bottom: var(--spacing-xs);
}
.conversion-results {
display: grid;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.conversion-item {
display: flex;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-base);
}
.conversion-item span {
color: var(--primary);
font-weight: 600;
}
@media (max-width: 768px) {
.grid-3 {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>📐 面积计算工具</h2>
<p>多边形面积计算基于平面坐标</p>
</div>
<div class="card">
<h3 class="card-title">输入顶点坐标</h3>
<div v-for="(point, index) in points" :key="index" class="point-input">
<span class="point-label"> {{ index + 1 }}</span>
<input type="number" v-model.number="point.x" class="input" placeholder="X坐标" step="0.0001">
<input type="number" v-model.number="point.y" class="input" placeholder="Y坐标" step="0.0001">
<button v-if="points.length > 3" @click="removePoint(index)" class="btn-remove" title="删除此点">
</button>
</div>
<div class="actions">
<button @click="addPoint" class="btn btn-secondary"> 添加顶点</button>
<button @click="calculate" class="btn btn-primary">计算面积</button>
</div>
<div v-if="area !== null" class="result mt-xl">
<div class="result-label">多边形面积</div>
<div class="result-value">
{{ area.toFixed(4) }} 平方单位
</div>
<div class="result-details mt-md">
<p><strong>顶点数量</strong>{{ points.length }}</p>
<p v-if="area > 10000"><strong>换算</strong>{{ (area / 10000).toFixed(4) }} 公顷</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { calculatePolygonArea } from '../utils/geometry'
const points = ref([
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 }
])
const area = ref(null)
const addPoint = () => {
points.value.push({ x: 0, y: 0 })
}
const removePoint = (index) => {
points.value.splice(index, 1)
}
const calculate = () => {
area.value = calculatePolygonArea(points.value)
}
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.point-input {
display: grid;
grid-template-columns: 60px 1fr 1fr auto;
gap: var(--spacing-sm);
align-items: center;
margin-bottom: var(--spacing-md);
}
.point-label {
font-weight: 500;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.btn-remove {
background: var(--danger);
color: white;
border: none;
border-radius: var(--border-radius-sm);
width: 36px;
height: 36px;
cursor: pointer;
transition: all var(--transition-base);
font-size: var(--font-size-lg);
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove:hover {
transform: scale(1.1);
box-shadow: var(--shadow-md);
}
.actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.result-details {
font-size: var(--font-size-base);
color: var(--text-secondary);
line-height: 1.8;
}
.result-details p {
margin-bottom: var(--spacing-xs);
}
@media (max-width: 768px) {
.point-input {
grid-template-columns: 50px 1fr 1fr 36px;
}
.actions {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>🧭 方位角计算工具</h2>
<p>根据两点坐标计算方位角和象限角</p>
</div>
<div class="card">
<div class="grid grid-2">
<div>
<h3 class="card-title">起点坐标</h3>
<div class="input-group">
<label class="input-label">X坐标或经度</label>
<input type="number" v-model.number="point1.x" class="input" step="0.0001" placeholder="例0">
</div>
<div class="input-group">
<label class="input-label">Y坐标或纬度</label>
<input type="number" v-model.number="point1.y" class="input" step="0.0001" placeholder="例0">
</div>
</div>
<div>
<h3 class="card-title">终点坐标</h3>
<div class="input-group">
<label class="input-label">X坐标或经度</label>
<input type="number" v-model.number="point2.x" class="input" step="0.0001" placeholder="例100">
</div>
<div class="input-group">
<label class="input-label">Y坐标或纬度</label>
<input type="number" v-model.number="point2.y" class="input" step="0.0001" placeholder="例100">
</div>
</div>
</div>
<button @click="calculate" class="btn btn-primary mt-lg">计算方位角</button>
<div v-if="result" class="result mt-xl">
<div class="result-label">计算结果</div>
<div class="bearing-display">
<div class="bearing-circle">
<div class="compass-marker north">N</div>
<div class="compass-marker east">E</div>
<div class="compass-marker south">S</div>
<div class="compass-marker west">W</div>
<div class="bearing-arrow" :style="{ transform: `rotate(${result.azimuth}deg)` }"></div>
</div>
<div class="bearing-values">
<div class="value-item">
<span class="value-label">真方位角</span>
<span class="value-number">{{ result.azimuth.toFixed(4) }}°</span>
</div>
<div class="value-item">
<span class="value-label">象限角</span>
<span class="value-number">{{ result.quadrant }}</span>
</div>
</div>
</div>
<div class="info-box mt-lg">
<p><strong>说明</strong></p>
<p>真方位角从正北方向顺时针旋转到目标方向的角度0°-360°</p>
<p>象限角从南北方向量起向东或向西的角度</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { calculateBearing } from '../utils/geometry'
const point1 = ref({ x: 0, y: 0 })
const point2 = ref({ x: 100, y: 100 })
const result = ref(null)
const calculate = () => {
result.value = calculateBearing(
point1.value.x,
point1.value.y,
point2.value.x,
point2.value.y
)
}
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.bearing-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
align-items: center;
margin-top: var(--spacing-lg);
}
.bearing-circle {
position: relative;
width: 200px;
height: 200px;
margin: 0 auto;
border: 3px solid var(--border-color);
border-radius: 50%;
background: var(--bg-secondary);
}
.compass-marker {
position: absolute;
font-weight: 700;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.compass-marker.north {
top: 10px;
left: 50%;
transform: translateX(-50%);
color: var(--danger);
}
.compass-marker.east {
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.compass-marker.south {
bottom: 10px;
left: 50%;
transform: translateX(-50%);
}
.compass-marker.west {
left: 10px;
top: 50%;
transform: translateY(-50%);
}
.bearing-arrow {
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 80px;
background: var(--gradient-primary);
transform-origin: bottom center;
margin-left: -2px;
margin-top: -80px;
border-radius: 2px;
transition: transform 0.5s ease;
}
.bearing-arrow::before {
content: '';
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 12px solid var(--primary);
}
.bearing-values {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.value-item {
display: flex;
flex-direction: column;
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
}
.value-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.value-number {
font-size: var(--font-size-2xl);
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.info-box {
padding: var(--spacing-lg);
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
border-left: 4px solid var(--primary);
}
.info-box p {
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.info-box p:first-child {
font-weight: 600;
color: var(--text-primary);
}
@media (max-width: 768px) {
.bearing-display {
grid-template-columns: 1fr;
}
.bearing-circle {
width: 180px;
height: 180px;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>🌍 坐标转换工具</h2>
<p>经纬度格式转换度分秒 十进制度数</p>
</div>
<div class="card">
<div class="grid grid-2">
<!-- DMS to Decimal -->
<div>
<h3 class="card-title">度分秒 十进制</h3>
<div class="input-group">
<label class="input-label">纬度度分秒</label>
<div class="dms-input">
<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">
<option value="N">N</option>
<option value="S">S</option>
</select>
</div>
</div>
<div class="input-group">
<label class="input-label">经度度分秒</label>
<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">
<option value="E">E</option>
<option value="W">W</option>
</select>
</div>
</div>
<button @click="convertToDecimal" class="btn btn-primary">转换为十进制</button>
</div>
<!-- Decimal to DMS -->
<div>
<h3 class="card-title">十进制 度分秒</h3>
<div class="input-group">
<label class="input-label">纬度十进制</label>
<input type="number" v-model.number="decimal.lat" class="input" placeholder="例39.9075"
step="0.000001">
<small class="hint">正数为北纬负数为南纬</small>
</div>
<div class="input-group">
<label class="input-label">经度十进制</label>
<input type="number" v-model.number="decimal.lon" class="input" placeholder="例116.3972"
step="0.000001">
<small class="hint">正数为东经负数为西经</small>
</div>
<button @click="convertToDms" class="btn btn-primary">转换为度分秒</button>
</div>
</div>
<!-- Results -->
<div v-if="result" class="result mt-xl">
<div class="result-label">转换结果</div>
<div class="result-content">
<div v-if="result.type === 'decimal'">
<p><strong>纬度</strong>{{ result.lat }}°</p>
<p><strong>经度</strong>{{ result.lon }}°</p>
</div>
<div v-else>
<p><strong>纬度</strong>{{ result.lat }}</p>
<p><strong>经度</strong>{{ result.lon }}</p>
</div>
</div>
<button @click="copyResult" class="btn btn-secondary mt-md">📋 复制结果</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { dmsToDecimal, decimalToDms, formatCoordinate } from '../utils/coordinate'
const dms = ref({
lat: { degrees: 39, minutes: 54, seconds: 27, direction: 'N' },
lon: { degrees: 116, minutes: 23, seconds: 50, direction: 'E' }
})
const decimal = ref({
lat: 39.9075,
lon: 116.3972
})
const result = ref(null)
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(6),
lon: (dms.value.lon.direction === 'W' ? -lonDecimal : lonDecimal).toFixed(6)
}
}
const convertToDms = () => {
result.value = {
type: 'dms',
lat: formatCoordinate(decimal.value.lat, 'lat'),
lon: formatCoordinate(decimal.value.lon, 'lon')
}
}
const copyResult = () => {
const text = result.value.type === 'decimal'
? `纬度: ${result.value.lat}°, 经度: ${result.value.lon}°`
: `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
navigator.clipboard.writeText(text).then(() => {
alert('已复制到剪贴板!')
})
}
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.dms-input {
display: grid;
grid-template-columns: 1fr 1fr 1fr 0.6fr;
gap: var(--spacing-sm);
}
.hint {
display: block;
margin-top: var(--spacing-xs);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.result-content {
font-size: var(--font-size-lg);
line-height: 1.8;
}
.result-content p {
margin-bottom: var(--spacing-sm);
}
@media (max-width: 768px) {
.dms-input {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>📏 距离计算工具</h2>
<p>计算两点间的距离</p>
</div>
<div class="card">
<div class="input-group">
<label class="input-label">坐标类型</label>
<div class="radio-group">
<label>
<input type="radio" v-model="coordType" value="geographic" name="coordType">
经纬度坐标
</label>
<label>
<input type="radio" v-model="coordType" value="planar" name="coordType">
平面坐标
</label>
</div>
</div>
<div class="grid grid-2">
<div>
<h3 class="card-title">起点坐标</h3>
<div class="input-group">
<label class="input-label">{{ coordType === 'geographic' ? '纬度' : 'Y坐标' }}</label>
<input type="number" v-model.number="point1.lat" class="input" step="0.000001"
placeholder="例39.9075">
</div>
<div class="input-group">
<label class="input-label">{{ coordType === 'geographic' ? '经度' : 'X坐标' }}</label>
<input type="number" v-model.number="point1.lon" class="input" step="0.000001"
placeholder="例116.3972">
</div>
</div>
<div>
<h3 class="card-title">终点坐标</h3>
<div class="input-group">
<label class="input-label">{{ coordType === 'geographic' ? '纬度' : 'Y坐标' }}</label>
<input type="number" v-model.number="point2.lat" class="input" step="0.000001"
placeholder="例31.2304">
</div>
<div class="input-group">
<label class="input-label">{{ coordType === 'geographic' ? '经度' : 'X坐标' }}</label>
<input type="number" v-model.number="point2.lon" class="input" step="0.000001"
placeholder="例121.4737">
</div>
</div>
</div>
<button @click="calculate" class="btn btn-primary mt-lg">计算距离</button>
<div v-if="distance !== null" class="result mt-xl">
<div class="result-label">计算结果</div>
<div class="result-value">
{{ formatDistance(distance) }}
</div>
<div class="result-details mt-md">
<p v-if="coordType === 'geographic'">
<strong></strong>{{ distance.toFixed(2) }} m<br>
<strong>千米</strong>{{ (distance / 1000).toFixed(3) }} km
</p>
<p v-else>
<strong>距离</strong>{{ distance.toFixed(4) }} 单位
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { haversineDistance, planarDistance } from '../utils/coordinate'
const coordType = ref('geographic')
const point1 = ref({ lat: 39.9075, lon: 116.3972 })
const point2 = ref({ lat: 31.2304, lon: 121.4737 })
const distance = ref(null)
const calculate = () => {
if (coordType.value === 'geographic') {
distance.value = haversineDistance(
point1.value.lat,
point1.value.lon,
point2.value.lat,
point2.value.lon
)
} else {
distance.value = planarDistance(
point1.value.lon,
point1.value.lat,
point2.value.lon,
point2.value.lat
)
}
}
const formatDistance = (dist) => {
if (coordType.value === 'geographic') {
if (dist < 1000) {
return `${dist.toFixed(2)}`
} else {
return `${(dist / 1000).toFixed(3)} 千米`
}
} else {
return `${dist.toFixed(4)}`
}
}
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.radio-group {
display: flex;
gap: var(--spacing-lg);
}
.radio-group label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
cursor: pointer;
font-size: var(--font-size-base);
color: var(--text-primary);
}
.radio-group input[type="radio"] {
cursor: pointer;
}
.result-details {
font-size: var(--font-size-base);
color: var(--text-secondary);
line-height: 1.8;
}
.result-details p {
margin: 0;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2> 高程计算工具</h2>
<p>高差坡度坡度角计算</p>
</div>
<div class="card">
<div class="grid grid-2">
<div>
<h3 class="card-title">起点信息</h3>
<div class="input-group">
<label class="input-label">高程</label>
<input type="number" v-model.number="elevation1" class="input" step="0.01"
placeholder="例100.00">
</div>
</div>
<div>
<h3 class="card-title">终点信息</h3>
<div class="input-group">
<label class="input-label">高程</label>
<input type="number" v-model.number="elevation2" class="input" step="0.01"
placeholder="例150.00">
</div>
</div>
</div>
<div class="input-group">
<label class="input-label">水平距离</label>
<input type="number" v-model.number="horizontalDistance" class="input" step="0.01"
placeholder="例1000.00">
</div>
<button @click="calculate" class="btn btn-primary mt-lg">计算</button>
<div v-if="results" class="results-grid mt-xl">
<div class="result-card">
<div class="result-icon"></div>
<div class="result-label">高差</div>
<div class="result-value">{{ results.elevationDiff.toFixed(3) }} m</div>
</div>
<div class="result-card">
<div class="result-icon">📈</div>
<div class="result-label">坡度</div>
<div class="result-value">{{ results.slope.toFixed(4) }}%</div>
</div>
<div class="result-card">
<div class="result-icon">📐</div>
<div class="result-label">坡度角</div>
<div class="result-value">{{ results.slopeAngle.toFixed(4) }}°</div>
</div>
<div class="result-card">
<div class="result-icon">📏</div>
<div class="result-label">斜距</div>
<div class="result-value">{{ results.slopeDistance.toFixed(3) }} m</div>
</div>
</div>
<div v-if="results" class="visualization mt-xl">
<div class="slope-diagram">
<svg viewBox="0 0 400 250" class="slope-svg">
<!-- Ground line -->
<line x1="50" y1="200" x2="350" y2="200" stroke="var(--border-color)" stroke-width="2" />
<!-- Horizontal distance -->
<line x1="50" y1="200" :x2="50 + horizontalScale" y2="200" stroke="var(--primary)"
stroke-width="3" />
<text x="200" y="220" class="svg-label">水平距离: {{ horizontalDistance }}m</text>
<!-- Vertical line (elevation diff) -->
<line :x1="50 + horizontalScale" y1="200" :x2="50 + horizontalScale" :y2="200 - elevationScale"
stroke="var(--danger)" stroke-width="3" stroke-dasharray="5,5" />
<text :x="60 + horizontalScale" :y="200 - elevationScale / 2" class="svg-label">
高差: {{ results.elevationDiff.toFixed(2) }}m
</text>
<!-- Slope line -->
<line x1="50" y1="200" :x2="50 + horizontalScale" :y2="200 - elevationScale"
stroke="var(--success)" stroke-width="3" />
<!-- Points -->
<circle cx="50" cy="200" r="5" fill="var(--primary)" />
<circle :cx="50 + horizontalScale" :cy="200 - elevationScale" r="5" fill="var(--primary)" />
<!-- Labels -->
<text x="30" y="215" class="svg-label">起点</text>
<text :x="40 + horizontalScale" :y="190 - elevationScale" class="svg-label">终点</text>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import {
calculateElevationDifference,
calculateSlope,
calculateSlopeAngle,
calculateSlopeDistance
} from '../utils/elevation'
const elevation1 = ref(100)
const elevation2 = ref(150)
const horizontalDistance = ref(1000)
const results = ref(null)
const horizontalScale = computed(() => {
return Math.min(300, horizontalDistance.value / 5)
})
const elevationScale = computed(() => {
if (!results.value) return 0
return Math.min(150, Math.abs(results.value.elevationDiff) * 2)
})
const calculate = () => {
const elevationDiff = calculateElevationDifference(elevation1.value, elevation2.value)
const slope = calculateSlope(elevationDiff, horizontalDistance.value)
const slopeAngle = calculateSlopeAngle(elevationDiff, horizontalDistance.value)
const slopeDistance = calculateSlopeDistance(horizontalDistance.value, slopeAngle)
results.value = {
elevationDiff,
slope,
slopeAngle,
slopeDistance
}
}
</script>
<style scoped>
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-block;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.result-card {
text-align: center;
padding: var(--spacing-lg);
background: var(--bg-secondary);
border-radius: var(--border-radius);
border: 2px solid var(--border-color);
transition: all var(--transition-base);
}
.result-card:hover {
transform: translateY(-4px);
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.result-icon {
font-size: 2rem;
margin-bottom: var(--spacing-sm);
}
.result-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.result-value {
font-size: var(--font-size-lg);
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.visualization {
background: var(--bg-secondary);
border-radius: var(--border-radius);
padding: var(--spacing-xl);
}
.slope-diagram {
max-width: 600px;
margin: 0 auto;
}
.slope-svg {
width: 100%;
height: auto;
}
.svg-label {
fill: var(--text-secondary);
font-size: 12px;
font-family: var(--font-family);
}
@media (max-width: 768px) {
.results-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

8
src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import './assets/css/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

59
src/router.js Normal file
View File

@@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
import CoordinateConverter from './components/CoordinateConverter.vue'
import DistanceCalculator from './components/DistanceCalculator.vue'
import AreaCalculator from './components/AreaCalculator.vue'
import AngleConverter from './components/AngleConverter.vue'
import BearingCalculator from './components/BearingCalculator.vue'
import ElevationCalculator from './components/ElevationCalculator.vue'
import AmapSearchTool from './components/AmapSearchTool.vue'
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
},
{
path: '/coordinate-converter',
name: 'CoordinateConverter',
component: CoordinateConverter
},
{
path: '/distance-calculator',
name: 'DistanceCalculator',
component: DistanceCalculator
},
{
path: '/area-calculator',
name: 'AreaCalculator',
component: AreaCalculator
},
{
path: '/angle-converter',
name: 'AngleConverter',
component: AngleConverter
},
{
path: '/bearing-calculator',
name: 'BearingCalculator',
component: BearingCalculator
},
{
path: '/elevation-calculator',
name: 'ElevationCalculator',
component: ElevationCalculator
},
{
path: '/amap-search',
name: 'AmapSearch',
component: AmapSearchTool
}
]
const router = createRouter({
history: createWebHistory('/geo-tools/'),
routes
})
export default router

72
src/utils/amap.js Normal file
View File

@@ -0,0 +1,72 @@
/**
* 高德地图坐标转换工具函数
* GCJ-02 (火星坐标系) 转 WGS-84 (GPS坐标系)
*/
const PI = 3.14159265358979324;
const a = 6378245.0; // 地球长半轴
const ee = 0.00669342162296594323; // 扁率
/**
* 检查坐标是否在国外
*/
function outOfChina(lng, lat) {
if (lng < 72.004 || lng > 137.8347) {
return true;
}
if (lat < 0.8293 || lat > 55.8271) {
return true;
}
return false;
}
/**
* 纬度转换
*/
function transformLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
/**
* 经度转换
*/
function transformLng(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
/**
* GCJ-02 转 WGS-84
* @param {number} lng - GCJ-02经度
* @param {number} lat - GCJ-02纬度
* @returns {Array} [WGS-84经度, WGS-84纬度]
*/
export function gcj02ToWgs84(lng, lat) {
// 检查是否在国内
if (outOfChina(lng, lat)) {
return [lng, lat];
}
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLng = transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI);
dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * PI);
const wgsLat = lat - dLat;
const wgsLng = lng - dLng;
return [parseFloat(wgsLng.toFixed(6)), parseFloat(wgsLat.toFixed(6))];
}

83
src/utils/coordinate.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* 坐标相关计算工具函数
*/
/**
* 将度分秒转换为十进制度数
* @param {number} degrees - 度
* @param {number} minutes - 分
* @param {number} seconds - 秒
* @returns {number} 十进制度数
*/
export function dmsToDecimal(degrees, minutes, seconds) {
return degrees + minutes / 60 + seconds / 3600;
}
/**
* 将十进制度数转换为度分秒
* @param {number} decimal - 十进制度数
* @returns {{degrees: number, minutes: number, seconds: number}}
*/
export function decimalToDms(decimal) {
const absDecimal = Math.abs(decimal);
const degrees = Math.floor(absDecimal);
const minutesDecimal = (absDecimal - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = (minutesDecimal - minutes) * 60;
return {
degrees: decimal < 0 ? -degrees : degrees,
minutes,
seconds: parseFloat(seconds.toFixed(6))
};
}
/**
* 计算两点间的距离Haversine公式
* @param {number} lat1 - 点1纬度十进制度数
* @param {number} lon1 - 点1经度十进制度数
* @param {number} lat2 - 点2纬度十进制度数
* @param {number} lon2 - 点2经度十进制度数
* @returns {number} 距离(米)
*/
export function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // 地球半径(米)
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* 计算平面坐标两点间的距离(勾股定理)
* @param {number} x1 - 点1 X坐标
* @param {number} y1 - 点1 Y坐标
* @param {number} x2 - 点2 X坐标
* @param {number} y2 - 点2 Y坐标
* @returns {number} 距离
*/
export function planarDistance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
/**
* 格式化坐标为字符串
* @param {number} decimal - 十进制度数
* @param {string} type - 类型 'lat' 或 'lon'
* @returns {string} 格式化的坐标字符串
*/
export function formatCoordinate(decimal, type) {
const dms = decimalToDms(decimal);
const direction = type === 'lat'
? (decimal >= 0 ? 'N' : 'S')
: (decimal >= 0 ? 'E' : 'W');
return `${Math.abs(dms.degrees)}° ${dms.minutes}' ${dms.seconds.toFixed(2)}" ${direction}`;
}

55
src/utils/elevation.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* 高程计算工具函数
*/
/**
* 计算高差
* @param {number} elevation1 - 起点高程
* @param {number} elevation2 - 终点高程
* @returns {number} 高差
*/
export function calculateElevationDifference(elevation1, elevation2) {
return elevation2 - elevation1;
}
/**
* 计算坡度(百分比)
* @param {number} elevationDiff - 高差
* @param {number} horizontalDistance - 水平距离
* @returns {number} 坡度(百分比)
*/
export function calculateSlope(elevationDiff, horizontalDistance) {
if (horizontalDistance === 0) return 0;
return (elevationDiff / horizontalDistance) * 100;
}
/**
* 计算坡度角(度)
* @param {number} elevationDiff - 高差
* @param {number} horizontalDistance - 水平距离
* @returns {number} 坡度角(度)
*/
export function calculateSlopeAngle(elevationDiff, horizontalDistance) {
if (horizontalDistance === 0) return 0;
return Math.atan(elevationDiff / horizontalDistance) * 180 / Math.PI;
}
/**
* 根据斜距和坡度角计算水平距离
* @param {number} slopeDistance - 斜距
* @param {number} slopeAngle - 坡度角(度)
* @returns {number} 水平距离
*/
export function calculateHorizontalDistance(slopeDistance, slopeAngle) {
return slopeDistance * Math.cos(slopeAngle * Math.PI / 180);
}
/**
* 根据水平距离和坡度角计算斜距
* @param {number} horizontalDistance - 水平距离
* @param {number} slopeAngle - 坡度角(度)
* @returns {number} 斜距
*/
export function calculateSlopeDistance(horizontalDistance, slopeAngle) {
return horizontalDistance / Math.cos(slopeAngle * Math.PI / 180);
}

94
src/utils/geometry.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* 几何计算工具函数
*/
/**
* 度转弧度
*/
export function degToRad(degrees) {
return degrees * Math.PI / 180;
}
/**
* 弧度转度
*/
export function radToDeg(radians) {
return radians * 180 / Math.PI;
}
/**
* 计算方位角从点1到点2
* @param {number} x1 - 点1 X坐标或经度
* @param {number} y1 - 点1 Y坐标或纬度
* @param {number} x2 - 点2 X坐标或经度
* @param {number} y2 - 点2 Y坐标或纬度
* @returns {{azimuth: number, quadrant: string}} 方位角(度)和象限角
*/
export function calculateBearing(x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
// 计算方位角(从北向顺时针)
let azimuth = Math.atan2(dx, dy) * 180 / Math.PI;
if (azimuth < 0) azimuth += 360;
// 计算象限角
let quadrant = '';
const angle = Math.abs(Math.atan2(dx, dy) * 180 / Math.PI);
if (dx >= 0 && dy >= 0) {
quadrant = `N ${angle.toFixed(2)}° E`;
} else if (dx >= 0 && dy < 0) {
quadrant = `S ${(180 - angle).toFixed(2)}° E`;
} else if (dx < 0 && dy < 0) {
quadrant = `S ${(180 - angle).toFixed(2)}° W`;
} else {
quadrant = `N ${angle.toFixed(2)}° W`;
}
return {
azimuth: parseFloat(azimuth.toFixed(4)),
quadrant
};
}
/**
* 计算多边形面积Shoelace公式
* @param {Array<{x: number, y: number}>} points - 顶点坐标数组
* @returns {number} 面积
*/
export function calculatePolygonArea(points) {
if (points.length < 3) return 0;
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i].x * points[j].y;
area -= points[j].x * points[i].y;
}
return Math.abs(area / 2);
}
/**
* 度分秒转十进制度(角度)
*/
export function dmsAngleToDecimal(degrees, minutes, seconds) {
return degrees + minutes / 60 + seconds / 3600;
}
/**
* 十进制度转度分秒(角度)
*/
export function decimalAngleToDms(decimal) {
const degrees = Math.floor(decimal);
const minutesDecimal = (decimal - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = (minutesDecimal - minutes) * 60;
return {
degrees,
minutes,
seconds: parseFloat(seconds.toFixed(6))
};
}

177
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,177 @@
<template>
<div class="container">
<div class="hero">
<h1 class="hero-title">📐 测绘工具箱</h1>
<p class="hero-subtitle">专业的测绘计算工具集助力您的测绘工作</p>
</div>
<div class="tools-grid">
<router-link v-for="tool in tools" :key="tool.path" :to="tool.path" class="tool-card">
<div class="tool-icon">{{ tool.icon }}</div>
<h3 class="tool-title">{{ tool.name }}</h3>
<p class="tool-description">{{ tool.description }}</p>
</router-link>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const tools = ref([
{
name: '坐标转换',
icon: '🌍',
description: '经纬度格式转换,支持度分秒与十进制互转',
path: '/coordinate-converter'
},
{
name: '距离计算',
icon: '📏',
description: '根据两点坐标计算距离,支持经纬度和平面坐标',
path: '/distance-calculator'
},
{
name: '面积计算',
icon: '📐',
description: '多边形面积计算,支持任意多边形',
path: '/area-calculator'
},
{
name: '角度转换',
icon: '📊',
description: '度分秒、十进制度数、弧度之间的转换',
path: '/angle-converter'
},
{
name: '方位角计算',
icon: '🧭',
description: '根据两点坐标计算方位角和象限角',
path: '/bearing-calculator'
},
{
name: '高程计算',
icon: '⛰️',
description: '高差、坡度、坡度角计算',
path: '/elevation-calculator'
},
{
name: '高德地名搜索',
icon: '🗺️',
description: '通过高德API搜索POI获取坐标并导出',
path: '/amap-search'
}
])
</script>
<style scoped>
.hero {
text-align: center;
margin-bottom: var(--spacing-2xl);
padding: var(--spacing-2xl) 0;
}
.hero-title {
font-size: var(--font-size-4xl);
margin-bottom: var(--spacing-md);
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: fadeIn 0.6s ease;
}
.hero-subtitle {
font-size: var(--font-size-lg);
color: var(--text-secondary);
animation: fadeIn 0.8s ease;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--spacing-xl);
margin-bottom: var(--spacing-2xl);
}
@media (max-width: 1024px) {
.tools-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.tool-card {
position: relative;
background: var(--bg-card);
border-radius: var(--border-radius);
padding: var(--spacing-xl);
border: 2px solid var(--border-color);
text-decoration: none;
color: var(--text-primary);
transition: all var(--transition-base);
overflow: hidden;
}
.tool-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--gradient-primary);
opacity: 0;
transition: opacity var(--transition-base);
z-index: 0;
}
.tool-card:hover::before {
opacity: 0.05;
}
.tool-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-xl);
border-color: var(--primary);
}
.tool-card>* {
position: relative;
z-index: 1;
}
.tool-icon {
font-size: 2rem;
margin-bottom: var(--spacing-md);
animation: fadeIn 1s ease;
}
.tool-title {
font-size: var(--font-size-xl);
font-weight: 600;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.tool-description {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: var(--spacing-sm);
}
.tool-card:hover .tool-arrow {
opacity: 1;
transform: translateX(5px);
}
@media (max-width: 640px) {
.tools-grid {
grid-template-columns: repeat(2, 1fr);
}
.hero-title {
font-size: var(--font-size-3xl);
}
}
</style>

8
vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
base: '/geo-tools/',
})