| name | travel-itinerary |
| description | 交互式生成自驾旅行路书 HTML 页面。地图 + 时间轴双栏布局,内嵌天气预报、雨天 Plan B、文字路书面板、途经点标记、移动端底部抽屉。支持国内外目的地。 |
| user-invokable | true |
| args | [{"name":"destination","description":"目的地描述,如\"华东自驾10天\"(可选,也可在对话中说明)","required":false}] |
生成单文件自包含旅行路书 HTML,带交互地图、窄时间轴 + 地图双栏布局、天气预报、雨天备案、文字路书面板、移动端底部抽屉。
第一步:信息收集(按顺序询问,不得跳过)
1. 确认行程
- 出发地 / 途经城市 / 终点?几天?
- 交通方式(自驾 / 公共交通)?
- 有特定主题吗(赏花 / 美食 / 历史 / 冒险)?
- 有没有必须避开的时间节点(节假日人流、景区旺季)?
2. 夜驾/住宿策略(自驾专属)
自驾行程需询问:
"每天计划几点出发、几点结束?住酒店还是睡车上(服务区)?"
常见模式:
- 服务区过夜:20-23点夜驾 + 7-9点晨驾,白天留给景点
- 城市酒店:白天游览,傍晚入住
- 混合:部分路段夜驾节省时间
3. 询问地图 Key
中国大陆目的地:
"需要地图显示路线,你有高德地图 JS API Key(和安全密钥)吗?没有用免费通用地图。"
海外目的地:
"你有 Google Maps API Key 吗?没有用免费的 OpenStreetMap。"
4. 确认设计风格(可选)
"页面风格偏好?(旅行杂志感 / 极简 / 深色冒险风 / 随你发挥)"
无偏好则根据目的地气质自由发挥,每次生成都要有所不同。
布局:窄时间轴 + 地图双栏(推荐)
body { display: grid; grid-template-columns: 148px 1fr; height: 100vh; overflow: hidden; }
#timeline { width: 148px; overflow-y: auto; padding: 12px 0; background: var(--white); border-right: 1px solid var(--bd); }
#map-wrap { position: relative; overflow: hidden; }
#map { height: 100vh; width: 100%; }
时间轴每项(.tl-item)点击后触发地图飞入 + InfoWindow:
function selectCity(i) {
if (isMobile()) showMobileSheet(i);
else openInfoWindow(i);
}
InfoWindow 防漂移(高德地图必须):
function openInfoWindow(i) {
const s = stops[i];
const zoomLevel = map.getZoom();
const latShift = zoomLevel >= 12 ? 0.12 : 0.7;
map.setZoomAndCenter(zoomLevel < 10 ? 10 : zoomLevel,
[s.lng, s.lat + latShift], false, 400);
const iw = new AMap.InfoWindow({
content: buildInfoHtml(s),
offset: new AMap.Pixel(0, -38),
autoMove: false,
closeWhenClickMap: true
});
iw.open(map, [s.lng, s.lat]);
}
地图方案选择
方案 A:高德地图(中国大陆 + 有 Key)
<script>
const AMAP_KEY = 'YOUR_KEY';
const AMAP_SECRET = 'YOUR_SECRET';
window._AMapSecurityConfig = { securityJsCode: AMAP_SECRET };
const sc = document.createElement('script');
sc.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}&plugin=AMap.Driving`;
sc.onload = () => {
const map = new AMap.Map('map', {
zoom: 5, center: [115, 33],
mapStyle: 'amap://styles/whitesmoke'
});
initMap(map);
};
document.head.appendChild(sc);
</script>
路线:AMap.Driving,多站用 waypoints 单次调用(比 N-1 次更可靠)。
注意:Key 必须是 Web端(JS API)类型,在高德控制台加域名白名单锁定到部署域名。
方案 B:谷歌地图(海外 + 有 Key)
function initGoogleMap() {
const map = new google.maps.Map(document.getElementById('map'), {
zoom: 11, center: { lat: CENTER_LAT, lng: CENTER_LNG },
disableDefaultUI: true, zoomControl: true
});
initMap(map);
}
方案 C:Leaflet + OpenStreetMap(无 Key 通用方案)
const map = L.map('map').setView([CENTER_LAT, CENTER_LNG], 10);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO', maxZoom: 19
}).addTo(map);
async function planRoute(coords, color = '#7060d0') {
const str = coords.map(c => `${c[1]},${c[0]}`).join(';');
try {
const res = await fetch(`https://router.project-osrm.org/route/v1/driving/${str}?overview=full&geometries=geojson`);
const data = await res.json();
if (data.routes?.[0])
return L.geoJSON(data.routes[0].geometry, { style: { color, weight: 4, opacity: 0.85 } }).addTo(map);
} catch {}
return L.polyline(coords, { color, weight: 3, opacity: 0.6, dashArray: '8 6' }).addTo(map);
}
数据结构
stops(主站点)
const stops = [
{
d: "APR 25",
name: "城市名",
lat: 39.904, lng: 116.407,
type: "start",
title: "副标题",
brief: "一句话概述",
places: [
{ name: "景点", lat: 0, lng: 0, desc: "介绍" }
],
spots: [
{ tag: "food", name: "名称", desc: "说明" }
],
history: "历史文化背景故事(InfoWindow 底部展示)",
stay: true
}
];
waypoints(途经打点,不可点击)
用于标记晚饭停留、服务区过夜等非主站点,打在路线上:
const waypoints = [
{ name: "全椒服务区", icon: "🛏️", label: "全椒 · 过夜", lng: 118.178, lat: 32.082 },
{ name: "滕州", icon: "🍗", label: "滕州 · 晚饭", lng: 117.157, lat: 34.984 },
];
waypoints.forEach(wp => {
const wrapEl = document.createElement('div');
wrapEl.className = 'mk-stop-wrap';
wrapEl.innerHTML = `<div class="mk-stop">${wp.icon}</div><div class="mk-stop-label">${wp.label}</div>`;
new AMap.Marker({ position:[wp.lng, wp.lat], content:wrapEl,
offset:new AMap.Pixel(-11, -11), map, zIndex: 85, bubble:true });
});
weather(Open-Meteo,无需 Key)
const weather = {
"APR 25": { icon:"🌤️", desc:"多云", hi:22, lo:10, wind:"南风3级", rain:0, risk:null, note:"" },
};
查询方式(Python,批量):
import requests
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": LAT, "longitude": LNG,
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max",
"timezone": "Asia/Shanghai",
"start_date": "2026-04-25", "end_date": "2026-05-05"
}
r = requests.get(url, params=params, timeout=15)
注意:Open-Meteo 仅提供 16 天预报,出发前 3 天复查。
planB(雨天备案,仅高风险日)
const planB = {
"APR 28": {
title: "雨天备案",
items: ["武夷山博物馆", "茶艺体验馆", "民宿棋牌"]
}
};
rbDays(文字路书时间轴)
const rbDays = [
{ d:"APR 25", city:"北京 → 全椒服务区", km:"~1050km",
weather: weather["APR 25"],
entries:[
{ time:"09:00", cls:"drive", action:"满电出发,京沪高速南下", note:"确认满电再走" },
{ time:"17:30", cls:"key", action:"滕州东下高速 · 菜煎饼+辣子鸡", note:"人均40元" },
{ time:"23:00", cls:"key", action:"全椒服务区 · 慢充过夜", note:"大站有淋浴" },
]
}
];
天气卡片 Marker(始终可见)
stops.forEach(s => {
const w = weather[s.d];
if (!w) return;
const wEl = document.createElement('div');
wEl.className = 'wmk';
wEl.innerHTML = `
<div class="wmk-row">
<span>${w.icon}</span>
<span class="thi">${w.hi}°</span>/<span class="tlo">${w.lo}°</span>
</div>
<div class="wmk-date">${s.d}</div>`;
new AMap.Marker({ position:[s.lng,s.lat], content:wEl,
offset:new AMap.Pixel(-48,-78), map, zIndex:90, bubble:true });
if (w.risk === 'high') {
const warnEl = document.createElement('div');
warnEl.className = 'rain-warn';
warnEl.textContent = '⚠️ 雨天 Plan B';
new AMap.Marker({ position:[s.lng,s.lat], content:warnEl,
offset:new AMap.Pixel(0,-108), map, zIndex:110, bubble:true });
}
});
InfoWindow / 卡片内容结构
function buildInfoHtml(s) {
const w = weather[s.d] || {};
const pb = planB[s.d];
const weatherStrip = w.icon ? `
<div class="iw-weather ${w.risk==='high'?'risk':''}">
${w.icon} ${w.desc} ${w.hi}°/${w.lo}° · ${w.wind}
${w.rain > 0 ? ` · 降水${w.rain}mm` : ''}
${w.note ? `<br><span style="font-size:10px">${w.note}</span>` : ''}
</div>` : '';
const planBHtml = pb ? `
<div class="iw-planb">
<div class="iw-planb-title">☂️ ${pb.title}</div>
${pb.items.map(it => `<div class="iw-planb-item">· ${it}</div>`).join('')}
</div>` : '';
const spotsHtml = s.spots.map(sp => `
<div class="spot spot-${sp.tag}">
<div class="spot-name">${sp.name}</div>
<div class="spot-desc">${sp.desc}</div>
</div>`).join('');
return `
<div class="iw">
<div class="iw-head">
<div class="iw-date">${s.d}</div>
<div class="iw-title">${s.title}</div>
</div>
${weatherStrip}
<div class="iw-brief">${s.brief}</div>
${planBHtml}
${spotsHtml}
${s.history ? `<div class="iw-history">${s.history}</div>` : ''}
</div>`;
}
文字路书面板
右上角固定按钮,点击弹出左侧滑动面板,每天包含:时间轴条目 + 景点 + 美食详情。
<div id="rb-btn" style="position:fixed;top:16px;right:16px;z-index:200">
<div class="rb-trigger" id="rb-open">📖 文字路书</div>
</div>
<div id="rb-overlay"></div>
<div id="rb-panel">
<div id="rb-header">
<span>文字路书</span>
<button id="rb-close">✕</button>
</div>
<div id="rb-body"></div>
</div>
function buildRoadbook() {
document.getElementById('rb-body').innerHTML = rbDays.map(day => {
const stop = stops.find(s => s.d === day.d);
const foodSpots = stop ? stop.spots.filter(s => s.tag === 'food') : [];
const placeList = stop ? (stop.places || []) : [];
const entries = day.entries.map(e => `
<div class="rb-entry ${e.cls||''}">
<div class="rb-time">${e.time}</div>
<div class="rb-entry-right">
<div class="rb-action">${e.action}</div>
${e.note ? `<div class="rb-note">${e.note}</div>` : ''}
</div>
</div>`).join('');
const spotsHtml = (foodSpots.length || placeList.length) ? `
<div class="rb-spots">
${placeList.length ? '<div class="rb-spots-title">📍 景点</div>' +
placeList.map(p => `<div class="rb-spot-item"><b>${p.name}</b><div class="rb-note">${p.desc||''}</div></div>`).join('') : ''}
${foodSpots.length ? '<div class="rb-spots-title">🍽️ 美食</div>' +
foodSpots.map(f => `<div class="rb-spot-item"><b>${f.name}</b><div class="rb-note">${f.desc||''}</div></div>`).join('') : ''}
</div>` : '';
return `
<div class="rb-day">
<div class="rb-day-head">
<span class="rb-day-date">${day.d}</span>
<span class="rb-day-city">${day.city}</span>
<span class="rb-day-km">${day.km}</span>
</div>
${day.weather?.icon ? `<div class="rb-day-weather">${day.weather.icon} ${day.weather.desc} ${day.weather.hi}°/${day.weather.lo}°</div>` : ''}
<div class="rb-entries">${entries}</div>
${spotsHtml}
</div>`;
}).join('');
}
移动端底部抽屉
@media (max-width: 767px) {
body { grid-template-columns: 1fr; }
#timeline {
position: fixed; left: 0; right: 0; bottom: 0;
width: 100%; height: 85vh;
border-radius: 18px 18px 0 0;
transform: translateY(calc(85vh - 72px));
transition: transform .38s cubic-bezier(.32,0,.15,1);
z-index: 500; overflow-y: auto;
}
#timeline[data-state="half"] { transform: translateY(calc(85vh - 45vh)); }
#timeline[data-state="full"] { transform: translateY(0); }
.amap-info-outer { display: none !important; }
}
function isMobile() { return window.innerWidth <= 767; }
function setDrawer(s) { document.getElementById('timeline').dataset.state = s; }
function selectCity(i) {
if (isMobile()) {
showMobileSheet(i);
setDrawer('half');
} else {
openInfoWindow(i);
}
}
部署建议
Nginx 路径结构(一个域名多次旅行):
travel.mediaprogram.cn/
2026-04-25/index.html ← 本次行程
2026-12-01/index.html ← 下次行程
server {
server_name travel.mediaprogram.cn;
location /2026-04-25/ {
alias /usr/share/nginx/html/travel/2026-04-25/;
index index.html;
add_header Cache-Control "no-cache";
}
}
SSL 证书用 certbot:
certbot --nginx -d travel.mediaprogram.cn --non-interactive --agree-tos -m YOUR_EMAIL
安全注意事项
- AMap JS Key 必须在高德控制台设置域名白名单,否则任何人都能拿 key 调 API
- Key 类型必须是 Web端(JS API),Web服务类型有 IP 白名单但不适合前端
- 行程页面会暴露出行时间和每日位置,酌情决定是否加 Nginx Basic Auth
设计风格参考
| 目的地类型 | 配色 | 字体 | 气质 |
|---|
| 中国古都 / 人文 | 宣纸米 + 朱砂红 | Noto Serif SC | 古典沉静 |
| 热带城市 | 深丛林绿 + 椰白 | 现代无衬线 | 清爽活力 |
| 日系城市 | 浅樱粉 + 深炭灰 | 精细线条 | 克制精致 |
| 欧洲历史 | 石灰白 + 赭石金 | 衬线古典 | 优雅厚重 |
| 国内自驾公路 | 暖米 + 琥珀橙 + 青绿 | DM Sans + Noto Sans SC | 公路旅行感 |
常见坑
| 问题 | 解决 |
|---|
| 手机媒体查询不生效 | 加 <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 高德路线空白 | Key + Secret 缺一不可,控制台看报错 |
| InfoWindow 焦点漂移到海上 | autoMove: false + 手动 latShift 北移地图中心 |
| 高德 Key 报 USERKEY_PLAT_NOMATCH | Key 类型错误(用了 Web服务 Key 而非 JS API Key) |
| OSRM 路线失败 | 加虚线直连 fallback |
| 谷歌地图国内无法访问 | 国内目的地不用谷歌方案 |
| 天气预报超出范围 | Open-Meteo 最多 16 天,出发前重新查 |
| 移动端 InfoWindow 遮挡地图 | 隐藏 .amap-info-outer,改用底部抽屉 |