commit 7e7bd600e453da559fb7c1bd40887dcc5dba6478 Author: eeuse Date: Tue Sep 29 04:31:48 2026 +0800 AI 生成: 一个面向个人/团队/家庭的智能旅游规划工具,支持AI行程生成、地图标注与景点打卡记录。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0176a62 --- /dev/null +++ b/README.md @@ -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 调用。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..c9f203c --- /dev/null +++ b/app.py @@ -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('/') +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) diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..a3e5b52 --- /dev/null +++ b/css/style.css @@ -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; } diff --git a/index.html b/index.html new file mode 100644 index 0000000..90d8d1e --- /dev/null +++ b/index.html @@ -0,0 +1,86 @@ + + + + + + Travel Planner · 智能旅游规划 + + + + +
+ +
+ +
+
+ +
+ + +
+
+
💡 在地图上点击任意位置可添加景点标注
+
+
+ + + + + + + + + + + diff --git a/js/ai.js b/js/ai.js new file mode 100644 index 0000000..b27832e --- /dev/null +++ b/js/ai.js @@ -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 }; +})(); diff --git a/js/auth.js b/js/auth.js new file mode 100644 index 0000000..d9a8069 --- /dev/null +++ b/js/auth.js @@ -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; +})(); diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..370829e --- /dev/null +++ b/js/main.js @@ -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 = `👤 ${user}` + + ``; + $('#logoutBtn').addEventListener('click', () => { + window.Auth.logout(); + renderUserArea(); + loadSpots(); + }); + } else { + area.innerHTML = ``; + $('#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 = '
  • 暂无景点
  • '; + return; + } + filtered.forEach(s => { + const li = document.createElement('li'); + li.innerHTML = `${s.name}` + + `${s.status === 'visited' ? '去过' : '想去'}`; + 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); +})(); diff --git a/js/map.js b/js/map.js new file mode 100644 index 0000000..2514de5 --- /dev/null +++ b/js/map.js @@ -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: `
    `, + 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( + `${spot.name}
    状态:${spot.status === 'visited' ? '去过 ✅' : '想去 ⭐'}
    ` + + `` + ); + 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); + } + }; +})();