AI 生成: 一个面向个人/团队/家庭的智能旅游规划工具,支持AI行程生成、地图标注与景点打卡记录。
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Travel Planner
|
||||
|
||||
一个面向个人、团队或家庭的智能旅游规划工具。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🗺️ **地图可视化**:使用 Leaflet + OpenStreetMap,无需 API Key
|
||||
- 🤖 **AI 智能规划**:根据目的地、天数、偏好自动生成行程建议
|
||||
- 📍 **景点标注**:登录后可将景点标记为「去过」或「想去」
|
||||
- 👨👩👧 **多用户支持**:每个账号独立保存自己的旅行记录
|
||||
- 💾 **本地存储**:数据保存在浏览器 localStorage,无需后端
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:直接用浏览器打开
|
||||
|
||||
直接用现代浏览器打开 `index.html` 即可。
|
||||
|
||||
### 方式二:使用 Python Flask 启动(推荐)
|
||||
|
||||
```bash
|
||||
pip install flask
|
||||
python app.py
|
||||
```
|
||||
|
||||
然后访问 http://127.0.0.1:5000
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 注册一个账号(本地模拟,数据存在浏览器中)
|
||||
2. 在左侧面板输入目的地、天数、偏好,点击「AI 生成行程」
|
||||
3. 在地图上点击任意位置,可以添加自定义景点标注
|
||||
4. 点击景点标记,可切换「想去 / 去过」状态
|
||||
5. 切换「显示模式」可以只看去过的或想去的景点
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
travel-planner/
|
||||
├── app.py # Flask 启动入口(可选)
|
||||
├── index.html # 主页面
|
||||
├── css/
|
||||
│ └── style.css # 样式
|
||||
├── js/
|
||||
│ ├── auth.js # 登录/注册逻辑
|
||||
│ ├── map.js # 地图与标注
|
||||
│ ├── ai.js # AI 行程生成
|
||||
│ └── main.js # 主入口
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 原生 HTML / CSS / JavaScript
|
||||
- [Leaflet](https://leafletjs.com/) 地图库
|
||||
- OpenStreetMap 瓦片
|
||||
- Flask(可选静态服务)
|
||||
|
||||
## 说明
|
||||
|
||||
AI 规划为本地规则引擎模拟(无需联网调用大模型)。如需接入真实大模型 API,可在 `js/ai.js` 的 `generatePlan` 中替换为 fetch 调用。
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Travel Planner - Flask 静态服务入口
|
||||
|
||||
运行:
|
||||
pip install flask
|
||||
python app.py
|
||||
然后访问 http://127.0.0.1:5000
|
||||
"""
|
||||
|
||||
from flask import Flask, send_from_directory
|
||||
import os
|
||||
|
||||
app = Flask(__name__, static_folder='.', static_url_path='')
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory('.', 'index.html')
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def static_files(path):
|
||||
# 保证静态资源(css/js 等)可访问
|
||||
if os.path.exists(path):
|
||||
return send_from_directory('.', path)
|
||||
return send_from_directory('.', 'index.html')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='127.0.0.1', port=5000, debug=True)
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
background: #1e293b;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.logo { font-size: 18px; font-weight: 600; }
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 340px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel h3 { margin: 0 0 10px; font-size: 14px; color: #334155; }
|
||||
.panel label { display: block; font-size: 13px; color: #475569; margin-bottom: 8px; }
|
||||
.panel input, .panel select {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #e2e8f0;
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #cbd5e1; }
|
||||
.btn.primary { background: #2563eb; color: #fff; }
|
||||
.btn.primary:hover { background: #1d4ed8; }
|
||||
.btn.ghost { background: transparent; border: 1px solid #cbd5e1; }
|
||||
|
||||
.filter-group { display: flex; gap: 12px; font-size: 13px; }
|
||||
.filter-group label { display: flex; align-items: center; gap: 4px; margin: 0; }
|
||||
|
||||
.spot-list { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; }
|
||||
.spot-list li {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px dashed #e2e8f0;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.spot-list li .status { font-size: 11px; padding: 2px 6px; border-radius: 10px; }
|
||||
.status.visited { background: #dcfce7; color: #166534; }
|
||||
.status.wish { background: #fef3c7; color: #92400e; }
|
||||
|
||||
.plan-result {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #334155;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.map-wrap { flex: 1; position: relative; }
|
||||
#map { width: 100%; height: 100%; }
|
||||
.hint {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal.hidden { display: none; }
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal-content input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.modal-actions { display: flex; gap: 8px; }
|
||||
.tip { font-size: 11px; color: #94a3b8; margin: 0; }
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Travel Planner · 智能旅游规划</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="logo">🌍 Travel Planner</div>
|
||||
<div class="user-area" id="userArea">
|
||||
<button id="loginBtn" class="btn">登录 / 注册</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<section class="panel">
|
||||
<h3>AI 智能规划</h3>
|
||||
<label>目的地
|
||||
<input id="destInput" type="text" placeholder="例如:杭州" value="杭州" />
|
||||
</label>
|
||||
<label>天数
|
||||
<input id="daysInput" type="number" min="1" max="15" value="3" />
|
||||
</label>
|
||||
<label>偏好
|
||||
<select id="prefInput">
|
||||
<option value="自然风光">自然风光</option>
|
||||
<option value="历史文化">历史文化</option>
|
||||
<option value="美食购物">美食购物</option>
|
||||
<option value="亲子家庭">亲子家庭</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="genBtn" class="btn primary">生成行程</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>显示模式</h3>
|
||||
<div class="filter-group">
|
||||
<label><input type="radio" name="filter" value="all" checked /> 全部</label>
|
||||
<label><input type="radio" name="filter" value="visited" /> 去过</label>
|
||||
<label><input type="radio" name="filter" value="wish" /> 想去</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>我的景点</h3>
|
||||
<ul id="spotList" class="spot-list"></ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>行程结果</h3>
|
||||
<div id="planResult" class="plan-result">还没有生成行程,试试点击「生成行程」吧~</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="map-wrap">
|
||||
<div id="map"></div>
|
||||
<div class="hint">💡 在地图上点击任意位置可添加景点标注</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 登录弹窗 -->
|
||||
<div id="loginModal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3 id="modalTitle">登录</h3>
|
||||
<input id="usernameInput" type="text" placeholder="用户名" />
|
||||
<input id="passwordInput" type="password" placeholder="密码" />
|
||||
<div class="modal-actions">
|
||||
<button id="doLogin" class="btn primary">登录</button>
|
||||
<button id="doRegister" class="btn">注册</button>
|
||||
<button id="closeModal" class="btn ghost">取消</button>
|
||||
</div>
|
||||
<p class="tip">数据保存在本地浏览器,仅用于演示。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/map.js"></script>
|
||||
<script src="js/ai.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
// AI 行程生成:本地规则引擎(可替换为真实大模型 API)
|
||||
(function () {
|
||||
// 一些常见城市的示例景点库
|
||||
const SPOT_DB = {
|
||||
'杭州': {
|
||||
'自然风光': ['西湖', '西溪湿地', '九溪烟树'],
|
||||
'历史文化': ['灵隐寺', '雷峰塔', '南宋御街'],
|
||||
'美食购物': ['河坊街', '武林夜市', '湖滨银泰'],
|
||||
'亲子家庭': ['杭州动物园', '极地海洋公园', '少年儿童公园']
|
||||
},
|
||||
'北京': {
|
||||
'自然风光': ['颐和园', '香山公园', '奥林匹克森林公园'],
|
||||
'历史文化': ['故宫', '天坛', '八达岭长城'],
|
||||
'美食购物': ['南锣鼓巷', '王府井', '簋街'],
|
||||
'亲子家庭': ['北京动物园', '欢乐谷', '中国科技馆']
|
||||
},
|
||||
'成都': {
|
||||
'自然风光': ['青城山', '都江堰', '西岭雪山'],
|
||||
'历史文化': ['武侯祠', '杜甫草堂', '金沙遗址'],
|
||||
'美食购物': ['宽窄巷子', '锦里', '春熙路'],
|
||||
'亲子家庭': ['成都动物园', '大熊猫繁育研究基地', '欢乐谷']
|
||||
},
|
||||
'上海': {
|
||||
'自然风光': ['外滩', '世纪公园', '佘山'],
|
||||
'历史文化': ['豫园', '城隍庙', '中共一大会址'],
|
||||
'美食购物': ['南京路', '新天地', '田子坊'],
|
||||
'亲子家庭': ['上海动物园', '迪士尼乐园', '上海科技馆']
|
||||
}
|
||||
};
|
||||
|
||||
// 默认通用景点(当城市不在库中时使用)
|
||||
const DEFAULT_SPOTS = {
|
||||
'自然风光': ['城市公园', '滨江步道', '近郊山景'],
|
||||
'历史文化': ['博物馆', '老城区', '历史街区'],
|
||||
'美食购物': ['美食街', '商业中心', '夜市'],
|
||||
'亲子家庭': ['动物园', '科技馆', '游乐场']
|
||||
};
|
||||
|
||||
// 生成行程计划
|
||||
function generatePlan(dest, days, pref) {
|
||||
const city = (dest || '').trim();
|
||||
const db = SPOT_DB[city] || DEFAULT_SPOTS;
|
||||
const pool = db[pref] || DEFAULT_SPOTS[pref] || DEFAULT_SPOTS['自然风光'];
|
||||
|
||||
const lines = [];
|
||||
lines.push(`📍 目的地:${city || '未指定'}`);
|
||||
lines.push(`🗓️ 天数:${days} 天`);
|
||||
lines.push(`🎯 偏好:${pref}`);
|
||||
lines.push('');
|
||||
|
||||
for (let d = 1; d <= days; d++) {
|
||||
lines.push(`—— 第 ${d} 天 ——`);
|
||||
const morning = pool[(d - 1) % pool.length];
|
||||
const afternoon = pool[(d) % pool.length];
|
||||
const evening = pool[(d + 1) % pool.length];
|
||||
lines.push(`上午:游览「${morning}」`);
|
||||
lines.push(`下午:前往「${afternoon}」`);
|
||||
lines.push(`晚上:在「${evening}」附近用餐 / 散步`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('💡 提示:可在地图上点击添加自定义景点并标注状态。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// 对外接口(异步,方便未来替换为真实 API)
|
||||
function generatePlanAsync(dest, days, pref) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(generatePlan(dest, days, pref)), 400);
|
||||
});
|
||||
}
|
||||
|
||||
window.AI = { generatePlanAsync };
|
||||
})();
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// 简单的本地账号系统:用户数据保存在 localStorage
|
||||
(function () {
|
||||
const USERS_KEY = 'tp_users';
|
||||
const SESSION_KEY = 'tp_session';
|
||||
|
||||
function getUsers() {
|
||||
try { return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }
|
||||
catch (e) { return {}; }
|
||||
}
|
||||
function saveUsers(users) {
|
||||
localStorage.setItem(USERS_KEY, JSON.stringify(users));
|
||||
}
|
||||
|
||||
const Auth = {
|
||||
currentUser() {
|
||||
return localStorage.getItem(SESSION_KEY) || null;
|
||||
},
|
||||
login(username, password) {
|
||||
const users = getUsers();
|
||||
if (!users[username]) return { ok: false, msg: '用户不存在,请先注册' };
|
||||
if (users[username] !== password) return { ok: false, msg: '密码错误' };
|
||||
localStorage.setItem(SESSION_KEY, username);
|
||||
return { ok: true };
|
||||
},
|
||||
register(username, password) {
|
||||
if (!username || !password) return { ok: false, msg: '用户名和密码不能为空' };
|
||||
const users = getUsers();
|
||||
if (users[username]) return { ok: false, msg: '用户已存在' };
|
||||
users[username] = password;
|
||||
saveUsers(users);
|
||||
localStorage.setItem(SESSION_KEY, username);
|
||||
return { ok: true };
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
},
|
||||
// 每个用户独立的数据命名空间
|
||||
dataKey(name) {
|
||||
const u = this.currentUser();
|
||||
return u ? `tp_data_${u}_${name}` : null;
|
||||
}
|
||||
};
|
||||
|
||||
window.Auth = Auth;
|
||||
})();
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// 主入口:串联 UI、认证、地图与 AI
|
||||
(function () {
|
||||
const $ = sel => document.querySelector(sel);
|
||||
|
||||
// ================== 用户界面 ==================
|
||||
function renderUserArea() {
|
||||
const user = window.Auth.currentUser();
|
||||
const area = $('#userArea');
|
||||
if (user) {
|
||||
area.innerHTML = `<span style="margin-right:10px;font-size:13px">👤 ${user}</span>` +
|
||||
`<button id="logoutBtn" class="btn">退出</button>`;
|
||||
$('#logoutBtn').addEventListener('click', () => {
|
||||
window.Auth.logout();
|
||||
renderUserArea();
|
||||
loadSpots();
|
||||
});
|
||||
} else {
|
||||
area.innerHTML = `<button id="loginBtn" class="btn">登录 / 注册</button>`;
|
||||
$('#loginBtn').addEventListener('click', openModal);
|
||||
}
|
||||
}
|
||||
|
||||
// ================== 弹窗 ==================
|
||||
function openModal() {
|
||||
$('#loginModal').classList.remove('hidden');
|
||||
}
|
||||
function closeModal() {
|
||||
$('#loginModal').classList.add('hidden');
|
||||
$('#usernameInput').value = '';
|
||||
$('#passwordInput').value = '';
|
||||
}
|
||||
|
||||
function bindModal() {
|
||||
$('#closeModal').addEventListener('click', closeModal);
|
||||
$('#doLogin').addEventListener('click', () => {
|
||||
const u = $('#usernameInput').value.trim();
|
||||
const p = $('#passwordInput').value;
|
||||
const res = window.Auth.login(u, p);
|
||||
if (!res.ok) return alert(res.msg);
|
||||
closeModal();
|
||||
renderUserArea();
|
||||
loadSpots();
|
||||
});
|
||||
$('#doRegister').addEventListener('click', () => {
|
||||
const u = $('#usernameInput').value.trim();
|
||||
const p = $('#passwordInput').value;
|
||||
const res = window.Auth.register(u, p);
|
||||
if (!res.ok) return alert(res.msg);
|
||||
closeModal();
|
||||
renderUserArea();
|
||||
loadSpots();
|
||||
});
|
||||
}
|
||||
|
||||
// ================== 景点数据存储 ==================
|
||||
function loadSpots() {
|
||||
const key = window.Auth.dataKey('spots');
|
||||
if (!key) {
|
||||
// 未登录:清空地图
|
||||
window.MapAPI.setSpots([]);
|
||||
renderSpotList([]);
|
||||
return;
|
||||
}
|
||||
let spots = [];
|
||||
try { spots = JSON.parse(localStorage.getItem(key)) || []; }
|
||||
catch (e) { spots = []; }
|
||||
window.MapAPI.setSpots(spots);
|
||||
renderSpotList(spots);
|
||||
}
|
||||
|
||||
function saveSpots(spots) {
|
||||
const key = window.Auth.dataKey('spots');
|
||||
if (!key) return;
|
||||
localStorage.setItem(key, JSON.stringify(spots));
|
||||
}
|
||||
|
||||
// 当 MapAPI 内部数据变化时回调
|
||||
window.MapAPI.onSpotsChange = function (spots) {
|
||||
saveSpots(spots);
|
||||
renderSpotList(spots);
|
||||
};
|
||||
|
||||
// ================== 景点列表渲染 ==================
|
||||
let currentFilter = 'all';
|
||||
|
||||
function renderSpotList(spots) {
|
||||
const list = $('#spotList');
|
||||
list.innerHTML = '';
|
||||
const filtered = spots.filter(s => {
|
||||
if (currentFilter === 'all') return true;
|
||||
return s.status === currentFilter;
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = '<li style="color:#94a3b8">暂无景点</li>';
|
||||
return;
|
||||
}
|
||||
filtered.forEach(s => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span>${s.name}</span>` +
|
||||
`<span class="status ${s.status}">${s.status === 'visited' ? '去过' : '想去'}</span>`;
|
||||
li.style.cursor = 'pointer';
|
||||
li.addEventListener('click', () => {
|
||||
window.MapAPI.flyTo(s.lat, s.lng, 14);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
// ================== 过滤器 ==================
|
||||
function bindFilter() {
|
||||
document.querySelectorAll('input[name="filter"]').forEach(el => {
|
||||
el.addEventListener('change', e => {
|
||||
currentFilter = e.target.value;
|
||||
renderSpotList(window.MapAPI.getSpots());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ================== AI 生成 ==================
|
||||
function bindAI() {
|
||||
$('#genBtn').addEventListener('click', async () => {
|
||||
const dest = $('#destInput').value.trim();
|
||||
const days = parseInt($('#daysInput').value, 10) || 1;
|
||||
const pref = $('#prefInput').value;
|
||||
const result = $('#planResult');
|
||||
result.textContent = '⏳ 正在生成行程...';
|
||||
try {
|
||||
const text = await window.AI.generatePlanAsync(dest, days, pref);
|
||||
result.textContent = text;
|
||||
// 顺便把目的地飞到地图中心
|
||||
const cityCoords = {
|
||||
'杭州': [30.2741, 120.1551],
|
||||
'北京': [39.9042, 116.4074],
|
||||
'成都': [30.5728, 104.0668],
|
||||
'上海': [31.2304, 121.4737]
|
||||
};
|
||||
if (cityCoords[dest]) {
|
||||
window.MapAPI.flyTo(cityCoords[dest][0], cityCoords[dest][1], 11);
|
||||
}
|
||||
} catch (err) {
|
||||
result.textContent = '生成失败:' + err.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ================== 启动 ==================
|
||||
function init() {
|
||||
renderUserArea();
|
||||
bindModal();
|
||||
bindFilter();
|
||||
bindAI();
|
||||
loadSpots();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
// 地图与景点标注逻辑
|
||||
(function () {
|
||||
// 初始化地图:默认定位到中国杭州
|
||||
const map = L.map('map').setView([30.2741, 120.1551], 11);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(map);
|
||||
|
||||
// 保存所有标注:{ id, name, lat, lng, status: 'visited' | 'wish' }
|
||||
let spots = [];
|
||||
const markers = {}; // id -> L.Marker
|
||||
|
||||
// 根据状态生成不同颜色图标
|
||||
function makeIcon(status) {
|
||||
const color = status === 'visited' ? '#16a34a' : '#f59e0b';
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="background:${color};width:16px;height:16px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
}
|
||||
|
||||
function renderSpot(spot) {
|
||||
if (markers[spot.id]) {
|
||||
map.removeLayer(markers[spot.id]);
|
||||
}
|
||||
const marker = L.marker([spot.lat, spot.lng], { icon: makeIcon(spot.status) }).addTo(map);
|
||||
marker.bindPopup(
|
||||
`<b>${spot.name}</b><br/>状态:${spot.status === 'visited' ? '去过 ✅' : '想去 ⭐'}<br/>` +
|
||||
`<button data-id="${spot.id}" class="popup-toggle">切换状态</button>`
|
||||
);
|
||||
markers[spot.id] = marker;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
// 清除旧 marker
|
||||
Object.values(markers).forEach(m => map.removeLayer(m));
|
||||
Object.keys(markers).forEach(k => delete markers[k]);
|
||||
spots.forEach(renderSpot);
|
||||
}
|
||||
|
||||
// 地图点击添加新景点
|
||||
map.on('click', function (e) {
|
||||
const user = window.Auth.currentUser();
|
||||
if (!user) {
|
||||
alert('请先登录后再添加景点标注');
|
||||
return;
|
||||
}
|
||||
const name = prompt('请输入景点名称:');
|
||||
if (!name) return;
|
||||
const spot = {
|
||||
id: 's_' + Date.now() + '_' + Math.floor(Math.random() * 1000),
|
||||
name: name.trim(),
|
||||
lat: e.latlng.lat,
|
||||
lng: e.latlng.lng,
|
||||
status: 'wish'
|
||||
};
|
||||
spots.push(spot);
|
||||
renderSpot(spot);
|
||||
window.MapAPI.onSpotsChange(spots);
|
||||
});
|
||||
|
||||
// 弹窗内切换状态
|
||||
map.on('popupopen', function (e) {
|
||||
const btn = e.popup.getElement().querySelector('.popup-toggle');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
const id = btn.getAttribute('data-id');
|
||||
const spot = spots.find(s => s.id === id);
|
||||
if (!spot) return;
|
||||
spot.status = spot.status === 'visited' ? 'wish' : 'visited';
|
||||
renderSpot(spot);
|
||||
window.MapAPI.onSpotsChange(spots);
|
||||
map.closePopup();
|
||||
});
|
||||
});
|
||||
|
||||
// 对外暴露的 API
|
||||
window.MapAPI = {
|
||||
getMap: () => map,
|
||||
getSpots: () => spots,
|
||||
setSpots(newSpots) {
|
||||
spots = newSpots || [];
|
||||
renderAll();
|
||||
},
|
||||
// 由 main.js 覆盖,用于同步到 UI 和存储
|
||||
onSpotsChange: function () {},
|
||||
// 定位到坐标
|
||||
flyTo(lat, lng, zoom) {
|
||||
map.flyTo([lat, lng], zoom || 12);
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user