Files
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026-11-05 20:25:03 +08:00

2026 lines
92 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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) => `<code>${p}</code>`)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}
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(`<p>${mdInline(para.join('<br>'))}</p>`); 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(`<pre><code>${buf.join('\n')}</code></pre>`);
continue;
}
const h = t.match(/^(#{1,4})\s+(.*)$/);
if (h) { flush(); out.push(`<h${h[1].length}>${mdInline(esc(h[2]))}</h${h[1].length}>`); i += 1; continue; }
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) { flush(); out.push('<hr>'); 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(`<table><thead><tr>${head.map((c) => `<th>${c}</th>`).join('')}</tr></thead><tbody>${body.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
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(`<blockquote><p>${buf.join('<br>')}</p></blockquote>`);
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(`<li>${mdInline(esc(lines[i].trim().replace(/^([-*+]|\d+\.)\s+/, '')))}</li>`); 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) => `<button type="button" class="chip ${set.has(x) ? 'on' : ''}" data-v="${esc(x)}">${esc(x)}</button>`).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) =>
`<button type="button" class="chip${state.lodging === l.key ? ' on' : ''}" data-k="${l.key}">${esc(l.label)}</button>`
).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 `<span class="${cls}">${k < idx ? '✓' : '·'} ${s[1]}</span>`;
}).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 = `<div class="suggest-empty">没找到「${esc(query)}」。${reason}</div>`;
box.classList.remove('hidden');
return;
}
const hint = usedQuery && usedQuery !== query
? `<div class="suggest-note">按「${esc(usedQuery)}」为你找到这些:</div>`
: '';
box.innerHTML = hint + list.map((r, i) =>
`<div class="suggest-item" data-i="${i}"><span>${esc(r.label || r.name)}</span><small>${r.lat.toFixed(2)}, ${r.lon.toFixed(2)}</small></div>`
).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 = `已定位:<b>${esc(state.origin.label)}</b>(${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 = `☀️ 近期天气较平稳的窗口:<b>${esc(w.start)} ~ ${esc(w.end)}</b>(${esc([...new Set(w.labels)].join('、'))})<button type="button" id="applyWindow">应用</button>`;
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 viewBox="0 0 400 400" role="img" aria-label="出行方位罗盘">`;
svg += `<circle cx="${cx}" cy="${cy}" r="${R}" fill="#f6f1e5" stroke="#cfc6b2" stroke-width="1"/>`;
[0.25, 0.5, 0.75, 1].forEach((f) => {
svg += `<circle cx="${cx}" cy="${cy}" r="${R * f}" fill="none" stroke="#d9d0bb" stroke-width="1" stroke-dasharray="3 4"/>`;
svg += `<text x="${cx + 4}" y="${cy - R * f - 4}" font-size="9" fill="#8b8a79">${Math.round(maxD * f)}km</text>`;
});
svg += `<line x1="${cx}" y1="${cy - R}" x2="${cx}" y2="${cy + R}" stroke="#d9d0bb"/>`;
svg += `<line x1="${cx - R}" y1="${cy}" x2="${cx + R}" y2="${cy}" stroke="#d9d0bb"/>`;
[['N', 0, -1], ['E', 1, 0], ['S', 0, 1], ['W', -1, 0]].forEach(([lab, dx, dy]) => {
svg += `<text x="${cx + dx * (R + 18)}" y="${cy + dy * (R + 18) + 4}" text-anchor="middle" font-size="12" font-weight="700" fill="#b04a2b">${lab}</text>`;
});
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 += `<circle class="cdot" data-id="${esc(c.id)}" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${rad.toFixed(1)}" fill="${col}" fill-opacity="0.82" stroke="#fbf8f0" stroke-width="1.2" style="cursor:pointer"><title>${esc(c.name)} · ${c.score}分 · ${fmtKm(c.distanceKm)} · ${compassCN(c.bearingDeg)}方向</title></circle>`;
});
svg += `<circle cx="${cx}" cy="${cy}" r="6" fill="#b04a2b" stroke="#fbf8f0" stroke-width="2"/>`;
svg += `<text x="${cx}" y="${cy + 22}" text-anchor="middle" font-size="10" fill="#6f766c">出发地</text>`;
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 `<svg viewBox="0 0 72 72">
<circle cx="36" cy="36" r="${r}" fill="none" stroke="#e4ddc9" stroke-width="6"/>
<circle cx="36" cy="36" r="${r}" fill="none" stroke="${scoreColor(score)}" stroke-width="6"
stroke-linecap="round" stroke-dasharray="${dash.toFixed(1)} ${C.toFixed(1)}" transform="rotate(-90 36 36)"/>
<text x="36" y="39" text-anchor="middle" class="score-num">${score}</text>
<text x="36" y="51" text-anchor="middle" class="score-cap">/ 100</text>
</svg>`;
}
function weatherCell(c) {
const s = c.weather?.summary;
if (!s) return `<div class="metric"><span class="k">天气</span><div class="v">暂无数据</div><div class="s">按中性计分</div></div>`;
return `<div class="metric"><span class="k">天气 ${s.days} 天</span>
<div class="v">${s.dominant?.icon || '🌡️'} ${esc(s.dominant?.label || '')} ${s.avgLow}~${s.avgHigh}℃</div>
<div class="s">降水概率 ${s.maxPrecipProb}% · 累计 ${s.totalPrecip}mm · 风 ${s.maxWind}km/h</div></div>`;
}
function dailyTable(c) {
const d = c.weather?.daily || [];
if (!d.length) return '';
return `<details class="daily" data-day="${esc(c.id)}"><summary>查看逐日天气(${d.length} 天)</summary>
<table><thead><tr><th>日期</th><th>天气</th><th>气温</th><th>降水</th><th>风</th></tr></thead><tbody>
${d.map((x) => `<tr><td>${esc(x.date)} ${esc(x.weekday || '')}</td><td>${x.icon || ''} ${esc(x.label)}</td><td>${x.tmin}~${x.tmax}℃</td><td>${x.precip}mm / ${x.precipProb}%</td><td>${x.wind}km/h</td></tr>`).join('')}
</tbody></table></details>`;
}
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 `<article class="dest-card ${picked ? 'picked' : ''}" data-id="${esc(c.id)}">
<div class="card-top">
<span class="rank">#${c.rank}</span>
<span class="verdict ${VERDICT_CLASS[c.verdict] || 'v3'}">${esc(c.verdict)}</span>
${c.niche ? '<span class="niche-badge">小众</span>' : ''}
<button type="button" class="pick-btn ${picked ? 'on' : ''}" data-id="${esc(c.id)}">${picked ? '✓ 已加入' : '+ 加入清单'}</button>
</div>
<div class="card-title">
<h4>${esc(c.name)}</h4>
<div class="sub">${esc(c.kind || '目的地')}${c.region ? ' · ' + esc(c.region) : ''} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}</div>
</div>
<div class="card-body">
<div class="score-ring">${scoreRing(c.score)}</div>
<div class="metrics">
${weatherCell(c)}
<div class="metric"><span class="k">自驾 / 公路</span>
<div class="v">${fmtMin(road.durationMinutes)} · ${fmtKm(road.distanceKm)}</div>
<div class="s ${road.source === '估算' ? 'est' : ''}">${esc(road.source || '')}${road.source === '估算' ? '(非实时)' : ''}</div></div>
<div class="metric"><span class="k">铁路 / 高铁</span>
<div class="v">${railMain}</div>
<div class="s ${rail.source === '估算' ? 'est' : ''}">${esc(rail.source || '')}${rail.source === '估算' ? '(估算,需核对 12306)' : ''}</div></div>
<div class="metric"><span class="k">空气质量</span>
<div class="v">${aqi}</div>
<div class="s">评分构成:天气 ${c.breakdown?.weather} / 交通 ${c.breakdown?.time} / 兴趣 ${c.breakdown?.interest}</div></div>
</div>
</div>
<ul class="reasons">${(c.reasons || []).map((r) => `<li>${esc(r)}</li>`).join('')}</ul>
${(c.warnings || []).length ? `<div class="warnings">${c.warnings.map((w) => `<span>⚠ ${esc(w)}</span>`).join('')}</div>` : ''}
${dailyTable(c)}
</article>`;
}
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('')
: `<div class="empty-mini">${noBase
? `当前半径内没有可去的地点${state.analysis?.constraints?.excludeLocal ? '(已排除出发地附近的同城点位)' : ''},试试取消「排除同城」或增大搜索半径。`
: state.onlyNiche
? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。'
: '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}</div>`;
$$('#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 = `
<div class="summary-grid">
<div class="stat"><div class="k">候选目的地</div><div class="v">${s.total}<small>个</small></div><div class="d">推荐及以上 ${s.recommended} 个</div></div>
<div class="stat"><div class="k">平均适配分</div><div class="v">${s.avgScore}<small>/100</small></div><div class="d">天气 / 交通 / 距离 / 偏好</div></div>
<div class="stat"><div class="k">区间平均高温</div><div class="v">${s.avgHigh ?? '—'}<small>℃</small></div><div class="d">需留意天气的候选 ${s.rainyCount} 个</div></div>
</div>
<div class="summary-lines">
<div><b>天气</b> ${esc(s.weatherNote)}</div>
<div><b>公路</b> ${esc(s.roadProvider === '估算' ? '路网服务不可用,里程-速度估算' : s.roadProvider === '高德' ? '高德 Web 服务驾车路径' : 'OSRM 公开路网矩阵')}</div>
<div><b>铁路</b> ${a.dataNotes.some((n) => n.includes('铁路接口')) && !a.dataNotes.some((n) => n.includes('未配置铁路接口')) ? '已配置铁路接口' : '未配置铁路接口,耗时与票价均为估算'}</div>
</div>`;
renderAlerts(a);
state.selected = new Set();
state.autoPreselected = false;
state.compareOpen = false;
$('#compareBtn').textContent = '对比已选';
renderCards();
$('#dataNotes').innerHTML = `<h4>数据来源与口径说明</h4><ul>${a.dataNotes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>`;
}
/**
* 顶部提示区(天气预警 + 降级说明)。
* 单独抽出是因为「保存设置」后需要原地刷新:用户刚填完高德 Key,
* 提示里就该从「去配置」变成「重新分析一次」,而不是让他自己猜。
*/
function renderAlerts(a) {
const s = a.summary || {};
const alerts = [];
if (s.weatherAlert) alerts.push(`<div class="alert warn">⚠️ ${esc(s.weatherAlert)}</div>`);
if (a.stats?.amapError) {
// Key 填错比"没取到"更值得优先告诉用户,否则他会反复重试
alerts.push(`<div class="alert warn">⚠️ ${esc(a.stats.amapError)}<br>请确认高德 Key 是「Web服务」类型、已启用且配额未用尽。<button class="alert-link" data-open-settings type="button">修改 Key</button></div>`);
} else if (a.stats?.fallbackOnly) {
const hasAmap = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad);
const action = hasAmap
? '<button class="alert-link" data-retry-analyze type="button">重新分析一次</button>看看这次能否取到实时数据。'
: '<button class="alert-link" data-open-settings type="button">去配置高德 Key</button>可获得更丰富的实时地点。';
alerts.push(`<div class="alert info">ℹ️ 本次未取到实时周边 POI(公共检索服务波动),清单来自内置地点库,坐标为近似值;<b>天气、空气质量与路况仍是实时数据,不受影响</b>。${action}</div>`);
}
$('#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 = '<div class="compare-empty">至少勾选 2 个目的地才能并排对比。</div>';
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 = `<div class="compare-head"><h4>已选目的地对比</h4><span>${list.length} 个 · 同屏比较关键指标</span></div>
<div class="compare-scroll"><table class="compare-table"><thead><tr><th>指标</th>${list.map((c) => `<th>${esc(c.name)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(([k, fn]) => `<tr><th>${k}</th>${list.map((c) => `<td>${fn(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
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) => `<span class="sel-chip" data-id="${esc(c.id)}" title="点击移除">${esc(c.name)} ✕</span>`).join('')
: '<span class="hint">尚未选择目的地,请返回上一步勾选。</span>';
$$('#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 = `<i></i>${demo ? '演示模式(未配置密钥)' : `AI 已配置 · ${esc(state.ai.model || '')}`}`;
const cfg = state.config;
$('#aiInline').innerHTML = demo
? '当前为 <b>演示模式</b>:由本地规则引擎依据同一份清单生成示例方案,用于验证完整流程。填入任意 OpenAI 兼容接口后由大模型生成。'
: `将调用 <b>${esc(state.ai.baseUrl || cfg?.ai.baseUrl || '')}</b> · 模型 <b>${esc(state.ai.model || cfg?.ai.model || '')}</b>,配合本站预设提示词生成方案。`;
}
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 ? '<span class="cursor">▍</span>' : '');
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
? `<polyline points="${pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')}" fill="none" stroke="#2f6fed" stroke-width="2.5" stroke-dasharray="6 5" opacity=".9"/>`
: '';
// 标注避让:行程点常挤在 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 `<g>
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="8" fill="#fff" stroke="#6b7280" stroke-width="2.5"/>
<text x="${p.x.toFixed(1)}" y="${(p.y - 15).toFixed(1)}" text-anchor="middle" class="map-name">出发地</text>
</g>`;
}
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
? `<text x="${(p.x + slot.dx).toFixed(1)}" y="${(p.y + slot.dy).toFixed(1)}" text-anchor="${slot.anchor}" class="map-name">${esc(nm)}</text>`
: '';
const tip = `<title>${esc(item.name || '')}</title>`;
return `<g>${tip}
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="10" fill="#2f6fed" stroke="#fff" stroke-width="2.5"/>
<text x="${p.x.toFixed(1)}" y="${(p.y + 4).toFixed(1)}" text-anchor="middle" class="rm-idx">${seq}</text>
${label}
</g>`;
}).join('');
return `<svg class="map-overlay" viewBox="0 0 ${meta.width} ${meta.height}">${line}${dots}</svg>`;
}
/**
* 从方案正文里提取「实际安排的地点」。
*
* 背景:路线图之前只画用户勾选的候选(常常只有 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 = '<div class="hotel-hint">还没有已选目的地。生成方案后,这里会画出方案里每个地点的实际位置与线路。</div>';
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 = `<div class="route-fallback">${svgRoute(picked, origin)}</div>`;
if (note) note.textContent = label;
const hasKey = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad);
if (!hasKey) {
wrap.innerHTML = `<div class="route-fallback">${svgRoute(picked, origin)}</div>
<div class="map-hint">配置高德 Key 可显示真实地图(当前为方位示意)。</div>`;
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 = `<div class="route-fallback">${svgRoute(picked, origin)}</div>
<div class="map-hint">地图暂不可用,已用方位示意:${esc(why)}</div>`;
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
? `<div class="map-hint">${notes.map((x) => esc(x)).join(';')}。</div>`
: '';
// 行程点清单:图上最多 9 个点,方案里的地点通常更多,
// 完整清单让用户能看到「每个安排的地点」到底是哪些、按什么顺序。
const allPoints = state.routePoints || picked;
const listHtml = allPoints.length ? `
<ol class="route-stops">${allPoints.map((p, i) => `
<li><b>${i + 1}</b><span class="sn">${esc(p.name)}</span>${p.kind ? `<em>${esc(p.kind)}</em>` : ''}</li>`).join('')}</ol>` : '';
wrap.innerHTML = `
<div class="map-layer">
<img src="${state.routeMapBlob}" alt="行程地图">
${meta ? buildMapOverlay(picked, meta) : ''}
<span class="map-legend">高德地图 · ${picked.length} 个地点(编号为行程顺序)</span>
</div>${missNote}${listHtml}`;
if (note) note.textContent = `${label} · 高德底图`;
} catch (e) {
wrap.innerHTML = `<div class="route-fallback">${svgRoute(picked, origin)}</div>
<div class="map-hint">地图请求失败:${esc(e.message)}</div>`;
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
? `<polyline points="${pos.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')}" fill="none" stroke="#2f6fed" stroke-width="2" stroke-dasharray="6 5" opacity=".5"/>`
: '';
let destSeq = 0;
const nodes = pos.map((p) => {
if (p.p.kind === 'origin') {
return `<g class="rm-origin-group">
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="6" fill="#fff" stroke="#6b7280" stroke-width="2"/>
<text x="${p.x.toFixed(1)}" y="${(p.y - 12).toFixed(1)}" text-anchor="middle" class="rm-origin">出发地</text>
</g>`;
}
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 `<g>
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="15" fill="#2f6fed" opacity=".10"/>
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="10" fill="#2f6fed" stroke="#fff" stroke-width="2"/>
<text x="${p.x.toFixed(1)}" y="${(p.y + 3.6).toFixed(1)}" text-anchor="middle" class="rm-idx">${destSeq}</text>
<text x="${p.x.toFixed(1)}" y="${(p.y + dy).toFixed(1)}" text-anchor="${anchor}" class="rm-name">${esc(short)}</text>
<text x="${(p.x).toFixed(1)}" y="${(p.y + dy + 13).toFixed(1)}" text-anchor="${anchor}" class="rm-dist">距出发地 ${dist}km</text>
</g>`;
}).join('');
const legend = origin
? '<span><i class="dot origin"></i>出发地</span><span><i class="dot dest"></i>目的地(虚线为行程动线)</span>'
: '<span><i class="dot dest"></i>已选目的地(编号为推荐顺序,虚线为行程动线)</span>';
return `<svg viewBox="0 0 ${W} ${H}" class="route-svg" role="img" aria-label="行程路线示意">
${line}${nodes}
</svg>
<div class="route-legend">${legend}</div>`;
}
/**
* 住宿建议:方案定稿后才给。
* 落脚点取「已选里评分最高的那个」——两天的行程通常围着他展开;
* 再按用户选的价位过滤,价格缺失的单独标注,不硬编一个假价格。
*/
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 = '<div class="hotel-hint">先在清单里勾选目的地。</div>';
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 = '<div class="hotel-hint">正在查住宿…</div>';
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 = `<div class="hotel-hint">${esc(r.reason || '住宿数据源不可用')};方案里的住宿预算仍会按你所选价位给出。</div>`;
return;
}
if (r.error) { box.innerHTML = `<div class="hotel-hint">住宿查询失败:${esc(r.error)}</div>`; return; }
const all = r.hotels || [];
if (!all.length) {
const why = r.reason ? `(${esc(r.reason)})` : '';
box.innerHTML = `<div class="hotel-hint">「${esc(anchor.name)}」周边 10km 内没查到住宿${why}。</div>`;
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 = `<div class="hotel-hint">周边没有落在 ${esc(rangeText)} 的住宿,可以放宽价位,或把价位改成「不限」。</div>`;
return;
}
state.hotels = list;
const priced = all.filter((h) => h.cost != null);
const rangeLine = inRange.length
? `价位 <b>${esc(rangeText)}</b> · 命中 ${inRange.length} 家${unknown.length ? ` · ${unknown.length} 家无价格` : ''}`
: `这批酒店都没带价格,<b>无法按 ${esc(rangeText)} 筛选</b>;按评分与距离排序`;
// 价格缺失是数据源覆盖面问题,不是解析失败。说一句就够,细节放 README。
const noPriceNote = !priced.length
? (r.provider === 'rollinggo'
? '<div class="price-warn">这批没返回房价,点「去预订」看实时价。</div>'
: '<div class="price-warn">当前只能查到酒店位置,<b>没有价格</b>。点「查房价」看实时报价。</div>')
: '';
const degradedNote = degraded
? '<div class="price-warn">实时报价源这次没命中,已退回到只有位置的酒店数据。</div>'
: '';
box.innerHTML = `
<div class="stay-range">${rangeLine}</div>
<ul class="hotel-list">
${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 `<li>
<span class="hn">${esc(h.name)}</span>
<span class="hm">${star}${price}${dist}</span>
<a class="hm-link" href="${esc(href)}" target="_blank" rel="noopener noreferrer">${safeBooking ? '去预订' : '查房价'}</a>
</li>`;
}).join('')}
</ul>
${degradedNote}
${noPriceNote}
<div class="hotel-note">${r.provider === 'rollinggo'
? '价格为每晚参考价,实际金额以下单页锁价为准。'
: '当前数据源只提供酒店位置与评分,未提供房价;实际价格请点「查房价」核对。'}</div>`;
} catch (e) {
box.innerHTML = `<div class="hotel-hint">住宿查询失败:${esc(e.message)}</div>`;
}
}
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 = `<div class="route-fallback">${svgRoute(
(state.analysis?.candidates || []).filter((c) => state.selected.has(c.id)).slice(0, 5),
)}</div><div class="map-hint">路线图绘制出错,已用方位示意:${esc(err.message)}</div>`;
});
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 += '<optgroup label="本次会话">' + list.map((v, i) =>
`<option value="v${i}">第 ${v.n} 版${v.needs ? ' · ' + esc(v.needs.slice(0, 12)) : ''}</option>`).join('') + '</optgroup>';
}
if (saved.length) {
html += '<optgroup label="已保存(本地)">' + 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 `<option value="s${i}">${esc(`${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}${s.origin ? ' · ' + s.origin : ''}`)}</option>`;
}).join('') + '<option value="__clear">— 清空已保存 —</option></optgroup>';
}
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 = `已定位:<b>${esc(state.origin.label)}</b>(${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) => `<button type="button" data-v="${esc(x)}">+ ${esc(x)}</button>`).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) => `<button type="button" data-i="${i}">${esc(p.name)}</button>`).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 = '<div class="alert info">ℹ️ 数据源设置已更新。点 <button class="alert-link" data-retry-analyze type="button">重新分析一次</button> 应用新的高德 Key。</div>';
$('#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 = '<i></i>配置读取失败';
$('#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 = `<span>这份清单来自 ${mins} 分钟前的缓存(同一组条件不会重复请求数据源)。</span>
<button type="button" class="link-btn" id="forceRefresh">重新获取最新数据</button>`;
$('#forceRefresh').addEventListener('click', () => runAnalyze(true));
}
init();