'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) => ``).join('')}${body.map((r) => `${r.map((c) => ``).join('')}`).join('')}
${c}
${c}
`); 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('')}`); continue; } para.push(esc(t)); i += 1; } flush(); return out.join('\n'); } // ------------------------------------------------------------------ chips function renderChips(el, items, set, onChange) { el.innerHTML = items.map((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) => `` ).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) => ``).join('')}
    日期天气气温降水风
    ${esc(x.date)} ${esc(x.weekday || '')}${x.icon || ''} ${esc(x.label)}${x.tmin}~${x.tmax}℃${x.precip}mm / ${x.precipProb}%${x.wind}km/h
    `; } 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 ? '小众' : ''}

    ${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.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 = `

    数据来源与口径说明

    `; } /** * 顶部提示区(天气预警 + 降级说明)。 * 单独抽出是因为「保存设置」后需要原地刷新:用户刚填完高德 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服务」类型、已启用且配额未用尽。
    `); } else if (a.stats?.fallbackOnly) { const hasAmap = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad); const action = hasAmap ? '看看这次能否取到实时数据。' : '可获得更丰富的实时地点。'; 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} 个 · 同屏比较关键指标
    ${list.map((c) => ``).join('')}${rows.map(([k, fn]) => `${list.map((c) => ``).join('')}`).join('')}
    指标${esc(c.name)}
    ${k}${fn(c)}
    `; 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) => `
    1. ${i + 1}${esc(p.name)}${p.kind ? `${esc(p.kind)}` : ''}
    2. `).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}
    ${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) => ``).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 ``; }).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) => ``).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) => ``).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();