初次提交
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal file
5
README.md
Normal 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
23
index.html
Normal 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
1359
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
1
public/vite.svg
Normal 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
143
src/App.vue
Normal 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>© 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
431
src/assets/css/index.css
Normal 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
79
src/assets/css/style.css
Normal 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
1
src/assets/vue.svg
Normal 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 |
648
src/components/AmapSearchTool.vue
Normal file
648
src/components/AmapSearchTool.vue
Normal 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>
|
||||
168
src/components/AngleConverter.vue
Normal file
168
src/components/AngleConverter.vue
Normal 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>
|
||||
143
src/components/AreaCalculator.vue
Normal file
143
src/components/AreaCalculator.vue
Normal 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>
|
||||
243
src/components/BearingCalculator.vue
Normal file
243
src/components/BearingCalculator.vue
Normal 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>
|
||||
176
src/components/CoordinateConverter.vue
Normal file
176
src/components/CoordinateConverter.vue
Normal 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>
|
||||
160
src/components/DistanceCalculator.vue
Normal file
160
src/components/DistanceCalculator.vue
Normal 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>
|
||||
224
src/components/ElevationCalculator.vue
Normal file
224
src/components/ElevationCalculator.vue
Normal 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>
|
||||
43
src/components/HelloWorld.vue
Normal file
43
src/components/HelloWorld.vue
Normal 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
8
src/main.js
Normal 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
59
src/router.js
Normal 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
72
src/utils/amap.js
Normal 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
83
src/utils/coordinate.js
Normal 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
55
src/utils/elevation.js
Normal 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
94
src/utils/geometry.js
Normal 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
177
src/views/HomeView.vue
Normal 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
8
vite.config.js
Normal 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/',
|
||||
})
|
||||
Reference in New Issue
Block a user