零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026 lines
92 KiB
JavaScript
2026 lines
92 KiB
JavaScript
'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, '"').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) => `<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();
|