'use strict';
/* ==================================================================
行知 · TripSense 前端逻辑
三步流程:出行条件 → 备选清单 → AI 行程规划
================================================================== */
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const TRANSPORTS = ['不限', '自驾', '高铁/铁路', '公共交通'];
const INTERESTS = ['自然风光', '人文历史', '亲子', '拍照', '美食', '徒步登山', '室内避雨', '亲水海滨', '古镇', '主题乐园', '休闲', '小众秘境'];
// 住宿价位:既用于筛酒店 POI,也随需求传给模型
const LODGING = [
{ key: 'any', label: '不限', min: null, max: null },
{ key: 'budget', label: '≤¥300', min: 0, max: 300 },
{ key: 'comfort', label: '¥300–600', min: 300, max: 600 },
{ key: 'premium', label: '¥600–1200', min: 600, max: 1200 },
{ key: 'luxury', label: '¥1200+', min: 1200, max: null },
{ key: 'custom', label: '自定义', min: null, max: null },
];
/** 当前生效的价位:选「自定义」时读用户输入的上下限,而不是写死档位 */
function currentLodging() {
const base = LODGING.find((l) => l.key === state.lodging) || LODGING[0];
if (base.key !== 'custom') return base;
const raw = state.lodgingCustom || {};
const min = Number.isFinite(Number(raw.min)) && raw.min !== '' && raw.min != null ? Number(raw.min) : null;
const max = Number.isFinite(Number(raw.max)) && raw.max !== '' && raw.max != null ? Number(raw.max) : null;
const lo = min != null && max != null ? Math.min(min, max) : min;
const hi = min != null && max != null ? Math.max(min, max) : max;
const label = lo == null && hi == null
? '自定义(未填)'
: `¥${lo == null ? '不限' : lo}–${hi == null ? '不限' : hi}`;
return { key: 'custom', label, min: lo, max: hi };
}
const QUICK_NEEDS = [
'带老人/小孩同行', '预算 1000 元以内', '不想走太多路', '避开雨天 / 要室内备选',
'不自驾,优先高铁', '想吃当地特色', '拍照出片优先', '两天一晚', '当天往返', '带宠物',
];
const AI_PRESETS = [
{ name: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
{ name: 'DeepSeek', base: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
{ name: '通义千问', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
{ name: 'Kimi', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
{ name: '智谱 GLM', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' },
{ name: '本地 Ollama', base: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },
];
const VERDICT_CLASS = { '强烈推荐': 'v1', '推荐': 'v2', '可考虑': 'v3', '不推荐': 'v4' };
function loadAI() {
const empty = { baseUrl: '', apiKey: '', model: '', forceDemo: false, amapKey: '' };
try {
return { ...empty, ...(JSON.parse(localStorage.getItem('tripsense.ai') || 'null') || {}) };
} catch {
return empty;
}
}
function saveAI() { localStorage.setItem('tripsense.ai', JSON.stringify(state.ai)); }
const state = {
config: null,
origin: null,
transport: new Set(['自驾', '高铁/铁路']),
interests: new Set(['自然风光', '拍照']),
analysis: null,
selected: new Set(),
sort: 'score',
onlyRec: false,
onlyNiche: false,
compareOpen: false,
autoPreselected: false,
datesTouched: false,
ai: loadAI(),
planText: '',
planning: false,
ctxInfo: '',
planVersions: [],
viewing: null,
hotels: [],
// 方案里点名的周边地点(餐饮/景点),带坐标,用于画方案的真实线路
nearby: null,
// 方案线路点:{ name, lat, lon, kind, source }
routePoints: null,
// 静态地图的 blob URL。必须在这里声明:drawRouteMap 里会读它,
// 未声明的变量在 'use strict' 下会抛 ReferenceError,
// 而 drawRouteMap 是 async 且调用处不 catch → 整个函数静默失败,路线图一片空白。
routeMapBlob: null,
lodging: 'any',
lodgingCustom: { min: '', max: '' },
};
// ------------------------------------------------------------------ utils
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
let toastTimer;
function toast(msg, type = '') {
const el = $('#toast');
el.textContent = msg;
el.className = `toast ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.add('hidden'), 3200);
}
function fmtMin(min) {
if (min == null || !Number.isFinite(min)) return '—';
const h = Math.floor(min / 60);
const m = Math.round(min % 60);
return h ? `${h}小时${m ? m + '分' : ''}` : `${m}分钟`;
}
function fmtKm(km) { return Number(km || 0) >= 10 ? `${Math.round(km)}km` : `${(Number(km) || 0).toFixed(1)}km`; }
function compassCN(deg) {
const arr = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
const d = ((Number(deg) || 0) % 360 + 360) % 360;
return arr[Math.round(d / 45) % 8];
}
function scoreColor(score) {
return score >= 60 ? '#4c6b4f' : score >= 45 ? '#9a7b32' : '#a53b33';
}
function addDays(iso, n) {
const d = new Date(`${iso}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function localToday() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
/** POST + SSE 读取 */
async function ssePost(url, body, headers, onEvent) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${await res.text().then((t) => t.slice(0, 160)).catch(() => '')}`);
const reader = res.body.getReader();
const dec = new TextDecoder('utf-8');
let buf = '';
const handle = (line) => {
const s = line.trim();
if (!s.startsWith('data:')) return;
const raw = s.slice(5).trim();
if (!raw) return;
try { onEvent(JSON.parse(raw)); } catch { /* 忽略非法片段 */ }
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n');
buf = parts.pop() || '';
parts.forEach(handle);
}
if (buf.trim()) handle(buf);
}
// ------------------------------------------------------- markdown renderer
function mdInline(s) {
return s
.replace(/`([^`]+)`/g, (m, p) => `${p}`)
.replace(/\*\*([^*]+)\*\*/g, '$1 ')
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2 ')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+)\)/g,
'$1 ');
}
function mdToHtml(src) {
const lines = String(src || '').replace(/\r/g, '').split('\n');
const out = [];
let para = [];
let i = 0;
const flush = () => { if (para.length) { out.push(`
${mdInline(para.join(' '))}
`); para = []; } };
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((x) => mdInline(esc(x.trim())));
while (i < lines.length) {
const t = lines[i].trim();
if (!t) { flush(); i += 1; continue; }
if (/^```/.test(t)) {
flush(); const buf = []; i += 1;
while (i < lines.length && !/^```/.test(lines[i].trim())) { buf.push(esc(lines[i])); i += 1; }
i += 1;
out.push(`${buf.join('\n')} `);
continue;
}
const h = t.match(/^(#{1,4})\s+(.*)$/);
if (h) { flush(); out.push(`${mdInline(esc(h[2]))} `); i += 1; continue; }
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) { flush(); out.push(' '); i += 1; continue; }
if (/^\|/.test(t) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1].trim())) {
flush();
const head = cells(lines[i]);
i += 2;
const body = [];
while (i < lines.length && /^\|/.test(lines[i].trim())) { body.push(cells(lines[i])); i += 1; }
out.push(`${head.map((c) => `${c} `).join('')} ${body.map((r) => `${r.map((c) => `${c} `).join('')} `).join('')}
`);
continue;
}
if (/^>\s?/.test(t)) {
flush(); const buf = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
buf.push(mdInline(esc(lines[i].trim().replace(/^>\s?/, '')))); i += 1;
}
out.push(`${buf.join(' ')}
`);
continue;
}
if (/^([-*+]|\d+\.)\s+/.test(t)) {
flush();
const ordered = /^\d+\./.test(t);
const tag = ordered ? 'ol' : 'ul';
const buf = [];
while (i < lines.length && /^([-*+]|\d+\.)\s+/.test(lines[i].trim())) {
buf.push(`${mdInline(esc(lines[i].trim().replace(/^([-*+]|\d+\.)\s+/, '')))} `); i += 1;
}
out.push(`<${tag}>${buf.join('')}${tag}>`);
continue;
}
para.push(esc(t)); i += 1;
}
flush();
return out.join('\n');
}
// ------------------------------------------------------------------ chips
function renderChips(el, items, set, onChange) {
el.innerHTML = items.map((x) => `${esc(x)} `).join('');
el.querySelectorAll('.chip').forEach((b) => {
b.addEventListener('click', () => onChange(b.dataset.v));
});
}
/** 住宿价位(单选):既筛酒店 POI,也作为约束传给模型 */
function renderLodging() {
const box = $('#lodgingChips');
if (!box) return;
box.innerHTML = LODGING.map((l) =>
`${esc(l.label)} `
).join('');
box.querySelectorAll('.chip').forEach((b) => b.addEventListener('click', () => {
if (state.lodging === b.dataset.k) return;
state.lodging = b.dataset.k;
renderLodging();
saveForm();
// 方案已经生成过,就按新价位重查一次住宿
if (state.planText && $('#planExtra') && !$('#planExtra').classList.contains('hidden')) loadStayPlan();
}));
// 自定义价位:只有选中「自定义」才展开两个输入框
const cust = $('#lodgingCustom');
if (cust) {
cust.classList.toggle('hidden', state.lodging !== 'custom');
const c = state.lodgingCustom || {};
if ($('#lodgingMin') && document.activeElement !== $('#lodgingMin')) $('#lodgingMin').value = c.min ?? '';
if ($('#lodgingMax') && document.activeElement !== $('#lodgingMax')) $('#lodgingMax').value = c.max ?? '';
}
const tip = $('#lodgingTip');
if (tip) {
const L = currentLodging();
tip.textContent = L.key === 'custom' && L.min == null && L.max == null
? '填上你能接受的每晚房价区间,会同时用于筛选住宿与方案里的预算'
: `当前:每晚 ${L.label === '不限' ? '不限价位' : L.label}`;
}
}
/** 自定义价位的输入:只更新状态,不重渲染 chips,避免打字时被覆盖 */
let lodgingTimer = null;
function onLodgingInput() {
state.lodging = 'custom';
state.lodgingCustom = {
min: $('#lodgingMin').value === '' ? '' : Number($('#lodgingMin').value),
max: $('#lodgingMax').value === '' ? '' : Number($('#lodgingMax').value),
};
const tip = $('#lodgingTip');
if (tip) {
const L = currentLodging();
tip.textContent = L.min == null && L.max == null
? '填上你能接受的每晚房价区间,会同时用于筛选住宿与方案里的预算'
: `当前:每晚 ${L.label}`;
}
saveFormSoon();
// 打完字再重查,别每敲一个数字就发一次请求
clearTimeout(lodgingTimer);
lodgingTimer = setTimeout(() => {
if (state.planText && $('#planExtra') && !$('#planExtra').classList.contains('hidden')) loadStayPlan();
}, 700);
}
// ------------------------------------------------------------ 步骤与进度
function gotoStep(n) {
$$('.step-panel').forEach((p, i) => p.classList.toggle('active', i === n - 1));
$$('#stepper li').forEach((li, i) => {
li.classList.toggle('active', i === n - 1);
li.classList.toggle('done', i < n - 1);
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
const STAGES = [
['origin', '解析出发地'], ['discover', '检索周边目的地'], ['weather', '获取天气 / 空气'],
['traffic', '计算公路 / 铁路'], ['score', '综合评分'], ['done', '完成'],
];
function setProgress(pct, text, stage) {
$('#progressBar').style.width = `${pct}%`;
$('#progressPct').textContent = `${pct}%`;
$('#progressText').textContent = text;
const idx = STAGES.findIndex((s) => s[0] === stage);
$('#progressStages').innerHTML = STAGES.map((s, k) => {
const cls = idx < 0 ? '' : k < idx ? 'done' : k === idx ? 'now' : '';
return `${k < idx ? '✓' : '·'} ${s[1]} `;
}).join('');
}
// ------------------------------------------------------------- 出发地联想
let suggestTimer = null;
let suggestResults = [];
function hideSuggest() { $('#suggestBox').classList.add('hidden'); }
function showSuggest(list, query = '', usedQuery = '', meta = {}) {
suggestResults = list;
const box = $('#suggestBox');
if (!list.length) {
// 以前这里是静默隐藏,用户输入查不到的词就完全没反馈,看起来像"功能坏了"
let reason = '试试只输入城市名(如「杭州」)或景点名(如「西湖」)。';
if (meta.amapError && /INVALID_USER_KEY|USERKEY_PLAT_NOMATCH|10001|10009/i.test(meta.amapError)) {
reason = `高德 Key 似乎无效(${esc(meta.amapError)}),请到「设置 → 数据源增强」检查。`;
} else if (meta.meteoError && /timeout|ETIMEDOUT|fetch failed|abort/i.test(meta.meteoError)) {
reason = '地名服务网络超时,请稍后重试。';
} else if (!meta.amapError && !(state.ai.amapKey || state.config?.features?.amapRoad)) {
reason += ' 也可以在「设置 → 数据源增强」填一个高德 Key,中文区县地名覆盖会好很多。';
}
box.innerHTML = `没找到「${esc(query)}」。${reason}
`;
box.classList.remove('hidden');
return;
}
const hint = usedQuery && usedQuery !== query
? `按「${esc(usedQuery)}」为你找到这些:
`
: '';
box.innerHTML = hint + list.map((r, i) =>
`${esc(r.label || r.name)} ${r.lat.toFixed(2)}, ${r.lon.toFixed(2)}
`
).join('');
box.classList.remove('hidden');
box.querySelectorAll('.suggest-item').forEach((el) => {
el.addEventListener('click', () => setOrigin(suggestResults[Number(el.dataset.i)]));
});
}
/**
* 地名检索。Open-Meteo 对「XX市XX区XX路」这类长地址经常直接返回空,
* 所以查不到时退一步用前 2~4 个字再试一次,避免用户以为联想坏了。
*/
/**
* 从「杭州市西湖区」「河池市金城江区」这类结构化长地址里取出城市部分。
* 只认行政后缀,不做无脑截字:像「金城江」这种区县名,截成「金城」会命中
* 台湾金门 / 美国加州的同名地点,比查不到更糟。
*/
function deriveShortQuery(q) {
const m = String(q).match(/^(.{2,8}?)(市|地区|自治州|盟|区|县|镇|乡|街道|路|街|号)/);
return m ? m[1].trim() : '';
}
async function geocodeLoose(q) {
let meta = {};
const ask = async (name) => {
try {
const r = await fetch(`/api/geocode?name=${encodeURIComponent(name)}`, { headers: dataHeaders() }).then((x) => x.json());
meta = { amapError: r.amapError || '', meteoError: r.meteoError || '', source: r.source || '' };
return r.results || [];
} catch (e) {
meta = { meteoError: e.message || 'fetch failed' };
return [];
}
};
const exact = await ask(q);
if (exact.length) return { list: exact, used: q, ...meta };
const cut = deriveShortQuery(q);
if (cut && cut !== q && cut.length >= 2) {
const alt = await ask(cut);
if (alt.length) return { list: alt, used: cut, ...meta };
}
return { list: [], used: q, ...meta };
}
function setOrigin(o) {
state.origin = {
name: o.name, label: o.label || o.name,
lat: Number(o.lat), lon: Number(o.lon),
admin1: o.admin1 || '', country: o.country || '',
};
$('#originInput').value = state.origin.label;
$('#originHint').innerHTML = `已定位:${esc(state.origin.label)} (${state.origin.lat.toFixed(3)}, ${state.origin.lon.toFixed(3)})`;
hideSuggest();
saveForm();
suggestBestWindow(state.origin.lat, state.origin.lon);
}
async function suggestBestWindow(lat, lon) {
const box = $('#windowHint');
box.classList.add('hidden');
if (state.datesTouched) return;
try {
const r = await fetch(`/api/best-window?lat=${lat}&lon=${lon}&days=2`).then((x) => x.json());
const w = r.window;
if (!w || state.datesTouched) return;
box.innerHTML = `☀️ 近期天气较平稳的窗口:${esc(w.start)} ~ ${esc(w.end)} (${esc([...new Set(w.labels)].join('、'))})应用 `;
box.classList.remove('hidden');
$('#applyWindow').addEventListener('click', () => {
$('#startDate').value = w.start;
$('#endDate').value = w.end;
box.classList.add('hidden');
saveForm();
toast('已应用推荐窗口', 'ok');
});
} catch { /* 静默降级 */ }
}
// ---------------------------------------------------------------- 罗盘
function renderCompass(cands, radiusKm) {
const maxD = Math.max(radiusKm, ...cands.map((c) => c.distanceKm || 0)) || radiusKm;
const cx = 200, cy = 200, R = 152;
let svg = ``;
svg += ` `;
[0.25, 0.5, 0.75, 1].forEach((f) => {
svg += ` `;
svg += `${Math.round(maxD * f)}km `;
});
svg += ` `;
svg += ` `;
[['N', 0, -1], ['E', 1, 0], ['S', 0, 1], ['W', -1, 0]].forEach(([lab, dx, dy]) => {
svg += `${lab} `;
});
cands.forEach((c) => {
const a = ((Number(c.bearingDeg) || 0) * Math.PI) / 180;
const r = R * Math.min(1, (c.distanceKm || 0) / maxD);
const x = cx + r * Math.sin(a);
const y = cy - r * Math.cos(a);
const col = scoreColor(c.score);
const rad = 3.4 + Math.max(0, c.score) / 22;
svg += `${esc(c.name)} · ${c.score}分 · ${fmtKm(c.distanceKm)} · ${compassCN(c.bearingDeg)}方向 `;
});
svg += ` `;
svg += `出发地 `;
svg += ` `;
$('#compass').innerHTML = svg;
$$('#compass .cdot').forEach((dot) => {
dot.addEventListener('click', () => {
const card = $(`.dest-card[data-id="${CSS.escape(dot.dataset.id)}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.remove('flash');
void card.offsetWidth;
card.classList.add('flash');
});
});
}
// ------------------------------------------------------------- 清单渲染
function scoreRing(score) {
const r = 30, C = 2 * Math.PI * r;
const dash = (C * score) / 100;
return `
${score}
/ 100
`;
}
function weatherCell(c) {
const s = c.weather?.summary;
if (!s) return ``;
return `天气 ${s.days} 天
${s.dominant?.icon || '🌡️'} ${esc(s.dominant?.label || '')} ${s.avgLow}~${s.avgHigh}℃
降水概率 ${s.maxPrecipProb}% · 累计 ${s.totalPrecip}mm · 风 ${s.maxWind}km/h
`;
}
function dailyTable(c) {
const d = c.weather?.daily || [];
if (!d.length) return '';
return `查看逐日天气(${d.length} 天)
日期 天气 气温 降水 风
${d.map((x) => `${esc(x.date)} ${esc(x.weekday || '')} ${x.icon || ''} ${esc(x.label)} ${x.tmin}~${x.tmax}℃ ${x.precip}mm / ${x.precipProb}% ${x.wind}km/h `).join('')}
`;
}
function cardHtml(c) {
const picked = state.selected.has(c.id);
const road = c.road || {};
const rail = c.rail || {};
const aqi = c.aqi != null ? `AQI ${c.aqi}${c.aqiInfo ? ' · ' + esc(c.aqiInfo.label) : ''}` : '暂无数据';
const railMain = rail.available ? `${fmtMin(rail.durationMinutes)}${rail.fareCny != null ? ` · ¥${rail.fareCny}` : ''}` : '无直达 / 建议自驾';
return `
#${c.rank}
${esc(c.verdict)}
${c.niche ? '小众 ' : ''}
${picked ? '✓ 已加入' : '+ 加入清单'}
${esc(c.name)}
${esc(c.kind || '目的地')}${c.region ? ' · ' + esc(c.region) : ''} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}
${scoreRing(c.score)}
${weatherCell(c)}
自驾 / 公路
${fmtMin(road.durationMinutes)} · ${fmtKm(road.distanceKm)}
${esc(road.source || '')}${road.source === '估算' ? '(非实时)' : ''}
铁路 / 高铁
${railMain}
${esc(rail.source || '')}${rail.source === '估算' ? '(估算,需核对 12306)' : ''}
空气质量
${aqi}
评分构成:天气 ${c.breakdown?.weather} / 交通 ${c.breakdown?.time} / 兴趣 ${c.breakdown?.interest}
${(c.reasons || []).map((r) => `${esc(r)} `).join('')}
${(c.warnings || []).length ? `${c.warnings.map((w) => `⚠ ${esc(w)} `).join('')}
` : ''}
${dailyTable(c)}
`;
}
function sortedCandidates() {
let list = [...state.analysis.candidates];
const key = state.sort;
list.sort((a, b) => {
if (key === 'weather') return (b.breakdown?.weather || 0) - (a.breakdown?.weather || 0) || b.score - a.score;
if (key === 'distance') return a.distanceKm - b.distanceKm;
if (key === 'time') return (a.travel?.bestHours ?? 99) - (b.travel?.bestHours ?? 99);
return b.score - a.score;
});
if (state.onlyRec) list = list.filter((c) => c.score >= 60);
if (state.onlyNiche) list = list.filter((c) => c.niche);
return list;
}
function renderCards() {
const list = sortedCandidates();
// 重渲染会重建 DOM:先记住哪些逐日天气是展开的,避免勾选/排序后莫名收起
const openDays = new Set($$('#cards .daily[open]').map((d) => d.dataset.day));
const noBase = !(state.analysis?.candidates || []).length;
$('#cards').innerHTML = list.length
? list.map(cardHtml).join('')
: `${noBase
? `当前半径内没有可去的地点${state.analysis?.constraints?.excludeLocal ? '(已排除出发地附近的同城点位)' : ''},试试取消「排除同城」或增大搜索半径。`
: state.onlyNiche
? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。'
: '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}
`;
$$('#cards .daily').forEach((d) => { if (openDays.has(d.dataset.day)) d.open = true; });
$$('#cards .pick-btn').forEach((b) => b.addEventListener('click', () => togglePick(b.dataset.id)));
$('#selectedCount').textContent = `已选 ${state.selected.size} 个`;
$('#preselectHint').classList.toggle('hidden', !state.autoPreselected);
renderCompass(list, state.analysis.constraints.radiusKm);
renderCompare();
}
function togglePick(id) {
if (state.selected.has(id)) state.selected.delete(id);
else state.selected.add(id);
state.autoPreselected = false;
// 住宿是按「已选里评分最高的目的地」查的,选择变了必须重查,否则旧数据会带进方案
state.hotels = [];
renderCards();
}
function renderStep2() {
const a = state.analysis;
const excl = a.constraints.excludeLocal ? ' · 已排除出发地所在城区' : '';
$('#resultSub').textContent = `${a.origin.label} · ${a.timeRange.start} ~ ${a.timeRange.end}(${a.timeRange.days} 天,${a.timeRange.weekdayHint}) · 半径 ${a.constraints.radiusKm}km${excl}`;
const s = a.summary;
$('#summaryPanel').innerHTML = `
候选目的地
${s.total}个
推荐及以上 ${s.recommended} 个
平均适配分
${s.avgScore}/100
天气 / 交通 / 距离 / 偏好
区间平均高温
${s.avgHigh ?? '—'}℃
需留意天气的候选 ${s.rainyCount} 个
天气 ${esc(s.weatherNote)}
公路 ${esc(s.roadProvider === '估算' ? '路网服务不可用,里程-速度估算' : s.roadProvider === '高德' ? '高德 Web 服务驾车路径' : 'OSRM 公开路网矩阵')}
铁路 ${a.dataNotes.some((n) => n.includes('铁路接口')) && !a.dataNotes.some((n) => n.includes('未配置铁路接口')) ? '已配置铁路接口' : '未配置铁路接口,耗时与票价均为估算'}
`;
renderAlerts(a);
state.selected = new Set();
state.autoPreselected = false;
state.compareOpen = false;
$('#compareBtn').textContent = '对比已选';
renderCards();
$('#dataNotes').innerHTML = `数据来源与口径说明 ${a.dataNotes.map((n) => `${esc(n)} `).join('')} `;
}
/**
* 顶部提示区(天气预警 + 降级说明)。
* 单独抽出是因为「保存设置」后需要原地刷新:用户刚填完高德 Key,
* 提示里就该从「去配置」变成「重新分析一次」,而不是让他自己猜。
*/
function renderAlerts(a) {
const s = a.summary || {};
const alerts = [];
if (s.weatherAlert) alerts.push(`⚠️ ${esc(s.weatherAlert)}
`);
if (a.stats?.amapError) {
// Key 填错比"没取到"更值得优先告诉用户,否则他会反复重试
alerts.push(`⚠️ ${esc(a.stats.amapError)} 请确认高德 Key 是「Web服务」类型、已启用且配额未用尽。修改 Key
`);
} else if (a.stats?.fallbackOnly) {
const hasAmap = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad);
const action = hasAmap
? '重新分析一次 看看这次能否取到实时数据。'
: '去配置高德 Key 可获得更丰富的实时地点。';
alerts.push(`ℹ️ 本次未取到实时周边 POI(公共检索服务波动),清单来自内置地点库,坐标为近似值;天气、空气质量与路况仍是实时数据,不受影响 。${action}
`);
}
$('#alertBox').innerHTML = alerts.join('');
$('#alertBox').classList.toggle('hidden', alerts.length === 0);
bindAlertActions();
}
/** 提示区里的动作按钮统一在这里绑定,重渲染后不会丢事件 */
function bindAlertActions() {
$$('#alertBox [data-open-settings]').forEach((b) => b.addEventListener('click', openSettings));
$$('#alertBox [data-retry-analyze]').forEach((b) => b.addEventListener('click', () => {
b.disabled = true;
runAnalyze();
}));
}
/** 已选目的地并排对比:勾完之后用户最想问的是「哪个更合适」 */
function renderCompare() {
const box = $('#comparePanel');
const btn = $('#compareBtn');
if (!box) return;
if (btn) btn.textContent = state.compareOpen ? '收起对比' : '对比已选';
if (!state.compareOpen) { box.classList.add('hidden'); box.innerHTML = ''; return; }
const list = (state.analysis?.candidates || [])
.filter((c) => state.selected.has(c.id)).slice(0, 4);
if (list.length < 2) {
box.innerHTML = '至少勾选 2 个目的地才能并排对比。
';
box.classList.remove('hidden');
return;
}
const rows = [
['适配分', (c) => `${c.score} · ${esc(c.verdict || '')}`],
['位置', (c) => `${compassCN(c.bearingDeg)}方向 · ${fmtKm(c.distanceKm)}`],
['最快单程', (c) => c.travel?.bestHours != null ? `${fmtMin(c.travel.bestHours * 60)}(${esc(c.travel.bestMode || '')})` : '无可达方案'],
['天气', (c) => c.weather?.summary ? `${esc(c.weather.summary.dominant?.label || '')} ${c.weather.summary.avgLow}~${c.weather.summary.avgHigh}℃` : '暂无'],
['空气质量', (c) => c.aqi != null ? `AQI ${c.aqi} · ${esc(c.aqiInfo?.label || '')}` : '暂无'],
['类型', (c) => `${esc(c.kind || '')}${c.niche ? ' · 小众' : ''}`],
['数据来源', (c) => `${esc(c.source || '')}${c.region ? ' · ' + esc(c.region) : ''}`],
];
box.innerHTML = `
已选目的地对比 ${list.length} 个 · 同屏比较关键指标
`;
box.classList.remove('hidden');
}
function toggleCompare() {
state.compareOpen = !state.compareOpen;
renderCompare();
}
// ------------------------------------------------------------- 第三步
function renderSelected() {
const a = state.analysis;
if (!a) return;
const list = a.candidates.filter((c) => state.selected.has(c.id));
$('#selectedChips').innerHTML = list.length
? list.map((c) => `${esc(c.name)} ✕ `).join('')
: '尚未选择目的地,请返回上一步勾选。 ';
$$('#selectedChips .sel-chip').forEach((el) => el.addEventListener('click', () => {
state.selected.delete(el.dataset.id);
renderCards();
renderSelected();
}));
}
function aiHeaders() {
return {
'X-AI-Base-Url': state.ai.baseUrl || '',
'X-AI-Model': state.ai.model || '',
'X-AI-Api-Key': state.ai.forceDemo ? '__demo__' : (state.ai.apiKey || '__server__'),
};
}
/** 数据源覆盖:高德 Key 只影响周边 POI 与驾车路径,与分析请求一起传 */
function dataHeaders() {
return state.ai.amapKey ? { 'X-AMAP-Key': state.ai.amapKey } : {};
}
function updateAIStatus() {
const demo = state.ai.forceDemo || (!state.config?.ai.hasKey && !state.ai.apiKey);
const pill = $('#aiStatus');
pill.className = `status-pill ${demo ? '' : 'live'}`;
pill.innerHTML = ` ${demo ? '演示模式(未配置密钥)' : `AI 已配置 · ${esc(state.ai.model || '')}`}`;
const cfg = state.config;
$('#aiInline').innerHTML = demo
? '当前为 演示模式 :由本地规则引擎依据同一份清单生成示例方案,用于验证完整流程。填入任意 OpenAI 兼容接口后由大模型生成。'
: `将调用 ${esc(state.ai.baseUrl || cfg?.ai.baseUrl || '')} · 模型 ${esc(state.ai.model || cfg?.ai.model || '')} ,配合本站预设提示词生成方案。`;
}
let renderTimer = null;
function paintPlan(text) {
const el = $('#planOutput');
let src;
if (typeof text === 'string') src = text;
else if (state.viewing != null && state.planVersions[state.viewing]) src = state.planVersions[state.viewing].text;
else src = state.planText;
el.innerHTML = mdToHtml(src) + (state.planning ? '▍ ' : '');
el.scrollTop = el.scrollHeight;
}
function schedulePaint() {
if (renderTimer) return;
renderTimer = setTimeout(() => { renderTimer = null; paintPlan(); }, 110);
}
/** 把 GCJ-02 经纬度投影成静态地图上的像素坐标(Web Mercator,与高德底图一致) */
function projectToMapPx(lng, lat, meta) {
const world = 256 * Math.pow(2, meta.zoom);
const toY = (la) => {
const r = (la * Math.PI) / 180;
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * world;
};
const cx = ((meta.center.lng + 180) / 360) * world;
const cy = toY(meta.center.lat);
return {
x: meta.width / 2 + (((lng + 180) / 360) * world - cx),
y: meta.height / 2 + (toY(lat) - cy),
};
}
/** 底图上的叠加层:编号圆点 + 名称 + 行程连线(自己画,不受 markers 参数限制) */
function buildMapOverlay(picked, meta) {
const pts = (meta.points || []).map(([lng, lat]) => projectToMapPx(lng, lat, meta));
if (!pts.length) return '';
// 第 0 点是出发地(如果有),用空心圈标出,后面依次是行程中的地点
const originAt = picked.length && picked[0].kind === 'origin' ? 0 : -1;
const line = pts.length > 1
? ` `
: '';
// 标注避让:行程点常挤在 1~2km 内(城市中心步行范围),
// 固定把名字放在点上方会全部叠成一团黑字。
// 做法:按 y 排序后依次分配标签槽位,同槽位内交替上下方,
// 并记录已占用的标签矩形,冲突时换一个槽。
// 全部槽位都冲突(极密集)时,只画编号不画名字——宁可少标也不能糊成一团。
const LABEL_SLOTS = [
{ dx: 0, dy: -18, anchor: 'middle' },
{ dx: 0, dy: 26, anchor: 'middle' },
{ dx: 12, dy: -12, anchor: 'start' },
{ dx: -12, dy: -12, anchor: 'end' },
{ dx: 12, dy: 22, anchor: 'start' },
{ dx: -12, dy: 22, anchor: 'end' },
];
const placed = [];
const collides = (r) => placed.some((q) => !(r.x2 < q.x1 || r.x1 > q.x2 || r.y2 < q.y1 || r.y1 > q.y2));
let seq = 0;
const dots = pts.map((p, i) => {
const item = picked[i] || {};
const nm = (item.name || '').slice(0, 8);
if (i === originAt) {
return `
出发地
`;
}
seq += 1;
// 给该点挑一个不与已放置标签冲突的槽位
const w = Math.max(34, nm.length * 11);
let slot = null;
for (const s of LABEL_SLOTS) {
const cx = p.x + s.dx;
const cy = p.y + s.dy;
const x1 = s.anchor === 'middle' ? cx - w / 2 : (s.anchor === 'start' ? cx : cx - w);
const r = { x1, y1: cy - 12, x2: x1 + w, y2: cy + 4 };
if (!collides(r)) { slot = s; placed.push(r); break; }
}
const label = slot
? `${esc(nm)} `
: '';
const tip = `${esc(item.name || '')} `;
return `${tip}
${seq}
${label}
`;
}).join('');
return `${line}${dots} `;
}
/**
* 从方案正文里提取「实际安排的地点」。
*
* 背景:路线图之前只画用户勾选的候选(常常只有 1 个城市),
* 但用户要的是**方案里安排的每个地点**的真实位置与串联线路。
*
* 提取策略(按可靠度从高到低):
* 1. nearby 列表里的点名地点——这是模型被强制点名的餐饮/景点,名字必定出现;
* 2. 候选清单里被选中的目的地——方案总览与逐日安排一定会提到;
* 3. 正文里加粗的 **地点名**——模型用来强调的具体点位。
* 再按它们在正文中首次出现的顺序排序,得到行程顺序。
*/
/**
* 地点名的形态判定:必须是「像地点的名字」。
*
* 方案正文里加粗的词有一大半不是地点:演示模式、需求备忘、自驾、合计、
* 若遇降雨或大风……用黑名单永远补不全。改成白名单:名字要以「场所类后缀」
* 结尾(景区/公园/博物馆/古镇/寺/山/岛……)才算地点。
*/
const PLACE_SUFFIX = /(景区|风景区|公园|博物馆|纪念馆|展览馆|美术馆|科技馆|体育馆|图书馆|古镇|古村|村|屯|镇|街区|老街|寺|庙|塔|桥|山|岭|峰|湖|江|河|岛|潭|瀑|瀑布|峡谷|溶洞|洞|草原|梯田|湿地|海滩|沙滩|海湾|温泉|漂流|基地|林场|农场|乐园|广场|大厦|酒店|客栈|民宿|山庄|度假区|度假村|渔村|渔港|码头|驿道|遗址|石窟|陵园|公园道|步道|绿道|驿站)/;
/** 加粗词里常见的「说明性词」:即使带后缀也不是可去的地方 */
const NOT_PLACE = /^(演示模式|需求备忘|合计|总计|小计|自驾|铁路|公共交通|高铁|客车|班车|合计|注意|说明|备注|触发条件|替换动作|若遇.*|以下.*|建议.*|推荐.*|可选.*|雨天.*|室内.*|户外.*|数据来源.*|出发前.*|门票.*|交通.*|住宿.*|预算.*|风险.*|清单.*|装备.*|证件.*|提醒.*|动作.*|政策.*|预约.*|换乘.*|停车.*|步行.*|骑行.*|拼车.*|包车.*|自驾.*)$/;
function extractPlanPlaces(text) {
const src = String(text || '');
if (!src.trim()) return [];
const norm = (s) => String(s || '').replace(/[\s*()()【】\[\],,。.、::·\-—]/g, '').toLowerCase();
const body = norm(src);
const pool = [];
const push = (name, extra = {}) => {
const n = String(name || '').trim();
if (!n || n.length < 2) return;
const key = norm(n);
if (!key) return;
if (pool.some((p) => norm(p.name) === key)) return;
pool.push({ name: n, ...extra });
};
// 1) nearby 点名地点(餐饮/景点):模型被强制点名的,名字必定出现在正文里
const nb = state.nearby || {};
for (const x of [...(nb.sights || []), ...(nb.food || [])]) {
if (x && x.name) push(x.name, { kind: x.type || '地点', lat: x.lat, lon: x.lon, from: 'nearby' });
}
// 2) 已选目的地
const a = state.analysis;
if (a && Array.isArray(a.candidates)) {
for (const c of a.candidates) {
if (!state.selected.has(c.id)) continue;
push(c.name, { kind: c.kind || '目的地', lat: c.lat, lon: c.lon, from: 'candidate' });
}
}
// 3) 正文里加粗的点名:只有「像地点」的才算
const bold = src.match(/\*\*([^*\n]{2,24})\*\*/g) || [];
for (const b of bold) {
const n = b.replace(/\*\*/g, '').trim();
if (NOT_PLACE.test(n)) continue;
if (/[,。,.\d]/.test(n)) continue;
if (!PLACE_SUFFIX.test(n)) continue;
push(n, { kind: '地点', from: 'bold' });
}
// 按在正文中首次出现的位置排序 → 行程顺序
const withPos = pool.map((p) => {
const k = norm(p.name);
let at = body.indexOf(k);
if (at < 0) {
// 加粗名可能是简称/带修饰,用最长前缀再试一次
const short = k.slice(0, Math.min(4, k.length));
at = short ? body.indexOf(short) : -1;
}
return { ...p, at: at < 0 ? 1e9 : at };
});
withPos.sort((x, y) => x.at - y.at);
return withPos.filter((p) => p.at < 1e9 || p.lat != null);
}
/**
* 把提取出的地点解析成真实坐标。
* 已有坐标的直接用;缺的走服务端按名称定位(高德 POI)。
*/
async function resolveRoutePoints(places) {
const need = places.filter((p) => p.lat == null || p.lon == null);
const out = places.map((p) => ({ ...p }));
if (!need.length) return { points: out, missing: [] };
const a = state.analysis;
const anchor = a && Array.isArray(a.candidates)
? a.candidates.filter((c) => state.selected.has(c.id))
.sort((x, y) => (Number(y.score) || 0) - (Number(x.score) || 0))[0]
: null;
try {
const r = await fetch('/api/route-points', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...dataHeaders() },
body: JSON.stringify({
names: need.map((p) => p.name),
city: anchor ? (anchor.city || anchor.district || '') : '',
lat: anchor ? anchor.lat : undefined,
lon: anchor ? anchor.lon : undefined,
}),
}).then((x) => x.json());
if (!r.available) return { points: out, missing: need.map((p) => p.name), reason: r.reason || '' };
const byQuery = new Map((r.points || []).map((p) => [p.query, p]));
const missing = [];
for (const p of out) {
if (p.lat != null && p.lon != null) continue;
const hit = byQuery.get(p.name);
if (hit) {
p.lat = hit.lat; p.lon = hit.lon;
p.kind = p.kind || hit.type || '地点';
p.city = hit.city; p.district = hit.district;
p.resolved = true;
} else {
missing.push(p.name);
}
}
return { points: out, missing };
} catch (e) {
return { points: out, missing: need.map((p) => p.name), reason: e.message };
}
}
/** 静态地图最多能承载的路线点数(服务端 staticMapMeta 同样按 9 点截取) */
const MAX_ROUTE_POINTS = 9;
/**
* 路线点抽稀:地图上只画 MAX_ROUTE_POINTS 个点,且不画几乎重合的点。
*
* 两个问题:
* 1. 超过 9 个点时,图上只画得出前 9 个,但图例还写着 16 个,对不上;
* 2. 方案点名的地点常在同一片街区(如「柳州市」与「市民广场」相距 400m),
* 叠在一起既看不清也连不出线。
* 所以:先按距离去掉 80m 内几乎重合的点(保留先出现的),
* 再按行程顺序均匀抽样到 9 个,并保证首尾都在。
*
* 出发地单独算:它是「起点」,不参与抽稀与计数,
* 否则 200km 外的起点会把目的地之间的相对距离压扁。
*/
function thinRoutePoints(points) {
if (!points.length) return [];
const kept = [];
for (const p of points) {
const last = kept[kept.length - 1];
if (last && Math.hypot((p.lat - last.lat) * 111, (p.lon - last.lon) * 102) < 0.08) continue;
kept.push(p);
}
if (kept.length <= MAX_ROUTE_POINTS) return kept;
const step = (kept.length - 1) / (MAX_ROUTE_POINTS - 1);
const out = [];
for (let i = 0; i < MAX_ROUTE_POINTS; i += 1) {
const idx = Math.round(i * step);
if (!out.includes(kept[idx])) out.push(kept[idx]);
}
return out;
}
async function drawRouteMap() {
const wrap = $('#routeMap');
if (!wrap) return;
const note = $('#routeNote');
const a = state.analysis;
if (!a) { wrap.innerHTML = ''; return; }
// 优先用「方案里实际安排的地点」画线路;方案还没生成时才退回已选候选
let picked = [];
let label = '';
let missing = [];
let total = 0;
if (state.routePoints && state.routePoints.length) {
total = state.routePoints.length;
picked = thinRoutePoints(state.routePoints);
label = `方案行程 ${total} 个地点`;
} else {
picked = a.candidates.filter((c) => state.selected.has(c.id)).slice(0, 5);
total = picked.length;
label = picked.length ? `已选 ${picked.length} 个目的地` : '';
}
if (!picked.length) {
wrap.innerHTML = '还没有已选目的地。生成方案后,这里会画出方案里每个地点的实际位置与线路。
';
if (note) note.textContent = '';
return;
}
// 地图只画目的地,不画出发地:
// 出发地往往在 100km 外,把它塞进取景范围会把目的地压成一个点。
// 起点信息改用文字标注(方位 + 直线距离),在降级示意图里才画出来。
const origin = a.origin && Number.isFinite(Number(a.origin.lat)) ? a.origin : null;
const originDir = origin && picked[0]
? (() => {
const dLat = (Number(picked[0].lat) - Number(origin.lat)) * 111;
const dLon = (Number(picked[0].lon) - Number(origin.lon)) * 102;
const deg = (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
const arr = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
const km = Math.round(Math.hypot(dLat, dLon));
return `出发地${origin.name || ''}在${arr[Math.round(deg / 45) % 8]}方 ${km}km`;
})()
: '';
if (state.routeMapBlob) { URL.revokeObjectURL(state.routeMapBlob); state.routeMapBlob = null; }
wrap.innerHTML = `${svgRoute(picked, origin)}
`;
if (note) note.textContent = label;
const hasKey = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad);
if (!hasKey) {
wrap.innerHTML = `${svgRoute(picked, origin)}
配置高德 Key 可显示真实地图(当前为方位示意)。
`;
if (note) note.textContent = `${label} · 方位示意`;
return;
}
const pts = picked.map((c) => `${c.lat},${c.lon}`).join(';');
try {
const r = await fetch(`/api/staticmap?pts=${encodeURIComponent(pts)}&w=640&h=360`, { headers: dataHeaders() });
if (!(r.headers.get('content-type') || '').startsWith('image')) {
const j = await r.json().catch(() => ({}));
const why = String(j.error || '未知原因').replace(/^高德静态地图:/, '').slice(0, 100);
wrap.innerHTML = `${svgRoute(picked, origin)}
地图暂不可用,已用方位示意:${esc(why)}
`;
if (note) note.textContent = `${label} · 方位示意`;
return;
}
// 服务端把取景参数放在响应头里,前端据此把经纬度投影成像素再叠加标记
let meta = null;
try {
const b64 = r.headers.get('x-map-meta');
if (b64) meta = JSON.parse(atob(b64));
} catch { meta = null; }
const blob = await r.blob();
state.routeMapBlob = URL.createObjectURL(blob);
const notes = [];
if (originDir) notes.push(originDir);
if (missing.length) {
notes.push(`有 ${missing.length} 个地点未能定位(${esc(missing.slice(0, 3).join('、'))}${missing.length > 3 ? ' 等' : ''}),未画在图上`);
}
if (total > picked.length) {
notes.push(`方案共 ${total} 个地点,图上按行程顺序抽样显示 ${picked.length} 个(重合点已合并)`);
}
const missNote = notes.length
? `${notes.map((x) => esc(x)).join(';')}。
`
: '';
// 行程点清单:图上最多 9 个点,方案里的地点通常更多,
// 完整清单让用户能看到「每个安排的地点」到底是哪些、按什么顺序。
const allPoints = state.routePoints || picked;
const listHtml = allPoints.length ? `
${allPoints.map((p, i) => `
${i + 1} ${esc(p.name)} ${p.kind ? `${esc(p.kind)} ` : ''} `).join('')} ` : '';
wrap.innerHTML = `
${meta ? buildMapOverlay(picked, meta) : ''}
高德地图 · ${picked.length} 个地点(编号为行程顺序)
${missNote}${listHtml}`;
if (note) note.textContent = `${label} · 高德底图`;
} catch (e) {
wrap.innerHTML = `${svgRoute(picked, origin)}
地图请求失败:${esc(e.message)}
`;
if (note) note.textContent = `${label} · 方位示意`;
}
}
/** 降级示意图:按真实经纬度等距投影(经度按纬度余弦修正)。
* 只有 1 个目的地时会带上出发点,否则图上只有一个孤点,看不出"行程"。 */
function svgRoute(picked, origin) {
const W = 640, H = 380, pad = 62;
const pts = picked.map((c) => ({ lat: Number(c.lat), lon: Number(c.lon), c, kind: 'dest' }));
if (origin) {
pts.unshift({ lat: Number(origin.lat), lon: Number(origin.lon), c: { name: '出发地' }, kind: 'origin' });
}
const cLat = pts.reduce((s, p) => s + p.lat, 0) / pts.length;
const cosLat = Math.cos((cLat * Math.PI) / 180) || 1;
const xs = pts.map((p) => p.lon * cosLat);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...pts.map((p) => p.lat));
const maxY = Math.max(...pts.map((p) => p.lat));
const spanX = maxX - minX, spanY = maxY - minY;
let pos;
if (pts.length === 1 || (spanX < 1e-6 && spanY < 1e-6)) {
pos = pts.map((p, i) => ({ p, i, x: W / 2, y: H / 2 }));
} else {
const scale = Math.min(
(W - pad * 2) / Math.max(spanX, 1e-6),
(H - pad * 2) / Math.max(spanY, 1e-6),
);
const offX = (W - spanX * scale) / 2;
const offY = (H - spanY * scale) / 2;
pos = pts.map((p, i) => ({
p, i,
x: offX + (xs[i] - minX) * scale,
y: H - offY - (p.lat - minY) * scale,
}));
}
const line = pos.length > 1
? ` `
: '';
let destSeq = 0;
const nodes = pos.map((p) => {
if (p.p.kind === 'origin') {
return `
出发地
`;
}
destSeq += 1;
const short = (p.p.c.name || '').slice(0, 10);
const dist = Math.round(Number(p.p.c.distanceKm) || 0);
const anchor = p.x < W * 0.2 ? 'start' : (p.x > W * 0.8 ? 'end' : 'middle');
const dy = p.y > H * 0.75 ? -15 : 27;
return `
${destSeq}
${esc(short)}
距出发地 ${dist}km
`;
}).join('');
const legend = origin
? ' 出发地 目的地(虚线为行程动线) '
: ' 已选目的地(编号为推荐顺序,虚线为行程动线) ';
return `
${line}${nodes}
${legend}
`;
}
/**
* 住宿建议:方案定稿后才给。
* 落脚点取「已选里评分最高的那个」——两天的行程通常围着他展开;
* 再按用户选的价位过滤,价格缺失的单独标注,不硬编一个假价格。
*/
async function loadStayPlan() {
const box = $('#hotelBox');
if (!box) return;
state.hotels = [];
const a = state.analysis;
const L = currentLodging();
const rangeText = (L.min == null && L.max == null)
? '价位不限'
: (L.min != null && L.max != null ? `¥${L.min}–${L.max}`
: (L.max != null ? `≤¥${L.max}` : `≥¥${L.min}`));
if (!a?.candidates?.length || !state.selected.size) {
box.innerHTML = '先在清单里勾选目的地。
';
return;
}
const picked = a.candidates.filter((c) => state.selected.has(c.id));
const anchor = picked.slice().sort((x, y) => y.score - x.score)[0];
const note = $('#stayNote');
if (note) note.textContent = `围绕「${anchor.name}」· ${rangeText}`;
// 数据源交给服务端决定:装了 rgh 且已登录就用 RollingGo 的实时报价,否则退回高德 POI
box.innerHTML = '正在查住宿…
';
try {
const nights = Math.max(1, (Number(a.timeRange?.days) || 2) - 1);
const qs = new URLSearchParams({
lat: String(anchor.lat),
lon: String(anchor.lon),
radius: '10',
name: anchor.name || '',
checkIn: a.timeRange?.start || '',
nights: String(nights),
});
if (L.max != null) qs.set('maxPrice', String(L.max));
const r = await fetch(`/api/hotels?${qs.toString()}`, { headers: dataHeaders() }).then((x) => x.json());
if (!r.available) {
box.innerHTML = `${esc(r.reason || '住宿数据源不可用')};方案里的住宿预算仍会按你所选价位给出。
`;
return;
}
if (r.error) { box.innerHTML = `住宿查询失败:${esc(r.error)}
`; return; }
const all = r.hotels || [];
if (!all.length) {
const why = r.reason ? `(${esc(r.reason)})` : '';
box.innerHTML = `「${esc(anchor.name)}」周边 10km 内没查到住宿${why}。
`;
return;
}
// 数据源降级要说清楚:实时报价没命中,退到了只有位置的 POI 源
const degraded = r.provider === 'amap' && r.fallbackFrom === 'rollinggo';
const inRange = [];
const unknown = [];
for (const h of all) {
if (h.cost == null) { unknown.push(h); continue; }
if (L.min != null && h.cost < L.min) continue;
if (L.max != null && h.cost > L.max) continue;
inRange.push(h);
}
const list = [...inRange, ...unknown].slice(0, 8);
if (!list.length) {
box.innerHTML = `周边没有落在 ${esc(rangeText)} 的住宿,可以放宽价位,或把价位改成「不限」。
`;
return;
}
state.hotels = list;
const priced = all.filter((h) => h.cost != null);
const rangeLine = inRange.length
? `价位 ${esc(rangeText)} · 命中 ${inRange.length} 家${unknown.length ? ` · ${unknown.length} 家无价格` : ''}`
: `这批酒店都没带价格,无法按 ${esc(rangeText)} 筛选 ;按评分与距离排序`;
// 价格缺失是数据源覆盖面问题,不是解析失败。说一句就够,细节放 README。
const noPriceNote = !priced.length
? (r.provider === 'rollinggo'
? '这批没返回房价,点「去预订」看实时价。
'
: '当前只能查到酒店位置,没有价格 。点「查房价」看实时报价。
')
: '';
const degradedNote = degraded
? '实时报价源这次没命中,已退回到只有位置的酒店数据。
'
: '';
box.innerHTML = `
${rangeLine}
${list.map((h) => {
// RollingGo 会给 bookingUrl,直接用;高德只有 POI,就给一个地图核对入口
const safeBooking = /^https?:\/\//i.test(String(h.bookingUrl || '')) ? h.bookingUrl : '';
const href = safeBooking || `https://uri.amap.com/search?keyword=${encodeURIComponent(h.name)}`;
const dist = h.distanceKm != null ? ` · ${h.distanceKm}km` : '';
// RollingGo 的 lowestPrice 是每晚均价(3 晚总价 1563 → 显示 ¥521/晚),高德的是人均参考价
const price = h.cost != null
? (h.source === 'RollingGo' ? `¥${Math.round(h.cost)}/晚` : `¥${Math.round(h.cost)} 起`)
: '无标注价';
const star = h.rating ? `★${h.rating} · ` : '';
return `
${esc(h.name)}
${star}${price}${dist}
${safeBooking ? '去预订' : '查房价'}
`;
}).join('')}
${degradedNote}
${noPriceNote}
${r.provider === 'rollinggo'
? '价格为每晚参考价,实际金额以下单页锁价为准。'
: '当前数据源只提供酒店位置与评分,未提供房价;实际价格请点「查房价」核对。'}
`;
} catch (e) {
box.innerHTML = `住宿查询失败:${esc(e.message)}
`;
}
}
async function runPlan(opts = {}) {
const a = state.analysis;
if (!a) return toast('请先完成目的地分析', 'err');
const shortlist = a.candidates.filter((c) => state.selected.has(c.id)).slice(0, 8)
.map((c) => ({ ...c, direction: compassCN(c.bearingDeg) }));
if (!shortlist.length) return toast('请至少选择一个目的地', 'err');
const isFollowUp = Boolean(opts.followUp);
// 追加轮把「当前这一版」交给模型当底稿;首次生成则是空的
const previous = isFollowUp ? state.planText : '';
// 先补「可点名的地点」:只给住宿 POI 的话,模型只能写"某某片区 + 自行核实"。
// 这里对主目的地查一次周边餐饮 / 景点,塞进 nearby 一起交给模型。
const btn = $('#planBtn');
btn.disabled = true;
$('#followupBtn').disabled = true;
let nearby = null;
if (!isFollowUp) {
// 已选变化后 state.hotels 会被清空,这里补查一次,避免拿旧住宿生成方案
if (!(state.hotels || []).length) {
try { await loadStayPlan(); } catch { /* 查不到就按空处理 */ }
}
$('#outputMeta').textContent = '正在获取周边地点…';
const anchor = shortlist.find((c) => Number.isFinite(Number(c.lat)) && Number.isFinite(Number(c.lon)));
if (anchor) {
try {
const r = await fetch(`/api/nearby-pois?lat=${anchor.lat}&lon=${anchor.lon}&radius=8`, { headers: dataHeaders() }).then((x) => x.json());
if ((r.food || []).length || (r.sights || []).length) {
nearby = { area: anchor.name, radiusKm: r.radiusKm || 8, food: r.food || [], sights: r.sights || [] };
}
} catch { /* 取不到就照常生成,不让它阻断流程 */ }
}
}
// 存一份供前端提取方案里的点名地点(路线图要用它们的坐标)
state.nearby = nearby;
const payload = {
origin: a.origin,
timeRange: a.timeRange,
constraints: a.constraints,
shortlist,
userNeeds: $('#needs').value.trim(),
dataNotes: a.dataNotes,
previous,
followUp: opts.followUp || '',
hotels: state.hotels || [],
nearby,
lodging: currentLodging(),
};
$('#followupBtn').disabled = true;
state.planning = true;
state.planText = '';
state.reasoningLen = 0;
state.lastNotice = '';
state.viewing = null;
if (!isFollowUp) { state.basePlan = ''; state.planRound = 0; state.planVersions = []; }
$('#planOutput').classList.remove('empty');
$('#outputMeta').textContent = isFollowUp ? '正在基于上一版调整…' : '正在生成…';
paintPlan();
let errMsg = null;
try {
await ssePost('/api/plan', payload, aiHeaders(), (ev) => {
if (ev.type === 'meta') {
state.ctxInfo = ev.approxTokens ? `上下文约 ${ev.approxTokens} token` : '';
$('#outputMeta').textContent = ev.mode === 'demo'
? `演示模式 · 本地规则引擎${state.ctxInfo ? ` · ${state.ctxInfo}` : ''}`
: `${ev.model}${ev.isFollowUp ? ' · 调整中' : ''}${state.ctxInfo ? ` · ${state.ctxInfo}` : ''}`;
} else if (ev.type === 'delta') {
state.planText += ev.text;
schedulePaint();
} else if (ev.type === 'reasoning') {
// 推理模型会先吐一堆思考内容,若不提示,用户会以为卡住了
state.reasoningLen = (state.reasoningLen || 0) + ev.text.length;
$('#outputMeta').textContent = `模型思考中…已 ${state.reasoningLen} 字`;
} else if (ev.type === 'notice') {
// 后端在自动放大 max_tokens 重试,得让用户知道不是卡住了
state.lastNotice = ev.message;
$('#outputMeta').textContent = ev.message;
} else if (ev.type === 'error') {
errMsg = ev.message;
} else if (ev.type === 'done') {
state.planning = false;
}
});
} catch (e) {
errMsg = e.message;
}
state.planning = false;
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
if (errMsg) {
const prefix = state.lastNotice ? `> ℹ️ ${state.lastNotice}\n\n` : '';
state.planText += `\n\n${prefix}> ⚠️ 生成中断:${errMsg}\n`;
}
if (!state.planText.trim()) {
state.planText = `> ⚠️ 未获得任何输出。${errMsg ? '' : '点右上角「设置」→「测试 AI 接口」可定位具体原因(地址 / 密钥 / 模型 / 参数不支持)。'}`;
}
// 记录版本:初版留底,追加轮递增
// 注意:演示模式的方案开头就是「> ⚠️ 演示模式…」的提示块,
// 不能用 startsWith('> ⚠️') 判失败,否则演示模式下永远看不到路线图与住宿。
const hasContent = Boolean(state.planText.trim())
&& !errMsg
&& !state.planText.includes('> ⚠️ 未获得任何输出');
if (hasContent && !errMsg) {
if (isFollowUp) state.planRound = (state.planRound || 1) + 1;
else { state.planRound = 1; state.basePlan = state.planText; state.planVersions = []; }
state.planVersions.push({
n: state.planRound,
needs: opts.followUp || '',
text: state.planText,
at: Date.now(),
});
renderVersionBar();
}
paintPlan();
const showFollow = hasContent;
$('#planFollowup').classList.toggle('hidden', !showFollow);
// 方案定稿后才给路线示意与住宿建议:先有行程,再谈住哪
if (hasContent) {
const wasHidden = $('#planExtra').classList.contains('hidden');
$('#planExtra').classList.remove('hidden');
// 先把方案里点名的地点解析成真实坐标,再画线路。
// 这一步要等方案正文定稿,所以放在生成之后而不是之前。
try {
const places = extractPlanPlaces(state.planText);
const r = await resolveRoutePoints(places);
state.routePoints = r.points.filter((p) => p.lat != null && p.lon != null);
state.routeMissing = r.missing || [];
} catch {
state.routePoints = null;
}
// drawRouteMap 是 async:必须 catch,否则它内部任何异常都会变成
// "未处理的 Promise 拒绝",路线图会无声消失(之前踩过:变量未声明 → ReferenceError)
drawRouteMap().catch((err) => {
const box = $('#routeMap');
if (box) box.innerHTML = `${svgRoute(
(state.analysis?.candidates || []).filter((c) => state.selected.has(c.id)).slice(0, 5),
)}
路线图绘制出错,已用方位示意:${esc(err.message)}
`;
});
if (wasHidden) loadStayPlan();
} else {
$('#planExtra').classList.add('hidden');
}
if (showFollow) {
$('#followupCount').textContent = (state.planRound || 1) > 1
? `当前是第 ${state.planRound} 版,可以继续改`
: '会基于当前方案重写完整版本';
$('#followupBtn').disabled = false;
}
const ctxSuffix = state.ctxInfo ? ` · ${state.ctxInfo}` : '';
$('#outputMeta').textContent = errMsg
? '生成中断'
: (showFollow && (state.planRound || 1) > 1
? `第 ${state.planRound} 版 · 生成完成${ctxSuffix}`
: `生成完成${ctxSuffix}`);
btn.disabled = false;
$('#followupBtn').disabled = false;
if (errMsg) toast(`生成出错:${errMsg}`, 'err');
else toast(isFollowUp ? `已更新到第 ${state.planRound} 版` : '行程规划已生成', 'ok');
}
// ------------------------------------------------------- 版本与保存
function loadSavedPlans() {
try { return JSON.parse(localStorage.getItem('tripsense.saved') || '[]') || []; } catch { return []; }
}
/** 当前正在看的方案文本(可能是历史版),复制/下载/保存都以它为准 */
function currentPlanText() {
if (state.viewing != null && state.planVersions[state.viewing]) return state.planVersions[state.viewing].text;
return state.planText;
}
/** 输出头的版本下拉:本次会话各版 + 本地保存的方案,一个控件完成切换与载入 */
function renderVersionBar() {
const sel = $('#versionSelect');
if (!sel) return;
const list = state.planVersions;
const saved = loadSavedPlans();
if (!list.length && !saved.length) {
sel.classList.add('hidden');
$('#comparePlan').classList.add('hidden');
$('#savePlan').classList.add('hidden');
return;
}
let html = '';
if (list.length) {
html += '' + list.map((v, i) =>
`第 ${v.n} 版${v.needs ? ' · ' + esc(v.needs.slice(0, 12)) : ''} `).join('') + ' ';
}
if (saved.length) {
html += '' + saved.map((s, i) => {
const d = new Date(s.at || Date.now());
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${esc(`${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}${s.origin ? ' · ' + s.origin : ''}`)} `;
}).join('') + '— 清空已保存 — ';
}
sel.innerHTML = html;
if (list.length) sel.value = `v${list.length - 1}`;
sel.classList.remove('hidden');
$('#comparePlan').classList.toggle('hidden', list.length < 2);
$('#savePlan').classList.toggle('hidden', !list.length);
}
function toggleCompare(force) {
const wrap = $('#compareWrap');
const out = $('#planOutput');
const open = typeof force === 'boolean' ? force : wrap.classList.contains('hidden');
if (!open) {
wrap.classList.add('hidden');
out.classList.remove('hidden');
$('#comparePlan').textContent = '对比上一版';
return;
}
const list = state.planVersions;
const idx = state.viewing != null ? state.viewing : list.length - 1;
if (idx < 1) return toast('至少要有两版才能对比', 'err');
const cur = list[idx];
const prev = list[idx - 1];
$('#compareLeftHead').textContent = `第 ${prev.n} 版${prev.needs ? ' · ' + prev.needs.slice(0, 14) : '(初版)'}`;
$('#compareRightHead').textContent = `第 ${cur.n} 版${cur.needs ? ' · ' + cur.needs.slice(0, 14) : ''}`;
$('#compareLeft').innerHTML = mdToHtml(prev.text);
$('#compareRight').innerHTML = mdToHtml(cur.text);
wrap.classList.remove('hidden');
out.classList.add('hidden');
$('#comparePlan').textContent = '退出对比';
}
// ------------------------------------------------------------- 设置弹窗
function openSettings() {
$('#aiBase').value = state.ai.baseUrl || state.config?.ai.baseUrl || '';
$('#aiModel').value = state.ai.model || state.config?.ai.model || '';
$('#aiKey').value = state.ai.apiKey || '';
const amapInput = $('#amapKey');
if (amapInput) amapInput.value = state.ai.amapKey || '';
$('#promptPreview').textContent = state.config?.systemPrompt || '(配置未加载)';
$('#settingsModal').classList.remove('hidden');
}
function closeSettings() { $('#settingsModal').classList.add('hidden'); }
/**
* 浏览器只在「安全上下文」里允许 geolocation:HTTPS 或 localhost / 127.0.0.1。
* 用局域网 IP(http://192.168.x.x:8788)打开时必然被拒,报错还是英文的
* "Only secure origins are allowed",所以这里提前判断、提前解释。
*/
function isSecureOrigin() {
if (typeof window.isSecureContext === 'boolean') return window.isSecureContext;
return location.protocol === 'https:'
|| /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
}
// ------------------------------------------------------------ 表单记忆
// 出发地、日期、时长、半径、排除同城、交通与兴趣都存本地,
// 刷新 / 从手机切回来时不必重新填一遍。日期会校验是否已过期。
const FORM_KEY = 'tripsense.form';
function loadForm() {
try { return JSON.parse(localStorage.getItem(FORM_KEY) || 'null') || null; } catch { return null; }
}
function saveForm() {
const el = (id) => $(id);
const form = {
origin: state.origin ? {
name: state.origin.name,
label: state.origin.label,
lat: state.origin.lat,
lon: state.origin.lon,
admin1: state.origin.admin1 || '',
country: state.origin.country || '',
} : null,
originText: el('#originInput') ? el('#originInput').value.trim() : '',
startDate: el('#startDate') ? el('#startDate').value : '',
endDate: el('#endDate') ? el('#endDate').value : '',
maxHours: el('#maxHours') ? el('#maxHours').value : '',
radius: el('#radius') ? el('#radius').value : '',
excludeLocal: el('#excludeLocal') ? el('#excludeLocal').checked : true,
transport: [...state.transport],
interests: [...state.interests],
lodging: state.lodging,
lodgingCustom: state.lodgingCustom,
};
try { localStorage.setItem(FORM_KEY, JSON.stringify(form)); } catch { /* 隐私模式写不进就算了 */ }
}
let formTimer = null;
function saveFormSoon() {
clearTimeout(formTimer);
formTimer = setTimeout(saveForm, 400);
}
// ------------------------------------------------------------------ init
function init() {
const today = localToday();
const saved = loadForm();
// 日期默认「今天+3」;存过且没过期就沿用,不必每次刷新重选
const okDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(String(d || '')) && String(d) >= today;
let start = saved && okDate(saved.startDate) ? saved.startDate : addDays(today, 3);
let end = saved && okDate(saved.endDate) ? saved.endDate : addDays(today, 4);
if (end < start) end = start;
$('#startDate').value = start;
$('#endDate').value = end;
$('#startDate').min = today;
$('#endDate').min = today;
['#startDate', '#endDate'].forEach((sel) => $(sel).addEventListener('change', () => {
state.datesTouched = true;
$('#windowHint').classList.add('hidden');
saveForm();
}));
// 恢复其余选项
if (saved) {
if (saved.maxHours) {
$('#maxHours').value = saved.maxHours;
$('#hoursVal').textContent = `${saved.maxHours} 小时`;
}
if (saved.radius) $('#radius').value = String(saved.radius);
if (typeof saved.excludeLocal === 'boolean') $('#excludeLocal').checked = saved.excludeLocal;
if (Array.isArray(saved.transport) && saved.transport.length) {
const keep = saved.transport.filter((x) => TRANSPORTS.includes(x));
if (keep.length) state.transport = new Set(keep);
}
if (Array.isArray(saved.interests)) {
state.interests = new Set(saved.interests.filter((x) => INTERESTS.includes(x)));
}
if (saved.lodging && LODGING.some((l) => l.key === saved.lodging)) state.lodging = saved.lodging;
if (saved.lodgingCustom && typeof saved.lodgingCustom === 'object') {
state.lodgingCustom = {
min: saved.lodgingCustom.min ?? '',
max: saved.lodgingCustom.max ?? '',
};
}
if (saved.origin && Number.isFinite(saved.origin.lat) && Number.isFinite(saved.origin.lon)) {
state.origin = { ...saved.origin };
$('#originInput').value = saved.origin.label || saved.origin.name || '';
// 非安全上下文时保留「定位不可用」的提示,不要被覆盖
if (isSecureOrigin()) {
$('#originHint').innerHTML = `已定位:${esc(state.origin.label)} (${Number(state.origin.lat).toFixed(3)}, ${Number(state.origin.lon).toFixed(3)})`;
}
} else if (saved.originText) {
$('#originInput').value = saved.originText;
}
}
$('#maxHours').addEventListener('input', (e) => {
$('#hoursVal').textContent = `${e.target.value} 小时`;
saveFormSoon();
});
// 其余控件变更即存
['#radius', '#excludeLocal'].forEach((sel) => $(sel).addEventListener('change', saveForm));
$('#originInput').addEventListener('change', saveForm);
const onTransport = (v) => {
if (v === '不限') { state.transport.clear(); state.transport.add('不限'); }
else {
state.transport.delete('不限');
if (state.transport.has(v)) state.transport.delete(v); else state.transport.add(v);
if (!state.transport.size) state.transport.add('不限');
}
renderChips($('#transportChips'), TRANSPORTS, state.transport, onTransport);
saveForm();
};
renderChips($('#transportChips'), TRANSPORTS, state.transport, onTransport);
const onInterest = (v) => {
if (state.interests.has(v)) state.interests.delete(v); else state.interests.add(v);
renderChips($('#interestChips'), INTERESTS, state.interests, onInterest);
saveForm();
};
renderChips($('#interestChips'), INTERESTS, state.interests, onInterest);
renderLodging();
['#lodgingMin', '#lodgingMax'].forEach((sel) => {
const el = $(sel);
if (el) el.addEventListener('input', onLodgingInput);
});
$('#quickNeeds').innerHTML = QUICK_NEEDS.map((x) => `+ ${esc(x)} `).join('');
$$('#quickNeeds button').forEach((b) => b.addEventListener('click', () => {
const ta = $('#needs');
const cur = ta.value.trim();
ta.value = cur ? `${cur};${b.dataset.v}` : b.dataset.v;
ta.focus();
}));
$('#aiPresets').innerHTML = AI_PRESETS.map((p, i) => `${esc(p.name)} `).join('');
$$('#aiPresets button').forEach((b) => b.addEventListener('click', () => {
const p = AI_PRESETS[Number(b.dataset.i)];
$('#aiBase').value = p.base;
$('#aiModel').value = p.model;
toast(`已填入 ${p.name} 预设,还需填写 API Key`, 'ok');
}));
// 出发地
$('#originInput').addEventListener('input', (e) => {
state.origin = null;
saveFormSoon();
const q = e.target.value.trim();
clearTimeout(suggestTimer);
if (!q) { hideSuggest(); return; }
suggestTimer = setTimeout(async () => {
try {
const { list, used, amapError, meteoError, source } = await geocodeLoose(q);
showSuggest(list, q, used, { amapError, meteoError, source });
} catch { hideSuggest(); }
}, 340);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.origin-row') && !e.target.closest('#suggestBox')) hideSuggest();
});
// 非安全上下文(局域网 http)时,提前把定位按钮和说明改成「不可用」的状态,
// 而不是等用户点了再弹一条英文报错
if (!isSecureOrigin()) {
const btn = $('#geoBtn');
btn.classList.add('geo-off');
btn.title = '浏览器只允许 HTTPS 或 localhost 页面调用定位';
const hint = $('#originHint');
hint.classList.add('hint-warn');
hint.innerHTML = '当前通过局域网 http 地址访问,浏览器不允许网页定位。直接输入地名即可(下拉里选);确实需要定位请改用 HTTPS,见 README「定位与 HTTPS」。';
}
$('#geoBtn').addEventListener('click', () => {
if (!isSecureOrigin()) {
return toast('局域网 http 页面不能调用定位:请直接输入地名,或改用 HTTPS 访问', 'err');
}
if (!navigator.geolocation) return toast('当前浏览器不支持定位,请手动输入地名', 'err');
toast('正在获取位置…');
navigator.geolocation.getCurrentPosition(async (pos) => {
const { latitude, longitude } = pos.coords;
try {
const r = await fetch(`/api/reverse?lat=${latitude}&lon=${longitude}`).then((x) => x.json());
if (r.place) setOrigin(r.place);
else toast('反向地理编码失败,请手动输入地名', 'err');
} catch (err) { toast(`定位解析失败:${err.message}`, 'err'); }
}, (err) => {
// 原始 err.message 是 "Only secure origins are allowed..." 之类的英文,对用户没意义
const reason = err.code === 1 ? '你拒绝了位置权限'
: err.code === 3 ? '定位超时'
: '浏览器未返回位置';
toast(`${reason},请手动输入地名`, 'err');
},
{ enableHighAccuracy: false, timeout: 12000, maximumAge: 300000 });
});
$('#analyzeBtn').addEventListener('click', () => runAnalyze());
$('#backBtn1').addEventListener('click', () => gotoStep(1));
$('#backBtn2').addEventListener('click', () => gotoStep(2));
$('#toStep3').addEventListener('click', () => {
if (!state.selected.size) return toast('请先在清单中勾选目的地', 'err');
renderSelected();
gotoStep(3);
});
$$('#sortSeg button').forEach((b) => b.addEventListener('click', () => {
$$('#sortSeg button').forEach((x) => x.classList.remove('active'));
b.classList.add('active');
state.sort = b.dataset.sort;
renderCards();
}));
$('#onlyRec').addEventListener('change', (e) => { state.onlyRec = e.target.checked; renderCards(); });
$('#onlyNiche').addEventListener('change', (e) => { state.onlyNiche = e.target.checked; renderCards(); });
$('#compareBtn').addEventListener('click', toggleCompare);
$('#planBtn').addEventListener('click', () => runPlan());
// 生成之后可以继续提要求,让模型在上一版上改,而不是只能推倒重来
$('#followupBtn').addEventListener('click', async () => {
if (state.planning) return;
const extra = $('#followupInput').value.trim();
if (!extra) { $('#followupInput').focus(); return toast('先写一句想怎么调整', 'err'); }
$('#followupInput').value = '';
await runPlan({ followUp: extra });
});
$('#followupInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); $('#followupBtn').click(); }
});
$('#resetPlan').addEventListener('click', () => {
if (!state.basePlan) return toast('还没有可回退的初版', 'err');
state.planText = state.basePlan;
state.planRound = 1;
paintPlan();
$('#outputMeta').textContent = '已回到初版';
$('#followupCount').textContent = '会基于当前方案重写完整版本';
toast('已回到初版方案', 'ok');
});
$('#versionSelect').addEventListener('change', (e) => {
const v = e.target.value;
if (v === '__clear') {
localStorage.removeItem('tripsense.saved');
toast('已清空保存的方案', 'ok');
renderVersionBar();
return;
}
if (v.startsWith('v')) {
const i = Number(v.slice(1));
if (!state.planVersions[i]) return;
state.viewing = i;
toggleCompare(false);
paintPlan();
const ver = state.planVersions[i];
$('#outputMeta').textContent = `第 ${ver.n} 版${ver.needs ? ' · ' + ver.needs.slice(0, 16) : ''}`;
return;
}
if (v.startsWith('s')) {
const s = loadSavedPlans()[Number(v.slice(1))];
if (!s) return;
state.viewing = null;
state.planText = s.text;
state.basePlan = s.text;
state.planVersions = [{ n: 1, needs: s.needs || '', text: s.text, at: s.at || Date.now() }];
state.planRound = 1;
toggleCompare(false);
$('#planOutput').classList.remove('empty');
paintPlan(s.text);
$('#planFollowup').classList.remove('hidden');
$('#outputMeta').textContent = '已载入保存的方案';
renderVersionBar();
toast('已载入保存的方案,可以继续调整', 'ok');
}
});
$('#comparePlan').addEventListener('click', () => toggleCompare());
$('#savePlan').addEventListener('click', () => {
const list = state.planVersions;
const idx = state.viewing != null ? state.viewing : list.length - 1;
const v = list[idx];
if (!v) return toast('还没有可保存的方案', 'err');
const saved = loadSavedPlans();
saved.unshift({ at: Date.now(), origin: state.analysis?.origin?.label || '', needs: v.needs, text: v.text });
const trimmed = saved.slice(0, 20);
try { localStorage.setItem('tripsense.saved', JSON.stringify(trimmed)); }
catch { return toast('本地存储已满,先清空一些已保存方案', 'err'); }
renderVersionBar();
toast(`已保存到本地(共 ${trimmed.length} 个)`, 'ok');
});
$('#copyBtn').addEventListener('click', async () => {
const txt = currentPlanText();
if (!txt) return toast('还没有可复制的内容', 'err');
try { await navigator.clipboard.writeText(txt); toast('已复制 Markdown', 'ok'); }
catch { toast('复制失败,请手动选中文本', 'err'); }
});
$('#downloadBtn').addEventListener('click', () => {
const txt = currentPlanText();
if (!txt) return toast('还没有可下载的内容', 'err');
const blob = new Blob([txt], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TripSense-行程规划-${state.analysis?.timeRange?.start || 'plan'}.md`;
a.click();
URL.revokeObjectURL(url);
toast('已下载 .md 文件', 'ok');
});
$('#openSettings').addEventListener('click', openSettings);
$('#closeSettings').addEventListener('click', closeSettings);
$('#cancelSettings').addEventListener('click', closeSettings);
$('#modalMask').addEventListener('click', closeSettings);
$('#saveSettings').addEventListener('click', () => {
const prevAmap = state.ai.amapKey || '';
state.ai.baseUrl = $('#aiBase').value.trim();
state.ai.model = $('#aiModel').value.trim();
state.ai.apiKey = $('#aiKey').value.trim();
const amapEl = $('#amapKey');
if (amapEl) state.ai.amapKey = amapEl.value.trim();
state.ai.forceDemo = false;
saveAI();
updateAIStatus();
closeSettings();
const changed = (state.ai.amapKey || '') !== prevAmap;
if (state.analysis) {
if (changed) {
// 旧结果是用旧配置跑出来的,继续显示旧报错会误导
$('#alertBox').innerHTML = 'ℹ️ 数据源设置已更新。点 重新分析一次 应用新的高德 Key。
';
$('#alertBox').classList.remove('hidden');
bindAlertActions();
} else {
renderAlerts(state.analysis);
}
}
if (changed && state.ai.amapKey) toast('高德 Key 已保存,点提示里的「重新分析一次」即可生效', 'ok');
else if (changed && !state.ai.amapKey) toast('已清除高德 Key,恢复免密钥数据源', 'ok');
else toast('设置已保存', 'ok');
});
$('#testAmap').addEventListener('click', async () => {
const key = ($('#amapKey').value || '').trim();
const out = $('#amapTestResult');
if (!key) {
out.textContent = '先填 Key 再测';
out.className = 'field-hint inline bad';
return;
}
const btn = $('#testAmap');
btn.disabled = true;
out.textContent = '测试中…';
out.className = 'field-hint inline';
try {
const r = await fetch(`/api/geocode?name=${encodeURIComponent('杭州')}`, { headers: { 'X-AMAP-Key': key } }).then((x) => x.json());
if (r.source === '高德' && r.results?.length) {
// 顺便确认静态地图能不能出图——这是「真实地图图层」的唯一依赖。
// 用 probe 分别试三种参数组合,能直接定位是哪个参数被高德拒绝。
let mapMsg = '';
try {
const p = await fetch('/api/staticmap?probe=1&pts=30.24,120.15&w=320&h=200', {
headers: { 'X-AMAP-Key': key },
}).then((x) => x.json());
const arr = p.probe || [];
const ok = arr.filter((x) => x.ok).map((x) => x.mode);
const errOf = (m) => (arr.find((x) => x.mode === m) || {}).error || '';
if (ok.includes('full')) mapMsg = ' · 地图可用';
else if (ok.includes('full-encode')) mapMsg = ' · 地图可用(需编码 URL)';
else if (ok.includes('markers')) mapMsg = ' · 标记可用,连线被拒';
else if (ok.includes('base')) mapMsg = ' · 底图可用但标记被拒';
else if (ok.includes('base-encode')) mapMsg = ' · 仅编码底图可用';
else mapMsg = ` · 地图不可用:${String(errOf('base')).slice(0, 50)}`;
} catch {
mapMsg = ' · 地图图层检测失败';
}
out.textContent = `可用 · 命中「${r.results[0].label}」${mapMsg}`;
out.className = 'field-hint inline good';
} else if (r.amapError) {
out.textContent = `不可用:${r.amapError}`;
out.className = 'field-hint inline bad';
} else {
out.textContent = '没返回高德结果,确认 Key 是「Web服务」类型';
out.className = 'field-hint inline bad';
}
} catch (e) {
out.textContent = `测试失败:${e.message}`;
out.className = 'field-hint inline bad';
} finally {
btn.disabled = false;
}
});
// 住宿数据源检测:装了 rgh 并登录就能拿到实时报价
const provBtn = $('#checkProviders');
if (provBtn) provBtn.addEventListener('click', async () => {
const out = $('#providerResult');
provBtn.disabled = true;
out.textContent = '检测中…';
out.className = 'field-hint inline';
try {
// 用输入框当前值:还没点保存也能测出来,别让用户以为填了不生效
const keyEl = $('#amapKey');
const key = String((keyEl && keyEl.value) || state.ai.amapKey || '').trim();
const r = await fetch('/api/hotel-providers', {
headers: key ? { 'X-AMAP-Key': key } : {},
}).then((x) => x.json());
const rg = r.rollinggo || {};
const amap = r.amap
? `高德 Key 已配置${r.amapSource === '页面设置' ? '(页面填写)' : ''}`
: '未配高德 Key';
if (rg.ready) {
out.textContent = `RollingGo 可用(实时报价)· ${amap}`;
out.className = 'field-hint inline good';
} else if (rg.installed) {
out.textContent = `${rg.reason || 'rgh 未登录'} · ${amap}`;
out.className = 'field-hint inline bad';
} else {
out.textContent = `未安装 rgh(npm i -g @rollinggo/hotel)· ${amap}`;
out.className = 'field-hint inline bad';
}
} catch (e) {
out.textContent = `检测失败:${e.message}`;
out.className = 'field-hint inline bad';
} finally {
provBtn.disabled = false;
}
});
$('#testAi').addEventListener('click', async () => {
const out = $('#aiTestResult');
const btn = $('#testAi');
const baseUrl = $('#aiBase').value.trim();
const apiKey = $('#aiKey').value.trim();
const model = $('#aiModel').value.trim();
if (!baseUrl) {
out.textContent = '先填接口地址';
out.className = 'field-hint inline bad';
return;
}
btn.disabled = true;
out.textContent = '测试中…';
out.className = 'field-hint inline';
try {
const r = await fetch('/api/ai-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...aiHeaders() },
body: JSON.stringify({ baseUrl, apiKey, model }),
}).then((x) => x.json());
if (r.ok) {
const bits = [`可用 · ${r.ms}ms`];
if (r.model) bits.push(r.model);
if (r.reasoningOnly) bits.push('只返回思考内容');
if (r.note) bits.push(r.note);
out.textContent = bits.join(' · ');
out.className = 'field-hint inline good';
} else {
out.textContent = r.message || '不可用';
out.className = 'field-hint inline bad';
}
} catch (e) {
out.textContent = `测试失败:${e.message}`;
out.className = 'field-hint inline bad';
} finally {
btn.disabled = false;
}
});
$('#demoMode').addEventListener('click', () => {
state.ai.forceDemo = !state.ai.forceDemo;
saveAI();
updateAIStatus();
closeSettings();
toast(state.ai.forceDemo ? '已切换为演示模式' : '已取消强制演示模式', 'ok');
});
$('#copyPrompt').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(state.config?.systemPrompt || ''); toast('预设提示词已复制', 'ok'); }
catch { toast('复制失败', 'err'); }
});
// 载入服务端配置
fetch('/api/config').then((r) => r.json()).then((cfg) => {
state.config = cfg;
if (!state.ai.baseUrl) state.ai.baseUrl = cfg.ai.baseUrl;
if (!state.ai.model) state.ai.model = cfg.ai.model;
$('#promptPreview').textContent = cfg.systemPrompt;
updateAIStatus();
}).catch(() => {
$('#aiStatus').innerHTML = ' 配置读取失败';
$('#promptPreview').textContent = '(无法读取服务端配置)';
$('#aiInline').innerHTML = '服务端配置读取失败,可点击右上角「设置」手动填写接口地址、密钥与高德 Key。';
});
renderVersionBar();
}
async function runAnalyze(force) {
// 注意:事件监听会把 Event 对象传进来,Boolean(event) 恒为 true,
// 会让每次点击都变成「强制刷新」,缓存永远不命中。这里只认严格的 true。
const forceRefresh = force === true;
const start = $('#startDate').value;
const end = $('#endDate').value;
if (!state.origin) return toast('请先选择出发地:输入地名后从下拉选择,或点击「使用当前位置」', 'err');
if (!start || !end) return toast('请选择出行起止日期', 'err');
if (end < start) return toast('结束日期不能早于开始日期', 'err');
const btn = $('#analyzeBtn');
btn.disabled = true;
state.analysis = null;
const wrap = $('#progressWrap');
wrap.classList.remove('hidden');
setProgress(3, '开始分析…', 'origin');
const payload = {
origin: state.origin,
startDate: start,
endDate: end,
maxOneWayHours: Number($('#maxHours').value),
radiusKm: Number($('#radius').value),
excludeLocal: $('#excludeLocal').checked,
transportPrefs: [...state.transport],
interests: [...state.interests],
// 强制刷新:跳过服务端缓存,重新打数据源
forceRefresh,
};
let errMsg = null;
let got = false;
try {
await ssePost('/api/analyze', payload, dataHeaders(), (ev) => {
if (ev.type === 'progress') setProgress(ev.pct, ev.text, ev.stage);
else if (ev.type === 'result') { state.analysis = ev.data; got = true; }
else if (ev.type === 'error') errMsg = ev.message;
});
} catch (e) { errMsg = e.message; }
btn.disabled = false;
if (errMsg) toast(`分析失败:${errMsg}`, 'err');
if (got && state.analysis) {
const cache = state.analysis.cache || {};
if (cache.hit) {
const mins = Math.max(1, Math.round((cache.ageMs || 0) / 60000));
setProgress(100, `用了缓存(${mins} 分钟前的数据)`, 'done');
toast(`已复用 ${mins} 分钟前的分析结果,天气与路况不会更旧`, 'ok');
} else {
setProgress(100, '分析完成', 'done');
}
renderStep2();
gotoStep(2);
setTimeout(() => wrap.classList.add('hidden'), 800);
renderCacheBar();
} else {
wrap.classList.add('hidden');
}
}
/**
* 缓存提示条:命中缓存时给一个「重新获取最新数据」的出口。
* 用户改了条件但结果看着像旧的时,可以直接强刷。
*/
function renderCacheBar() {
const bar = $('#cacheBar');
if (!bar) return;
const cache = state.analysis?.cache || {};
if (!cache.hit) { bar.classList.add('hidden'); return; }
const mins = Math.max(1, Math.round((cache.ageMs || 0) / 60000));
bar.classList.remove('hidden');
bar.innerHTML = `这份清单来自 ${mins} 分钟前的缓存(同一组条件不会重复请求数据源)。
重新获取最新数据 `;
$('#forceRefresh').addEventListener('click', () => runAnalyze(true));
}
init();