commit
e546f5e936
@ -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?
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
# \ud83e\udded \u667a\u80fd\u884c\u7a0b\u89c4\u5212\u7cfb\u7edf
|
||||
|
||||
> \u4e00\u7ad9\u5f0f\u65c5\u884c\u884c\u7a0b\u89c4\u5212\u5e73\u53f0\uff0c\u4ece\u201c\u7075\u611f\u8f93\u5165\u201d\u5230\u201c\u6700\u7ec8\u4ea4\u4ed8\u201d\u7684\u5b8c\u6574\u95ed\u73af\u3002
|
||||
|
||||
## \u9879\u76ee\u6982\u8ff0
|
||||
|
||||
\u57fa\u4e8e Vue 3 + Vite + Leaflet \u6784\u5efa\u7684\u4ea4\u4e92\u5f0f\u884c\u7a0b\u89c4\u5212\u7cfb\u7edf\uff0c\u5b9e\u73b0\u4e86 PRD \u4e2d\u5b9a\u4e49\u7684\u4e09\u9636\u6bb5\u4ea4\u4e92\u67b6\u6784\u3002
|
||||
|
||||
## \u6280\u672f\u6808
|
||||
|
||||
- **\u524d\u7aef\u6846\u67b6**: Vue 3 (Composition API)
|
||||
- **\u6784\u5efa\u5de5\u5177**: Vite 5
|
||||
- **\u72b6\u6001\u7ba1\u7406**: Pinia
|
||||
- **\u5730\u56fe\u5e93**: Leaflet
|
||||
- **\u62d6\u62fd\u6392\u5e8f**: SortableJS
|
||||
- **\u8def\u7531 API**: OSRM (\u5f00\u6e90\u514d\u8d39)
|
||||
|
||||
## \u5feb\u901f\u5f00\u59cb
|
||||
|
||||
```bash
|
||||
# \u5b89\u88c5\u4f9d\u8d56
|
||||
NODE_ENV=development npm install
|
||||
|
||||
# \u5f00\u53d1\u6a21\u5f0f
|
||||
npm run dev
|
||||
|
||||
# \u6784\u5efa\u751f\u4ea7\u7248\u672c
|
||||
npm run build
|
||||
|
||||
# \u9884\u89c8\u751f\u4ea7\u7248\u672c
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## \u9879\u76ee\u7ed3\u6784
|
||||
|
||||
```
|
||||
src/
|
||||
\u251c\u2500\u2500 components/
|
||||
\u2502 \u251c\u2500\u2500 App.vue # \u6839\u7ec4\u4ef6\uff0c\u9636\u6bb5\u5207\u6362
|
||||
\u2502 \u251c\u2500\u2500 ChatInterface.vue # Phase 1: \u5bf9\u8bdd\u63a5\u53e3
|
||||
\u2502 \u251c\u2500\u2500 ComparisonView.vue # Phase 2: \u65b9\u6848\u5bf9\u6bd4
|
||||
\u2502 \u251c\u2500\u2500 Workbench.vue # Phase 3: \u6c89\u6d78\u5de5\u4f5c\u53f0
|
||||
\u2502 \u251c\u2500\u2500 TimelinePanel.vue # \u5de6\u4fa7\u65f6\u95f4\u7ebf\uff08SortableJS\u62d6\u62fd\uff09
|
||||
\u2502 \u251c\u2500\u2500 DetailPanel.vue # \u4e2d\u95f4\u8be6\u60c5\u9762\u677f
|
||||
\u2502 \u251c\u2500\u2500 MapView.vue # Leaflet \u5730\u56fe\u7ec4\u4ef6\uff08\u771f\u5b9e\u8def\u7ebf\uff09
|
||||
\u2502 \u251c\u2500\u2500 ModeToggle.vue # \u9884\u89c8/\u7f16\u8f91\u6a21\u5f0f\u5207\u6362
|
||||
\u2502 \u2514\u2500\u2500 ReplacementPanel.vue # \u667a\u80fd\u66ff\u6362\u63a8\u8350\u9762\u677f\uff08\u60ac\u505c\u9884\u6f14\uff09
|
||||
\u251c\u2500\u2500 stores/
|
||||
\u2502 \u2514\u2500\u2500 itinerary.js # Pinia \u72b6\u6001\u7ba1\u7406\uff08\u8def\u7ebf\u4f18\u5316\uff09
|
||||
\u251c\u2500\u2500 data/
|
||||
\u2502 \u2514\u2500\u2500 sampleData.js # \u4e91\u53574\u5929\u884c\u7a0b\u793a\u4f8b\u6570\u636e + AI\u6a21\u677f
|
||||
\u251c\u2500\u2500 services/
|
||||
\u2502 \u2514\u2500\u2500 routeService.js # OSRM \u8def\u5f84\u8ba1\u7b97\u670d\u52a1 + \u7ed5\u8def\u68c0\u6d4b
|
||||
\u251c\u2500\u2500 utils/
|
||||
\u2502 \u2514\u2500\u2500 exporter.js # \u9759\u6001 HTML \u5bfc\u51fa\u5668
|
||||
\u2514\u2500\u2500 main.js # \u5165\u53e3\u6587\u4ef6
|
||||
```
|
||||
|
||||
## \u5df2\u5b9e\u73b0\u529f\u80fd\u2705
|
||||
|
||||
### Phase 1: \u5bf9\u8bdd\u63a5\u53e3
|
||||
- [x] ChatGPT \u98ce\u683c\u5bf9\u8bdd\u6d41
|
||||
- [x] \u7528\u6237\u8f93\u5165\u4e0e AI \u54cd\u5e94\u6a21\u62df
|
||||
- [x] \u65b9\u6848\u5361\u7247\u5f39\u51fa\u52a8\u753b
|
||||
- [x] \u601d\u8003\u4e2d\u52a8\u753b\u6548\u679c
|
||||
|
||||
### Phase 2: \u65b9\u6848\u5bf9\u6bd4
|
||||
- [x] \u4e09\u65b9\u6848\u5e76\u5217\u5c55\u793a
|
||||
- [x] \u5bf9\u6bd4\u7ef4\u5ea6\uff1a\u91cc\u7a0b/\u9a7e\u9a76/\u9884\u7b97/\u666f\u70b9\u5bc6\u5ea6
|
||||
- [x] \u8def\u7ebf\u6982\u89c8\u4e0e\u6838\u5fc3\u4eae\u70b9
|
||||
- [x] \u70b9\u51fb\u9009\u62e9\u8fdb\u5165\u5de5\u4f5c\u53f0
|
||||
|
||||
### Phase 3: \u6c89\u6d78\u5de5\u4f5c\u53f0
|
||||
- [x] \u9884\u89c8\u6a21\u5f0f\uff1a\u5168\u5c4f\u5730\u56fe + \u81ea\u52a8\u64ad\u653e\u5c0f\u8f66\u52a8\u753b + \u7cbe\u7f8e\u8be6\u60c5\u5361
|
||||
- [x] \u7f16\u8f91\u6a21\u5f0f\uff1a\u62d6\u62fd\u624b\u67c4 + \u5220\u9664/\u66ff\u6362\u6309\u94ae + \u865a\u7ebf\u8def\u7ebf
|
||||
- [x] \u53cc\u6a21\u5f0f\u65e0\u7f1d\u5207\u6362
|
||||
- [x] \u5730\u56fe\u6807\u8bb0\u70b9\u51fb\u4ea4\u4e92
|
||||
- [x] \u5c0f\u8f66\u6cbf\u8def\u7ebf\u52a8\u753b\uff08requestAnimationFrame\uff09
|
||||
- [x] \u65f6\u95f4\u7ebf\u5bfc\u822a\uff08\u4e0a\u4e00\u7ad9/\u4e0b\u4e00\u7ad9/\u952e\u76d8\u65b9\u5411\u952e\uff09
|
||||
- [x] \u884c\u7a0b\u7edf\u8ba1\uff08\u603b\u91cc\u7a0b/\u9a7e\u9a76\u65f6\u95f4/\u5929\u6570\uff09
|
||||
|
||||
### Phase 4: \u667a\u80fd\u4e0e\u63a8\u8350
|
||||
- [x] \u667a\u80fd\u66ff\u6362\u63a8\u8350\u9762\u677f
|
||||
- [x] **\u60ac\u505c\u865a\u7ebf\u9884\u6f14** - \u9f20\u6807\u60ac\u505c\u5373\u53ef\u5728\u5730\u56fe\u4e0a\u9884\u89c8\u66ff\u6362\u540e\u7684\u8def\u7ebf
|
||||
- [x] **\u771f\u5b9e\u8def\u7ebf\u83b7\u53d6** - \u8c03\u7528 OSRM \u514d\u8d39 API \u83b7\u53d6\u771f\u5b9e\u9a7e\u8f66\u8def\u7ebf
|
||||
- [x] **\u667a\u80fd\u7ea0\u504f** - \u81ea\u52a8\u68c0\u6d4b\u7ed5\u8def\u5e76\u5f39\u51fa\u4f18\u5316\u63d0\u793a
|
||||
- [x] **\u4e00\u952e\u4f18\u5316** - \u81ea\u52a8\u6309\u5730\u7406\u987a\u5e8f\u91cd\u6392\u884c\u7a0b\u8282\u70b9
|
||||
- [x] **\u62d6\u62fd\u6392\u5e8f** - SortableJS \u5b9e\u73b0\u65f6\u95f4\u7ebf\u62d6\u62fd\uff0c\u5730\u56fe\u5b9e\u65f6\u66f4\u65b0
|
||||
|
||||
### Phase 5: \u5bfc\u51fa\u4e0e\u5206\u4eab
|
||||
- [x] **\u5bfc\u51fa\u9759\u6001 HTML** - \u5c06\u5f53\u524d\u7f16\u8f91\u597d\u7684\u884c\u7a0b\u5bfc\u51fa\u4e3a\u72ec\u7acb HTML \u6587\u4ef6
|
||||
- [x] \u5bfc\u51fa\u6587\u4ef6\u5305\u542b\u5b8c\u6574\u5730\u56fe\u3001\u52a8\u753b\u3001\u8be6\u60c5\u5c55\u793a
|
||||
- [x] \u53ef\u76f4\u63a5\u6253\u5f00\u6d4f\u89c8\uff0c\u65e0\u9700\u670d\u52a1\u5668
|
||||
|
||||
## \u5f85\u5b9e\u73b0\u529f\u80fd
|
||||
|
||||
- [ ] \u771f\u5b9e AI \u63a5\u53e3\u8c03\u7528\uff08\u5df2\u6709\u914d\u7f6e\u9875\u9762\uff0c\u5f85\u63a5\u5165\u5b9e\u9645\u751f\u6210\uff09
|
||||
- [ ] \u751f\u6210\u5206\u4eab\u94fe\u63a5\uff08\u9700\u540e\u7aef\u652f\u6301\uff09
|
||||
- [ ] \u591a\u884c\u7a0b\u6587\u4ef6\u7ba1\u7406
|
||||
- [ ] \u7528\u6237\u6536\u85cf\u4e0e\u5386\u53f2\u8bb0\u5f55
|
||||
|
||||
## \u2699\ufe0f \u540e\u53f0\u914d\u7f6e
|
||||
|
||||
\u7cfb\u7edf\u652f\u6301\u901a\u8fc7\u53f3\u4e0a\u89d2\u9f7f\u8f6e\u6309\u94ae\u8fdb\u5165 **AI \u6a21\u578b\u914d\u7f6e\u9875\u9762**\uff0c\u76ee\u524d\u4ec5\u652f\u6301 **\u963f\u91cc\u4e91 DashScope\uff08\u901a\u4e49\u5343\u95ee\uff09**\uff1a
|
||||
|
||||
| \u914d\u7f6e\u9879 | \u8bf4\u660e |
|
||||
|--------|------|
|
||||
| API Key | \u4ece[\u963f\u91cc\u4e91\u767e\u70bc\u63a7\u5236\u53f0](https://bailian.console.aliyun.com/)\u83b7\u53d6 |
|
||||
| \u6a21\u578b\u9009\u62e9 | Qwen-Turbo\uff08\u5feb\u901f\uff09\u3001Qwen-Plus\uff08\u5747\u8861\uff09\u3001Qwen-Max\uff08\u9ad8\u8d28\u91cf\uff09\u3001Qwen-Long\uff08\u957f\u6587\u672c\uff09 |
|
||||
| Base URL | \u9ed8\u8ba4 `https://dashscope.aliyuncs.com/compatible-mode/v1` |
|
||||
| Temperature | \u521b\u9020\u6027\u63a7\u5236\uff080=\u7cbe\u786e\uff0c1=\u968f\u673a\uff09 |
|
||||
| Max Tokens | \u6700\u5927\u8f93\u51fa\u957f\u5ea6 |
|
||||
| \u6d4b\u8bd5\u8fde\u63a5 | \u9a8c\u8bc1 API Key \u548c\u6a21\u578b\u662f\u5426\u53ef\u7528 |
|
||||
|
||||
\u914d\u7f6e\u6570\u636e\u901a\u8fc7 `localStorage` \u6301\u4e45\u5316\u5b58\u50a8\uff0c\u5237\u65b0\u9875\u9762\u4e0d\u4e22\u5931\u3002
|
||||
|
||||
## \u6838\u5fc3\u4ea4\u4e92\u7ec6\u8282
|
||||
|
||||
### \u66ff\u6362\u9884\u6f14
|
||||
1. \u70b9\u51fb\u8282\u70b9 \u2192 \u9009\u62e9\u201c\u66ff\u6362\u201d
|
||||
2. \u4fa7\u8fb9\u6ed1\u51fa\u63a8\u8350\u5217\u8868
|
||||
3. **\u60ac\u505c\u9884\u89c8**: \u9f20\u6807\u60ac\u505c\u5373\u5728\u5730\u56fe\u4e0a\u663e\u793a\u865a\u7ebf\u9884\u6f14
|
||||
4. \u786e\u8ba4\u66ff\u6362 \u2192 \u5b9e\u7ebf\u66f4\u65b0\uff0c\u8def\u7ebf\u81ea\u52a8\u91cd\u7b97
|
||||
|
||||
### \u667a\u80fd\u7ea0\u504f
|
||||
- \u5f53\u7528\u6237\u62d6\u62fd\u5bfc\u81f4\u987a\u5e8f\u4e0d\u5408\u7406\uff0c\u9876\u90e8\u5f39\u51fa\u63d0\u793a
|
||||
- \u70b9\u51fb\u201c\u4e00\u952e\u4f18\u5316\u201d\u5373\u53ef\u81ea\u52a8\u91cd\u6392\u4e3a\u6700\u77ed\u8def\u5f84
|
||||
|
||||
### \u5b9e\u65f6\u53cd\u9988
|
||||
- \u6bcf\u6b21\u4fee\u6539\u540e\uff0c\u9876\u90e8\u6570\u636e\u6761\u5b9e\u65f6\u66f4\u65b0\u603b\u91cc\u7a0b\u3001\u603b\u8017\u65f6
|
||||
- \u7ed5\u8def\u6bb5\u4ee5\u7ea2\u8272\u7a81\u51fa\u663e\u793a
|
||||
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗺️</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能行程规划系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "trip-planner",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<ChatInterface v-if="store.phase === 'chat'" />
|
||||
<ComparisonView v-else-if="store.phase === 'comparison'" />
|
||||
<Workbench v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useItineraryStore } from './stores/itinerary'
|
||||
import ChatInterface from './components/ChatInterface.vue'
|
||||
import ComparisonView from './components/ComparisonView.vue'
|
||||
import Workbench from './components/Workbench.vue'
|
||||
|
||||
const store = useItineraryStore()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div ref="mapContainer" class="map-container"></div>
|
||||
<div v-if="store.isFetchingRoutes" class="loading-routes">加载路线中...</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useItineraryStore } from '../stores/itinerary'
|
||||
|
||||
const props = defineProps({ autoAnimate: { type: Boolean, default: false } })
|
||||
const store = useItineraryStore()
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let markers = []
|
||||
let routeLines = []
|
||||
let previewLine = null
|
||||
let detourLine = null
|
||||
let carMarker = null
|
||||
let animationFrameId = null
|
||||
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
const createCustomIcon = (point, isActive) => {
|
||||
const size = isActive ? 36 : 28
|
||||
return L.divIcon({
|
||||
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${isActive ? '#f4a261' : '#2a9d8f'};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:${isActive ? 16 : 12}px">${point.icon}</div>`,
|
||||
className: 'custom-marker', iconSize: [size, size], iconAnchor: [size/2, size/2]
|
||||
})
|
||||
}
|
||||
|
||||
const createCarIcon = () => L.divIcon({
|
||||
html: '<div style="font-size:24px;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">🚗</div>',
|
||||
className: 'car-marker', iconSize: [30, 30], iconAnchor: [15, 15]
|
||||
})
|
||||
|
||||
const initMap = () => {
|
||||
if (!mapContainer.value) return
|
||||
map = L.map(mapContainer.value, { zoomControl: false, attributionControl: false }).setView([25.0, 102.0], 7)
|
||||
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
|
||||
maxZoom: 19,
|
||||
subdomains: ['1', '2', '3', '4']
|
||||
}).addTo(map)
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map)
|
||||
updateMarkers()
|
||||
updateRoute()
|
||||
if (props.autoAnimate) animateCar(0)
|
||||
}
|
||||
|
||||
const updateMarkers = () => {
|
||||
markers.forEach(m => map.removeLayer(m))
|
||||
markers = []
|
||||
store.points.forEach((point, idx) => {
|
||||
const isActive = idx === store.currentStep
|
||||
const marker = L.marker([point.lat, point.lng], { icon: createCustomIcon(point, isActive) }).addTo(map)
|
||||
marker.on('click', () => {
|
||||
const prevStep = store.currentStep
|
||||
store.setCurrentStep(idx)
|
||||
if (store.mode === 'preview') animateCar(idx, prevStep)
|
||||
})
|
||||
marker.bindTooltip(point.name, { permanent: false, direction: 'top', offset: [0, -15] })
|
||||
markers.push(marker)
|
||||
})
|
||||
if (carMarker) map.removeLayer(carMarker)
|
||||
const sp = store.points[store.currentStep]
|
||||
if (sp) carMarker = L.marker([sp.lat, sp.lng], { icon: createCarIcon(), zIndexOffset: 1000 }).addTo(map)
|
||||
}
|
||||
|
||||
const clearRouteLines = () => {
|
||||
routeLines.forEach(l => map.removeLayer(l))
|
||||
routeLines = []
|
||||
if (previewLine) { map.removeLayer(previewLine); previewLine = null }
|
||||
if (detourLine) { map.removeLayer(detourLine); detourLine = null }
|
||||
}
|
||||
|
||||
const updateRoute = (skipFitBounds = false) => {
|
||||
clearRouteLines()
|
||||
const coords = store.points.map(p => [p.lat, p.lng])
|
||||
if (coords.length < 2) return
|
||||
|
||||
// Use real route segments if available
|
||||
if (store.routeSegments && store.routeSegments.length > 0) {
|
||||
store.routeSegments.forEach((seg, i) => {
|
||||
if (!seg) return
|
||||
const isDetour = store.detourWarnings.some(d => d.index === i)
|
||||
const line = L.polyline(seg.coords, {
|
||||
color: isDetour ? '#e76f51' : '#2a9d8f',
|
||||
weight: isDetour ? 5 : 4,
|
||||
opacity: 0.8,
|
||||
dashArray: store.mode === 'edit' ? '8, 8' : null
|
||||
}).addTo(map)
|
||||
routeLines.push(line)
|
||||
})
|
||||
if (!skipFitBounds) {
|
||||
let bounds = null
|
||||
routeLines.forEach(l => {
|
||||
if (bounds) bounds.extend(l.getBounds())
|
||||
else bounds = l.getBounds()
|
||||
})
|
||||
if (bounds) map.fitBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
} else {
|
||||
const line = L.polyline(coords, {
|
||||
color: '#2a9d8f', weight: 4, opacity: 0.8,
|
||||
dashArray: store.mode === 'edit' ? '8, 8' : null
|
||||
}).addTo(map)
|
||||
routeLines.push(line)
|
||||
if (!skipFitBounds) map.fitBounds(line.getBounds(), { padding: [50, 50] })
|
||||
}
|
||||
}
|
||||
|
||||
const updatePreviewLine = (targetPoint) => {
|
||||
if (previewLine) { map.removeLayer(previewLine); previewLine = null }
|
||||
if (!targetPoint || store.selectedForReplace === null) return
|
||||
const from = store.points[store.selectedForReplace]
|
||||
const prev = store.selectedForReplace > 0 ? store.points[store.selectedForReplace - 1] : null
|
||||
const next = store.selectedForReplace < store.points.length - 1 ? store.points[store.selectedForReplace + 1] : null
|
||||
|
||||
const segments = []
|
||||
if (prev) segments.push([prev.lat, prev.lng])
|
||||
segments.push([targetPoint.lat, targetPoint.lng])
|
||||
if (next) segments.push([next.lat, next.lng])
|
||||
|
||||
if (segments.length >= 2) {
|
||||
previewLine = L.polyline(segments, {
|
||||
color: '#f4a261', weight: 3, opacity: 0.7, dashArray: '6, 6'
|
||||
}).addTo(map)
|
||||
}
|
||||
}
|
||||
|
||||
const animateCar = (targetIdx, fromStep = null) => {
|
||||
if (animationFrameId) cancelAnimationFrame(animationFrameId)
|
||||
if (!carMarker) return
|
||||
|
||||
const startIdx = fromStep !== null ? fromStep : store.currentStep
|
||||
if (startIdx === targetIdx) return
|
||||
|
||||
const direction = targetIdx > startIdx ? 1 : -1
|
||||
const totalSegments = Math.abs(targetIdx - startIdx)
|
||||
let driveTime = 1.5
|
||||
if (totalSegments > 1) {
|
||||
const pauseTime = 0.1
|
||||
driveTime = (1.5 - pauseTime * (totalSegments - 1)) / totalSegments
|
||||
driveTime = Math.max(0.2, driveTime)
|
||||
}
|
||||
const durationMs = driveTime * 1000
|
||||
|
||||
let animStep = startIdx
|
||||
|
||||
const nextSegment = () => {
|
||||
let nextIdx = animStep + direction
|
||||
if (nextIdx < 0 || nextIdx >= store.points.length) return
|
||||
|
||||
// Use real route coords if available, otherwise fall back to straight line
|
||||
let routeCoords = null
|
||||
const segIdx = direction === 1 ? animStep : nextIdx
|
||||
if (store.routeSegments && store.routeSegments[segIdx]) {
|
||||
const seg = store.routeSegments[segIdx]
|
||||
if (direction === -1) {
|
||||
// Reverse the coords for backward travel
|
||||
routeCoords = [...seg.coords].reverse()
|
||||
} else {
|
||||
routeCoords = seg.coords
|
||||
}
|
||||
}
|
||||
|
||||
const startLoc = store.points[animStep]
|
||||
const endLoc = store.points[nextIdx]
|
||||
|
||||
map.flyTo([endLoc.lat, endLoc.lng], 8, { duration: driveTime })
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
const driveFrame = (currentTime) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const t = Math.min(elapsed / durationMs, 1)
|
||||
|
||||
let cLat, cLng
|
||||
if (routeCoords && routeCoords.length > 1) {
|
||||
// Interpolate along real route
|
||||
const totalPoints = routeCoords.length - 1
|
||||
const currentPoint = t * totalPoints
|
||||
const idx = Math.floor(currentPoint)
|
||||
const frac = currentPoint - idx
|
||||
if (idx >= totalPoints) {
|
||||
cLat = routeCoords[routeCoords.length - 1][0]
|
||||
cLng = routeCoords[routeCoords.length - 1][1]
|
||||
} else {
|
||||
const p1 = routeCoords[idx]
|
||||
const p2 = routeCoords[idx + 1]
|
||||
cLat = p1[0] + (p2[0] - p1[0]) * frac
|
||||
cLng = p1[1] + (p2[1] - p1[1]) * frac
|
||||
}
|
||||
} else {
|
||||
// Straight line fallback
|
||||
cLat = startLoc.lat + (endLoc.lat - startLoc.lat) * t
|
||||
cLng = startLoc.lng + (endLoc.lng - startLoc.lng) * t
|
||||
}
|
||||
|
||||
carMarker.setLatLng([cLat, cLng])
|
||||
|
||||
if (t < 1) { animationFrameId = requestAnimationFrame(driveFrame) }
|
||||
else {
|
||||
animStep = nextIdx
|
||||
store.setCurrentStep(nextIdx)
|
||||
if (nextIdx !== targetIdx) setTimeout(nextSegment, 100)
|
||||
else { animationFrameId = null }
|
||||
}
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(driveFrame)
|
||||
}
|
||||
nextSegment()
|
||||
}
|
||||
|
||||
watch(() => store.currentStep, () => { updateMarkers(); updateRoute(!!animationFrameId) })
|
||||
watch(() => store.points, () => { updateMarkers(); updateRoute() }, { deep: true })
|
||||
watch(() => store.mode, () => { updateRoute() })
|
||||
watch(() => store.hoveredReplacement, (opt) => { updatePreviewLine(opt) })
|
||||
watch(() => store.routeSegments, () => { updateRoute() }, { deep: true })
|
||||
|
||||
onMounted(() => { initMap() })
|
||||
onUnmounted(() => { if (animationFrameId) cancelAnimationFrame(animationFrameId); if (map) map.remove() })
|
||||
|
||||
defineExpose({ animateCar })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-container { width: 100%; height: 100%; }
|
||||
:deep(.custom-marker) { background: none !important; border: none !important; }
|
||||
:deep(.car-marker) { background: none !important; border: none !important; }
|
||||
.loading-routes {
|
||||
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(27, 67, 50, 0.9); color: #fff; padding: 8px 16px;
|
||||
border-radius: 8px; font-size: 13px; z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="timeline-panel">
|
||||
<div class="sidebar-header">
|
||||
<span>📋 行程时间线</span>
|
||||
<small v-if="store.mode === 'edit'">拖拽排序 · 点击编辑</small>
|
||||
<small v-else>点击节点查看详情</small>
|
||||
</div>
|
||||
|
||||
<div class="timeline-list" ref="timelineList">
|
||||
<div
|
||||
v-for="(point, idx) in store.points"
|
||||
:key="point.id"
|
||||
class="timeline-item"
|
||||
:class="{ active: idx === store.currentStep, completed: idx < store.currentStep, 'no-drag': idx === 0 || idx === store.points.length - 1 }"
|
||||
:data-idx="idx"
|
||||
@click="handleClick(idx)"
|
||||
>
|
||||
<div class="tl-line">
|
||||
<div class="tl-dot" :class="getDotClass(idx)"></div>
|
||||
<div v-if="idx < store.points.length - 1" class="tl-connector" :class="{ passed: idx < store.currentStep }"></div>
|
||||
</div>
|
||||
<div class="tl-content">
|
||||
<div class="tl-day">{{ point.day }}</div>
|
||||
<div class="tl-name">{{ point.icon }} {{ point.name }}</div>
|
||||
<div class="tl-meta" v-if="point.km && point.km !== '返程'">
|
||||
<span>{{ point.km }}</span>
|
||||
<span v-if="point.driveTime"> · {{ point.driveTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="store.mode === 'edit' && idx > 0 && idx < store.points.length - 1" class="tl-actions">
|
||||
<span class="drag-handle" title="拖拽排序">⋮⋮</span>
|
||||
<button class="action-btn" @click.stop="toggleReplace(idx)" title="替换">🔄</button>
|
||||
<button class="action-btn delete-btn" @click.stop="handleDelete(idx)" title="删除">✖</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-controls" v-if="store.mode === 'preview'">
|
||||
<button class="nav-btn prev" :disabled="store.currentStep <= 0" @click="handlePrev">← 上一站</button>
|
||||
<button class="nav-btn next" :disabled="store.currentStep >= store.points.length - 1" @click="handleNext">下一站 →</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-footer">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总里程</span>
|
||||
<span class="stat-value">{{ store.totalKm }}km</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">驾驶</span>
|
||||
<span class="stat-value">{{ store.totalDriveTime }}h</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">天数</span>
|
||||
<span class="stat-value">{{ store.travelDays }}天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useItineraryStore } from '../stores/itinerary'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
const store = useItineraryStore()
|
||||
const emit = defineEmits(['replace', 'animate', 'prevStep', 'nextStep'])
|
||||
const timelineList = ref(null)
|
||||
let sortableInstance = null
|
||||
|
||||
const getDotClass = (idx) => {
|
||||
if (idx === 0) return 'start'
|
||||
if (idx === store.points.length - 1) return 'end'
|
||||
if (idx === store.currentStep) return 'active'
|
||||
if (idx < store.currentStep) return 'passed'
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleClick = (idx) => {
|
||||
const prevStep = store.currentStep
|
||||
store.setCurrentStep(idx)
|
||||
if (store.mode === 'preview') emit('animate', idx, prevStep)
|
||||
}
|
||||
|
||||
const handleDelete = (idx) => {
|
||||
if (confirm(`确认删除「${store.points[idx].name}」?`)) store.deletePoint(idx)
|
||||
}
|
||||
|
||||
const handlePrev = () => { emit('prevStep') }
|
||||
const handleNext = () => { emit('nextStep') }
|
||||
|
||||
const toggleReplace = (idx) => {
|
||||
store.selectedForReplace = idx
|
||||
store.showReplacementPanel = !store.showReplacementPanel
|
||||
}
|
||||
|
||||
const initSortable = () => {
|
||||
if (!timelineList.value) return
|
||||
if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null }
|
||||
if (store.mode !== 'edit') return
|
||||
|
||||
sortableInstance = Sortable.create(timelineList.value, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '.no-drag',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex === evt.newIndex) return
|
||||
store.reorderPoints(evt.oldIndex, evt.newIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => { nextTick(() => initSortable()) })
|
||||
|
||||
// Re-init when mode changes or points change
|
||||
import { watch } from 'vue'
|
||||
watch(() => store.mode, () => { nextTick(() => initSortable()) })
|
||||
watch(() => store.points.length, () => { nextTick(() => initSortable()) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-panel { display: flex; flex-direction: column; height: 100%; background: #1b4332; color: #fff; }
|
||||
.sidebar-header { padding: 16px; border-bottom: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; gap: 4px; }
|
||||
.sidebar-header span { font-size: 16px; font-weight: 600; }
|
||||
.sidebar-header small { color: rgba(255,255,255,0.6); font-size: 12px; }
|
||||
.timeline-list { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||||
.timeline-item { display: flex; align-items: flex-start; padding: 8px 16px; cursor: pointer; transition: all 0.2s; position: relative; }
|
||||
.timeline-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.timeline-item.active { background: rgba(244,162,97,0.15); }
|
||||
.timeline-item.completed .tl-content { opacity: 0.7; }
|
||||
.tl-line { position: relative; width: 20px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; margin-top: 4px; }
|
||||
.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: #52796f; border: 2px solid rgba(255,255,255,0.3); transition: all 0.3s; z-index: 2; }
|
||||
.tl-dot.start { background: #2a9d8f; border-color: #2a9d8f; }
|
||||
.tl-dot.end { background: #e76f51; border-color: #e76f51; }
|
||||
.tl-dot.active { background: #f4a261; border-color: #f4a261; transform: scale(1.3); }
|
||||
.tl-dot.passed { background: #2a9d8f; border-color: #2a9d8f; }
|
||||
.tl-connector { width: 2px; height: 40px; background: #52796f; margin-top: 2px; transition: all 0.3s; }
|
||||
.tl-connector.passed { background: #2a9d8f; }
|
||||
.tl-content { flex: 1; min-width: 0; }
|
||||
.tl-day { font-size: 11px; color: rgba(255,255,255,0.5); margin-bottom: 2px; }
|
||||
.tl-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.timeline-item.active .tl-name { color: #f4a261; }
|
||||
.tl-meta { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 2px; }
|
||||
.tl-actions { display: flex; gap: 4px; margin-left: 8px; flex-shrink: 0; }
|
||||
.drag-handle { color: rgba(255,255,255,0.4); cursor: grab; font-size: 12px; user-select: none; padding: 0 4px; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
.action-btn { background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-size: 12px; transition: all 0.2s; }
|
||||
.action-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
.delete-btn:hover { color: #e76f51; }
|
||||
.nav-controls { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid rgba(255,255,255,0.1); }
|
||||
.nav-btn { flex: 1; padding: 10px 12px; border: none; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.2s; color: #fff; }
|
||||
.nav-btn.prev { background: #40916c; }
|
||||
.nav-btn.next { background: #f4a261; color: #1b4332; }
|
||||
.nav-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.stats-footer { display: flex; border-top: 1px solid rgba(255,255,255,0.1); padding: 12px 16px; gap: 12px; }
|
||||
.stat-item { flex: 1; text-align: center; }
|
||||
.stat-label { display: block; font-size: 10px; color: rgba(255,255,255,0.5); text-transform: uppercase; }
|
||||
.stat-value { display: block; font-size: 14px; font-weight: 600; color: #f4a261; margin-top: 2px; }
|
||||
|
||||
/* Sortable styles */
|
||||
:deep(.sortable-ghost) { opacity: 0.4; background: rgba(244,162,97,0.2) !important; }
|
||||
:deep(.sortable-chosen) { background: rgba(244,162,97,0.3) !important; }
|
||||
:deep(.sortable-drag) { opacity: 0.8; box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
|
||||
</style>
|
||||
@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@ -0,0 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AppView from './App.vue'
|
||||
import SettingsPage from './views/SettingsPage.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: AppView },
|
||||
{ path: '/settings', component: SettingsPage }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@ -0,0 +1,54 @@
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1/driving'
|
||||
|
||||
export async function getRoute(fromPoint, toPoint) {
|
||||
const url = `${OSRM_BASE}/${fromPoint[1]},${fromPoint[0]};${toPoint[1]},${toPoint[0]}?overview=full&geometries=geojson`
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) return null
|
||||
const route = data.routes[0]
|
||||
const coords = route.geometry.coordinates.map(c => [c[1], c[0]])
|
||||
return { coords, distance: Math.round(route.distance / 1000), duration: Math.round(route.duration / 60) }
|
||||
} catch (e) {
|
||||
console.warn('OSRM routing failed, fallback to straight line:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMultiRoute(points) {
|
||||
if (points.length < 2) return []
|
||||
const segments = []
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const from = [points[i].lat, points[i].lng]
|
||||
const to = [points[i + 1].lat, points[i + 1].lng]
|
||||
const route = await getRoute(from, to)
|
||||
if (route) {
|
||||
segments.push(route)
|
||||
} else {
|
||||
segments.push({ coords: [from, to], distance: calcDist(from, to), duration: Math.round(calcDist(from, to) / 60 * 2) })
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
function calcDist(from, to) {
|
||||
const R = 6371
|
||||
const dLat = (to[0] - from[0]) * Math.PI / 180
|
||||
const dLng = (to[1] - from[1]) * Math.PI / 180
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(from[0]*Math.PI/180)*Math.cos(to[0]*Math.PI/180)*Math.sin(dLng/2)**2
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
|
||||
export function detectDetour(segments) {
|
||||
const detours = []
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
if (!seg || !seg.coords || seg.coords.length < 2) continue
|
||||
const straightDist = calcDist([seg.coords[0][0], seg.coords[0][1]], [seg.coords[seg.coords.length-1][0], seg.coords[seg.coords.length-1][1]])
|
||||
if (straightDist > 0 && seg.distance / straightDist > 1.5 && seg.distance > 100) {
|
||||
const ratio = seg.distance / straightDist
|
||||
detours.push({ index: i, ratio: Math.round(ratio * 100), message: `第${i + 1}段路线可能绕路,实际距离是直线距离的${Math.round((ratio - 1) * 100)}%` })
|
||||
}
|
||||
}
|
||||
return detours
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { sampleItinerary, replacementPool } from '../data/sampleData'
|
||||
import { getMultiRoute, detectDetour } from '../services/routeService'
|
||||
|
||||
export const useItineraryStore = defineStore('itinerary', () => {
|
||||
const phase = ref('workbench')
|
||||
const mode = ref('preview')
|
||||
const points = ref(JSON.parse(JSON.stringify(sampleItinerary.points)))
|
||||
const currentStep = ref(0)
|
||||
const selectedForReplace = ref(null)
|
||||
const showReplacementPanel = ref(false)
|
||||
const showOptimizationAlert = ref(false)
|
||||
const optimizationMessage = ref('')
|
||||
const isFetchingRoutes = ref(false)
|
||||
const routeSegments = ref([])
|
||||
const detourWarnings = ref([])
|
||||
const hoveredReplacement = ref(null)
|
||||
|
||||
const startPoint = computed(() => points.value.find(p => p.badge === 'START'))
|
||||
const endPoint = computed(() => points.value.find(p => p.badge === 'END'))
|
||||
|
||||
const totalKm = computed(() => {
|
||||
if (routeSegments.value.length > 0) {
|
||||
return routeSegments.value.reduce((sum, s) => sum + (s ? s.distance : 0), 0)
|
||||
}
|
||||
let km = 0
|
||||
points.value.forEach(p => { const n = parseInt(p.km); if (!isNaN(n)) km += n })
|
||||
return km
|
||||
})
|
||||
|
||||
const totalDriveTime = computed(() => {
|
||||
if (routeSegments.value.length > 0) {
|
||||
const totalMin = routeSegments.value.reduce((sum, s) => sum + (s ? s.duration : 0), 0)
|
||||
return Math.round(totalMin / 60 * 10) / 10
|
||||
}
|
||||
let hours = 0
|
||||
points.value.forEach(p => {
|
||||
if (p.driveTime && p.driveTime !== '—') {
|
||||
const parts = p.driveTime.replace('h', '').split('.')
|
||||
hours += parseInt(parts[0]) || 0
|
||||
if (parts[1]) hours += parseInt(parts[1]) * 0.1
|
||||
}
|
||||
})
|
||||
return Math.round(hours * 10) / 10
|
||||
})
|
||||
|
||||
const travelDays = computed(() => {
|
||||
return points.value.filter(p => p.day && !['START', 'END'].includes(p.badge)).length
|
||||
})
|
||||
|
||||
const currentPoint = computed(() => points.value[currentStep.value] || null)
|
||||
|
||||
const replacementOptions = computed(() => {
|
||||
if (selectedForReplace.value === null) return []
|
||||
const point = points.value[selectedForReplace.value]
|
||||
if (!point) return []
|
||||
return replacementPool[point.name] || []
|
||||
})
|
||||
|
||||
function setPhase(p) { phase.value = p }
|
||||
function setMode(m) { mode.value = m }
|
||||
function setCurrentStep(idx) { if (idx >= 0 && idx < points.value.length) currentStep.value = idx }
|
||||
function nextStep() { if (currentStep.value < points.value.length - 1) currentStep.value++ }
|
||||
function prevStep() { if (currentStep.value > 0) currentStep.value-- }
|
||||
|
||||
function deletePoint(index) {
|
||||
if (index <= 0 || index >= points.value.length - 1) return
|
||||
points.value.splice(index, 1)
|
||||
routeSegments.value = []
|
||||
if (currentStep.value >= points.value.length) currentStep.value = points.value.length - 1
|
||||
checkRouteOptimization()
|
||||
}
|
||||
|
||||
function insertPoint(beforeIndex, newPoint) {
|
||||
points.value.splice(beforeIndex, 0, {
|
||||
id: 'p_' + Date.now(), name: newPoint.name, lat: newPoint.lat, lng: newPoint.lng,
|
||||
day: '', badge: '', km: '', driveTime: '', desc: newPoint.desc,
|
||||
icon: newPoint.icon || '📍', images: [], heroImage: '',
|
||||
schedule: [], foods: [], hotel: '', tips: ''
|
||||
})
|
||||
routeSegments.value = []
|
||||
checkRouteOptimization()
|
||||
}
|
||||
|
||||
function replacePoint(index, newPoint) {
|
||||
if (index < 0 || index >= points.value.length) return
|
||||
const old = points.value[index]
|
||||
points.value[index] = { ...old, name: newPoint.name, lat: newPoint.lat, lng: newPoint.lng, desc: newPoint.desc, icon: newPoint.icon || '📍' }
|
||||
selectedForReplace.value = null
|
||||
showReplacementPanel.value = false
|
||||
routeSegments.value = []
|
||||
checkRouteOptimization()
|
||||
}
|
||||
|
||||
function reorderPoints(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return
|
||||
if (fromIndex <= 0 || fromIndex >= points.value.length - 1) return
|
||||
const item = points.value.splice(fromIndex, 1)[0]
|
||||
points.value.splice(toIndex, 0, item)
|
||||
currentStep.value = toIndex
|
||||
routeSegments.value = []
|
||||
checkRouteOptimization()
|
||||
}
|
||||
|
||||
async function fetchRealRoutes() {
|
||||
if (isFetchingRoutes.value) return
|
||||
isFetchingRoutes.value = true
|
||||
try {
|
||||
routeSegments.value = await getMultiRoute(points.value)
|
||||
detourWarnings.value = detectDetour(routeSegments.value)
|
||||
if (detourWarnings.value.length > 0) {
|
||||
showOptimizationAlert.value = true
|
||||
optimizationMessage.value = detourWarnings.value[0].message
|
||||
} else {
|
||||
showOptimizationAlert.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch routes:', e)
|
||||
routeSegments.value = []
|
||||
} finally {
|
||||
isFetchingRoutes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function checkRouteOptimization() {
|
||||
if (points.value.length < 3) {
|
||||
showOptimizationAlert.value = false
|
||||
return
|
||||
}
|
||||
for (let i = 1; i < points.value.length - 1; i++) {
|
||||
const prev = points.value[i - 1], curr = points.value[i], next = points.value[i + 1]
|
||||
const distPrev = calcDist(prev.lat, prev.lng, curr.lat, curr.lng)
|
||||
const distNext = calcDist(curr.lat, curr.lng, next.lat, next.lng)
|
||||
const distDirect = calcDist(prev.lat, prev.lng, next.lat, next.lng)
|
||||
if (distPrev + distNext > distDirect * 1.8 && distDirect > 50) {
|
||||
showOptimizationAlert.value = true
|
||||
optimizationMessage.value = `检测到「${curr.name}」可能使路线绕路,是否优化?`
|
||||
return
|
||||
}
|
||||
}
|
||||
showOptimizationAlert.value = false
|
||||
}
|
||||
|
||||
function autoOptimizeRoute() {
|
||||
if (points.value.length < 3) return
|
||||
const middle = points.value.slice(1, -1)
|
||||
const start = points.value[0]
|
||||
const end = points.value[points.value.length - 1]
|
||||
middle.sort((a, b) => {
|
||||
const da = Math.sqrt((a.lat - start.lat)**2 + (a.lng - start.lng)**2)
|
||||
const db = Math.sqrt((b.lat - start.lat)**2 + (b.lng - start.lng)**2)
|
||||
return da - db
|
||||
})
|
||||
points.value = [start, ...middle, end]
|
||||
currentStep.value = 0
|
||||
routeSegments.value = []
|
||||
showOptimizationAlert.value = false
|
||||
}
|
||||
|
||||
function dismissOptimization() {
|
||||
showOptimizationAlert.value = false
|
||||
}
|
||||
|
||||
function resetToSample() {
|
||||
points.value = JSON.parse(JSON.stringify(sampleItinerary.points))
|
||||
currentStep.value = 0
|
||||
routeSegments.value = []
|
||||
showOptimizationAlert.value = false
|
||||
showReplacementPanel.value = false
|
||||
selectedForReplace.value = null
|
||||
hoveredReplacement.value = null
|
||||
}
|
||||
|
||||
function setHoveredReplacement(opt) {
|
||||
hoveredReplacement.value = opt
|
||||
}
|
||||
|
||||
return {
|
||||
phase, mode, points, currentStep, selectedForReplace, showReplacementPanel,
|
||||
showOptimizationAlert, optimizationMessage, isFetchingRoutes, routeSegments, detourWarnings,
|
||||
hoveredReplacement, startPoint, endPoint, totalKm, totalDriveTime, travelDays, currentPoint, replacementOptions,
|
||||
setPhase, setMode, setCurrentStep, nextStep, prevStep,
|
||||
deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample,
|
||||
fetchRealRoutes, checkRouteOptimization, autoOptimizeRoute, dismissOptimization,
|
||||
setHoveredReplacement
|
||||
}
|
||||
})
|
||||
|
||||
function calcDist(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
@ -0,0 +1,267 @@
|
||||
export function generateStaticHTML(itineraryData) {
|
||||
const points = itineraryData.points
|
||||
|
||||
const pointsJSON = JSON.stringify(points)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>${itineraryData.title || '行程规划'}</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow:hidden}
|
||||
.header{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;background:#1b4332;color:#fff;z-index:1000;position:relative}
|
||||
.header h1{font-size:18px;margin:0}
|
||||
.header .subtitle{font-size:12px;color:rgba(255,255,255,0.6)}
|
||||
.stats-bar{display:flex;gap:20px}
|
||||
.stat-item{font-size:13px;color:rgba(255,255,255,0.7)}
|
||||
.stat-item strong{color:#f4a261}
|
||||
.main{display:flex;height:calc(100vh - 50px)}
|
||||
.left{width:280px;background:#1b4332;color:#fff;overflow-y:auto;flex-shrink:0}
|
||||
.sidebar-header{padding:16px;border-bottom:1px solid rgba(255,255,255,0.1)}
|
||||
.sidebar-header span{font-size:16px;font-weight:600}
|
||||
.timeline-item{display:flex;align-items:flex-start;padding:8px 16px;cursor:pointer;transition:all 0.2s}
|
||||
.timeline-item:hover{background:rgba(255,255,255,0.05)}
|
||||
.timeline-item.active{background:rgba(244,162,97,0.15)}
|
||||
.tl-line{position:relative;width:20px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;margin-top:4px}
|
||||
.tl-dot{width:12px;height:12px;border-radius:50%;background:#52796f;border:2px solid rgba(255,255,255,0.3);z-index:2}
|
||||
.tl-dot.start{background:#2a9d8f;border-color:#2a9d8f}
|
||||
.tl-dot.end{background:#e76f51;border-color:#e76f51}
|
||||
.tl-dot.active{background:#f4a261;border-color:#f4a261;transform:scale(1.3)}
|
||||
.tl-dot.passed{background:#2a9d8f;border-color:#2a9d8f}
|
||||
.tl-connector{width:2px;height:40px;background:#52796f;margin-top:2px}
|
||||
.tl-connector.passed{background:#2a9d8f}
|
||||
.tl-content{flex:1;min-width:0}
|
||||
.tl-day{font-size:11px;color:rgba(255,255,255,0.5)}
|
||||
.tl-name{font-size:14px;font-weight:500}
|
||||
.timeline-item.active .tl-name{color:#f4a261}
|
||||
.tl-meta{font-size:11px;color:rgba(255,255,255,0.5);margin-top:2px}
|
||||
.nav-controls{display:flex;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,0.1)}
|
||||
.nav-btn{flex:1;padding:10px;border:none;border-radius:8px;font-size:13px;cursor:pointer;color:#fff}
|
||||
.nav-btn.prev{background:#40916c}
|
||||
.nav-btn.next{background:#f4a261;color:#1b4332}
|
||||
.nav-btn:disabled{opacity:0.3;cursor:not-allowed}
|
||||
.stats-footer{display:flex;border-top:1px solid rgba(255,255,255,0.1);padding:12px 16px;gap:12px}
|
||||
.stat-box{flex:1;text-align:center}
|
||||
.stat-box label{font-size:10px;color:rgba(255,255,255,0.5)}
|
||||
.stat-box span{display:block;font-size:14px;font-weight:600;color:#f4a261;margin-top:2px}
|
||||
.middle{width:340px;border-left:1px solid #e0e0e0;border-right:1px solid #e0e0e0;overflow-y:auto;flex-shrink:0;background:#fff}
|
||||
.hero{position:relative;height:200px;overflow:hidden}
|
||||
.hero img{width:100%;height:100%;object-fit:cover}
|
||||
.hero-overlay{position:absolute;bottom:0;left:0;right:0;padding:20px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#fff}
|
||||
.hero-badge{display:inline-block;background:#f4a261;color:#1b4332;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;margin-bottom:8px}
|
||||
.hero-overlay h2{margin:0 0 4px;font-size:20px}
|
||||
.hero-overlay p{margin:0;font-size:13px;opacity:0.9}
|
||||
.dstats{display:flex;gap:12px;padding:16px;border-bottom:1px solid #eee}
|
||||
.dcard{flex:1;text-align:center;padding:12px 8px;background:#f8f9fa;border-radius:10px}
|
||||
.dcard .emoji{font-size:20px}
|
||||
.dcard .val{font-size:16px;font-weight:600;color:#2a9d8f;margin:4px 0}
|
||||
.dcard .lbl{font-size:11px;color:#666}
|
||||
.section{padding:16px;border-bottom:1px solid #eee}
|
||||
.section h3{margin:0 0 12px;font-size:15px;color:#1b4332}
|
||||
.tdi{display:flex;gap:8px;align-items:flex-start;margin-bottom:8px}
|
||||
.tdi .time{font-size:12px;color:#f4a261;font-weight:600;min-width:40px}
|
||||
.tdi .content{font-size:13px;color:#444}
|
||||
.foods{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.food-tag{background:#e8f5e9;color:#2a9d8f;padding:4px 12px;border-radius:16px;font-size:12px}
|
||||
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
|
||||
.gitem{aspect-ratio:4/3;border-radius:8px;overflow:hidden;background:#e8f5e9}
|
||||
.gitem img{width:100%;height:100%;object-fit:cover}
|
||||
.hotel{display:flex;align-items:center;gap:10px;padding:12px;background:#f8f9fa;border-radius:10px}
|
||||
.hotel .icon{font-size:24px}
|
||||
.hotel .text{font-size:13px;color:#444}
|
||||
.tips{background:#fff3cd;border-left:3px solid #f4a261;padding:12px;border-radius:0 8px 8px 0;font-size:13px;color:#856404}
|
||||
.right{flex:1;position:relative}
|
||||
#map{width:100%;height:100%}
|
||||
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#999}
|
||||
.empty span{font-size:48px;margin-bottom:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>${itineraryData.icon || '🚗'} ${itineraryData.title || '行程规划'}</h1>
|
||||
<div class="subtitle">${itineraryData.subtitle || ''}</div>
|
||||
</div>
|
||||
<div class="stats-bar" id="statsBar"></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="left">
|
||||
<div class="sidebar-header"><span>📋 行程时间线</span></div>
|
||||
<div id="timeline"></div>
|
||||
<div class="nav-controls">
|
||||
<button class="nav-btn prev" id="btnPrev">← 上一站</button>
|
||||
<button class="nav-btn next" id="btnNext">下一站 →</button>
|
||||
</div>
|
||||
<div class="stats-footer" id="statsFooter"></div>
|
||||
</div>
|
||||
<div class="middle" id="middle"></div>
|
||||
<div class="right"><div id="map"></div></div>
|
||||
</div>
|
||||
<script>
|
||||
var LOCATIONS = ${pointsJSON};
|
||||
var currentStep = 0;
|
||||
var map, routeLine, carMarker, markers = [];
|
||||
var animationFrameId = null, isAnimating = false;
|
||||
|
||||
function init() {
|
||||
map = L.map('map', {zoomControl: false}).setView([25.0, 102.0], 7);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
|
||||
L.control.zoom({position: 'bottomright'}).addTo(map);
|
||||
renderTimeline();
|
||||
renderDetail();
|
||||
renderMap();
|
||||
updateStats();
|
||||
document.getElementById('btnPrev').addEventListener('click', function() { goTo(currentStep - 1); });
|
||||
document.getElementById('btnNext').addEventListener('click', function() { goTo(currentStep + 1); });
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); goTo(currentStep + 1); }
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); goTo(currentStep - 1); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
var tl = document.getElementById('timeline');
|
||||
var html = '';
|
||||
LOCATIONS.forEach(function(loc, i) {
|
||||
var cls = i === currentStep ? 'active' : (i < currentStep ? 'completed' : '');
|
||||
var dotCls = i === 0 ? 'start' : (i === LOCATIONS.length - 1 ? 'end' : (i === currentStep ? 'active' : (i < currentStep ? 'passed' : '')));
|
||||
var connCls = i < currentStep ? ' passed' : '';
|
||||
html += '<div class="timeline-item ' + cls + '" onclick="goTo(' + i + ')">';
|
||||
html += '<div class="tl-line"><div class="tl-dot ' + dotCls + '"></div>';
|
||||
if (i < LOCATIONS.length - 1) html += '<div class="tl-connector' + connCls + '"></div>';
|
||||
html += '</div><div class="tl-content"><div class="tl-day">' + loc.day + '</div>';
|
||||
html += '<div class="tl-name">' + loc.icon + ' ' + loc.name + '</div>';
|
||||
if (loc.km && loc.km !== '返程') html += '<div class="tl-meta">' + loc.km + ' · ' + loc.driveTime + '</div>';
|
||||
html += '</div></div>';
|
||||
});
|
||||
tl.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
var loc = LOCATIONS[currentStep];
|
||||
if (!loc) return;
|
||||
var mp = document.getElementById('middle');
|
||||
var h = '';
|
||||
if (loc.heroImage) {
|
||||
h += '<div class="hero"><img src="' + loc.heroImage + '" alt="" onerror="this.parentElement.style.background=\\'linear-gradient(135deg,#a8e6cf,#dcedc1)\\';this.style.display=\\'none\\'">';
|
||||
h += '<div class="hero-overlay"><span class="hero-badge">' + loc.badge + '</span>';
|
||||
h += '<h2>' + loc.icon + ' ' + loc.name + '</h2><p>' + loc.desc + '</p></div></div>';
|
||||
}
|
||||
h += '<div class="dstats">';
|
||||
h += '<div class="dcard"><div class="emoji">🚗</div><div class="val">' + loc.km + '</div><div class="lbl">行驶里程</div></div>';
|
||||
h += '<div class="dcard"><div class="emoji">⏱️</div><div class="val">' + loc.driveTime + '</div><div class="lbl">驾驶时间</div></div>';
|
||||
h += '<div class="dcard"><div class="emoji">📍</div><div class="val">' + loc.day + '</div><div class="lbl">第几天</div></div>';
|
||||
h += '</div>';
|
||||
if (loc.schedule && loc.schedule.length) {
|
||||
h += '<div class="section"><h3>📅 行程安排</h3>';
|
||||
loc.schedule.forEach(function(s) { h += '<div class="tdi"><span class="time">' + s.time + '</span><span class="content">' + s.content + '</span></div>'; });
|
||||
h += '</div>';
|
||||
}
|
||||
if (loc.foods && loc.foods.length) {
|
||||
h += '<div class="section"><h3>🍽️ 美食推荐</h3><div class="foods">';
|
||||
loc.foods.forEach(function(f) { h += '<span class="food-tag">' + f + '</span>'; });
|
||||
h += '</div></div>';
|
||||
}
|
||||
if (loc.images && loc.images.length) {
|
||||
h += '<div class="section"><h3>📸 风景美图</h3><div class="gallery">';
|
||||
loc.images.forEach(function(img) { h += '<div class="gitem"><img src="' + img + '" alt="" loading="lazy" onerror="this.style.display=\\'none\\';this.parentElement.style.background=\\'linear-gradient(135deg,#a8e6cf,#dcedc1)\\'"></div>'; });
|
||||
h += '</div></div>';
|
||||
}
|
||||
if (loc.hotel) {
|
||||
h += '<div class="section"><h3>🛏️ 住宿推荐</h3><div class="hotel"><span class="icon">🛏️</span><span class="text">' + loc.hotel + '</span></div></div>';
|
||||
}
|
||||
if (loc.tips) {
|
||||
h += '<div class="section"><h3>💡 注意事项</h3><div class="tips">' + loc.tips + '</div></div>';
|
||||
}
|
||||
mp.innerHTML = h;
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
markers.forEach(function(m) { map.removeLayer(m); });
|
||||
markers = [];
|
||||
LOCATIONS.forEach(function(loc, i) {
|
||||
var isActive = i === currentStep;
|
||||
var size = isActive ? 36 : 28;
|
||||
var icon = L.divIcon({
|
||||
html: '<div style="width:' + size + 'px;height:' + size + 'px;border-radius:50%;background:' + (isActive ? '#f4a261' : '#2a9d8f') + ';border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:' + (isActive ? 16 : 12) + 'px">' + loc.icon + '</div>',
|
||||
className: 'custom-marker', iconSize: [size, size], iconAnchor: [size/2, size/2]
|
||||
});
|
||||
var m = L.marker([loc.lat, loc.lng], { icon: icon }).addTo(map);
|
||||
m.bindTooltip(loc.name, { permanent: false, direction: 'top', offset: [0, -15] });
|
||||
m.on('click', function() { goTo(i); });
|
||||
markers.push(m);
|
||||
});
|
||||
if (routeLine) map.removeLayer(routeLine);
|
||||
var coords = LOCATIONS.map(function(p) { return [p.lat, p.lng]; });
|
||||
if (coords.length >= 2) {
|
||||
routeLine = L.polyline(coords, { color: '#2a9d8f', weight: 4, opacity: 0.8 }).addTo(map);
|
||||
map.fitBounds(routeLine.getBounds(), { padding: [50, 50] });
|
||||
}
|
||||
if (carMarker) map.removeLayer(carMarker);
|
||||
var sp = LOCATIONS[currentStep];
|
||||
if (sp) {
|
||||
carMarker = L.marker([sp.lat, sp.lng], {
|
||||
icon: L.divIcon({ html: '<div style="font-size:24px;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">🚗</div>', className: 'car-marker', iconSize: [30, 30], iconAnchor: [15, 15] }),
|
||||
zIndexOffset: 1000
|
||||
}).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
var totalKm = 0, totalTime = 0;
|
||||
LOCATIONS.forEach(function(p) {
|
||||
var n = parseInt(p.km); if (!isNaN(n)) totalKm += n;
|
||||
if (p.driveTime && p.driveTime !== '—') {
|
||||
var parts = p.driveTime.replace('h', '').split('.');
|
||||
totalTime += (parseInt(parts[0]) || 0) + (parts[1] ? parseInt(parts[1]) * 0.1 : 0);
|
||||
}
|
||||
});
|
||||
var days = LOCATIONS.filter(function(p) { return p.day && p.badge !== 'START' && p.badge !== 'END'; }).length;
|
||||
document.getElementById('statsBar').innerHTML = '<span class="stat-item">总里程 <strong>' + totalKm + 'km</strong></span><span class="stat-item">驾驶 <strong>' + Math.round(totalTime * 10) / 10 + 'h</strong></span><span class="stat-item">天数 <strong>' + days + '天</strong></span>';
|
||||
document.getElementById('statsFooter').innerHTML = '<div class="stat-box"><label>总里程</label><span>' + totalKm + 'km</span></div><div class="stat-box"><label>驾驶</label><span>' + Math.round(totalTime * 10) / 10 + 'h</span></div><div class="stat-box"><label>天数</label><span>' + days + '天</span></div>';
|
||||
}
|
||||
|
||||
function goTo(targetIdx) {
|
||||
if (isAnimating || targetIdx < 0 || targetIdx >= LOCATIONS.length) return;
|
||||
isAnimating = true;
|
||||
var dir = targetIdx > currentStep ? 1 : -1;
|
||||
var segs = Math.abs(targetIdx - currentStep);
|
||||
var dur = segs > 1 ? Math.max(0.2, (1.5 - 0.1 * (segs - 1)) / segs) : 1.5;
|
||||
var durMs = dur * 1000;
|
||||
|
||||
function nextSeg() {
|
||||
var ni = currentStep + dir;
|
||||
if (ni < 0 || ni >= LOCATIONS.length) { isAnimating = false; return; }
|
||||
var from = LOCATIONS[currentStep], to = LOCATIONS[ni];
|
||||
map.flyTo([to.lat, to.lng], 8, { duration: dur });
|
||||
var start = performance.now();
|
||||
function frame(t) {
|
||||
var elapsed = t - start, ratio = Math.min(elapsed / durMs, 1);
|
||||
var cl = from.lat + (to.lat - from.lat) * ratio, cg = from.lng + (to.lng - from.lng) * ratio;
|
||||
carMarker.setLatLng([cl, cg]);
|
||||
if (ratio < 1) { animationFrameId = requestAnimationFrame(frame); }
|
||||
else { currentStep = ni; renderTimeline(); renderDetail(); updateButtons();
|
||||
if (currentStep !== targetIdx) setTimeout(nextSeg, 100);
|
||||
else isAnimating = false;
|
||||
}
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(frame);
|
||||
}
|
||||
nextSeg();
|
||||
}
|
||||
|
||||
function updateButtons() {
|
||||
document.getElementById('btnPrev').disabled = currentStep <= 0;
|
||||
document.getElementById('btnNext').disabled = currentStep >= LOCATIONS.length - 1;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
server: {
|
||||
historyApiFallback: true
|
||||
}
|
||||
})
|
||||
Loading…
Reference in new issue