fix: 初始化代码

master
Lxy 1 week ago
commit e546f5e936

24
.gitignore vendored

@ -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>

1379
package-lock.json generated

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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

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,399 @@
<template>
<div class="chat-interface">
<div class="chat-header">
<h1>🧭 智能行程规划</h1>
<p>告诉我你的旅行想法我来为你规划完美行程</p>
</div>
<!-- Chat messages -->
<div class="chat-messages" ref="messagesContainer">
<div class="message bot">
<div class="avatar">🤖</div>
<div class="bubble">
你好我是你的行程规划助手请告诉我<br/>
想去哪里<br/>
几月出发<br/>
几天行程<br/>
交通方式自驾/公共交通/步行<br/>
有什么特殊需求带老人/小孩/特定景点
</div>
</div>
<div v-for="(msg, i) in messages" :key="i" class="message" :class="msg.role">
<div class="avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="bubble">{{ msg.content }}</div>
</div>
<div v-if="isGenerating" class="message bot">
<div class="avatar">🤖</div>
<div class="bubble thinking">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
正在生成方案...
</div>
</div>
</div>
<!-- Input area -->
<div class="chat-input-area">
<input
v-model="inputText"
@keyup.enter="sendMessage"
placeholder="例如9月去云南5天自驾带老人"
/>
<button @click="sendMessage" :disabled="!inputText.trim() || isGenerating">发送</button>
</div>
<!-- Scheme cards (appear after AI response) -->
<transition name="slide-up">
<div v-if="showCards" class="scheme-cards">
<h3>📋 为你生成了 3 个方案</h3>
<div class="cards-grid">
<div
v-for="(scheme, i) in schemes"
:key="i"
class="scheme-card"
@click="selectScheme(i)"
>
<div class="card-header">
<span class="card-badge">{{ scheme.badge }}</span>
<h4>{{ scheme.name }}</h4>
</div>
<div class="card-stats">
<span>🚗 {{ scheme.km }}</span>
<span> {{ scheme.driveTime }}</span>
<span>💰 {{ scheme.budget }}</span>
</div>
<div class="card-highlights">
<span v-for="(h, j) in scheme.highlights" :key="j" class="highlight-tag">{{ h }}</span>
</div>
<div class="card-mini-map">
<span>🗺 {{ routePreview(i) }}</span>
</div>
<div class="card-action">选择此方案 </div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
const store = useItineraryStore()
const messages = ref([])
const inputText = ref('')
const isGenerating = ref(false)
const showCards = ref(false)
const schemes = ref([
{
badge: '方案A',
name: '经典环线:昆明-普者黑-大理-丽江',
km: '~1450km',
driveTime: '14h',
budget: '¥4000',
highlights: ['喀斯特地貌', '苍山洱海', '古城文化', '玉龙雪山'],
route: '昆明 → 普者黑 → 坝美 → 大理 → 丽江'
},
{
badge: '方案B',
name: '南线深度:昆明-建水-元阳-普者黑',
km: '~980km',
driveTime: '11h',
budget: '¥3200',
highlights: ['元阳梯田', '建水古城', '抚仙湖', '少打卡多体验'],
route: '昆明 → 抚仙湖 → 建水 → 元阳 → 普者黑'
},
{
badge: '方案C',
name: '西线探险:昆明-大理-沙溪-香格里拉',
km: '~1200km',
driveTime: '16h',
budget: '¥4800',
highlights: ['茶马古道', '高原秘境', '松赞林寺', '纳帕海'],
route: '昆明 → 大理 → 沙溪 → 香格里拉 → 返程'
}
])
const routePreview = (i) => schemes.value[i].route
const messagesContainer = ref(null)
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
const sendMessage = async () => {
if (!inputText.value.trim() || isGenerating.value) return
const userMsg = inputText.value.trim()
messages.value.push({ role: 'user', content: userMsg })
inputText.value = ''
await scrollToBottom()
isGenerating.value = true
// Simulate AI response
setTimeout(async () => {
isGenerating.value = false
messages.value.push({
role: 'bot',
content: `根据你的需求"${userMsg}"我为你精心设计了以下3个方案。每个方案在路线、体验和预算上有所侧重请点击方案卡片进入对比页面。`
})
showCards.value = true
await scrollToBottom()
}, 2000)
}
const selectScheme = (index) => {
store.setPhase('comparison')
}
</script>
<style scoped>
.chat-interface {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
position: relative;
}
.chat-header {
text-align: center;
padding: 30px 20px;
background: linear-gradient(135deg, #1b4332, #2d6a4f);
color: #fff;
}
.chat-header h1 {
margin: 0 0 8px;
font-size: 24px;
}
.chat-header p {
margin: 0;
opacity: 0.8;
font-size: 14px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
max-width: 80%;
}
.message.bot { align-self: flex-start; }
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: #e8f5e9;
flex-shrink: 0;
}
.message.user .avatar { background: #e3f2fd; }
.bubble {
padding: 12px 16px;
border-radius: 16px;
font-size: 14px;
line-height: 1.5;
}
.message.bot .bubble {
background: #fff;
color: #333;
border-bottom-left-radius: 4px;
}
.message.user .bubble {
background: #2a9d8f;
color: #fff;
border-bottom-right-radius: 4px;
}
.thinking {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2a9d8f;
animation: pulse 1.4s infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.chat-input-area {
display: flex;
gap: 8px;
padding: 16px 20px;
background: #fff;
border-top: 1px solid #eee;
}
.chat-input-area input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 24px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area input:focus { border-color: #2a9d8f; }
.chat-input-area button {
padding: 12px 24px;
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 24px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.chat-input-area button:hover:not(:disabled) { background: #21867a; }
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Scheme cards */
.scheme-cards {
position: absolute;
bottom: 80px;
left: 20px;
right: 20px;
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.1);
}
.scheme-cards h3 {
margin: 0 0 16px;
font-size: 16px;
color: #1b4332;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.scheme-card {
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.scheme-card:hover {
border-color: #2a9d8f;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(42,157,143,0.2);
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.card-badge {
background: #f4a261;
color: #1b4332;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.card-header h4 {
margin: 0;
font-size: 14px;
color: #333;
}
.card-stats {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.card-highlights {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.highlight-tag {
background: #e8f5e9;
color: #2a9d8f;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.card-mini-map {
font-size: 11px;
color: #888;
margin-bottom: 12px;
padding: 6px;
background: #f8f9fa;
border-radius: 6px;
}
.card-action {
text-align: center;
color: #2a9d8f;
font-weight: 600;
font-size: 13px;
}
.slide-up-enter-active, .slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from, .slide-up-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>

@ -0,0 +1,340 @@
<template>
<div class="comparison-view">
<div class="comparison-header">
<button class="back-btn" @click="store.setPhase('chat')"> </button>
<h2>📊 方案对比</h2>
<span class="hint">选择一个方案进入沉浸工作台</span>
</div>
<div class="comparison-grid">
<div
v-for="(scheme, i) in schemes"
:key="i"
class="comparison-card"
:class="{ selected: selectedIdx === i }"
@click="selectedIdx = i"
>
<div class="card-badge">{{ scheme.badge }}</div>
<h3>{{ scheme.name }}</h3>
<div class="comparison-stats">
<div class="comp-stat">
<span class="comp-stat-label">总里程</span>
<span class="comp-stat-value">{{ scheme.km }}</span>
</div>
<div class="comp-stat">
<span class="comp-stat-label">日均驾驶</span>
<span class="comp-stat-value">{{ scheme.avgDrive }}</span>
</div>
<div class="comp-stat">
<span class="comp-stat-label">预估预算</span>
<span class="comp-stat-value">{{ scheme.budget }}</span>
</div>
<div class="comp-stat">
<span class="comp-stat-label">景点密度</span>
<span class="comp-stat-value">{{ scheme.density }}</span>
</div>
</div>
<div class="comp-highlights">
<h4>核心亮点</h4>
<div class="tags">
<span v-for="(h, j) in scheme.highlights" :key="j" class="tag">{{ h }}</span>
</div>
</div>
<div class="comp-route">
<h4>路线概览</h4>
<div class="route-steps">
<span v-for="(step, j) in scheme.steps" :key="j" class="route-step">
{{ step.icon }} {{ step.name }}
</span>
</div>
</div>
<div class="comp-mini-map">
<div class="mini-map-placeholder">
<span>🗺 路线走向预览</span>
<div class="mini-map-line" :style="getLineStyle(i)"></div>
</div>
</div>
<button class="select-btn" @click.stop="selectScheme(i)">
选择此方案
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
const store = useItineraryStore()
const selectedIdx = ref(0)
const schemes = [
{
badge: '方案A',
name: '经典环线:昆明-普者黑-大理-丽江',
km: '~1450km',
avgDrive: '3.5h/天',
budget: '¥4000',
density: '中等',
highlights: ['喀斯特地貌', '苍山洱海', '古城文化', '玉龙雪山'],
steps: [
{ icon: '🏙️', name: '昆明' },
{ icon: '🌿', name: '普者黑' },
{ icon: '🏘️', name: '坝美' },
{ icon: '🏔️', name: '大理' },
{ icon: '🏯', name: '丽江' }
]
},
{
badge: '方案B',
name: '南线深度:昆明-建水-元阳-普者黑',
km: '~980km',
avgDrive: '2.8h/天',
budget: '¥3200',
density: '低(慢节奏)',
highlights: ['元阳梯田', '建水古城', '抚仙湖', '少打卡多体验'],
steps: [
{ icon: '🏙️', name: '昆明' },
{ icon: '🏖️', name: '抚仙湖' },
{ icon: '🏛️', name: '建水' },
{ icon: '🌾', name: '元阳' },
{ icon: '🌿', name: '普者黑' }
]
},
{
badge: '方案C',
name: '西线探险:昆明-大理-沙溪-香格里拉',
km: '~1200km',
avgDrive: '4h/天',
budget: '¥4800',
density: '高(探险型)',
highlights: ['茶马古道', '高原秘境', '松赞林寺', '纳帕海'],
steps: [
{ icon: '🏙️', name: '昆明' },
{ icon: '🏔️', name: '大理' },
{ icon: '🏘️', name: '沙溪' },
{ icon: '⛰️', name: '香格里拉' },
{ icon: '✈️', name: '返程' }
]
}
]
const getLineStyle = (i) => {
const lines = [
'linear-gradient(90deg, #2a9d8f 0%, #f4a261 100%)',
'linear-gradient(90deg, #e76f51 0%, #2a9d8f 100%)',
'linear-gradient(90deg, #264653 0%, #e9c46a 100%)'
]
return `background: ${lines[i]};`
}
const selectScheme = (index) => {
store.setPhase('workbench')
store.resetToSample()
}
</script>
<style scoped>
.comparison-view {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.comparison-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
background: #1b4332;
color: #fff;
}
.back-btn {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.comparison-header h2 {
margin: 0;
font-size: 18px;
}
.hint {
margin-left: auto;
font-size: 12px;
color: rgba(255,255,255,0.6);
}
.comparison-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 24px;
overflow-y: auto;
}
.comparison-card {
background: #fff;
border-radius: 16px;
padding: 20px;
position: relative;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.comparison-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
}
.comparison-card.selected {
border-color: #2a9d8f;
box-shadow: 0 8px 24px rgba(42,157,143,0.2);
}
.card-badge {
position: absolute;
top: 16px;
right: 16px;
background: #f4a261;
color: #1b4332;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.comparison-card h3 {
margin: 0 0 16px;
font-size: 15px;
color: #1b4332;
padding-right: 60px;
}
.comparison-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
}
.comp-stat {
background: #f8f9fa;
padding: 8px 12px;
border-radius: 8px;
text-align: center;
}
.comp-stat-label {
display: block;
font-size: 10px;
color: #888;
text-transform: uppercase;
}
.comp-stat-value {
display: block;
font-size: 14px;
font-weight: 600;
color: #2a9d8f;
margin-top: 2px;
}
.comp-highlights h4, .comp-route h4 {
margin: 0 0 8px;
font-size: 13px;
color: #666;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 16px;
}
.tag {
background: #e8f5e9;
color: #2a9d8f;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
}
.route-steps {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 16px;
}
.route-step {
background: #f0f7f4;
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
color: #333;
}
.comp-mini-map {
margin-bottom: 16px;
}
.mini-map-placeholder {
height: 60px;
background: #f8f9fa;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: #888;
position: relative;
overflow: hidden;
}
.mini-map-line {
position: absolute;
bottom: 12px;
left: 20px;
right: 20px;
height: 3px;
border-radius: 2px;
}
.select-btn {
width: 100%;
padding: 12px;
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.select-btn:hover {
background: #21867a;
transform: translateY(-1px);
}
</style>

@ -0,0 +1,268 @@
<template>
<div class="detail-panel">
<template v-if="point">
<!-- Hero image -->
<div class="hero-section" v-if="point.heroImage">
<img :src="point.heroImage" :alt="point.name" @error="handleImageError" />
<div class="hero-overlay">
<span class="hero-badge">{{ point.badge }}</span>
<h2>{{ point.icon }} {{ point.name }}</h2>
<p>{{ point.desc }}</p>
</div>
</div>
<!-- Stats cards -->
<div class="detail-stats">
<div class="stat-card">
<span class="stat-emoji">🚗</span>
<span class="stat-val">{{ point.km || '-' }}</span>
<span class="stat-label">行驶里程</span>
</div>
<div class="stat-card">
<span class="stat-emoji"></span>
<span class="stat-val">{{ point.driveTime || '-' }}</span>
<span class="stat-label">驾驶时间</span>
</div>
<div class="stat-card">
<span class="stat-emoji">📍</span>
<span class="stat-val">{{ point.day }}</span>
<span class="stat-label">第几天</span>
</div>
</div>
<!-- Schedule -->
<div class="detail-section" v-if="point.schedule.length">
<h3>📅 行程安排</h3>
<div class="timeline-detail">
<div v-for="(s, i) in point.schedule" :key="i" class="timeline-detail-item">
<span class="tdi-time">{{ s.time }}</span>
<span class="tdi-content">{{ s.content }}</span>
</div>
</div>
</div>
<!-- Food recommendations -->
<div class="detail-section" v-if="point.foods && point.foods.length">
<h3>🍽 美食推荐</h3>
<div class="food-tags">
<span v-for="(f, i) in point.foods" :key="i" class="food-tag">{{ f }}</span>
</div>
</div>
<!-- Gallery -->
<div class="detail-section" v-if="point.images && point.images.length">
<h3>📸 风景美图</h3>
<div class="gallery">
<div v-for="(img, i) in point.images" :key="i" class="gallery-item">
<img :src="img" alt="风景" loading="lazy" @error="handleGalleryError" />
</div>
</div>
</div>
<!-- Hotel -->
<div class="detail-section" v-if="point.hotel">
<h3>🛏 住宿推荐</h3>
<div class="hotel-card">
<span class="hotel-icon">🛏</span>
<span class="hotel-text">{{ point.hotel }}</span>
</div>
</div>
<!-- Tips -->
<div class="detail-section" v-if="point.tips">
<h3>💡 注意事项</h3>
<div class="tips-box">{{ point.tips }}</div>
</div>
</template>
<div v-else class="empty-state">
<span class="empty-icon">🧭</span>
<p>选择一个行程节点查看详情</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
const store = useItineraryStore()
const point = computed(() => store.currentPoint)
const handleImageError = (e) => {
e.target.parentElement.style.background = 'linear-gradient(135deg, #a8e6cf, #dcedc1)'
e.target.style.display = 'none'
}
const handleGalleryError = (e) => {
e.target.style.display = 'none'
e.target.parentElement.style.background = 'linear-gradient(135deg, #a8e6cf, #dcedc1)'
}
</script>
<style scoped>
.detail-panel {
overflow-y: auto;
height: 100%;
background: #fff;
}
.hero-section {
position: relative;
height: 200px;
overflow: hidden;
}
.hero-section 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;
}
.detail-stats {
display: flex;
gap: 12px;
padding: 16px;
border-bottom: 1px solid #eee;
}
.stat-card {
flex: 1;
text-align: center;
padding: 12px 8px;
background: #f8f9fa;
border-radius: 10px;
}
.stat-emoji { font-size: 20px; display: block; }
.stat-val { display: block; font-size: 16px; font-weight: 600; color: #2a9d8f; margin: 4px 0; }
.stat-label { font-size: 11px; color: #666; }
.detail-section {
padding: 16px;
border-bottom: 1px solid #eee;
}
.detail-section h3 {
margin: 0 0 12px;
font-size: 15px;
color: #1b4332;
}
.timeline-detail { display: flex; flex-direction: column; gap: 8px; }
.timeline-detail-item {
display: flex;
gap: 8px;
align-items: flex-start;
}
.tdi-time {
flex-shrink: 0;
font-size: 12px;
color: #f4a261;
font-weight: 600;
min-width: 40px;
}
.tdi-content {
font-size: 13px;
color: #444;
line-height: 1.4;
}
.food-tags { 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;
}
.gallery-item {
aspect-ratio: 4/3;
border-radius: 8px;
overflow: hidden;
background: #e8f5e9;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hotel-card {
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-box {
background: #fff3cd;
border-left: 3px solid #f4a261;
padding: 12px;
border-radius: 0 8px 8px 0;
font-size: 13px;
color: #856404;
line-height: 1.5;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { margin: 0; font-size: 14px; }
</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,59 @@
<template>
<div class="mode-toggle">
<button
class="toggle-btn"
:class="{ active: store.mode === 'preview' }"
@click="store.setMode('preview')"
title="预览模式"
>
👁 预览
</button>
<button
class="toggle-btn edit-btn"
:class="{ active: store.mode === 'edit' }"
@click="store.setMode('edit')"
title="编辑模式"
>
编辑
</button>
</div>
</template>
<script setup>
import { useItineraryStore } from '../stores/itinerary'
const store = useItineraryStore()
</script>
<style scoped>
.mode-toggle {
display: flex;
gap: 4px;
background: rgba(27, 67, 50, 0.9);
padding: 4px;
border-radius: 10px;
backdrop-filter: blur(10px);
}
.toggle-btn {
padding: 8px 14px;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
color: rgba(255,255,255,0.6);
background: transparent;
transition: all 0.2s;
white-space: nowrap;
}
.toggle-btn.active {
background: #f4a261;
color: #1b4332;
font-weight: 600;
}
.toggle-btn:hover:not(.active) {
color: #fff;
background: rgba(255,255,255,0.1);
}
</style>

@ -0,0 +1,71 @@
<template>
<transition name="slide">
<div v-if="store.showReplacementPanel" class="replacement-panel">
<div class="panel-header">
<h4>🔄 替换推荐</h4>
<button class="close-btn" @click="closePanel"></button>
</div>
<div class="current-info" v-if="currentPoint">
<span>当前{{ currentPoint.icon }} {{ currentPoint.name }}</span>
</div>
<div class="options-list">
<div
v-for="opt in store.replacementOptions"
:key="opt.id"
class="option-item"
:class="{ hovered: store.hoveredReplacement && store.hoveredReplacement.id === opt.id }"
@click="handleReplace(opt)"
@mouseenter="store.setHoveredReplacement(opt)"
@mouseleave="store.setHoveredReplacement(null)"
>
<span class="opt-icon">{{ opt.icon }}</span>
<div class="opt-info">
<div class="opt-name">{{ opt.name }}</div>
<div class="opt-desc">{{ opt.desc }}</div>
</div>
<span class="opt-action">替换 </span>
</div>
</div>
<div class="panel-hint">💡 悬浮可预览路线变化</div>
</div>
</transition>
</template>
<script setup>
import { computed } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
const store = useItineraryStore()
const currentPoint = computed(() => store.selectedForReplace !== null ? store.points[store.selectedForReplace] : null)
const closePanel = () => { store.showReplacementPanel = false; store.selectedForReplace = null; store.setHoveredReplacement(null) }
const handleReplace = (opt) => { if (store.selectedForReplace !== null) store.replacePoint(store.selectedForReplace, opt) }
</script>
<style scoped>
.replacement-panel {
position: absolute; top: 0; left: 0; bottom: 0; width: 280px;
background: #fff; border-right: 1px solid #e0e0e0; z-index: 20;
box-shadow: 2px 0 12px rgba(0,0,0,0.1); display: flex; flex-direction: column;
}
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #eee; }
.panel-header h4 { margin: 0; font-size: 14px; color: #1b4332; }
.close-btn { background: none; border: none; font-size: 16px; color: #999; cursor: pointer; padding: 4px; }
.close-btn:hover { color: #333; }
.current-info { padding: 12px 16px; background: #f0f7f4; font-size: 13px; color: #2a9d8f; }
.options-list { flex: 1; overflow-y: auto; padding: 8px; }
.option-item {
display: flex; align-items: center; gap: 10px; padding: 12px; border-radius: 8px;
cursor: pointer; transition: all 0.2s; margin-bottom: 4px; border: 1px solid transparent;
}
.option-item:hover { background: #e8f5e9; border-color: #2a9d8f; }
.option-item.hovered { background: #fff3cd; border-color: #f4a261; }
.opt-icon { font-size: 24px; }
.opt-info { flex: 1; }
.opt-name { font-size: 14px; font-weight: 500; color: #333; }
.opt-desc { font-size: 11px; color: #888; margin-top: 2px; }
.opt-action { font-size: 12px; color: #2a9d8f; opacity: 0; transition: opacity 0.2s; }
.option-item:hover .opt-action { opacity: 1; }
.panel-hint { padding: 8px 16px; font-size: 11px; color: #888; border-top: 1px solid #eee; text-align: center; }
.slide-enter-active, .slide-leave-active { transition: transform 0.3s ease; }
.slide-enter-from, .slide-leave-to { transform: translateX(-100%); }
</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,146 @@
<template>
<div class="workbench" :class="{ 'edit-mode': store.mode === 'edit' }">
<!-- Optimization Alert -->
<transition name="slide-down">
<div v-if="store.showOptimizationAlert" class="optimization-alert">
<span class="alert-icon"></span>
<span class="alert-msg">{{ store.optimizationMessage }}</span>
<div class="alert-actions">
<button class="alert-btn yes" @click="handleOptimize"></button>
<button class="alert-btn no" @click="store.dismissOptimization"></button>
</div>
</div>
</transition>
<!-- Top bar -->
<div class="workbench-header">
<div class="header-left">
<h1>🚗 {{ itineraryTitle }}</h1>
<span class="subtitle">{{ itinerarySubtitle }}</span>
</div>
<div class="header-center">
<div class="stats-bar">
<span class="stat-item">总里程 <strong>{{ store.totalKm }}km</strong></span>
<span class="stat-item">驾驶 <strong>{{ store.totalDriveTime }}h</strong></span>
<span class="stat-item">天数 <strong>{{ store.travelDays }}</strong></span>
</div>
</div>
<div class="header-right">
<ModeToggle />
<button class="route-btn" @click="handleFetchRoutes" :disabled="store.isFetchingRoutes" title="获取真实路线">
{{ store.isFetchingRoutes ? '加载中...' : '🗺️ 真实路线' }}
</button>
<button class="export-btn" @click="handleExport" title="导出静态 HTML">📥 导出</button>
<button class="reset-btn" @click="store.resetToSample()" title="重置行程">🔄</button>
</div>
</div>
<!-- Main content -->
<div class="workbench-body">
<div class="workbench-left">
<ReplacementPanel />
<TimelinePanel @animate="handleAnimate" @prevStep="handlePrevStep" @nextStep="handleNextStep" />
</div>
<div class="workbench-middle">
<DetailPanel />
</div>
<div class="workbench-right">
<MapView ref="mapView" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
import ModeToggle from './ModeToggle.vue'
import TimelinePanel from './TimelinePanel.vue'
import DetailPanel from './DetailPanel.vue'
import ReplacementPanel from './ReplacementPanel.vue'
import MapView from './MapView.vue'
import { sampleItinerary } from '../data/sampleData'
import { generateStaticHTML } from '../utils/exporter'
const store = useItineraryStore()
const mapView = ref(null)
const itineraryTitle = sampleItinerary.title
const itinerarySubtitle = sampleItinerary.subtitle
const handleAnimate = (idx, fromStep) => { if (mapView.value) mapView.value.animateCar(idx, fromStep) }
const handlePrevStep = () => {
const prevStep = store.currentStep
store.prevStep()
if (mapView.value) mapView.value.animateCar(store.currentStep, prevStep)
}
const handleNextStep = () => {
const prevStep = store.currentStep
store.nextStep()
if (mapView.value) mapView.value.animateCar(store.currentStep, prevStep)
}
const handleFetchRoutes = async () => { await store.fetchRealRoutes() }
const handleExport = () => {
const data = {
title: store.points[0] ? store.points[0].name.replace('(起点)', '') : '行程规划',
subtitle: itinerarySubtitle,
icon: '🚗',
points: store.points
}
const html = generateStaticHTML(data)
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${data.title}_行程.html`
a.click()
URL.revokeObjectURL(url)
}
const handleOptimize = () => { store.autoOptimizeRoute() }
</script>
<style scoped>
.workbench { display: flex; flex-direction: column; height: 100vh; background: #f5f5f5; }
.optimization-alert {
position: fixed; top: 60px; left: 50%; transform: translateX(-50%);
background: #fff3cd; border: 1px solid #f4a261; border-radius: 12px;
padding: 12px 20px; z-index: 2000; display: flex; align-items: center; gap: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.alert-icon { font-size: 20px; }
.alert-msg { font-size: 13px; color: #856404; }
.alert-actions { display: flex; gap: 8px; }
.alert-btn { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; }
.alert-btn.yes { background: #f4a261; color: #1b4332; }
.alert-btn.no { background: #e0e0e0; color: #666; }
.workbench-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 20px;
background: #1b4332; color: #fff; z-index: 10; flex-shrink: 0;
}
.header-left h1 { margin: 0; font-size: 18px; }
.subtitle { font-size: 12px; color: rgba(255,255,255,0.6); }
.header-center { flex: 1; display: flex; justify-content: center; }
.stats-bar { display: flex; gap: 20px; }
.stat-item { font-size: 13px; color: rgba(255,255,255,0.7); }
.stat-item strong { color: #f4a261; margin-left: 4px; }
.header-right { display: flex; align-items: center; gap: 8px; }
.reset-btn, .export-btn, .route-btn {
padding: 8px 12px; border: 1px solid rgba(255,255,255,0.2); color: #fff;
border-radius: 8px; cursor: pointer; font-size: 12px; transition: all 0.2s;
}
.reset-btn { background: rgba(255,255,255,0.1); }
.export-btn { background: #2a9d8f; border-color: #2a9d8f; }
.route-btn { background: rgba(42,157,143,0.3); }
.route-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.reset-btn:hover { background: rgba(255,255,255,0.2); }
.export-btn:hover { background: #21867a; }
.route-btn:hover:not(:disabled) { background: rgba(42,157,143,0.5); }
.workbench-body { flex: 1; display: flex; overflow: hidden; }
.workbench-left { width: 280px; flex-shrink: 0; position: relative; display: flex; }
.workbench-middle { width: 340px; flex-shrink: 0; border-left: 1px solid #e0e0e0; border-right: 1px solid #e0e0e0; overflow: hidden; }
.workbench-right { flex: 1; position: relative; }
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.3s ease; }
.slide-down-enter-from, .slide-down-leave-to { transform: translateX(-50%) translateY(-20px); opacity: 0; }
</style>

@ -0,0 +1,171 @@
// 云南4天自驾行程示例数据
export const sampleItinerary = {
title: '云南4天自驾行程',
subtitle: '北京出发 · 9月 · 昆明落地自驾',
totalKm: '~1450km',
points: [
{
id: 'p0', name: '昆明(起点)', lat: 25.04, lng: 102.71,
day: '出发日', badge: 'START', km: '0km', driveTime: '—',
desc: '北京飞抵昆明,取车出发', icon: '🏙️',
images: [
'https://picsum.photos/400/300?random=1',
'https://picsum.photos/400/300?random=2',
'https://picsum.photos/400/300?random=3'
],
heroImage: 'https://picsum.photos/800/400?random=1',
schedule: [
{ time: '上午', content: '北京飞昆明约3.5小时),抵达长水机场' },
{ time: '中午', content: '机场取车推荐SUV检查车况' },
{ time: '下午', content: '昆明市区:翠湖公园、斗南花市' },
{ time: '晚上', content: '南强街夜市吃野生菌火锅' }
],
foods: ['野生菌火锅', '过桥米线', '官渡粑粑', '鲜花饼'],
hotel: '昆明市区酒店(翠湖或南屏街附近)',
tips: '取车拍照记录车况,下载离线地图。'
},
{
id: 'p1', name: '普者黑', lat: 24.12, lng: 104.12,
day: 'Day 1', badge: 'D1', km: '280km', driveTime: '3.5h',
desc: '昆明 → 普者黑,喀斯特地貌、荷花世界', icon: '🌿',
images: [
'https://picsum.photos/400/300?random=4',
'https://picsum.photos/400/300?random=5'
],
heroImage: 'https://picsum.photos/800/400?random=4',
schedule: [
{ time: '上午', content: '昆明出发,沿广昆高速前往普者黑' },
{ time: '中午', content: '抵达丘北县,品尝当地特色午餐' },
{ time: '下午', content: '游普者黑景区:青龙山、仙人洞、荷花世界' },
{ time: '晚上', content: '入住景区民宿,观星空' }
],
foods: ['荷花宴', '丘北辣椒鸡', '酸汤鱼', '紫洋芋'],
hotel: '普者黑景区内民宿(仙人洞村)',
tips: '9月荷花正盛建议穿轻便鞋带防晒霜。'
},
{
id: 'p2', name: '坝美', lat: 24.08, lng: 104.65,
day: 'Day 2', badge: 'D2', km: '60km', driveTime: '1.5h',
desc: '普者黑 → 坝美,世外桃源般的壮族村落', icon: '🏘️',
images: [
'https://picsum.photos/400/300?random=6',
'https://picsum.photos/400/300?random=7'
],
heroImage: 'https://picsum.photos/800/400?random=6',
schedule: [
{ time: '上午', content: '普者黑出发,前往坝美村' },
{ time: '中午', content: '乘船穿过水洞,进入坝美村' },
{ time: '下午', content: '漫步古村、田园风光、体验壮族文化' },
{ time: '晚上', content: '返回丘北县城住宿' }
],
foods: ['壮族五色糯米饭', '河鲜', '农家土鸡'],
hotel: '丘北县城酒店',
tips: '坝美需乘船进入,轻装出行,贵重物品防水。'
},
{
id: 'p3', name: '大理', lat: 25.69, lng: 100.26,
day: 'Day 3', badge: 'D3', km: '520km', driveTime: '6.5h',
desc: '丘北 → 大理,苍山洱海、古城漫步', icon: '🏔️',
images: [
'https://picsum.photos/400/300?random=8',
'https://picsum.photos/400/300?random=9',
'https://picsum.photos/400/300?random=10'
],
heroImage: 'https://picsum.photos/800/400?random=8',
schedule: [
{ time: '上午', content: '丘北出发,沿高速前往大理(车程较长)' },
{ time: '中午', content: '途经石林,可短暂停留午餐' },
{ time: '下午', content: '抵达大理,入住酒店,逛大理古城' },
{ time: '晚上', content: '古城酒吧街或人民路夜市' }
],
foods: ['乳扇', '喜洲粑粑', '酸辣鱼', '生皮'],
hotel: '大理古城周边酒店',
tips: '车程较长建议中途休息,注意高反。'
},
{
id: 'p4', name: '丽江', lat: 26.87, lng: 100.23,
day: 'Day 4', badge: 'D4', km: '180km', driveTime: '2.5h',
desc: '大理 → 丽江,丽江古城、玉龙雪山', icon: '🏯',
images: [
'https://picsum.photos/400/300?random=11',
'https://picsum.photos/400/300?random=12',
'https://picsum.photos/400/300?random=13'
],
heroImage: 'https://picsum.photos/800/400?random=11',
schedule: [
{ time: '上午', content: '大理出发,沿大丽高速前往丽江' },
{ time: '中午', content: '抵达丽江,入住古城客栈' },
{ time: '下午', content: '游丽江古城、木府、黑龙潭' },
{ time: '晚上', content: '古城夜景,酒吧街体验纳西文化' }
],
foods: ['腊排骨火锅', '鸡豆凉粉', '纳西烤鱼', '酥油茶'],
hotel: '丽江古城内客栈',
tips: '丽江古城维护费已取消,玉龙雪山需提前购票。'
},
{
id: 'p5', name: '返程', lat: 25.04, lng: 102.71,
day: '返程日', badge: 'END', km: '返程', driveTime: '—',
desc: '丽江机场还车,飞返北京', icon: '✈️',
images: [],
heroImage: '',
schedule: [
{ time: '上午', content: '丽江机场还车,办理登机' },
{ time: '全天', content: '飞返北京,结束愉快旅程' }
],
foods: [],
hotel: '',
tips: '提前2小时到达机场预留还车时间。'
}
]
}
export const replacementPool = {
'普者黑': [
{ id: 'r1', name: '弥勒', lat: 24.41, lng: 103.41, icon: '♨️', desc: '温泉小镇,东风韵艺术小镇' },
{ id: 'r2', name: '抚仙湖', lat: 24.55, lng: 102.93, icon: '🏖️', desc: '云南最深湖泊,水质清澈' },
{ id: 'r3', name: '建水古城', lat: 23.62, lng: 102.82, icon: '🏛️', desc: '千年古城,建水豆腐' }
],
'坝美': [
{ id: 'r4', name: '元阳梯田', lat: 23.22, lng: 102.83, icon: '🌾', desc: '世界遗产,哈尼梯田' },
{ id: 'r5', name: '蒙自', lat: 23.37, lng: 103.38, icon: '🍜', desc: '过桥米线发源地' }
],
'大理': [
{ id: 'r6', name: '沙溪古镇', lat: 26.32, lng: 99.85, icon: '🏘️', desc: '茶马古道重镇,宁静古朴' },
{ id: 'r7', name: '巍山古城', lat: 25.23, lng: 100.30, icon: '🏛️', desc: '南诏故都,小吃天堂' }
],
'丽江': [
{ id: 'r8', name: '香格里拉', lat: 27.83, lng: 99.70, icon: '🏔️', desc: '高原秘境,松赞林寺' },
{ id: 'r9', name: '泸沽湖', lat: 27.72, lng: 100.82, icon: '💧', desc: '摩梭人走婚,湖光山色' }
]
}
// AI 对话方案模板
export const aiTemplates = [
{
badge: '方案A',
name: '经典环线:昆明-普者黑-大理-丽江',
km: '~1450km',
driveTime: '14h',
budget: '¥4000',
highlights: ['喀斯特地貌', '苍山洱海', '古城文化', '玉龙雪山'],
route: '昆明 → 普者黑 → 坝美 → 大理 → 丽江'
},
{
badge: '方案B',
name: '南线深度:昆明-建水-元阳-普者黑',
km: '~980km',
driveTime: '11h',
budget: '¥3200',
highlights: ['元阳梯田', '建水古城', '抚仙湖', '少打卡多体验'],
route: '昆明 → 抚仙湖 → 建水 → 元阳 → 普者黑'
},
{
badge: '方案C',
name: '西线探险:昆明-大理-沙溪-香格里拉',
km: '~1200km',
driveTime: '16h',
budget: '¥4800',
highlights: ['茶马古道', '高原秘境', '松赞林寺', '纳帕海'],
route: '昆明 → 大理 → 沙溪 → 香格里拉 → 返程'
}
]

@ -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,104 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const STORAGE_KEY = 'trip_planner_ai_config'
const DEFAULT_CONFIG = {
provider: 'aliyun',
apiKey: '',
model: 'qwen-plus',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
temperature: 0.7,
maxTokens: 2000
}
const ALIYUN_MODELS = [
{ value: 'qwen-turbo', label: 'Qwen-Turbo快速', desc: '速度快,成本低,适合简单对话' },
{ value: 'qwen-plus', label: 'Qwen-Plus均衡', desc: '速度与质量平衡,推荐使用' },
{ value: 'qwen-max', label: 'Qwen-Max高质量', desc: '复杂推理,质量最高' },
{ value: 'qwen-max-latest', label: 'Qwen-Max-Latest', desc: '最新版本,能力最强' },
{ value: 'qwen-long', label: 'Qwen-Long长文本', desc: '支持超长上下文100万token' }
]
export const useSettingsStore = defineStore('settings', () => {
const config = ref(loadConfig())
const isConfigured = computed(() => !!config.value.apiKey)
const isTesting = ref(false)
const testResult = ref(null)
const showConfigPage = ref(false)
function loadConfig() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }
} catch (e) {
console.warn('Failed to load config:', e)
}
return { ...DEFAULT_CONFIG }
}
function saveConfig() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config.value))
} catch (e) {
console.warn('Failed to save config:', e)
}
}
function updateConfig(updates) {
config.value = { ...config.value, ...updates }
saveConfig()
}
function resetConfig() {
config.value = { ...DEFAULT_CONFIG, apiKey: '' }
saveConfig()
}
async function testConnection() {
isTesting.value = true
testResult.value = null
if (!config.value.apiKey) {
testResult.value = { success: false, message: '请先输入 API Key' }
isTesting.value = false
return false
}
try {
const response = await fetch(`${config.value.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.value.apiKey}`
},
body: JSON.stringify({
model: config.value.model,
messages: [{ role: 'user', content: '你好请回复OK' }],
max_tokens: 10
})
})
const data = await response.json()
if (response.ok && data.choices) {
testResult.value = { success: true, message: `连接成功!模型: ${data.model || config.value.model}` }
saveConfig()
return true
} else {
testResult.value = { success: false, message: data.error?.message || '连接失败' }
return false
}
} catch (error) {
testResult.value = { success: false, message: `网络错误: ${error.message}` }
return false
} finally {
isTesting.value = false
}
}
return {
config, isConfigured, isTesting, testResult, showConfigPage, ALIYUN_MODELS,
updateConfig, resetConfig, testConnection
}
})

@ -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,500 @@
<template>
<div class="settings-page">
<div class="settings-header">
<button class="back-btn" @click="goBack"> </button>
<h1> AI 模型配置</h1>
<div class="header-spacer"></div>
</div>
<div class="settings-content">
<!-- Provider Selection -->
<div class="settings-section">
<h2>🤖 服务商</h2>
<div class="provider-selector">
<div class="provider-card active">
<span class="provider-icon"></span>
<div class="provider-info">
<span class="provider-name">Aliyun DashScope</span>
<span class="provider-desc">通义千问系列模型</span>
</div>
<span class="check-mark"></span>
</div>
</div>
</div>
<!-- API Key -->
<div class="settings-section">
<h2>🔑 API Key</h2>
<div class="input-group">
<div class="input-wrapper">
<input
:type="showKey ? 'text' : 'password'"
v-model="localConfig.apiKey"
placeholder="请输入 DashScope API Key"
class="api-input"
@input="debounceSave"
/>
<button class="toggle-key-btn" @click="showKey = !showKey">
{{ showKey ? '🙈' : '👁️' }}
</button>
</div>
<p class="input-hint">
获取地址<a href="https://bailian.console.aliyun.com/" target="_blank">阿里云百炼控制台 API-KEY 管理</a>
</p>
</div>
</div>
<!-- Model Selection -->
<div class="settings-section">
<h2>📦 模型选择</h2>
<div class="model-grid">
<div
v-for="model in ALIYUN_MODELS"
:key="model.value"
class="model-card"
:class="{ selected: localConfig.model === model.value }"
@click="selectModel(model.value)"
>
<div class="model-header">
<span class="model-radio" :class="{ checked: localConfig.model === model.value }"></span>
<span class="model-name">{{ model.label }}</span>
</div>
<span class="model-desc">{{ model.desc }}</span>
</div>
</div>
</div>
<!-- Base URL -->
<div class="settings-section">
<h2>🌐 Base URL</h2>
<div class="input-group">
<input
v-model="localConfig.baseUrl"
placeholder="API 基础地址"
class="text-input"
@input="debounceSave"
/>
<p class="input-hint">默认https://dashscope.aliyuncs.com/compatible-mode/v1</p>
</div>
</div>
<!-- Advanced Settings -->
<div class="settings-section">
<h2>🔧 高级设置</h2>
<div class="advanced-grid">
<div class="advanced-item">
<label>Temperature</label>
<div class="slider-group">
<input
type="range"
min="0"
max="1"
step="0.1"
v-model.number="localConfig.temperature"
@input="debounceSave"
/>
<span class="slider-value">{{ localConfig.temperature }}</span>
</div>
<span class="item-desc">创造性 (0=精确, 1=随机)</span>
</div>
<div class="advanced-item">
<label>Max Tokens</label>
<div class="slider-group">
<input
type="range"
min="500"
max="8000"
step="500"
v-model.number="localConfig.maxTokens"
@input="debounceSave"
/>
<span class="slider-value">{{ localConfig.maxTokens }}</span>
</div>
<span class="item-desc">最大输出长度</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="settings-actions">
<button class="test-btn" @click="handleTest" :disabled="isTesting">
<span v-if="isTesting" class="spinner"></span>
{{ isTesting ? '测试中...' : '🔗 测试连接' }}
</button>
<button class="save-btn" @click="handleSave">💾 </button>
<button class="reset-btn" @click="handleReset">🔄 </button>
</div>
<!-- Test Result -->
<transition name="fade">
<div v-if="testResult" class="test-result" :class="{ success: testResult.success, error: !testResult.success }">
<span class="result-icon">{{ testResult.success ? '✅' : '❌' }}</span>
<span class="result-msg">{{ testResult.message }}</span>
<button class="close-result" @click="testResult = null">×</button>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSettingsStore } from '../stores/settings'
const router = useRouter()
const store = useSettingsStore()
const emit = defineEmits(['back'])
const localConfig = ref({ ...store.config })
const showKey = ref(false)
const testResult = ref(null)
const ALIYUN_MODELS = computed(() => store.ALIYUN_MODELS)
const isTesting = computed(() => store.isTesting)
let saveTimer = null
const debounceSave = () => {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
store.updateConfig(localConfig.value)
}, 500)
}
const selectModel = (model) => {
localConfig.value.model = model
store.updateConfig({ model })
}
const goBack = () => router.push('/')
const handleTest = async () => {
store.updateConfig(localConfig.value)
const success = await store.testConnection()
testResult.value = store.testResult
}
const handleSave = () => {
store.updateConfig(localConfig.value)
testResult.value = { success: true, message: '✅ 配置已保存' }
setTimeout(() => { testResult.value = null }, 2000)
}
const handleReset = () => {
if (confirm('确定要重置所有配置吗?')) {
store.resetConfig()
localConfig.value = { ...store.config }
testResult.value = { success: true, message: '🔄 已重置为默认配置' }
}
}
onMounted(() => {
localConfig.value = { ...store.config }
})
</script>
<style scoped>
.settings-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.settings-header {
display: flex;
align-items: center;
padding: 12px 20px;
background: #1b4332;
color: #fff;
gap: 16px;
}
.back-btn {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.settings-header h1 {
margin: 0;
font-size: 18px;
}
.header-spacer { flex: 1; }
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.settings-section {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.settings-section h2 {
margin: 0 0 16px;
font-size: 15px;
color: #1b4332;
}
/* Provider */
.provider-selector {
display: flex;
gap: 12px;
}
.provider-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 2px solid #2a9d8f;
border-radius: 10px;
background: #f0f7f4;
flex: 1;
}
.provider-icon { font-size: 28px; }
.provider-info { flex: 1; }
.provider-name { display: block; font-weight: 600; color: #333; }
.provider-desc { display: block; font-size: 12px; color: #888; }
.check-mark { color: #2a9d8f; font-size: 20px; font-weight: bold; }
/* Input */
.input-group { display: flex; flex-direction: column; gap: 8px; }
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.input-wrapper:focus-within { border-color: #2a9d8f; }
.api-input, .text-input {
flex: 1;
padding: 12px 16px;
border: none;
font-size: 14px;
outline: none;
}
.toggle-key-btn {
background: none;
border: none;
padding: 8px 12px;
cursor: pointer;
font-size: 16px;
color: #888;
}
.input-hint {
margin: 0;
font-size: 12px;
color: #888;
}
.input-hint a {
color: #2a9d8f;
text-decoration: none;
}
.input-hint a:hover { text-decoration: underline; }
/* Model Grid */
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.model-card {
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.model-card:hover { border-color: #b0d4c8; }
.model-card.selected {
border-color: #2a9d8f;
background: #f0f7f4;
}
.model-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.model-radio {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
}
.model-radio.checked {
border-color: #2a9d8f;
}
.model-radio.checked::after {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: #2a9d8f;
}
.model-name { font-weight: 500; color: #333; }
.model-desc { font-size: 12px; color: #888; }
/* Advanced */
.advanced-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.advanced-item { display: flex; flex-direction: column; gap: 8px; }
.advanced-item label { font-size: 13px; color: #666; font-weight: 500; }
.slider-group {
display: flex;
align-items: center;
gap: 12px;
}
.slider-group input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: #e0e0e0;
border-radius: 2px;
outline: none;
}
.slider-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2a9d8f;
cursor: pointer;
}
.slider-value {
min-width: 40px;
text-align: right;
font-weight: 600;
color: #2a9d8f;
}
.item-desc { font-size: 11px; color: #aaa; }
/* Actions */
.settings-actions {
display: flex;
gap: 12px;
padding: 20px 0;
}
.test-btn, .save-btn, .reset-btn {
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.test-btn { background: #e3f2fd; color: #1976d2; }
.test-btn:hover:not(:disabled) { background: #bbdefb; }
.test-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.save-btn { background: #2a9d8f; color: #fff; }
.save-btn:hover { background: #21867a; }
.reset-btn { background: #f5f5f5; color: #666; }
.reset-btn:hover { background: #e0e0e0; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(25,118,210,0.3);
border-top-color: #1976d2;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Test Result */
.test-result {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
padding: 14px 20px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
z-index: 1000;
}
.test-result.success { background: #e8f5e9; color: #2e7d32; }
.test-result.error { background: #ffebee; color: #c62828; }
.result-icon { font-size: 18px; }
.result-msg { font-size: 14px; }
.close-result {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: inherit;
opacity: 0.6;
margin-left: 8px;
}
.close-result:hover { opacity: 1; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
@media (max-width: 600px) {
.advanced-grid { grid-template-columns: 1fr; }
.model-grid { grid-template-columns: 1fr; }
.settings-actions { flex-direction: column; }
}
</style>

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: './',
server: {
historyApiFallback: true
}
})
Loading…
Cancel
Save