feat: 新增高铁游与车宿徒步游技能及相关基础设施
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 899 KiB |
|
After Width: | Height: | Size: 883 KiB |
|
After Width: | Height: | Size: 777 KiB |
|
After Width: | Height: | Size: 770 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 792 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||
stroke="#2f6fed" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img"
|
||||
aria-label="上海周边2日车宿徒步游">
|
||||
<title>上海周边2日车宿徒步游</title>
|
||||
<!-- 车宿徒步:车厢 + 下方山径 -->
|
||||
<path d="M3 15.5h18v-4.2l-1.9-4.1a1.6 1.6 0 0 0-1.45-.9H6.35a1.6 1.6 0 0 0-1.45.9L3 11.3v4.2Z" />
|
||||
<path d="M6.2 16v1.7M17.8 16v1.7" />
|
||||
<circle cx="6.8" cy="13.2" r="1" fill="#2f6fed" stroke="none" />
|
||||
<circle cx="17.2" cy="13.2" r="1" fill="#2f6fed" stroke="none" />
|
||||
<path d="M3.4 11.3h17.2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
@@ -0,0 +1,743 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>上海周边2日车宿徒步游 · 游山玩水</title>
|
||||
<style>
|
||||
/* 本页样式全部以 tc- 前缀隔离,不引用其他 skill / lab / tool 的任何样式 */
|
||||
:root {
|
||||
--tc-bg:#f6f7f9; --tc-card:#fff; --tc-line:#e5e7eb; --tc-text:#1f2328;
|
||||
--tc-sub:#6b7280; --tc-accent:#2f6fed;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--tc-bg); color:var(--tc-text);
|
||||
font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; }
|
||||
.tc-page { max-width:1080px; margin:0 auto; padding:20px 16px 72px; }
|
||||
.tc-top { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
|
||||
.tc-top h1 { font-size:20px; margin:0; }
|
||||
.tc-sub { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-back { font-size:12px; color:var(--tc-accent); text-decoration:none; }
|
||||
.tc-back:hover { text-decoration:underline; }
|
||||
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
|
||||
border-radius:8px; padding:8px 12px; margin-bottom:14px; }
|
||||
.tc-meta b { color:var(--tc-text); }
|
||||
.tc-note { border-radius:8px; padding:10px 12px; font-size:13px; border:1px solid; margin-bottom:12px; }
|
||||
.tc-note-info { background:#f2f7ff; border-color:#cfe0ff; }
|
||||
.tc-note-warn { background:#fffaf0; border-color:#f0dcae; }
|
||||
.tc-note-danger { background:#fdece8; border-color:#f3c0b6; }
|
||||
.tc-note b { display:block; margin-bottom:2px; }
|
||||
.tc-note ol { margin:6px 0 0; padding-left:20px; }
|
||||
.tc-note ol li { margin-bottom:3px; }
|
||||
|
||||
.tc-block { background:var(--tc-card); border:1px solid var(--tc-line); border-radius:10px;
|
||||
padding:16px; margin-bottom:16px; }
|
||||
.tc-block-title { font-size:15px; margin:0 0 4px; }
|
||||
.tc-block-hint { font-size:12px; color:var(--tc-sub); margin-bottom:12px; }
|
||||
|
||||
/* 表单 */
|
||||
.tc-form { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px; }
|
||||
.tc-field { display:flex; flex-direction:column; gap:4px; }
|
||||
.tc-label { font-size:12px; color:#374151; font-weight:500; }
|
||||
.tc-label .tc-req { color:#b42318; margin-left:2px; }
|
||||
.tc-input, .tc-select {
|
||||
font-size:13px; padding:7px 9px; border:1px solid var(--tc-line); border-radius:8px;
|
||||
background:#fff; color:var(--tc-text); font-family:inherit;
|
||||
}
|
||||
.tc-input:focus, .tc-select:focus { outline:none; border-color:var(--tc-accent); box-shadow:0 0 0 3px #e8f0fe; }
|
||||
.tc-hint { font-size:11px; color:var(--tc-sub); }
|
||||
.tc-affects { font-size:11px; color:#8a6100; }
|
||||
|
||||
.tc-actions { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:14px; }
|
||||
.tc-btn { display:inline-block; text-decoration:none; font-size:13px; border:none; cursor:pointer;
|
||||
background:var(--tc-accent); color:#fff; border-radius:8px; padding:9px 18px; font-family:inherit; }
|
||||
.tc-btn:hover:not(:disabled) { background:#2560d8; }
|
||||
.tc-btn:disabled { background:#c3cddb; cursor:not-allowed; }
|
||||
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
|
||||
.tc-btn-ghost:hover { background:#f0f4ff; }
|
||||
.tc-gen-status { font-size:12px; color:var(--tc-sub); }
|
||||
|
||||
.tc-empty { color:var(--tc-sub); font-size:13px; padding:8px 0; }
|
||||
|
||||
/* 结果区徽标 */
|
||||
.tc-skill-tags { display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.tc-badge { font-size:11px; padding:1px 7px; border-radius:999px; background:#eef2f7; color:#4b5563; }
|
||||
.tc-badge-info { background:#e8f0fe; color:#1d4ed8; }
|
||||
.tc-badge-warn { background:#fff5e0; color:#8a6100; }
|
||||
.tc-badge-danger { background:#fdece8; color:#b42318; }
|
||||
.tc-badge-good { background:#e8f6ee; color:#1a7a45; }
|
||||
|
||||
/* 交互弹窗 */
|
||||
.tc-modal-mask { position:fixed; inset:0; background:rgba(15,20,30,.45); display:none;
|
||||
align-items:center; justify-content:center; z-index:9000; padding:16px; }
|
||||
.tc-modal-mask.tc-show { display:flex; }
|
||||
.tc-modal { background:#fff; border-radius:12px; max-width:820px; width:100%;
|
||||
max-height:86vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
.tc-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:10px;
|
||||
padding:14px 16px; border-bottom:1px solid var(--tc-line); }
|
||||
.tc-modal-title { font-size:15px; font-weight:600; margin:0; }
|
||||
.tc-modal-sub { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-modal-body { padding:14px 16px; overflow:auto; }
|
||||
.tc-modal-foot { padding:10px 16px; border-top:1px solid var(--tc-line);
|
||||
display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.tc-kv { display:grid; grid-template-columns:112px 1fr; gap:4px 10px; font-size:13px; margin-bottom:10px; }
|
||||
.tc-kv-k { color:var(--tc-sub); }
|
||||
.tc-kv-v { word-break:break-all; }
|
||||
.tc-pre { background:#f7f8fa; border:1px solid var(--tc-line); border-radius:8px; padding:10px;
|
||||
font:12px/1.5 Consolas,Monaco,monospace; white-space:pre-wrap; word-break:break-all;
|
||||
max-height:280px; overflow:auto; margin:0; }
|
||||
.tc-sec-label { font-size:12px; font-weight:600; margin:12px 0 6px; color:#374151; }
|
||||
.tc-state { font-size:12px; padding:1px 8px; border-radius:999px; display:inline-block; }
|
||||
.tc-state-ok { background:#e8f6ee; color:#1a7a45; }
|
||||
.tc-state-error { background:#fdece8; color:#b42318; }
|
||||
.tc-state-pending { background:#eef2f7; color:#4b5563; }
|
||||
|
||||
/* Toast */
|
||||
.tc-toasts { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:9500;
|
||||
display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; }
|
||||
.tc-toast { font-size:13px; color:#fff; border-radius:8px; padding:8px 16px;
|
||||
box-shadow:0 4px 14px rgba(0,0,0,.18); }
|
||||
.tc-toast-info { background:#2f6fed; }
|
||||
.tc-toast-success { background:#1a7a45; }
|
||||
.tc-toast-error { background:#b42318; }
|
||||
|
||||
.tc-result-head { display:flex; justify-content:space-between; align-items:baseline; gap:10px;
|
||||
flex-wrap:wrap; margin-bottom:10px; }
|
||||
.tc-result-title { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
|
||||
@media (max-width:640px){ .tc-kv{ grid-template-columns:88px 1fr; } .tc-block{ padding:13px; } }
|
||||
</style>
|
||||
<!-- 报告样式单独一份:类名统一 tcr- 前缀,与上面的表单/页面样式完全隔离 -->
|
||||
<link rel="stylesheet" href="./tc_report.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="tc-page" id="tcr_top">
|
||||
<div class="tc-top">
|
||||
<h1>上海周边2日车宿徒步游</h1>
|
||||
<span class="tc-sub">游山玩水 · 单 skill 页</span>
|
||||
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
|
||||
</div>
|
||||
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
|
||||
|
||||
<div class="tc-note tc-note-danger" id="tc_auth_note" style="display:none">
|
||||
<b>缺少导航通行凭证(nav_gate),接口被拒绝</b>
|
||||
本工具的<b>页面</b>允许同源导航进入,但<b>接口</b>只认 nav_gate cookie,所以会出现「页面能打开、接口全是 401」。按下面做一次即可恢复:
|
||||
<ol>
|
||||
<li>从<b>首页九宫格</b>点「游山玩水」卡片进来(必须经过首页,它才会种 cookie)</li>
|
||||
<li>全程用<b>同一个地址</b>:<code>localhost</code> 与 <code>127.0.0.1</code> 的 cookie 不通用,中途切换域名就会失联</li>
|
||||
<li>nav_gate 是<b>会话级 cookie</b>,关闭浏览器后失效,需要重新从首页进一次</li>
|
||||
</ol>
|
||||
<div class="tc-actions">
|
||||
<a class="tc-btn" href="/">重新从首页进入</a>
|
||||
<button class="tc-btn tc-btn-ghost" id="tc_auth_retry">我已重新进入,重试</button>
|
||||
</div>
|
||||
<div id="tc_auth_diag"></div>
|
||||
</div>
|
||||
|
||||
<div class="tc-note tc-note-warn" id="tc_slow_note" style="display:none">
|
||||
<b>生成较慢,请勿关闭页面</b>
|
||||
整份行程需要模型输出 1.2-1.6 万 token,实测约 6-8 分钟。页面会显示已用时间,完成后自动渲染。
|
||||
</div>
|
||||
|
||||
<div class="tc-block">
|
||||
<h2 class="tc-block-title">1 · 填写行程参数</h2>
|
||||
<div class="tc-block-hint" id="tc_form_hint">正在读取问答定义…</div>
|
||||
<div class="tc-form" id="tc_form"></div>
|
||||
<div class="tc-actions">
|
||||
<button class="tc-btn" id="tc_gen" disabled>生成行程</button>
|
||||
<button class="tc-btn tc-btn-ghost" id="tc_gen_last" title="不重新生成,直接载入上一次的结果(一次生成要 4 分钟)">载入最近一次生成</button>
|
||||
<span class="tc-gen-status" id="tc_gen_status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tc_result"></div>
|
||||
|
||||
<div class="tc-note tc-note-info">
|
||||
<b>打不开 / 提示未授权?</b>
|
||||
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接把链接粘贴到地址栏会被拒绝(无同源来源)。
|
||||
<br><a href="./sample/index.html">查看离线样例:南通如东</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
|
||||
<link rel="stylesheet" href="../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
|
||||
<script src="../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
|
||||
<script src="./tc_render.js"></script>
|
||||
<!-- 单文件 HTML 导出器:纯前端拼字符串 + 内联样式/图片,导出不消耗任何 token -->
|
||||
<script src="./tc_export.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict'
|
||||
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
|
||||
var API = '/api/thought_lab/travel_citytour/skills/sh_2d_car_hike'
|
||||
var state = { uiMode: 'debug', meta: null, generating: false, timer: null, lastResult: null }
|
||||
|
||||
var $ = function (id) { return document.getElementById(id) }
|
||||
|
||||
/* ---------- 通用 UI 组件(双模式:debug / normal) ---------- */
|
||||
|
||||
function nowBeijing() {
|
||||
try {
|
||||
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
|
||||
} catch (e) { return new Date().toLocaleString() }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s === undefined || s === null ? '' : s)
|
||||
.replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] })
|
||||
}
|
||||
|
||||
function toast(text, kind) {
|
||||
var box = $('tc_toasts')
|
||||
if (!box) {
|
||||
box = document.createElement('div')
|
||||
box.className = 'tc-toasts'
|
||||
box.id = 'tc_toasts'
|
||||
document.body.appendChild(box)
|
||||
}
|
||||
var el = document.createElement('div')
|
||||
el.className = 'tc-toast tc-toast-' + (kind || 'info')
|
||||
el.textContent = text
|
||||
box.appendChild(el)
|
||||
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el) }, 2000)
|
||||
}
|
||||
|
||||
/* 导航通行凭证缺失(页面守卫认 Referer、接口守卫只认 cookie)时给出明确指引 */
|
||||
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
|
||||
function showAuthNote() {
|
||||
var el = $('tc_auth_note')
|
||||
if (el) el.style.display = ''
|
||||
// 顺手拉一次 lab 级公开诊断接口(注册在鉴权守卫之前),把「到底缺什么」直接摊在页面上
|
||||
// 注意:诊断接口归 lab 层所有(红线),本 skill 不重复实现
|
||||
fetch('/api/thought_lab/travel_citytour/auth/diag?t=' + Date.now(), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (d) {
|
||||
var box = $('tc_auth_diag')
|
||||
if (!box) return
|
||||
box.innerHTML = '<div class="tc-sec-label">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
|
||||
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
|
||||
})
|
||||
.catch(function () {})
|
||||
}
|
||||
|
||||
/* 交互详情弹窗:完整记录触发动作 / 时间 / 接口 / 方法 / 参数 / 返回 / 错误 / 耗时 / 状态 */
|
||||
var interactionTimer = null
|
||||
function showInteractionDialog(rec) {
|
||||
var mask = $('tc_modal')
|
||||
var okState = rec.state === 'ok'
|
||||
var canAutoClose = okState
|
||||
var autoSec = state.uiMode === 'debug' ? 3 : 2
|
||||
|
||||
mask.querySelector('.tc-modal-title').textContent = rec.action
|
||||
mask.querySelector('.tc-modal-head .tc-modal-sub').textContent =
|
||||
state.uiMode === 'debug' ? '研发调试模式 · 完整交互详情' : '交互详情'
|
||||
|
||||
var body = mask.querySelector('.tc-modal-body')
|
||||
body.innerHTML =
|
||||
'<div class="tc-kv">' +
|
||||
'<div class="tc-kv-k">当前状态</div><div class="tc-kv-v"><span class="tc-state tc-state-' +
|
||||
(rec.state === 'ok' ? 'ok' : (rec.state === 'pending' ? 'pending' : 'error')) + '">' +
|
||||
esc(rec.state === 'ok' ? '成功' : (rec.state === 'pending' ? '进行中' : '失败')) + '</span></div>' +
|
||||
'<div class="tc-kv-k">触发动作</div><div class="tc-kv-v">' + esc(rec.action) + '</div>' +
|
||||
'<div class="tc-kv-k">时间</div><div class="tc-kv-v">' + esc(rec.time) + '(北京时间)</div>' +
|
||||
'<div class="tc-kv-k">接口地址</div><div class="tc-kv-v">' + esc(rec.method + ' ' + rec.url) + '</div>' +
|
||||
'<div class="tc-kv-k">耗时</div><div class="tc-kv-v">' + esc(rec.elapsed) + '</div>' +
|
||||
'<div class="tc-kv-k">结果摘要</div><div class="tc-kv-v">' + esc(rec.summary || '—') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="tc-sec-label">请求参数</div><pre class="tc-pre">' + esc(rec.request || '(无)') + '</pre>' +
|
||||
'<div class="tc-sec-label">返回结果</div><pre class="tc-pre">' + esc(rec.response || '(无)') + '</pre>' +
|
||||
(rec.error ? '<div class="tc-sec-label">错误信息</div><pre class="tc-pre">' + esc(rec.error) + '</pre>' : '')
|
||||
|
||||
mask.querySelector('.tc-modal-foot .tc-gen-status').textContent = canAutoClose
|
||||
? ('窗口将在 ' + autoSec + 's 后自动关闭') : '出现异常,窗口不会自动关闭,便于排查'
|
||||
|
||||
mask.classList.add('tc-show')
|
||||
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
|
||||
if (canAutoClose) {
|
||||
var left = autoSec
|
||||
interactionTimer = setInterval(function () {
|
||||
left--
|
||||
var tip = mask.querySelector('.tc-modal-foot .tc-gen-status')
|
||||
if (tip) tip.textContent = '窗口将在 ' + left + 's 后自动关闭'
|
||||
if (left <= 0) { closeInteractionDialog(); }
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
function closeInteractionDialog() {
|
||||
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
|
||||
var mask = $('tc_modal')
|
||||
if (mask) mask.classList.remove('tc-show')
|
||||
}
|
||||
|
||||
/* 统一包装一次后台交互:记录细节 + 按模式决定弹窗还是 Toast */
|
||||
function runTrackedInteraction(opt) {
|
||||
var t0 = Date.now()
|
||||
var rec = {
|
||||
action: opt.action, method: opt.method || 'GET', url: opt.url,
|
||||
request: opt.request ? JSON.stringify(opt.request, null, 2) : '', time: nowBeijing()
|
||||
}
|
||||
if (opt.showPending && state.uiMode === 'debug') {
|
||||
rec.state = 'pending'; rec.elapsed = '—'; rec.summary = '请求已发出,等待返回…'
|
||||
showInteractionDialog(rec)
|
||||
}
|
||||
return opt.run().then(function (res) {
|
||||
rec.state = 'ok'
|
||||
rec.elapsed = (Date.now() - t0) + ' ms'
|
||||
rec.summary = opt.summary ? opt.summary(res) : '成功'
|
||||
rec.response = JSON.stringify(opt.responseOf ? opt.responseOf(res) : res, null, 2)
|
||||
if (state.uiMode === 'debug') showInteractionDialog(rec)
|
||||
else toast(rec.summary, 'success')
|
||||
return res
|
||||
}).catch(function (err) {
|
||||
rec.state = 'error'
|
||||
rec.elapsed = (Date.now() - t0) + ' ms'
|
||||
rec.summary = '失败'
|
||||
rec.error = (err && err.message) || String(err)
|
||||
if (err && err.detail) rec.response = JSON.stringify(err.detail, null, 2)
|
||||
// 失败在任何模式下都强提示,不降级、不自动关闭
|
||||
showInteractionDialog(rec)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
function requestJson(url, options) {
|
||||
var opt = Object.assign({ cache: 'no-store' }, options || {})
|
||||
var timeoutMs = opt.timeoutMs || 0
|
||||
delete opt.timeoutMs
|
||||
var timer = null
|
||||
if (timeoutMs && window.AbortController) {
|
||||
var ctrl = new AbortController()
|
||||
opt.signal = ctrl.signal
|
||||
timer = setTimeout(function () { ctrl.abort() }, timeoutMs)
|
||||
}
|
||||
var clear = function () { if (timer) { clearTimeout(timer); timer = null } }
|
||||
return fetch(url + '?t=' + Date.now(), opt)
|
||||
.then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null
|
||||
try { j = JSON.parse(t) } catch (e) {}
|
||||
if (!r.ok) {
|
||||
var err = new Error((j && (j.error || (j.hint ? j.error + ':' + j.hint : j.error))) || ('HTTP ' + r.status))
|
||||
err.detail = j || t.slice(0, 400)
|
||||
throw err
|
||||
}
|
||||
if (!j) throw new Error('返回不是合法 JSON')
|
||||
return j
|
||||
})
|
||||
})
|
||||
.then(function (j) { clear(); return j }, function (e) {
|
||||
clear()
|
||||
if (e && e.name === 'AbortError') {
|
||||
// 长请求经 8975 反代存在「服务端已返回、客户端收不到」的情况,此前界面会无限停在「生成中」
|
||||
var err = new Error('等待 ' + Math.round(timeoutMs / 60000) + ' 分钟仍未收到响应。服务端可能已经生成完成,请点「载入最近一次生成」查看')
|
||||
err.detail = { timeout_ms: timeoutMs, url: url }
|
||||
throw err
|
||||
}
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 启动:读本技能元信息(含 ui_mode / qa / report_spec / destinations) ---------- */
|
||||
|
||||
function loadMeta() {
|
||||
return runTrackedInteraction({
|
||||
action: '读取技能元信息', method: 'GET', url: API + '/meta',
|
||||
run: function () { return requestJson(API + '/meta') },
|
||||
summary: function (r) {
|
||||
return 'ui_mode=' + r.ui_mode + ',模型=' + r.ai_model +
|
||||
',' + (r.report_spec && r.report_spec.sections || []).length + ' 个区块'
|
||||
},
|
||||
responseOf: function (r) {
|
||||
return {
|
||||
ok: r.ok, ui_mode: r.ui_mode, ai_model: r.ai_model, ai_ready: r.ai_ready,
|
||||
skill: r.skill, sections: (r.report_spec && r.report_spec.sections || []).length,
|
||||
destinations: (r.destinations || []).map(function (d) { return d.name })
|
||||
}
|
||||
}
|
||||
}).then(function (r) {
|
||||
state.meta = r
|
||||
state.uiMode = r.ui_mode || 'debug'
|
||||
var s = r.skill || {}
|
||||
$('tc_meta').innerHTML =
|
||||
'ui_mode:<b>' + esc(state.uiMode) + '</b>' +
|
||||
' · 技能:<b>' + esc(s.name || '') + '</b>(v' + esc(s.version || '') + ')' +
|
||||
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
|
||||
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
|
||||
$('tc_slow_note').style.display = ''
|
||||
renderForm(r)
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 渲染问答表单(按本技能 qa.json 动态生成,不写死字段) ---------- */
|
||||
|
||||
function renderForm(meta) {
|
||||
var qa = meta.qa || {}
|
||||
var host = $('tc_form')
|
||||
host.innerHTML = ''
|
||||
var s = meta.skill || {}
|
||||
$('tc_form_hint').textContent = '当前技能:' + (s.name || '') + '(v' + (s.version || '') + ')· '
|
||||
+ (s.anchors_count || 0) + ' 个骨架槽位 · 带 * 为必填。'
|
||||
|
||||
var dest = qa.destination_input || {}
|
||||
var destSuggestions = (meta.destinations || []).map(function (d) { return d.name }).join(' / ')
|
||||
addField(host, {
|
||||
key: 'destination', label: dest.label || '目的地',
|
||||
type: 'text', required: dest.required !== false,
|
||||
placeholder: dest.placeholder || '例如:南通如东',
|
||||
hint: (dest.hint || '') + (destSuggestions ? '(本技能已跑过:' + destSuggestions + ')' : '')
|
||||
})
|
||||
|
||||
;(qa.questions || []).forEach(function (q) {
|
||||
addField(host, {
|
||||
key: q.key, label: q.label || q.key, type: q.type || 'text',
|
||||
required: q.required !== false, default: q.default, options: q.options,
|
||||
min: q.min, max: q.max, hint: q.hint || '',
|
||||
affects: q.affects || []
|
||||
})
|
||||
})
|
||||
|
||||
var btn = $('tc_gen')
|
||||
btn.disabled = false
|
||||
btn.textContent = '生成行程'
|
||||
$('tc_gen_status').textContent = '参数填好后点右侧按钮;生成需 6-8 分钟'
|
||||
}
|
||||
|
||||
function addField(host, f) {
|
||||
var wrap = document.createElement('div')
|
||||
wrap.className = 'tc-field'
|
||||
var lab = document.createElement('label')
|
||||
lab.className = 'tc-label'
|
||||
lab.textContent = f.label
|
||||
if (f.required) {
|
||||
var st = document.createElement('span'); st.className = 'tc-req'; st.textContent = '*'
|
||||
lab.appendChild(st)
|
||||
}
|
||||
wrap.appendChild(lab)
|
||||
|
||||
var input
|
||||
if (f.type === 'select') {
|
||||
input = document.createElement('select')
|
||||
;(f.options || []).forEach(function (o) {
|
||||
var op = document.createElement('option')
|
||||
op.value = o.value
|
||||
op.textContent = o.label || o.value
|
||||
input.appendChild(op)
|
||||
})
|
||||
} else {
|
||||
input = document.createElement('input')
|
||||
input.className = 'tc-input'
|
||||
input.type = (f.type === 'number' ? 'number' : (f.type === 'date' ? 'date' : (f.type === 'time' ? 'time' : 'text')))
|
||||
if (f.placeholder) input.placeholder = f.placeholder
|
||||
if (f.min !== undefined && f.min !== null) input.min = f.min
|
||||
if (f.max !== undefined && f.max !== null) input.max = f.max
|
||||
}
|
||||
if (f.type === 'select') input.className = 'tc-select'
|
||||
input.id = 'qa_' + f.key
|
||||
if (f.default !== undefined && f.default !== null && f.default !== '') input.value = f.default
|
||||
wrap.appendChild(input)
|
||||
|
||||
if (f.hint) {
|
||||
var h = document.createElement('div'); h.className = 'tc-hint'; h.textContent = f.hint
|
||||
wrap.appendChild(h)
|
||||
}
|
||||
if (f.affects && f.affects.length) {
|
||||
var a = document.createElement('div'); a.className = 'tc-affects'
|
||||
a.textContent = '影响:' + f.affects.join('、')
|
||||
wrap.appendChild(a)
|
||||
}
|
||||
host.appendChild(wrap)
|
||||
}
|
||||
|
||||
function collectAnswers() {
|
||||
var qa = (state.meta && state.meta.qa) || {}
|
||||
var out = {}
|
||||
var keys = ['destination'].concat((qa.questions || []).map(function (q) { return q.key }))
|
||||
keys.forEach(function (k) {
|
||||
var el = $('qa_' + k)
|
||||
if (!el) return
|
||||
var v = String(el.value || '').trim()
|
||||
if (v === '') return
|
||||
var q = (qa.questions || []).filter(function (x) { return x.key === k })[0]
|
||||
out[k] = (q && q.type === 'number') ? Number(v) : v
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- 生成 ---------- */
|
||||
|
||||
function startTimer() {
|
||||
var t0 = Date.now()
|
||||
state.timer = setInterval(function () {
|
||||
var s = Math.floor((Date.now() - t0) / 1000)
|
||||
$('tc_gen_status').textContent = '生成中… 已用 ' + s + ' 秒(整份行程实测约 3-5 分钟,请勿关闭页面)'
|
||||
}, 1000)
|
||||
}
|
||||
function stopTimer() {
|
||||
if (state.timer) { clearInterval(state.timer); state.timer = null }
|
||||
}
|
||||
|
||||
/* 载入最近一次生成:不重新烧 4 分钟,直接看上一次落盘的结果(本技能私有 data/) */
|
||||
function onLoadLast() {
|
||||
var btn = $('tc_gen_last')
|
||||
var old = btn.textContent
|
||||
btn.disabled = true
|
||||
btn.textContent = '载入中…'
|
||||
runTrackedInteraction({
|
||||
action: '载入最近一次生成', method: 'GET', url: API + '/last',
|
||||
run: function () { return requestJson(API + '/last') },
|
||||
summary: function (r) {
|
||||
return '已载入:' + ((r.payload && r.payload.summary && r.payload.summary.title) || '未命名行程')
|
||||
},
|
||||
responseOf: function (r) {
|
||||
return {
|
||||
ok: r.ok, meta: r.meta,
|
||||
check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) }
|
||||
}
|
||||
}
|
||||
}).then(function (r) {
|
||||
if (renderResultSafe(r, '载入成功,但渲染失败(问题在前端渲染器)')) toast('已载入最近一次生成', 'success')
|
||||
}).catch(function (e) {
|
||||
if (isUnauthorized(e)) showAuthNote()
|
||||
$('tc_result').innerHTML = '<div class="tc-note tc-note-warn"><b>载入失败</b>:' + esc((e && e.message) || String(e)) + '</div>'
|
||||
}).then(function () {
|
||||
btn.disabled = false
|
||||
btn.textContent = old
|
||||
})
|
||||
}
|
||||
|
||||
function onGenerate() {
|
||||
if (state.generating) return
|
||||
var answers = collectAnswers()
|
||||
if (!answers.destination) { toast('请先填写目的地', 'error'); return }
|
||||
|
||||
state.generating = true
|
||||
var btn = $('tc_gen')
|
||||
btn.disabled = true
|
||||
btn.textContent = '生成中…'
|
||||
startTimer()
|
||||
|
||||
runTrackedInteraction({
|
||||
action: '生成行程(AI)',
|
||||
method: 'POST',
|
||||
url: API + '/generate',
|
||||
request: { answers: answers },
|
||||
showPending: true,
|
||||
run: function () {
|
||||
return requestJson(API + '/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: answers }),
|
||||
timeoutMs: 8 * 60 * 1000
|
||||
})
|
||||
},
|
||||
summary: function (r) {
|
||||
var c = r.check || {}
|
||||
// 有结构问题时如实说明(结果已被保留,可查看/导出),不要一律报「生成成功」
|
||||
var errs = c.errors || []
|
||||
return (errs.length ? '已生成但有 ' + errs.length + ' 处结构问题:' : '生成成功:') +
|
||||
(r.check && r.check.stats ? r.check.stats.cards : '?') + ' 张卡片,' +
|
||||
(c.warnings ? c.warnings.length : 0) + ' 条提醒,耗时 ' + Math.round((r.meta && r.meta.elapsed_ms || 0) / 1000) + 's'
|
||||
},
|
||||
responseOf: function (r) {
|
||||
return {
|
||||
ok: r.ok, meta: r.meta, check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) },
|
||||
payload_summary: r.payload && r.payload.summary,
|
||||
destination_override: r.destination_override
|
||||
}
|
||||
}
|
||||
}).then(function (r) {
|
||||
if (renderResultSafe(r, '生成成功,但渲染失败(数据是好的,问题在前端渲染器)')) {
|
||||
var nErr = (r.check && r.check.errors) || []
|
||||
// 有结构问题时不用绿色成功提示,避免「看起来一切正常」的错觉
|
||||
if (nErr.length) toast('已生成,但有 ' + nErr.length + ' 处结构问题(见下方红色提示)', 'error')
|
||||
else toast('行程已生成并渲染', 'success')
|
||||
}
|
||||
}).catch(function (err) {
|
||||
if (isUnauthorized(err)) showAuthNote()
|
||||
$('tc_gen_status').textContent = '生成失败:' + (err && err.message || '')
|
||||
}).then(function () {
|
||||
state.generating = false
|
||||
stopTimer()
|
||||
btn.disabled = false
|
||||
btn.textContent = '重新生成'
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染必须永远有反馈:异常时把错误摊在页面上,绝不留白、绝不静默
|
||||
function renderResultSafe(r, title) {
|
||||
try {
|
||||
renderResult(r)
|
||||
return true
|
||||
} catch (e) {
|
||||
$('tc_result').innerHTML =
|
||||
'<div class="tc-note tc-note-warn"><b>' + esc(title) + '</b><br>' +
|
||||
'· 错误:' + esc((e && e.message) || String(e)) + '<br>' +
|
||||
'· 位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) + '</div>'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(r) {
|
||||
var host = $('tc_result')
|
||||
var payload = r.payload
|
||||
var spec = r.report_spec || {}
|
||||
// 导出按钮吃的是这份「已经生成好」的结果:导出不再调用 AI,不消耗 token
|
||||
state.lastResult = r
|
||||
var head = document.createElement('div')
|
||||
head.className = 'tc-result-head'
|
||||
var warns = (r.check && r.check.warnings) || []
|
||||
// 结构问题(errors):结果已被服务端保留(不再整份丢弃),这里必须醒目告知,而不是假装一切正常
|
||||
var errs = (r.check && r.check.errors) || []
|
||||
head.innerHTML =
|
||||
'<div class="tc-result-title">' +
|
||||
'<h2 class="tc-block-title" style="margin:0">2 · 生成结果</h2>' +
|
||||
'<button class="tc-btn tc-btn-ghost" id="tc_export" style="padding:5px 12px"' +
|
||||
' title="把下面这份结果导出成一个独立的 HTML 单文件,离线随时查看(不重新生成、不消耗 token)">导出至 HTML</button>' +
|
||||
'</div>' +
|
||||
'<div class="tc-skill-tags">' +
|
||||
'<span class="tc-badge tc-badge-info">模型 ' + esc((r.meta && r.meta.model) || '') + '</span>' +
|
||||
'<span class="tc-badge tc-badge-info">耗时 ' + Math.round(((r.meta && r.meta.elapsed_ms) || 0) / 1000) + 's</span>' +
|
||||
(r.meta && r.meta.is_new_destination ? '<span class="tc-badge tc-badge-warn">首个新目的地</span>' : '<span class="tc-badge tc-badge-good">技能包内已有覆盖</span>') +
|
||||
(errs.length
|
||||
? '<span class="tc-badge tc-badge-danger">' + errs.length + ' 处结构问题</span>'
|
||||
: (warns.length
|
||||
? '<span class="tc-badge tc-badge-warn">' + warns.length + ' 条校验提醒</span>'
|
||||
: '<span class="tc-badge tc-badge-good">结构校验通过</span>')) +
|
||||
'</div>'
|
||||
host.innerHTML = ''
|
||||
host.appendChild(head)
|
||||
|
||||
var exportBtn = $('tc_export')
|
||||
if (exportBtn) {
|
||||
if (window.TCExport) exportBtn.addEventListener('click', onExport)
|
||||
else { exportBtn.disabled = true; exportBtn.title = '导出器未加载(tc_export.js 未成功执行)' }
|
||||
}
|
||||
|
||||
if (errs.length) {
|
||||
var e = document.createElement('div')
|
||||
e.className = 'tc-note tc-note-danger'
|
||||
e.innerHTML = '<b>本次结果有 ' + errs.length + ' 处结构问题(前 12 条)—— 已保留,未丢弃</b>' +
|
||||
errs.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>') +
|
||||
'<br>下方内容仍可正常查看与导出。若这些报错其实是「校验器误判」,' +
|
||||
'请把这段文字发给 AI 定位(不必重新生成);若确属内容缺失,再点「重新生成」。'
|
||||
host.appendChild(e)
|
||||
}
|
||||
|
||||
if (warns.length) {
|
||||
var w = document.createElement('div')
|
||||
w.className = 'tc-note tc-note-warn'
|
||||
w.innerHTML = '<b>结构校验提醒(' + warns.length + ' 条,前 12 条)</b>' +
|
||||
warns.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>')
|
||||
host.appendChild(w)
|
||||
}
|
||||
|
||||
var mount = document.createElement('div')
|
||||
mount.id = 'tc_report'
|
||||
host.appendChild(mount)
|
||||
if (!window.TCRender) {
|
||||
mount.innerHTML = '<div class="tc-empty">渲染器未加载(tc_render.js 未成功执行)</div>'
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.TCRender.render(payload, spec, mount)
|
||||
} catch (e) {
|
||||
// 渲染中断时保留已渲染部分,并把错误摊在页面上 —— 绝不静默留白
|
||||
var box = document.createElement('div')
|
||||
box.className = 'tc-note tc-note-warn'
|
||||
box.innerHTML = '<b>渲染中断</b>:' + esc((e && e.message) || String(e)) +
|
||||
'<br>位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) +
|
||||
'<br>以上为已成功渲染的部分。把这行错误发给 AI 即可定位。'
|
||||
mount.appendChild(box)
|
||||
}
|
||||
}
|
||||
|
||||
/* 导出至 HTML:把当前这份结果拼成「单文件离线文档」并下载。
|
||||
全程纯前端(读同目录 tc_export.css + 内嵌配图),不调用任何后端接口、不消耗 token。 */
|
||||
function onExport() {
|
||||
var r = state.lastResult
|
||||
if (!r) { toast('还没有可导出的结果,先生成或载入一次', 'error'); return }
|
||||
if (!window.TCExport) { toast('导出器未加载(tc_export.js)', 'error'); return }
|
||||
|
||||
var btn = $('tc_export')
|
||||
if (btn && btn.disabled) return
|
||||
var old = btn ? btn.textContent : ''
|
||||
if (btn) { btn.disabled = true; btn.textContent = '导出中…' }
|
||||
|
||||
var payload = r.payload || {}
|
||||
var summary = payload.summary || {}
|
||||
var sections = (r.report_spec && r.report_spec.sections) || []
|
||||
var cards = Array.isArray(payload.cards) ? payload.cards : []
|
||||
var markers = (payload.map && payload.map.markers) || []
|
||||
|
||||
runTrackedInteraction({
|
||||
action: '导出至 HTML(单文件·离线可看)', method: 'LOCAL', url: './tc_export.css → blob 下载',
|
||||
request: {
|
||||
title: summary.title || '(无标题)',
|
||||
sections: sections.length,
|
||||
cards: cards.length,
|
||||
markers: markers.length,
|
||||
note: '内联样式 + 内嵌配图 + 内联 SVG 地图;不调用 AI、不消耗 token'
|
||||
},
|
||||
showPending: true,
|
||||
run: function () { return window.TCExport.download(payload, r.report_spec || {}, { meta: r.meta }) },
|
||||
summary: function (res) {
|
||||
return '已导出 ' + res.filename + '(' + Math.round(res.bytes / 1024) + ' KB,' + res.sections + ' 个区块' +
|
||||
(res.images && res.images.total ? ',配图内嵌 ' + res.images.embedded + '/' + res.images.total : '') + ')'
|
||||
},
|
||||
responseOf: function (res) { return res }
|
||||
}).then(function (res) {
|
||||
toast('已导出:' + res.filename, 'success')
|
||||
}).catch(function (e) {
|
||||
toast('导出失败:' + ((e && e.message) || ''), 'error')
|
||||
}).then(function () {
|
||||
if (btn) { btn.disabled = false; btn.textContent = old }
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
|
||||
// 交互弹窗骨架
|
||||
var mask = document.createElement('div')
|
||||
mask.className = 'tc-modal-mask'
|
||||
mask.id = 'tc_modal'
|
||||
mask.innerHTML =
|
||||
'<div class="tc-modal">' +
|
||||
'<div class="tc-modal-head">' +
|
||||
'<div><h3 class="tc-modal-title">交互详情</h3><div class="tc-modal-sub"></div></div>' +
|
||||
'<button class="tc-btn tc-btn-ghost" id="tc_modal_close" style="padding:5px 12px">关闭</button>' +
|
||||
'</div>' +
|
||||
'<div class="tc-modal-body"></div>' +
|
||||
'<div class="tc-modal-foot">' +
|
||||
'<button class="tc-btn tc-btn-ghost" id="tc_modal_copy" style="padding:5px 12px">复制完整交互日志</button>' +
|
||||
'<span class="tc-gen-status"></span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
document.body.appendChild(mask)
|
||||
|
||||
$('tc_modal_close').addEventListener('click', closeInteractionDialog)
|
||||
mask.addEventListener('click', function (e) { if (e.target === mask) closeInteractionDialog() })
|
||||
$('tc_modal_copy').addEventListener('click', function () {
|
||||
var txt = mask.querySelector('.tc-modal-body').innerText
|
||||
var done = function () { toast('交互日志已复制', 'success') }
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done, done)
|
||||
else {
|
||||
var ta = document.createElement('textarea')
|
||||
ta.value = txt; document.body.appendChild(ta); ta.select()
|
||||
try { document.execCommand('copy'); done() } catch (e) { toast('复制失败', 'error') }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
})
|
||||
|
||||
$('tc_gen').addEventListener('click', onGenerate)
|
||||
$('tc_gen_last').addEventListener('click', onLoadLast)
|
||||
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
|
||||
|
||||
loadMeta().catch(function (e) {
|
||||
if (isUnauthorized(e)) showAuthNote()
|
||||
$('tc_form_hint').textContent = '技能元信息加载失败:' + (e && e.message || '')
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>南通如东 · 两日车宿徒步|游山玩水(离线样例)</title>
|
||||
<style>
|
||||
/* 全部样式以 tc- 前缀隔离,避免与其他 lab / tool 的样式互相污染 */
|
||||
:root {
|
||||
--tc-bg: #f6f7f9;
|
||||
--tc-card: #ffffff;
|
||||
--tc-line: #e5e7eb;
|
||||
--tc-text: #1f2328;
|
||||
--tc-sub: #6b7280;
|
||||
--tc-accent: #2f6fed;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-text);
|
||||
font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.tc-page { max-width: 1080px; margin: 0 auto; padding: 20px 16px 64px; }
|
||||
|
||||
.tc-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.tc-top h1 { font-size: 20px; margin: 0; }
|
||||
.tc-top .tc-mode { font-size: 12px; color: var(--tc-sub); }
|
||||
.tc-meta {
|
||||
font-size: 12px; color: var(--tc-sub); background: #fff;
|
||||
border: 1px solid var(--tc-line); border-radius: 8px; padding: 8px 12px; margin-bottom: 14px;
|
||||
}
|
||||
.tc-meta b { color: var(--tc-text); font-weight: 600; }
|
||||
|
||||
.tc-disclaimer {
|
||||
background: #fff8e6; border: 1px solid #f0d79a; color: #7a5a12;
|
||||
border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* 报告区(tcr- 前缀)的样式已统一收进 ../tc_report.css,本页只保留页面级样式 */
|
||||
|
||||
.tc-error { background: #fff5f4; border: 1px solid #f5c9c3; color: #b42318; border-radius: 8px; padding: 14px; font-size: 13px; }
|
||||
.tc-error code { background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 4px; }
|
||||
</style>
|
||||
<!-- 报告样式单独一份:类名统一 tcr- 前缀,与上面的页面级样式完全隔离 -->
|
||||
<link rel="stylesheet" href="../tc_report.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="tc-page" id="tcr_top">
|
||||
<div class="tc-top">
|
||||
<h1>南通如东 · 两日车宿徒步</h1>
|
||||
<span class="tc-mode">游山玩水 · 技能包「上海周边2日车宿徒步游」· 离线样例</span>
|
||||
</div>
|
||||
<div class="tc-meta" id="tc_meta">正在载入样例数据…</div>
|
||||
<div class="tc-disclaimer">
|
||||
<b>这是效果验证样例:</b>行程由技能包规则生成,所有主观信息(免费停车、本地人常去、温泉档次与卫生)均标注置信度并列入「待核实清单」。
|
||||
本样例<b>不编造店名与价格</b>,餐饮与超市只给业态与位置描述。出行前请自行复核关键信息。
|
||||
</div>
|
||||
<div id="tc_mount"></div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
|
||||
<link rel="stylesheet" href="../../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
|
||||
<script src="../../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
|
||||
<!-- 渲染器与报告样式归本 skill 私有:同目录一份,改本 skill 不影响其他 skill -->
|
||||
<script src="../tc_render.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var mount = document.getElementById('tc_mount')
|
||||
var metaBox = document.getElementById('tc_meta')
|
||||
|
||||
function fail(msg, err) {
|
||||
mount.innerHTML = ''
|
||||
var box = document.createElement('div')
|
||||
box.className = 'tc-error'
|
||||
box.innerHTML = '<b>' + msg + '</b><br/>' + (err ? String(err.message || err) : '')
|
||||
+ '<br/>请通过首页九宫格进入本工具后再打开本页(静态资源受导航鉴权保护)。'
|
||||
mount.appendChild(box)
|
||||
metaBox.textContent = '载入失败'
|
||||
}
|
||||
|
||||
function renderMeta(payload) {
|
||||
var m = payload.meta || {}
|
||||
var d = m.destination || {}
|
||||
var qa = m.qa || {}
|
||||
var vehicle = qa.vehicle_type === 'ev' ? '电车' : (qa.vehicle_type === 'fuel' ? '油车' : (qa.vehicle_type || '—'))
|
||||
metaBox.innerHTML =
|
||||
'skill:<b>' + (m.skill_name || '—') + '</b>(v' + (m.skill_version || '—') + ')' +
|
||||
' · 目的地:<b>' + (d.name || '—') + '</b>' + (d.admin ? '(' + d.admin + ')' : '') +
|
||||
' · 出行:<b>' + (qa.departure_date || '—') + ' ' + (qa.departure_time || '') + '</b>' +
|
||||
' · 车辆:<b>' + vehicle + '</b> · 人数:<b>' + (qa.headcount === undefined ? '—' : qa.headcount) + '</b>' +
|
||||
' · 生成方式:<b>' + (m.generator || '—') + '</b> · 生成时间:<b>' + (m.generated_at || '—') + '</b>(北京时间)'
|
||||
}
|
||||
|
||||
if (!window.TCRender) { fail('渲染器未加载', null); return }
|
||||
|
||||
var files = ['./rudong_2026-09-19.json', './report_spec.json']
|
||||
Promise.all(files.map(function (u) {
|
||||
return fetch(u + '?t=' + Date.now(), { cache: 'no-store' }).then(function (r) {
|
||||
if (!r.ok) throw new Error(u + ' HTTP ' + r.status)
|
||||
return r.json()
|
||||
})
|
||||
})).then(function (res) {
|
||||
var payload = res[0]
|
||||
var spec = res[1]
|
||||
window.TCRender.render(payload, spec, mount)
|
||||
renderMeta(payload)
|
||||
}).catch(function (e) {
|
||||
fail('样例数据载入失败', e)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_comment": "【阶段一离线样例专用】本文件是技能包 report_spec.json 的样例副本,仅为了让 sample/index.html 能独立打开。阶段二由后端从 skills/sh_2d_car_hike/report_spec.json 读取并注入,届时本副本可直接删除。",
|
||||
"version": "0.1.0",
|
||||
"block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"],
|
||||
"layout": "single_column",
|
||||
"show_index": true,
|
||||
"sections": [
|
||||
{ "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" },
|
||||
{ "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] },
|
||||
{ "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" },
|
||||
{ "id": "sec_cards", "type": "cards", "title": "节点详情", "data": "$.cards", "group_by": "day" },
|
||||
{ "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" },
|
||||
{ "id": "sec_drive_out", "type": "table", "title": "去程 · 双版本对比", "data": "$.tables", "pick_id": "drive_out_compare" },
|
||||
{ "id": "sec_drive_back", "type": "table", "title": "返程 · 双版本对比", "data": "$.tables", "pick_id": "drive_back_compare" },
|
||||
{ "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" },
|
||||
{ "id": "sec_parking", "type": "table", "title": "各节点停车一览", "data": "$.tables", "pick_id": "parking_summary" },
|
||||
{ "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" },
|
||||
{ "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" },
|
||||
{ "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" }
|
||||
],
|
||||
"confidence_badge": { "high": "已核实", "medium": "待确认", "low": "需核实" },
|
||||
"image_disclaimer_default": "AI 示意图,非实景",
|
||||
"map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹"
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
{
|
||||
"meta": {
|
||||
"skill_id": "sh_2d_car_hike",
|
||||
"skill_name": "上海周边2日车宿徒步游",
|
||||
"skill_version": "0.3.0",
|
||||
"schema_version": "0.2.0",
|
||||
"destination": {
|
||||
"key": "rudong",
|
||||
"name": "南通如东",
|
||||
"admin": "江苏省南通市如东县",
|
||||
"center": [32.31, 121.19]
|
||||
},
|
||||
"qa": {
|
||||
"departure_origin": "上海宝山富锦路地铁站",
|
||||
"departure_date": "2026-09-19",
|
||||
"departure_time": "06:40",
|
||||
"vehicle_type": "fuel",
|
||||
"headcount": 1
|
||||
},
|
||||
"generated_at": "2026-09-17 21:30:00",
|
||||
"generator": "manual_sample",
|
||||
"disclaimer": "本行程按技能包规则生成,用于效果验证;所有主观信息均标注置信度,出行前请按『待核实清单』自行复核。"
|
||||
},
|
||||
|
||||
"summary": {
|
||||
"title": "南通如东 · 两日车宿徒步",
|
||||
"one_liner": "沿海堤岸走两段平路徒步,落脚栟茶古镇逛三小时,傍晚泡温泉,夜里睡在县城公园,周日再走一段林带后返沪。",
|
||||
"tags": ["2天1夜", "单人自驾", "车内过夜", "两次1.5h徒步", "历史街区", "温泉"],
|
||||
"stats": [
|
||||
{ "label": "全程车程", "value": "约 340 km", "hint": "往返里程,高速版口径;起点为宝山富锦路地铁站" },
|
||||
{ "label": "徒步合计", "value": "3.0 小时", "hint": "两段各 1.5h,平路无爬升" },
|
||||
{ "label": "预估花费", "value": "≈ 900 元", "hint": "含过路费/油费/两餐/温泉/补给" },
|
||||
{ "label": "硬时间点", "value": "17:00 / ≤22:00 / 05:00", "hint": "浴场 / 车宿(不晚于)/ 晨起" },
|
||||
{ "label": "地形提醒", "value": "平原·无山", "hint": "已触发地形降级,两段徒步爬升均约 0" }
|
||||
]
|
||||
},
|
||||
|
||||
"timeline": {
|
||||
"days": [
|
||||
{
|
||||
"label": "第一天 · 周六",
|
||||
"date": "2026-09-19",
|
||||
"rows": [
|
||||
{ "time": "06:40", "title": "宝山富锦路地铁站出发(可 06:30 提前)", "anchor_id": "drive_out", "kind": "soft", "activity": "move", "note": "高速版 2.6h / 非高速版 3.5h" },
|
||||
{ "time": "09:15", "title": "抵达沿海堤岸徒步起点", "anchor_id": "hike_1", "kind": "soft", "activity": "move", "note": "06:40 出发 + 约 2.6h 车程;到达即走" },
|
||||
{ "time": "09:20-10:50", "title": "第一段徒步 1.5h", "anchor_id": "hike_1", "kind": "soft", "activity": "hike", "note": "平路,约 6.5km,爬升约 0m" },
|
||||
{ "time": "11:05", "title": "抵达茶古镇外围,先解决停车", "anchor_id": "district_parking", "kind": "soft", "activity": "move", "note": "免费与否需现场确认" },
|
||||
{ "time": "11:20-12:25", "title": "午饭(本地小馆)", "anchor_id": "lunch_d1", "kind": "soft", "activity": "eat", "note": "三选一,人均约 100" },
|
||||
{ "time": "12:30-15:30", "title": "古镇打卡动线约 3 小时", "anchor_id": "district_walk", "kind": "soft", "activity": "walk", "note": "5 个点位,步行约 3.2km" },
|
||||
{ "time": "16:15", "title": "离开古镇前往温泉", "anchor_id": "bathhouse", "kind": "soft", "activity": "move", "note": "车程约 35 分钟,含找车位" },
|
||||
{ "time": "17:00", "title": "抵达温泉 / 洗浴中心", "anchor_id": "bathhouse", "kind": "hard", "activity": "move", "note": "硬时间点:骨架声明的到达时刻(16:45-17:15)" },
|
||||
{ "time": "17:05-19:40", "title": "泡汤、淋浴、休息", "anchor_id": "bathhouse", "kind": "soft", "note": "建议不超过 2.5 小时" },
|
||||
{ "time": "19:40-20:10", "title": "浴场旁超市补给(与浴场一起完成)", "anchor_id": "grocery", "kind": "soft", "activity": "shop", "note": "同商圈,与浴场共用停车点,不挪车" },
|
||||
{ "time": "20:20", "title": "前往车宿点", "anchor_id": "car_sleep", "kind": "soft", "activity": "move", "note": "往县城方向,车程约 45 分钟" },
|
||||
{ "time": "21:10", "title": "抵达车宿停车点", "anchor_id": "car_sleep", "kind": "hard", "activity": "sleep", "note": "硬点要求不晚于 22:00,本次提前到位" },
|
||||
{ "time": "21:10-22:00", "title": "车内整理、吃夜宵、准备就寝", "anchor_id": "car_sleep", "kind": "soft", "activity": "sleep", "note": "提前到就提前休息,不必等到 22:00" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "第二天 · 周日",
|
||||
"date": "2026-09-20",
|
||||
"rows": [
|
||||
{ "time": "05:00", "title": "起床散步", "anchor_id": "morning_walk", "kind": "hard", "activity": "walk", "note": "硬时间点;日出约 05:35,此时为黎明微光" },
|
||||
{ "time": "05:05-06:10", "title": "公园环线晨走", "anchor_id": "morning_walk", "kind": "soft", "activity": "walk", "note": "约 3km 环线" },
|
||||
{ "time": "06:15-07:10", "title": "早饭 + 洗漱", "anchor_id": "breakfast_wash", "kind": "soft", "activity": "eat", "note": "早点铺 + 公园公厕/加油站卫生间" },
|
||||
{ "time": "07:20", "title": "驱车前往第二徒步点", "anchor_id": "hike_2", "kind": "soft", "activity": "move", "note": "沿海林带方向" },
|
||||
{ "time": "08:15-09:45", "title": "第二段徒步 1.5h", "anchor_id": "hike_2", "kind": "soft", "activity": "hike", "note": "林道 + 水岸,约 6.5km,爬升约 0m" },
|
||||
{ "time": "10:00-11:20", "title": "打卡点 1-2 个", "anchor_id": "photo_spots", "kind": "soft", "activity": "walk", "note": "人文点位为主,与昨日古镇点位去重" },
|
||||
{ "time": "11:30-12:40", "title": "午饭(县城本地家常菜)", "anchor_id": "lunch_d2", "kind": "soft", "activity": "eat", "note": "优先能停车" },
|
||||
{ "time": "13:00", "title": "返程上海", "anchor_id": "drive_back", "kind": "soft", "activity": "move", "note": "注意周日下午进城方向高峰" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"cards": [
|
||||
{
|
||||
"id": "c_drive_out",
|
||||
"anchor_id": "drive_out",
|
||||
"day": 1,
|
||||
"title": "去程 · 双版本自驾",
|
||||
"subtitle": "宝山富锦路地铁站 → 如东(沿海徒步点)",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_cover.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "沿海公路与堤岸意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "双版本", "tone": "info" },
|
||||
{ "text": "里程为估算", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "高速版", "value": { "text": "170 km / 约 2.6 小时 / 过路费约 85 元", "confidence": "medium", "verify": "出发前用导航核对实时里程与路况" } },
|
||||
{ "label": "非高速版", "value": { "text": "162 km / 约 3.5 小时 / 无过路费", "confidence": "low", "verify": "国道走向与限速以导航为准,国省道易遇施工与拥堵" } },
|
||||
{ "label": "加油点", "value": "高速版服务区 + 沿途连锁加油站充足;出高速后县城加油站密集" },
|
||||
{ "label": "拥堵风险", "value": { "text": "周六上午出城方向 07:00-09:00 相对顺畅,过江通道节假日上午易排队", "confidence": "medium", "verify": "出行当天看实时路况" } },
|
||||
{ "label": "取舍建议", "value": "赶时间走高速;想省钱且不赶时间走非高速,但非高速要多留 1 小时余量" }
|
||||
],
|
||||
"bullets": [
|
||||
"起点:上海宝山富锦路地铁站(示例坐标仅用于地图示意)",
|
||||
"中途拐点:过江后经南通方向转如东",
|
||||
"终点:沿海堤岸徒步起点,路边可临停卸装备",
|
||||
"出发前把两套路线都存进导航,现场按实时路况二选一"
|
||||
],
|
||||
"map_marker_ids": ["m_sh_start", "m_way_sutong", "m_way_nantong", "m_hike1_start"]
|
||||
},
|
||||
{
|
||||
"id": "c_hike_1",
|
||||
"anchor_id": "hike_1",
|
||||
"day": 1,
|
||||
"title": "第一段徒步 · 沿海堤岸",
|
||||
"subtitle": "1.5 小时 · 到达即走",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_hike1_embankment.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "海堤与滩涂意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "已触发地形降级", "tone": "warn" },
|
||||
{ "text": "平路无爬升", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "起点 / 终点", "value": { "text": "沿海堤岸起点 → 沿堤折返或走至下一闸口", "confidence": "low", "verify": "堤岸是否允许步行、是否有施工封闭,需现场确认" } },
|
||||
{ "label": "折算里程", "value": "按 4.3 km/h 常规步速,1.5 小时约 6.5 km" },
|
||||
{ "label": "累计爬升", "value": "约 0 m(平路徒步,运动量按时长与步数计)" },
|
||||
{ "label": "路面类型", "value": "堤顶水泥路 / 土路混合,风力较大" },
|
||||
{ "label": "难度", "value": "低(无技术路段),但日晒与海风体感明显" },
|
||||
{ "label": "停车", "value": { "text": "堤岸起点附近路侧空地可临停", "confidence": "low", "verify": "是否允许停车、是否属禁停区需现场看标识" } },
|
||||
{ "label": "补水 / 卫生间", "value": { "text": "堤岸沿线基本无补给,需自带水;卫生间需回镇上解决", "confidence": "medium", "verify": "出发前确认最近公厕位置" } }
|
||||
],
|
||||
"bullets": [
|
||||
"降级说明:如东为平原沿海地区,无山可爬,本段改走堤岸与滩涂边,累计爬升约 0,不得按山地徒步的运动量期待",
|
||||
"想出汗的做法:把配速提到 5.5-6 km/h 快走,或做 4 组 200 米折返快走",
|
||||
"海边风力大,注意防晒与帽子固定;鞋建议防滑、不穿白鞋(滩涂泥)"
|
||||
],
|
||||
"map_marker_ids": ["m_hike1_start", "m_hike1_end"]
|
||||
},
|
||||
{
|
||||
"id": "c_district_arrive",
|
||||
"anchor_id": "district_arrive",
|
||||
"day": 1,
|
||||
"title": "街区 · 茶古镇一带",
|
||||
"subtitle": "历史街区",
|
||||
"badges": [
|
||||
{ "text": "历史街区", "tone": "good" },
|
||||
{ "text": "待确认", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "街区定性", "value": "历史街区(古镇型),非纯商业步行街" },
|
||||
{ "label": "范围", "value": { "text": "古镇老街一带,可逛核心范围不大,约 2-3 条主街", "confidence": "medium", "verify": "实际开放范围与是否局部封闭,出发前查当地公告" } },
|
||||
{ "label": "可逛时长", "value": "约 3 小时(含吃饭与休憩),节奏偏慢" },
|
||||
{ "label": "选择理由", "value": "如东县城以现代街区为主,历史街区以古镇型最符合本条要求" },
|
||||
{ "label": "备选", "value": "如东县城掘港镇老街一带(更偏闹市街区,若古镇封闭可切换)" }
|
||||
],
|
||||
"bullets": [
|
||||
"古镇型街区的价值在街巷肌理与老建筑,不追求商铺数量",
|
||||
"若遇整修或大面积围挡,直接切到备选的县城闹市街区,动线按同规则重排"
|
||||
],
|
||||
"map_marker_ids": ["m_bengcha_gate", "m_bengcha_center"]
|
||||
},
|
||||
{
|
||||
"id": "c_district_parking",
|
||||
"anchor_id": "district_parking",
|
||||
"day": 1,
|
||||
"title": "街区停车(免费优先)",
|
||||
"subtitle": "先停车,再吃饭",
|
||||
"badges": [
|
||||
{ "text": "需核实", "tone": "danger" },
|
||||
{ "text": "不得默认免费", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "首选停车点", "value": { "text": "古镇入口外围路侧泊位 / 镇区公共停车场", "confidence": "low", "verify": "出发前打电话给古镇管理方或看地图实景确认是否免费" } },
|
||||
{ "label": "免费判定依据", "value": { "text": "暂无可靠依据,需现场看标识或电话确认", "confidence": "low", "verify": "以现场收费公示牌为准" } },
|
||||
{ "label": "预估费用", "value": "若收费,按小客车 5-10 元/次估(占位口径)" },
|
||||
{ "label": "步行距离", "value": "预计 5 分钟内进主街;实际以现场为准" },
|
||||
{ "label": "风险提示", "value": "古镇周末可能有临时交通管制,停车点会临时变化" }
|
||||
],
|
||||
"bullets": [
|
||||
"规则要求:本节点不允许把收费点写成免费点;如无免费方案,必须给收费方案与费用",
|
||||
"若愿意多走 5-10 分钟,可把车停在镇区外围再步行进古镇核心"
|
||||
],
|
||||
"map_marker_ids": ["m_bengcha_park"]
|
||||
},
|
||||
{
|
||||
"id": "c_lunch_d1",
|
||||
"anchor_id": "lunch_d1",
|
||||
"day": 1,
|
||||
"title": "第一天午饭 · 本地小馆",
|
||||
"subtitle": "人均约 100 · 单人份",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_lunch_local.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "沿海小镇家常菜意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "3 家候选", "tone": "info" },
|
||||
{ "text": "店名待核实", "tone": "danger" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "候选 1(走路可达)", "value": { "text": "古镇外围沿街海鲜小馆 —— 以文蛤、小海鲜、时令河鲜为招牌的本地小馆", "confidence": "low", "verify": "店名与实际人均需现场确认;本样例不编造店名" } },
|
||||
{ "label": "候选 2(走路可达)", "value": { "text": "镇区非游客主街的家常菜馆 —— 做本地家常菜,中午有本地人吃饭", "confidence": "low", "verify": "同上" } },
|
||||
{ "label": "候选 3(需挪车)", "value": { "text": "镇区外围临街面馆 / 小炒 —— 停车方便,适合赶时间", "confidence": "low", "verify": "同上" } },
|
||||
{ "label": "判断依据", "value": "加分项:不在游客主街、有本地招牌菜、评价中本地标签占比高、开店年限长;减分项:门口拉客、中英双语菜单、团餐大巴停靠" },
|
||||
{ "label": "单人怎么点", "value": "一荤一素 + 一份主食,或点一碗海鲜面/文蛤面;人均 100 通常吃得很饱" },
|
||||
{ "label": "停车", "value": "候选 1、2 用街区停车点;候选 3 门口可停" }
|
||||
],
|
||||
"bullets": [
|
||||
"本地口味偏清淡鲜甜,偏咸辣可要求少盐",
|
||||
"海鲜按时令,若遇禁渔期价格会明显上行,点单前先问价",
|
||||
"不确定店名时合规做法:只给业态 + 位置描述,不给编造名字"
|
||||
],
|
||||
"map_marker_ids": ["m_bengcha_center", "m_lunch_d1_alt"]
|
||||
},
|
||||
{
|
||||
"id": "c_district_walk",
|
||||
"anchor_id": "district_walk",
|
||||
"day": 1,
|
||||
"title": "街区打卡动线 · 约 3 小时",
|
||||
"subtitle": "5 个点位 · 步行约 3.2 km",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_bengcha.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "古镇街巷意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "含点线顺序", "tone": "info" },
|
||||
{ "text": "逗留节奏偏慢", "tone": "plain" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "总时长", "value": "约 3 小时(12:40-15:40),含 1 次坐下休憩" },
|
||||
{ "label": "步行距离", "value": "约 3.2 km,平路,随时可缩短" },
|
||||
{ "label": "休憩点", "value": { "text": "主街上的茶馆 / 咖啡位(店名待核实)", "confidence": "low", "verify": "现场找有座位的店即可,不必指定" } }
|
||||
],
|
||||
"bullets": [
|
||||
"① 古镇入口牌坊 12:40-12:55 —— 拍门头与街口全景,建立方位感",
|
||||
"② 主街老建筑段 12:55-13:40 —— 看沿街立面与老字号门面,慢走细看",
|
||||
"③ 巷弄纵深段 13:40-14:25 —— 拐进横向支巷,人少、更出片",
|
||||
"④ 水岸 / 桥头 14:25-15:05 —— 坐 20 分钟休憩,看水面与老码头方向",
|
||||
"⑤ 出口前土产段 15:05-15:40 —— 顺路买点海产干货或糕点带走",
|
||||
"地图连线顺序即为 ①→⑤,用于生成点线路线图",
|
||||
"如某个点位封闭或施工,直接跳过并把时间匀给 ③④"
|
||||
],
|
||||
"map_marker_ids": ["m_bengcha_gate", "m_bengcha_center", "m_bengcha_alley", "m_bengcha_water", "m_bengcha_shop"]
|
||||
},
|
||||
{
|
||||
"id": "c_bathhouse",
|
||||
"anchor_id": "bathhouse",
|
||||
"day": 1,
|
||||
"title": "下午浴场 · 温泉替代",
|
||||
"subtitle": "17:00 抵达(硬时间点)",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_bathhouse.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "温泉汤池意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "已触发降级", "tone": "warn" },
|
||||
{ "text": "需核实", "tone": "danger" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "降级说明", "value": "如东本地无传统大型浴场,本条按规则降级为温泉 / 大型洗浴中心(营业面积大、本地评价量级高)" },
|
||||
{ "label": "候选", "value": { "text": "沿海温泉 / 度假区方向的大型汤池(候选)", "confidence": "low", "verify": "营业状态、是否需预约、当日是否开放,出发前电话确认" } },
|
||||
{ "label": "免费停车", "value": { "text": "度假区类通常有免费停车,但需确认", "confidence": "low", "verify": "电话确认是否免费、是否对非住客开放" } },
|
||||
{ "label": "人均", "value": { "text": "约 150-200 元 / 次(温泉类普遍高于市区洗浴)", "confidence": "low", "verify": "以当日价目为准" } },
|
||||
{ "label": "卫生依据", "value": { "text": "看本地评价中卫生关键词、开业年限与翻新时间;不空口说‘干净’", "confidence": "low", "verify": "出发前看最新评价" } },
|
||||
{ "label": "建议时长", "value": "2-2.5 小时(17:10-19:30),长时间泡汤易累,之后还要开车" },
|
||||
{ "label": "自带物品", "value": "毛巾、拖鞋、洗护、换洗衣物、密封袋装湿衣、少量现金" }
|
||||
],
|
||||
"bullets": [
|
||||
"硬时间点 17:00 是为了给 19:45 的补给和 22:00 的车宿留余量",
|
||||
"泡完不要立刻长时间开车,建议先吃点东西再走",
|
||||
"若当日温泉闭馆,备选:市区规模较大的洗浴中心,或直接跳过本条改早到车宿点"
|
||||
],
|
||||
"map_marker_ids": ["m_bathhouse"]
|
||||
},
|
||||
{
|
||||
"id": "c_grocery",
|
||||
"anchor_id": "grocery",
|
||||
"day": 1,
|
||||
"title": "大卖场补给",
|
||||
"subtitle": "19:45-20:25 · 买次日早餐",
|
||||
"badges": [
|
||||
{ "text": "店名待核实", "tone": "danger" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "首选", "value": { "text": "县城掘港镇大型超市 / 卖场(连锁品牌优先)", "confidence": "low", "verify": "店名与营业时间用地图核实;本样例不编造店名" } },
|
||||
{ "label": "营业时间", "value": { "text": "大型卖场通常到 21:30-22:00,需确认", "confidence": "low", "verify": "出发前查当日营业时间" } },
|
||||
{ "label": "停车", "value": { "text": "大卖场自带停车场,通常免费 1-2 小时", "confidence": "medium", "verify": "以现场公示为准" } },
|
||||
{ "label": "购物清单", "value": "常温牛奶(小包装)、面包 / 包子、鸡蛋或卤蛋、水果、瓶装水 2L、湿巾、垃圾袋" },
|
||||
{ "label": "牛奶提示", "value": "车内过夜无冷藏,优先常温奶或小包装,避免大瓶冷藏奶;看清保质期与是否需要冷藏" }
|
||||
],
|
||||
"bullets": [
|
||||
"顺手补:纸巾、驱蚊液、车载垃圾袋、备用水",
|
||||
"不要买需要冷藏的熟食,夜间车内温度不可控"
|
||||
],
|
||||
"map_marker_ids": ["m_grocery"]
|
||||
},
|
||||
{
|
||||
"id": "c_car_sleep",
|
||||
"anchor_id": "car_sleep",
|
||||
"day": 1,
|
||||
"title": "车宿过夜停车点",
|
||||
"subtitle": "不晚于 22:00 抵达(硬时间点)",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_carsleep_park.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "夜间城市公园停车场意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "禁停风险 中", "tone": "warn" },
|
||||
{ "text": "需核实", "tone": "danger" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "首选", "value": { "text": "县城公园类市政停车场(市中心、空气好、有公厕)", "confidence": "low", "verify": "过夜是否被劝离,以现场标识与当地执法口径为准" } },
|
||||
{ "label": "卫生间", "value": { "text": "公园公厕白天开放;夜间是否开放需现场确认", "confidence": "low", "verify": "到场后先确认最近公厕位置与是否上锁" } },
|
||||
{ "label": "照明", "value": { "text": "市政公园停车场通常有路灯,但部分区域后半夜会关灯", "confidence": "medium", "verify": "到场后选有常亮路灯的位置停" } },
|
||||
{ "label": "早锻炼人群", "value": { "text": "5-6 点公园通常已有晨练人群,安全性反而更好", "confidence": "medium", "verify": "现场观察" } },
|
||||
{ "label": "备选点", "value": "① 高速服务区(最稳,但离市区远)② 24 小时加油站(次稳)③ 收费营地 / 房车营地(花钱买安心)" },
|
||||
{ "label": "风险等级", "value": { "text": "中 —— 市政公园对过夜停车普遍不明确表态,存在夜间劝离可能", "confidence": "low", "verify": "到场看标识;若被劝离直接切服务区备选" } },
|
||||
{ "label": "本次到达", "value": "21:10 抵达,早于硬点(不晚于 22:00),属提前到位,不算漂移" }
|
||||
],
|
||||
"bullets": [
|
||||
"车宿安全:靠路灯停、避开主通道与出入口、留 2-3cm 通风缝、不长时间怠速开空调",
|
||||
"贵重物品不外露,前排不要放包;垃圾自己带走,不留痕迹",
|
||||
"停车位置避开低洼与树下(秋季落叶堵塞排水、潮气重)"
|
||||
],
|
||||
"map_marker_ids": ["m_car_sleep"]
|
||||
},
|
||||
{
|
||||
"id": "c_morning_walk",
|
||||
"anchor_id": "morning_walk",
|
||||
"day": 2,
|
||||
"title": "晨间散步 · 05:00",
|
||||
"subtitle": "公园环线约 3 km",
|
||||
"badges": [
|
||||
{ "text": "硬时间点", "tone": "info" },
|
||||
{ "text": "天未全亮", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "日出时间", "value": { "text": "2026-09-19/20 如东日出约 05:35,05:00 起床时天为黎明微光", "confidence": "medium", "verify": "出行前用天气 App 核对当日日出时间" } },
|
||||
{ "label": "路线", "value": "从车宿点出发绕公园主环线一圈(不走机动车道,不远离停车位)" },
|
||||
{ "label": "距离 / 用时", "value": "约 3 km / 60-70 分钟(含拉伸)" },
|
||||
{ "label": "照明", "value": { "text": "公园主路有路灯,支路可能关灯", "confidence": "medium", "verify": "到场确认;建议带头灯或手机照明" } },
|
||||
{ "label": "公厕", "value": { "text": "公园公厕位置到场确认,作为洗漱点备选", "confidence": "low", "verify": "现场确认" } }
|
||||
],
|
||||
"bullets": [
|
||||
"05:00 天没亮,穿反光条或亮色外套更安全",
|
||||
"晨走就是当天的‘第一次热身’,为 08:15 的第二段徒步做准备"
|
||||
],
|
||||
"map_marker_ids": ["m_car_sleep", "m_morning_park"]
|
||||
},
|
||||
{
|
||||
"id": "c_breakfast_wash",
|
||||
"anchor_id": "breakfast_wash",
|
||||
"day": 2,
|
||||
"title": "早饭 + 洗漱",
|
||||
"subtitle": "06:15-07:10",
|
||||
"badges": [
|
||||
{ "text": "开门时间待核实", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "方案 A · 店内吃", "value": { "text": "县城本地早点铺(面条 / 汤包 / 烧饼类)", "confidence": "low", "verify": "5-6 点是否开门需现场或电话确认;很多面馆 06:30 后才开" } },
|
||||
{ "label": "方案 B · 车内吃", "value": "昨晚大卖场买的常温奶 + 面包 + 卤蛋,最稳妥,不依赖店铺开门" },
|
||||
{ "label": "洗漱点·首选", "value": { "text": "公园公厕(免费,需确认是否上锁)", "confidence": "low", "verify": "现场确认" } },
|
||||
{ "label": "洗漱点·备选 1", "value": { "text": "24 小时加油站卫生间(多数可借用)", "confidence": "medium", "verify": "看店内标识" } },
|
||||
{ "label": "洗漱点·备选 2", "value": { "text": "连锁健身房单次体验卡(花钱但热水稳定)", "confidence": "low", "verify": "当地是否有门店需核实" } }
|
||||
],
|
||||
"bullets": [
|
||||
"建议顺序:先晨走 → 洗漱 → 早饭,避免吃完再运动",
|
||||
"自备:漱口水、湿巾、免洗洗手液、便携毛巾(公厕不一定有热水)"
|
||||
],
|
||||
"map_marker_ids": ["m_breakfast"]
|
||||
},
|
||||
{
|
||||
"id": "c_hike_2",
|
||||
"anchor_id": "hike_2",
|
||||
"day": 2,
|
||||
"title": "第二段徒步 · 沿海林带",
|
||||
"subtitle": "1.5 小时 · 与昨日不重复",
|
||||
"image": {
|
||||
"url": "../assets/photos/rudong_hike2_forest.jpg",
|
||||
"kind": "ai_illustration",
|
||||
"caption": "沿海林带林道意象",
|
||||
"disclaimer": "AI 示意图,非实景"
|
||||
},
|
||||
"badges": [
|
||||
{ "text": "已触发地形降级", "tone": "warn" },
|
||||
{ "text": "免费停车待核实", "tone": "danger" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "起点 / 终点", "value": { "text": "沿海林带 / 河道绿廊入口 → 纵深林道折返", "confidence": "low", "verify": "林带开放情况与出入口位置需核实" } },
|
||||
{ "label": "折算里程", "value": "约 6.5 km(林道平路,1.5 小时口径)" },
|
||||
{ "label": "累计爬升", "value": "约 0 m(平路 + 林道)" },
|
||||
{ "label": "路面类型", "value": "林地土路 / 碎石路,树荫好、比昨天晒得少" },
|
||||
{ "label": "停车", "value": { "text": "林带入口路边空地(尽量免费)", "confidence": "low", "verify": "是否允许停车需现场确认" } },
|
||||
{ "label": "与第一段的区别", "value": "第一段是开阔海堤(风大日晒),第二段是林荫林道(凉爽安静),景观与体感差异明显" }
|
||||
],
|
||||
"bullets": [
|
||||
"降级说明同昨日:如东无山,‘山水优先’退化为‘水 + 林’,本段爬升约 0",
|
||||
"林地秋季蚊虫仍多,出发前喷驱蚊液、穿长裤",
|
||||
"林道岔路多,建议开轨迹记录,避免绕远"
|
||||
],
|
||||
"map_marker_ids": ["m_hike2_start", "m_hike2_end"]
|
||||
},
|
||||
{
|
||||
"id": "c_photo_spots",
|
||||
"anchor_id": "photo_spots",
|
||||
"day": 2,
|
||||
"title": "打卡点 · 1-2 个",
|
||||
"subtitle": "10:00-11:20 · 人文优先",
|
||||
"badges": [
|
||||
{ "text": "点位待核实", "tone": "danger" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "候选 1(人文)", "value": { "text": "海堤 / 范公堤类人文点 —— 沿海堤防历史线索,看的是‘人与海的关系’", "confidence": "low", "verify": "具体点位、是否有标识与开放说明需核实" } },
|
||||
{ "label": "候选 2(人文/街区)", "value": { "text": "古镇内人文点位(故居 / 纪念馆类)—— 如开放则优先,未开放则跳过", "confidence": "low", "verify": "开放时间与门票需核实" } },
|
||||
{ "label": "开放时间 / 门票", "value": { "text": "均为待核实项", "confidence": "low", "verify": "出发前查当地文旅公告" } },
|
||||
{ "label": "停车", "value": "沿用林带入口或就近路侧泊位" },
|
||||
{ "label": "去重要求", "value": "已排除昨天古镇动线里的 5 个点位,避免重复" }
|
||||
],
|
||||
"bullets": [
|
||||
"本条不追求名气,追求‘有典故、看得到东西’",
|
||||
"若两个候选都不可达,宁可把时间留给午饭,不要硬凑景点"
|
||||
],
|
||||
"map_marker_ids": ["m_spot1"]
|
||||
},
|
||||
{
|
||||
"id": "c_lunch_d2",
|
||||
"anchor_id": "lunch_d2",
|
||||
"day": 2,
|
||||
"title": "第二天午饭 · 县城家常菜",
|
||||
"subtitle": "11:30-12:40 · 优先能停车",
|
||||
"badges": [
|
||||
{ "text": "店名待核实", "tone": "danger" },
|
||||
{ "text": "与昨天不同店型", "tone": "info" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "候选 1", "value": { "text": "县城本地家常菜老店 —— 做本地菜、中午本地人多、门口可停车", "confidence": "low", "verify": "店名与实际停车条件需现场核实" } },
|
||||
{ "label": "候选 2", "value": { "text": "县城面馆 / 小炒(备选)—— 出餐快、便于下午长途驾驶", "confidence": "low", "verify": "同上" } },
|
||||
{ "label": "判断依据", "value": "非游客区、本地客占比高、开店年限长、菜品以本地食材为主" },
|
||||
{ "label": "停车", "value": { "text": "优先选门口能停的;不能停则用就近社会停车场", "confidence": "medium", "verify": "现场确认" } },
|
||||
{ "label": "单人怎么点", "value": "一菜一汤 + 米饭;若下午要开 3 小时车,避免过饱与饮酒" }
|
||||
],
|
||||
"bullets": [
|
||||
"与昨天的区别:昨天是古镇海鲜小馆,今天是县城家常菜,避免两天吃同一类型",
|
||||
"出发前先把返程导航设好,吃完直接走"
|
||||
],
|
||||
"map_marker_ids": ["m_lunch2"]
|
||||
},
|
||||
{
|
||||
"id": "c_drive_back",
|
||||
"anchor_id": "drive_back",
|
||||
"day": 2,
|
||||
"title": "返程 · 双版本自驾",
|
||||
"subtitle": "如东 → 上海宝山富锦路地铁站",
|
||||
"badges": [
|
||||
{ "text": "双版本", "tone": "info" },
|
||||
{ "text": "下午高峰", "tone": "warn" }
|
||||
],
|
||||
"fields": [
|
||||
{ "label": "高速版", "value": { "text": "170 km / 约 2.6 小时 / 过路费约 85 元", "confidence": "medium", "verify": "出发前用导航核对实时路况" } },
|
||||
{ "label": "非高速版", "value": { "text": "162 km / 约 3.5 小时 / 无过路费", "confidence": "low", "verify": "国省道限速与施工以导航为准" } },
|
||||
{ "label": "加油点", "value": "县城与高速服务区均可加油;建议午饭后就近加满" },
|
||||
{ "label": "高峰提示", "value": { "text": "周日下午 15:00-20:00 为进城方向高峰,过江通道易缓行", "confidence": "medium", "verify": "出发前看实时路况" } },
|
||||
{ "label": "建议", "value": "13:00 出发走高速最稳;若想省钱走非高速,务必 12:30 前出发" }
|
||||
],
|
||||
"bullets": [
|
||||
"返程前检查:胎压、玻璃水、油量/电量、车内垃圾清空",
|
||||
"疲劳管理:每 1.5-2 小时进一次服务区,下车走 5 分钟"
|
||||
],
|
||||
"map_marker_ids": ["m_lunch2", "m_way_nantong", "m_sh_end"]
|
||||
}
|
||||
],
|
||||
|
||||
"map": {
|
||||
"center": [31.95, 121.15],
|
||||
"zoom": 9,
|
||||
"disclaimer": "点位顺序示意,非真实道路/轨迹;坐标多为概略值",
|
||||
"markers": [
|
||||
{ "id": "m_sh_start", "name": "上海宝山富锦路地铁站(出发)", "kind": "start", "lat": 31.376, "lng": 121.414, "coord_precision": "approx", "day": 1, "seq": 1 },
|
||||
{ "id": "m_way_sutong", "name": "过江通道方向", "kind": "waypoint", "lat": 31.72, "lng": 120.95, "coord_precision": "approx", "day": 1, "seq": 2 },
|
||||
{ "id": "m_way_nantong", "name": "南通方向拐点", "kind": "waypoint", "lat": 31.98, "lng": 120.89, "coord_precision": "approx", "day": 1, "seq": 3 },
|
||||
{ "id": "m_hike1_start", "name": "第一段徒步起点(堤岸)", "kind": "hike", "lat": 32.50, "lng": 121.10, "coord_precision": "approx", "day": 1, "seq": 4 },
|
||||
{ "id": "m_hike1_end", "name": "第一段徒步终点(闸口)", "kind": "hike", "lat": 32.47, "lng": 121.06, "coord_precision": "approx", "day": 1, "seq": 5 },
|
||||
{ "id": "m_bengcha_park", "name": "古镇停车点", "kind": "parking", "lat": 32.556, "lng": 120.946, "coord_precision": "approx", "day": 1, "seq": 6, "note": "免费与否待核实" },
|
||||
{ "id": "m_bengcha_gate", "name": "古镇入口牌坊", "kind": "district", "lat": 32.554, "lng": 120.949, "coord_precision": "approx", "day": 1, "seq": 7 },
|
||||
{ "id": "m_bengcha_center", "name": "古镇主街", "kind": "district", "lat": 32.552, "lng": 120.952, "coord_precision": "approx", "day": 1, "seq": 8 },
|
||||
{ "id": "m_bengcha_alley", "name": "巷弄纵深", "kind": "district", "lat": 32.550, "lng": 120.955, "coord_precision": "approx", "day": 1, "seq": 9 },
|
||||
{ "id": "m_bengcha_water", "name": "水岸 / 桥头", "kind": "district", "lat": 32.548, "lng": 120.958, "coord_precision": "approx", "day": 1, "seq": 10 },
|
||||
{ "id": "m_bengcha_shop", "name": "出口土产段", "kind": "district", "lat": 32.546, "lng": 120.960, "coord_precision": "approx", "day": 1, "seq": 11 },
|
||||
{ "id": "m_lunch_d1_alt", "name": "午饭候选 3(需挪车)", "kind": "food", "lat": 32.558, "lng": 120.943, "coord_precision": "approx", "day": 1, "seq": 12 },
|
||||
{ "id": "m_bathhouse", "name": "温泉 / 洗浴中心(候选)", "kind": "bathhouse", "lat": 32.53, "lng": 121.03, "coord_precision": "approx", "day": 1, "seq": 13 },
|
||||
{ "id": "m_grocery", "name": "浴场旁超市(同商圈)", "kind": "shop", "lat": 32.534, "lng": 121.038, "coord_precision": "approx", "day": 1, "seq": 14, "note": "与浴场共用停车点" },
|
||||
{ "id": "m_car_sleep", "name": "车宿停车点(候选)", "kind": "sleep", "lat": 32.312, "lng": 121.196, "coord_precision": "approx", "day": 1, "seq": 15 },
|
||||
{ "id": "m_morning_park", "name": "公园晨走环线", "kind": "walk", "lat": 32.314, "lng": 121.199, "coord_precision": "approx", "day": 2, "seq": 16 },
|
||||
{ "id": "m_breakfast", "name": "早点铺(候选)", "kind": "breakfast", "lat": 32.309, "lng": 121.193, "coord_precision": "approx", "day": 2, "seq": 17 },
|
||||
{ "id": "m_hike2_start", "name": "第二段徒步起点(林带)", "kind": "hike", "lat": 32.45, "lng": 121.25, "coord_precision": "approx", "day": 2, "seq": 18 },
|
||||
{ "id": "m_hike2_end", "name": "第二段徒步纵深处", "kind": "hike", "lat": 32.43, "lng": 121.30, "coord_precision": "approx", "day": 2, "seq": 19 },
|
||||
{ "id": "m_spot1", "name": "人文打卡点(候选)", "kind": "spot", "lat": 32.47, "lng": 121.15, "coord_precision": "approx", "day": 2, "seq": 20 },
|
||||
{ "id": "m_lunch2", "name": "第二天午饭(候选)", "kind": "food", "lat": 32.30, "lng": 121.18, "coord_precision": "approx", "day": 2, "seq": 21 },
|
||||
{ "id": "m_sh_end", "name": "返回上海", "kind": "end", "lat": 31.23, "lng": 121.47, "coord_precision": "approx", "day": 2, "seq": 22 }
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "r_drive_out",
|
||||
"name": "去程(示意)",
|
||||
"kind": "drive",
|
||||
"color": "#2f6fed",
|
||||
"dashed": false,
|
||||
"marker_ids": ["m_sh_start", "m_way_sutong", "m_way_nantong", "m_hike1_start"],
|
||||
"note": "点位顺序示意,非真实道路"
|
||||
},
|
||||
{
|
||||
"id": "r_hike_1",
|
||||
"name": "第一段徒步(示意)",
|
||||
"kind": "hike",
|
||||
"color": "#2e9e5b",
|
||||
"dashed": true,
|
||||
"marker_ids": ["m_hike1_start", "m_hike1_end"],
|
||||
"note": "点位顺序示意,非真实轨迹"
|
||||
},
|
||||
{
|
||||
"id": "r_district_walk",
|
||||
"name": "古镇动线 ①→⑤",
|
||||
"kind": "stroll",
|
||||
"color": "#c2701a",
|
||||
"dashed": false,
|
||||
"marker_ids": ["m_bengcha_park", "m_bengcha_gate", "m_bengcha_center", "m_bengcha_alley", "m_bengcha_water", "m_bengcha_shop"],
|
||||
"note": "按打卡点顺序连线,示意"
|
||||
},
|
||||
{
|
||||
"id": "r_morning_walk",
|
||||
"name": "晨走环线(示意)",
|
||||
"kind": "stroll",
|
||||
"color": "#8a5cf6",
|
||||
"dashed": true,
|
||||
"marker_ids": ["m_car_sleep", "m_morning_park", "m_breakfast"],
|
||||
"note": "公园环线示意,非真实步道"
|
||||
},
|
||||
{
|
||||
"id": "r_hike_2",
|
||||
"name": "第二段徒步(示意)",
|
||||
"kind": "hike",
|
||||
"color": "#1f8a70",
|
||||
"dashed": true,
|
||||
"marker_ids": ["m_hike2_start", "m_spot1", "m_hike2_end"],
|
||||
"note": "点位顺序示意,非真实轨迹"
|
||||
},
|
||||
{
|
||||
"id": "r_drive_back",
|
||||
"name": "返程(示意)",
|
||||
"kind": "drive",
|
||||
"color": "#d14343",
|
||||
"dashed": false,
|
||||
"marker_ids": ["m_lunch2", "m_way_nantong", "m_sh_end"],
|
||||
"note": "点位顺序示意,非真实道路"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"tables": [
|
||||
{
|
||||
"id": "drive_out_compare",
|
||||
"title": "去程 · 高速版 vs 非高速版",
|
||||
"columns": [
|
||||
{ "key": "version", "label": "版本" },
|
||||
{ "key": "distance", "label": "里程", "align": "right" },
|
||||
{ "key": "hours", "label": "预计时长", "align": "right" },
|
||||
{ "key": "toll", "label": "过路费", "align": "right" },
|
||||
{ "key": "supply", "label": "加油 / 补能" },
|
||||
{ "key": "risk", "label": "风险提示" },
|
||||
{ "key": "fit", "label": "适合" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "hw", "version": "高速版", "distance": "约 170 km", "hours": "约 2.6 h", "toll": "约 85 元", "supply": "服务区加油充足", "risk": "节假日上午过江通道可能排队", "fit": "赶时间 / 首选" },
|
||||
{ "_key": "nh", "version": "非高速版", "distance": "约 162 km", "hours": "约 3.5 h", "toll": "0 元", "supply": "沿途加油站密集", "risk": "国省道施工、限速变化、穿镇拥堵", "fit": "省钱 / 不赶时间" }
|
||||
],
|
||||
"note": "起点:上海宝山富锦路地铁站。里程与时长均为估算示意,未接实时路况;换出发点需重算。",
|
||||
"highlight_row_keys": ["hw"]
|
||||
},
|
||||
{
|
||||
"id": "drive_back_compare",
|
||||
"title": "返程 · 高速版 vs 非高速版",
|
||||
"columns": [
|
||||
{ "key": "version", "label": "版本" },
|
||||
{ "key": "distance", "label": "里程", "align": "right" },
|
||||
{ "key": "hours", "label": "预计时长", "align": "right" },
|
||||
{ "key": "toll", "label": "过路费", "align": "right" },
|
||||
{ "key": "supply", "label": "加油 / 补能" },
|
||||
{ "key": "risk", "label": "风险提示" },
|
||||
{ "key": "fit", "label": "适合" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "hw", "version": "高速版", "distance": "约 170 km", "hours": "约 2.6 h", "toll": "约 85 元", "supply": "服务区加油充足", "risk": "周日下午 15:00-20:00 进城方向高峰", "fit": "13:00 出发首选" },
|
||||
{ "_key": "nh", "version": "非高速版", "distance": "约 162 km", "hours": "约 3.5 h", "toll": "0 元", "supply": "沿途加油站密集", "risk": "穿城镇路段多,夜间视距差", "fit": "12:30 前出发" }
|
||||
],
|
||||
"note": "终点:上海宝山富锦路地铁站。估算示意,未接实时路况。",
|
||||
"highlight_row_keys": ["hw"]
|
||||
},
|
||||
{
|
||||
"id": "food_summary",
|
||||
"title": "三餐与补给一览",
|
||||
"columns": [
|
||||
{ "key": "meal", "label": "餐次" },
|
||||
{ "key": "time", "label": "时间" },
|
||||
{ "key": "candidates", "label": "候选" },
|
||||
{ "key": "area", "label": "位置" },
|
||||
{ "key": "per", "label": "人均", "align": "right" },
|
||||
{ "key": "parking", "label": "停车" },
|
||||
{ "key": "conf", "label": "置信度" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "d1", "meal": "D1 午饭", "time": "11:30-12:35", "candidates": "3 家(海鲜小馆 / 家常菜 / 面馆)", "area": "栟茶古镇一带", "per": "约 100 元", "parking": "用街区停车点", "conf": "需核实" },
|
||||
{ "_key": "d2", "meal": "D2 午饭", "time": "11:30-12:40", "candidates": "2 家(家常菜老店 / 面馆)", "area": "如东县城", "per": "约 90 元", "parking": "优先门口可停", "conf": "需核实" },
|
||||
{ "_key": "bf", "meal": "D2 早饭", "time": "06:15-07:10", "candidates": "本地早点铺 / 车内补给", "area": "车宿点附近", "per": "约 15 元", "parking": "路边临停", "conf": "需核实" },
|
||||
{ "_key": "sp", "meal": "夜间补给", "time": "19:40-20:10", "candidates": "浴场旁超市(与浴场一起完成)", "area": "小洋口 / 浴场商圈", "per": "约 60 元", "parking": "与浴场共用", "conf": "需核实" }
|
||||
],
|
||||
"note": "本样例不编造店名,只给业态 + 位置描述,店名与人均需现场核实。"
|
||||
},
|
||||
{
|
||||
"id": "parking_summary",
|
||||
"title": "各节点停车一览",
|
||||
"columns": [
|
||||
{ "key": "node", "label": "节点" },
|
||||
{ "key": "park", "label": "停车点" },
|
||||
{ "key": "free", "label": "是否免费" },
|
||||
{ "key": "fee", "label": "预估费用", "align": "right" },
|
||||
{ "key": "walk", "label": "步行距离" },
|
||||
{ "key": "risk", "label": "风险" },
|
||||
{ "key": "conf", "label": "置信度" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "hike1", "node": "第一段徒步", "park": "堤岸起点路侧空地", "free": "待核实", "fee": "0-10 元", "walk": "0 min", "risk": "可能禁停", "conf": "需核实" },
|
||||
{ "_key": "d1", "node": "古镇街区", "park": "古镇外围泊位 / 镇区公共停车场", "free": "待核实(不得默认免费)", "fee": "0-10 元/次", "walk": "≤5 min", "risk": "周末或临时管制", "conf": "需核实" },
|
||||
{ "_key": "bath", "node": "温泉 / 洗浴", "park": "度假区停车场", "free": "待核实", "fee": "0-10 元", "walk": "≤3 min", "risk": "非住客是否可停", "conf": "需核实" },
|
||||
{ "_key": "shop", "node": "浴场旁超市", "park": "与浴场共用停车点", "free": "随浴场", "fee": "0 元", "walk": "≤5 min", "risk": "低", "conf": "需核实" },
|
||||
{ "_key": "sleep", "node": "车宿点", "park": "公园类市政停车场", "free": "待核实", "fee": "0 元", "walk": "0 min", "risk": "夜间可能被劝离", "conf": "需核实" },
|
||||
{ "_key": "hike2", "node": "第二段徒步", "park": "林带入口路侧空地", "free": "待核实", "fee": "0-10 元", "walk": "0 min", "risk": "可能禁停", "conf": "需核实" }
|
||||
],
|
||||
"note": "免费与否一律不得默认写成免费,需现场看收费公示牌。"
|
||||
}
|
||||
],
|
||||
|
||||
"cost": {
|
||||
"currency": "CNY",
|
||||
"items": [
|
||||
{ "label": "过路费(往返高速版)", "amount": 170, "unit": "元", "note": "走非高速版则为 0" },
|
||||
{ "label": "油费(往返约 340 km)", "amount": 205, "unit": "元", "note": "按 8L/100km、7.5 元/L 估" },
|
||||
{ "label": "D1 午饭", "amount": 100, "unit": "元", "note": "单人" },
|
||||
{ "label": "D2 午饭", "amount": 90, "unit": "元", "note": "单人" },
|
||||
{ "label": "温泉 / 洗浴", "amount": 180, "unit": "元", "note": "温泉类估值偏高" },
|
||||
{ "label": "超市补给(浴场旁)", "amount": 60, "unit": "元", "note": "含次日早餐" },
|
||||
{ "label": "D2 早饭", "amount": 15, "unit": "元", "note": "若车内吃可省" },
|
||||
{ "label": "停车费预埋", "amount": 30, "unit": "元", "note": "若全部免费可省" },
|
||||
{ "label": "杂项(水、零食、湿巾)", "amount": 50, "unit": "元", "note": "浮动项" }
|
||||
],
|
||||
"total": 900,
|
||||
"note": "全部为估算口径,供预算参考;走非高速版可省约 170 元过路费但多约 1 小时。起点为宝山富锦路地铁站,换出发点需重算。"
|
||||
},
|
||||
|
||||
"checklist": [
|
||||
{ "anchor_id": "district_parking", "field": "free_parking", "text": "古镇停车是否免费、周末是否交通管制", "confidence": "low", "verify": "出发前致电古镇管理方或看地图实景" },
|
||||
{ "anchor_id": "bathhouse", "field": "bathhouse_grade", "text": "温泉 / 洗浴中心当日是否营业、是否需预约、是否对非住客开放", "confidence": "low", "verify": "出发前电话确认" },
|
||||
{ "anchor_id": "bathhouse", "field": "hygiene", "text": "温泉卫生情况与规模", "confidence": "low", "verify": "看最新本地评价中的卫生关键词" },
|
||||
{ "anchor_id": "district_arrive", "field": "open_status", "text": "古镇是否局部施工或围挡、夜间照明情况", "confidence": "medium", "verify": "查当地文旅公告" },
|
||||
{ "anchor_id": "district_walk", "field": "rest_point", "text": "动线中段可坐下的茶馆 / 咖啡位是否营业、是否有空位", "confidence": "low", "verify": "现场找有座位的店即可,不必指定具体店" },
|
||||
{ "anchor_id": "car_sleep", "field": "car_sleep_legality", "text": "公园停车场是否允许过夜停车", "confidence": "low", "verify": "以现场标识与当地执法口径为准;被劝离直接切服务区备选" },
|
||||
{ "anchor_id": "morning_walk", "field": "sunrise_and_facility", "text": "当日日出时间、公园路灯后半夜是否关灯、公厕夜间是否上锁", "confidence": "low", "verify": "出行前查天气 App 的日出时间;到场确认照明与公厕" },
|
||||
{ "anchor_id": "hike_1", "field": "access", "text": "海堤 / 滩涂段是否允许步行、是否有施工封闭", "confidence": "low", "verify": "现场确认" },
|
||||
{ "anchor_id": "hike_2", "field": "access", "text": "林带是否开放、出入口位置", "confidence": "low", "verify": "现场确认" },
|
||||
{ "anchor_id": "grocery", "field": "store_info", "text": "浴场旁超市的位置、约 20 点是否还在营业、能否与浴场共用停车点", "confidence": "low", "verify": "地图或电话核实;同商圈若无超市,按规则就近找并标注" },
|
||||
{ "anchor_id": "breakfast_wash", "field": "opening", "text": "早点铺 5-6 点是否开门;公园公厕夜间是否上锁", "confidence": "low", "verify": "现场或电话确认" },
|
||||
{ "anchor_id": "photo_spots", "field": "open_status", "text": "打卡点开放时间与门票", "confidence": "low", "verify": "查当地文旅公告" },
|
||||
{ "anchor_id": "lunch_d1", "field": "locals_favorite", "text": "午饭 3 家候选的店名、人均与真实口碑", "confidence": "low", "verify": "到店前看最新本地评价" },
|
||||
{ "anchor_id": "lunch_d2", "field": "locals_favorite", "text": "第二天午饭店名与停车条件", "confidence": "low", "verify": "现场或电话确认" },
|
||||
{ "anchor_id": "drive_out", "field": "route_data", "text": "去程里程 / 时长 / 过路费为估算值", "confidence": "medium", "verify": "出发前用导航核对" },
|
||||
{ "anchor_id": "drive_back", "field": "route_data", "text": "返程里程 / 时长 / 过路费为估算值", "confidence": "medium", "verify": "出发前用导航核对" }
|
||||
],
|
||||
|
||||
"notes": [
|
||||
{
|
||||
"level": "danger",
|
||||
"title": "车宿合规风险:中",
|
||||
"text": "如东县城公园类停车场能否过夜停车并无明确说法,存在夜间被劝离的可能。若被劝离,请直接切换到备选点:高速服务区(最稳)或 24 小时加油站。"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"title": "两段徒步均为平路,爬升约 0",
|
||||
"text": "如东是平原沿海地区,无山可爬,已触发行程规则中的地形降级。1.5 小时的口径按步速折算里程执行,运动量靠时长与配速来,不靠爬升。若你需要‘爬坡出汗’的强度,这个目的地不合适,建议换丘陵/山地目的地。"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"title": "06:40 出发是底线",
|
||||
"text": "若 06:40 之后出发,17:00 抵达温泉与 22:00 抵达车宿点都将被压缩。车程偏长时请提前到 06:00-06:30 出发。"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"title": "牛奶与熟食不要冷藏型",
|
||||
"text": "车内过夜没有冷藏条件,牛奶优先常温奶或小包装,不要买需要冷藏的熟食与鲜奶大瓶装。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "地图与路线为示意图",
|
||||
"text": "所有点位顺序连线仅用于理解动线关系,不是真实道路或徒步轨迹;坐标多为概略值。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "本样例不编造店名与价格",
|
||||
"text": "为如实反映信息可信度,餐饮、超市、温泉等未给出编造的店名,只给业态与位置描述,并全部列入『待核实清单』。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "出发点可改,换了就要重算",
|
||||
"text": "本行程以「上海宝山富锦路地铁站」为起点。换出发点后,去返程的里程、时长、过路费与油费都需重算,不要沿用本页数字。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "补给已并入浴场,不再单独跑大卖场",
|
||||
"text": "按你的口径,本 Skill 取消了「找大卖场」这条行程:买牛奶与次日早餐改在浴场同商圈的超市顺手完成,与浴场共用停车点、不挪车。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/* ============================================================
|
||||
* tc_export.css —— 游山玩水 · 导出文档样式(只服务 tc_export.js)
|
||||
*
|
||||
* 定位:这是【导出的单文件 HTML】用的样式,不在页面上加载。
|
||||
* tc_export.js 导出时会把本文件全文读出来,内联进 <style>,
|
||||
* 这样导出的 .html 双击即可离线查看(不依赖网络、不依赖本工具)。
|
||||
*
|
||||
* 与 tc_report.css 的关系:互不相干。
|
||||
* tc_report.css 服务页面内的 tc_render.js(tcr- 前缀);
|
||||
* 本文件服务导出文档(page/hero/section/card/table 等文档级类名)。
|
||||
* 两份样式不共享类名、不互相引用,删掉任一份都不影响另一份。
|
||||
* ============================================================ */
|
||||
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#f4f5f7;
|
||||
--card:#ffffff;
|
||||
--line:#e6e8ec;
|
||||
--line-soft:#f0f1f4;
|
||||
--text:#1f2328;
|
||||
--sub:#6b7280;
|
||||
--accent:#2f6fed;
|
||||
--radius:14px;
|
||||
}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
margin:0;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei","PingFang SC","Hiragino Sans GB",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
text-rendering:optimizeLegibility;
|
||||
}
|
||||
.page{max-width:1100px;margin:0 auto;padding:28px 18px 80px}
|
||||
|
||||
/* ---------- HERO / 行程概览 ---------- */
|
||||
.hero{
|
||||
position:relative;overflow:hidden;border-radius:18px;padding:30px 30px 26px;
|
||||
color:#fff;
|
||||
background:radial-gradient(1100px 460px at 10% -12%,#3f7bf0 0%,#2456c9 44%,#1b3d94 100%);
|
||||
box-shadow:0 16px 40px rgba(27,61,148,.24);
|
||||
margin-bottom:18px;
|
||||
}
|
||||
.hero::after{
|
||||
content:"";position:absolute;right:-90px;top:-110px;width:360px;height:360px;border-radius:50%;
|
||||
background:radial-gradient(circle,rgba(255,255,255,.18),transparent 62%);
|
||||
pointer-events:none;
|
||||
}
|
||||
.hero-inner{position:relative;z-index:1}
|
||||
.kicker{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;letter-spacing:.08em;color:rgba(255,255,255,.76);margin-bottom:12px}
|
||||
.kicker .chip{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:2px 10px;letter-spacing:.02em;color:#fff}
|
||||
.hero h1{margin:0 0 9px;font-size:26px;line-height:1.32;letter-spacing:.01em;font-weight:700}
|
||||
.lede{margin:0 0 16px;max-width:780px;font-size:14px;line-height:1.75;color:rgba(255,255,255,.87)}
|
||||
.hero-tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
|
||||
.hero-tags span{font-size:12px;padding:3px 11px;border-radius:999px;background:rgba(255,255,255,.13);border:1px solid rgba(255,255,255,.24)}
|
||||
.hero-meta{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
|
||||
.hero-meta .mb{font-size:11.5px;padding:3px 10px;border-radius:999px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:rgba(255,255,255,.94)}
|
||||
.hero-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
|
||||
.hstat{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.18);border-radius:12px;padding:12px 14px}
|
||||
.hstat .l{font-size:11.5px;color:rgba(255,255,255,.72)}
|
||||
.hstat .v{font-size:17px;font-weight:700;margin:4px 0 3px;font-variant-numeric:tabular-nums}
|
||||
.hstat .v.sm{font-size:14px;line-height:1.5}
|
||||
.hstat .h{font-size:11px;line-height:1.55;color:rgba(255,255,255,.66)}
|
||||
|
||||
/* ---------- 目录 ---------- */
|
||||
.index{
|
||||
position:sticky;top:10px;z-index:30;
|
||||
display:flex;flex-wrap:wrap;gap:6px;padding:9px;margin-bottom:18px;
|
||||
background:rgba(255,255,255,.9);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(10px);
|
||||
backdrop-filter:saturate(180%) blur(10px);
|
||||
border:1px solid var(--line);border-radius:14px;
|
||||
box-shadow:0 6px 18px rgba(15,23,42,.06);
|
||||
}
|
||||
.index a{font-size:12.5px;text-decoration:none;color:#2f6fed;background:#f0f4ff;padding:4px 10px;border-radius:8px;transition:background .15s,color .15s}
|
||||
.index a:hover{background:#e2eaff}
|
||||
|
||||
/* ---------- 区块 ---------- */
|
||||
.section{
|
||||
background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:20px 20px 22px;margin-bottom:16px;box-shadow:0 1px 2px rgba(16,24,40,.03);
|
||||
}
|
||||
.sec-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}
|
||||
.sec-title{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:700;margin:0;letter-spacing:.01em}
|
||||
.sec-title::before{content:"";width:4px;height:17px;border-radius:3px;background:linear-gradient(180deg,#2f6fed,#5b8df6)}
|
||||
.sec-top{font-size:12px;color:var(--sub);text-decoration:none;white-space:nowrap;transition:color .15s}
|
||||
.sec-top:hover{color:var(--accent)}
|
||||
.empty{color:var(--sub);font-size:13px;padding:8px 0}
|
||||
|
||||
/* ---------- 提示块 ---------- */
|
||||
.note{border-radius:10px;padding:12px 14px;margin-bottom:10px;border:1px solid}
|
||||
.note:last-child{margin-bottom:0}
|
||||
.note-title{font-weight:600;font-size:13.5px;margin-bottom:3px}
|
||||
.note-text{font-size:13px;line-height:1.72;color:#374151}
|
||||
.note-warn{background:#fffaf0;border-color:#f0dcae}
|
||||
.note-info{background:#f2f7ff;border-color:#cfe0ff}
|
||||
.note-danger{background:#fff5f4;border-color:#f5c9c3}
|
||||
|
||||
/* ---------- 表格 ---------- */
|
||||
.tablebox{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||||
.table-title{font-size:13px;font-weight:600;margin-bottom:9px;color:#374151}
|
||||
table.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff}
|
||||
table.tbl th,table.tbl td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
|
||||
table.tbl thead th{background:#f7f8fa;font-weight:600;white-space:nowrap;color:#374151}
|
||||
table.tbl tbody tr:hover td{background:#fafbfc}
|
||||
table.tbl tbody tr.row-hl td{background:#eef4ff}
|
||||
table.tbl tbody tr.row-hard td{background:#fff7ed}
|
||||
table.tbl tfoot td{font-weight:700;background:#f2f6ff;font-size:13.5px}
|
||||
.col-time{white-space:nowrap;color:#374151;width:104px;font-variant-numeric:tabular-nums}
|
||||
.col-kind{width:150px}
|
||||
.row-title{font-weight:500}
|
||||
.al-right{text-align:right !important;font-variant-numeric:tabular-nums}
|
||||
.cell-note{color:var(--sub);font-size:12px}
|
||||
.tbl-note{font-size:12px;color:var(--sub);margin-top:9px}
|
||||
|
||||
/* ---------- 类型标签 ---------- */
|
||||
.kind{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#f3f4f6;color:#6b7280;white-space:nowrap}
|
||||
.kind-act{background:#eef2ff;color:#4f46e5;margin-left:4px}
|
||||
.kind-hard{background:#fdece8;color:#b42318;font-weight:600}
|
||||
|
||||
/* ---------- 置信度徽标 ---------- */
|
||||
.badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#eef2f7;color:#4b5563;white-space:nowrap;vertical-align:1px}
|
||||
.badge-high{background:#e8f6ee;color:#1a7a45}
|
||||
.badge-medium{background:#fff5e0;color:#8a6100}
|
||||
.badge-low{background:#fdece8;color:#b42318}
|
||||
.badge-info{background:#e8f0fe;color:#1d4ed8}
|
||||
.badge-good{background:#e8f6ee;color:#1a7a45}
|
||||
.badge-warn{background:#fff5e0;color:#8a6100}
|
||||
.badge-danger{background:#fdece8;color:#b42318}
|
||||
|
||||
/* ---------- 时序 ---------- */
|
||||
.day{margin-bottom:18px}
|
||||
.day:last-child{margin-bottom:0}
|
||||
.day-head{display:flex;align-items:baseline;gap:8px;margin-bottom:7px}
|
||||
.day-label{font-weight:700;font-size:14px}
|
||||
.day-date{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||
|
||||
/* ---------- 卡片 ---------- */
|
||||
.group{margin-bottom:18px}
|
||||
.group:last-child{margin-bottom:0}
|
||||
.group-title{display:flex;align-items:center;gap:9px;font-size:14px;font-weight:700;color:#374151;margin:0 0 11px}
|
||||
.group-title::after{content:"";flex:1;height:1px;background:var(--line-soft)}
|
||||
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px}
|
||||
.card{border:1px solid var(--line);border-radius:12px;padding:14px 15px 15px;background:#fff;transition:box-shadow .18s,border-color .18s}
|
||||
.card:hover{box-shadow:0 8px 24px rgba(16,24,40,.08);border-color:#d7dce5}
|
||||
.card-title{font-size:14.5px;font-weight:700;margin:0;line-height:1.45}
|
||||
.card-sub{font-size:12px;color:var(--sub);margin-top:2px}
|
||||
.card-badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
|
||||
.fields{margin-top:11px}
|
||||
.field{display:grid;grid-template-columns:98px 1fr;gap:8px;padding:7px 0;border-top:1px dashed #eceef2}
|
||||
.field:first-child{border-top:0;padding-top:0}
|
||||
.field-label{color:var(--sub);font-size:12px;line-height:1.6}
|
||||
.field-value{font-size:13px;line-height:1.66}
|
||||
.field-hint{font-size:11px;color:var(--sub);margin-top:3px;line-height:1.55}
|
||||
.fact-text{margin-right:4px}
|
||||
.verify{display:block;font-size:11px;color:#8a6100;margin-top:3px;line-height:1.55}
|
||||
.bullets{margin:11px 0 0;padding-left:19px;font-size:13px}
|
||||
.bullets li{margin-bottom:4px}
|
||||
.bullets li::marker{color:#9aa3af}
|
||||
.card figure{margin:9px 0 0}
|
||||
.card figure img{width:100%;height:auto;display:block;border-radius:9px;border:1px solid var(--line)}
|
||||
.figcap{font-size:11px;color:var(--sub);margin-top:6px;line-height:1.55}
|
||||
.img-missing{font-size:12px;color:var(--sub);background:#f7f8fa;border:1px dashed var(--line);border-radius:9px;padding:14px;text-align:center}
|
||||
|
||||
/* ---------- 点位与路线 ---------- */
|
||||
.map-wrap{background:#fafbfc;border:1px solid var(--line);border-radius:12px;padding:16px 14px}
|
||||
.map-svg{width:100%;height:auto;display:block}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;font-size:12px;color:#374151}
|
||||
.legend-item{display:inline-flex;align-items:center;gap:6px}
|
||||
.swatch{width:20px;height:4px;border-radius:2px;display:inline-block}
|
||||
.map-note{font-size:12px;color:var(--sub);margin-top:10px}
|
||||
.points{display:grid;grid-template-columns:repeat(auto-fill,minmax(228px,1fr));gap:7px;margin-top:15px;border-top:1px dashed #e9ebef;padding-top:15px}
|
||||
.point{display:flex;align-items:center;gap:8px;font-size:12.5px;color:#374151}
|
||||
.point .num{flex:none;width:20px;height:20px;border-radius:50%;color:#fff;font-size:10.5px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}
|
||||
|
||||
/* ---------- 待核实清单 ---------- */
|
||||
.checklist{margin:0;padding:0;list-style:none;counter-reset:c}
|
||||
.check-item{counter-increment:c;position:relative;padding:10px 0 10px 36px;border-top:1px dashed #eceef2;font-size:13px;line-height:1.65}
|
||||
.check-item:first-child{border-top:0;padding-top:4px}
|
||||
.check-item::before{
|
||||
content:counter(c);position:absolute;left:0;top:11px;width:22px;height:22px;border-radius:50%;
|
||||
background:#eef2ff;color:#2f6fed;font-size:11px;font-weight:700;
|
||||
display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.check-item:first-child::before{top:5px}
|
||||
.check-text{margin-right:8px}
|
||||
|
||||
/* ---------- 页脚 ---------- */
|
||||
.doc-foot{text-align:center;font-size:12px;color:var(--sub);padding:22px 0 0;line-height:1.8}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
@media (max-width:640px){
|
||||
.page{padding:16px 12px 60px}
|
||||
.hero{padding:22px 18px 20px;border-radius:14px}
|
||||
.hero h1{font-size:21px}
|
||||
.lede{font-size:13.5px}
|
||||
.card-grid{grid-template-columns:1fr}
|
||||
.field{grid-template-columns:84px 1fr}
|
||||
.sec-title{font-size:15px}
|
||||
.index{top:0;border-radius:0;margin:0 -12px 16px;border-left:0;border-right:0}
|
||||
}
|
||||
|
||||
/* ---------- 打印 ---------- */
|
||||
@media print{
|
||||
body{background:#fff}
|
||||
.page{max-width:none;padding:0}
|
||||
.index{display:none}
|
||||
.section,.card,.hero,.note{box-shadow:none}
|
||||
.card,.section,.note,.day{break-inside:avoid}
|
||||
.hero{background:#1b3d94 !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
table.tbl{font-size:11.5px}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
/* ============================================================
|
||||
* tc_export.js —— 游山玩水 · 单文件 HTML 导出器
|
||||
*
|
||||
* 干什么:把后端已经生成好的 payload + 技能包的 report_spec,在【浏览器本地】
|
||||
* 拼成一份完整的、自包含的 .html 文档并下载。纯前端、零 AI 调用,
|
||||
* 导出一次不消耗任何 token(就是「我不想为了再看一遍再烧 4 分钟」)。
|
||||
*
|
||||
* 怎么做到「单文件、离线可看」:
|
||||
* 1. 样式内联:把同目录 tc_export.css 全文读出来塞进 <style>,不引用外部 css。
|
||||
* 2. 图片内嵌:卡片的配图就地 fetch 成 base64 data URI 塞进 <img>,不引用外部图。
|
||||
* 3. 地图内联:不用 Leaflet(它要联网拉瓦片),改用内置 SVG 画点位顺序示意图。
|
||||
* → 导出的文件双击即可打开,断网也能看。
|
||||
*
|
||||
* 设计原则(与 tc_render.js 一致):
|
||||
* - 完全独立:不引用工具箱任何其他模块,删除本文件只影响「导出」按钮。
|
||||
* - 只认 report_spec 的 8 种 block 类型 + 数据路径,不认识任何业务字段。
|
||||
* - 与 tc_render.js 各写一套(一个是建 DOM 给页面看,一个是拼字符串给文件看),
|
||||
* 互不依赖:tc_render.js 挂了,导出照样能用。
|
||||
*
|
||||
* 依赖:无。tc_export.css 由本文件在导出时读取并内联。
|
||||
* ============================================================ */
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
/* ---------------- 常量 ---------------- */
|
||||
|
||||
var DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
|
||||
var KIND_LABEL = { hard: '硬时间点', soft: '常规' }
|
||||
var ACTIVITY_LABEL = { move: '移动', eat: '吃饭', hike: '徒步', shop: '采买', sleep: '过夜', walk: '步行' }
|
||||
var DEFAULT_MAP_NOTE = '点位顺序示意,非真实道路/轨迹'
|
||||
var MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
var PIN_COLORS = {
|
||||
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98', parking: '#c2701a',
|
||||
hike: '#2e9e5b', district: '#b8860b', food: '#e0673a', bathhouse: '#1f8a70',
|
||||
shop: '#5a67d8', sleep: '#2d3748', walk: '#8a5cf6', breakfast: '#c98a00', spot: '#0ea5e9'
|
||||
}
|
||||
|
||||
// 本脚本所在目录:用来定位同目录的 tc_export.css
|
||||
// 必须在「脚本加载时」取(异步回调里 document.currentScript 已经是 null)
|
||||
var SELF_DIR = (function () {
|
||||
try {
|
||||
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
|
||||
return String(document.currentScript.src).replace(/[^/]*$/, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
return ''
|
||||
})()
|
||||
|
||||
/* ---------------- 基础工具 ---------------- */
|
||||
|
||||
function esc(s) {
|
||||
return String(s === undefined || s === null ? '' : s)
|
||||
.replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
})
|
||||
}
|
||||
|
||||
function isArr(v) { return Array.isArray(v) }
|
||||
function isPlainObject(v) { return !!v && typeof v === 'object' && !isArr(v) }
|
||||
|
||||
function resolvePath(root, path) {
|
||||
if (!path) return root
|
||||
var p = String(path).replace(/^\$\.?/, '')
|
||||
if (!p) return root
|
||||
var cur = root
|
||||
var keys = p.split('.')
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (cur === null || cur === undefined) return undefined
|
||||
cur = cur[keys[i]]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
function pickById(arr, id) {
|
||||
if (!isArr(arr)) return null
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === id) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function badgeText(spec, level) {
|
||||
var map = (spec && spec.confidence_badge) || DEFAULT_BADGE
|
||||
return map[level] || DEFAULT_BADGE[level] || level
|
||||
}
|
||||
|
||||
function pinColor(kind) { return PIN_COLORS[kind] || '#4b5563' }
|
||||
|
||||
function dayName(d) {
|
||||
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : '其他'))
|
||||
}
|
||||
|
||||
function dayNameForMap(d) {
|
||||
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : ''))
|
||||
}
|
||||
|
||||
function toneClass(tone) {
|
||||
var map = { info: 'info', warn: 'warn', danger: 'danger', good: 'good', high: 'high', medium: 'medium', low: 'low' }
|
||||
return map[tone] || ''
|
||||
}
|
||||
|
||||
function absUrl(u, baseUrl) {
|
||||
try { return new URL(String(u), baseUrl || undefined).href } catch (e) { return String(u) }
|
||||
}
|
||||
|
||||
function nowBeijing() {
|
||||
try {
|
||||
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
|
||||
} catch (e) { return new Date().toLocaleString() }
|
||||
}
|
||||
|
||||
function utf8Length(s) {
|
||||
try { return new Blob([s]).size } catch (e) { return String(s).length }
|
||||
}
|
||||
|
||||
/* ---------------- 事实 / 置信度 ---------------- */
|
||||
|
||||
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
|
||||
function factHtml(value, spec) {
|
||||
if (isPlainObject(value)) {
|
||||
var out = '<span class="fact-text">' + esc(value.text === undefined ? '' : value.text) + '</span>'
|
||||
if (value.confidence) {
|
||||
out += '<span class="badge badge-' + esc(value.confidence) + '">' + esc(badgeText(spec, value.confidence)) + '</span>'
|
||||
}
|
||||
if (value.verify) out += '<span class="verify">核实:' + esc(value.verify) + '</span>'
|
||||
return out
|
||||
}
|
||||
return '<span class="fact-text">' + esc(value === null || value === undefined ? '—' : value) + '</span>'
|
||||
}
|
||||
|
||||
/* ---------------- 各 block 渲染(返回 HTML 字符串) ---------------- */
|
||||
|
||||
function metaBadges(meta) {
|
||||
var out = []
|
||||
if (!meta) return out
|
||||
var keys = Object.keys(meta)
|
||||
if (!keys.length) return out
|
||||
if (meta.model) out.push('模型 ' + meta.model)
|
||||
if (meta.elapsed_ms) out.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
|
||||
if (Object.prototype.hasOwnProperty.call(meta, 'is_new_destination')) {
|
||||
out.push(meta.is_new_destination ? '首个新目的地' : '技能包内已有覆盖')
|
||||
}
|
||||
var warns = isArr(meta.warnings) ? meta.warnings : null
|
||||
if (warns) out.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
|
||||
return out
|
||||
}
|
||||
|
||||
function overviewHtml(data, spec, ctx) {
|
||||
if (!isPlainObject(data)) return '<div class="hero-inner"><div class="empty">无概览数据</div></div>'
|
||||
var html = '<div class="hero-inner">'
|
||||
html += '<div class="kicker">' + esc(ctx.kicker) + '<span class="chip">' + esc(ctx.chip) + '</span></div>'
|
||||
if (data.title) html += '<h1>' + esc(data.title) + '</h1>'
|
||||
if (data.one_liner) html += '<p class="lede">' + esc(data.one_liner) + '</p>'
|
||||
if (isArr(data.tags) && data.tags.length) {
|
||||
html += '<div class="hero-tags">' + data.tags.map(function (t) {
|
||||
return '<span>' + esc(t) + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
var mbs = metaBadges(ctx.meta)
|
||||
if (mbs.length) {
|
||||
html += '<div class="hero-meta">' + mbs.map(function (t) {
|
||||
return '<span class="mb">' + esc(t) + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
if (isArr(data.stats) && data.stats.length) {
|
||||
html += '<div class="hero-stats">' + data.stats.map(function (s) {
|
||||
var v = s.value === undefined || s.value === null ? '' : String(s.value)
|
||||
return '<div class="hstat">' +
|
||||
'<div class="l">' + esc(s.label || '') + '</div>' +
|
||||
'<div class="v' + (v.length > 9 ? ' sm' : '') + '">' + esc(v) + '</div>' +
|
||||
(s.hint ? '<div class="h">' + esc(s.hint) + '</div>' : '') +
|
||||
'</div>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function notesHtml(data, section) {
|
||||
var list = isArr(data) ? data.slice() : []
|
||||
if (section && isArr(section.filter_levels) && section.filter_levels.length) {
|
||||
list = list.filter(function (n) { return section.filter_levels.indexOf(n.level) >= 0 })
|
||||
}
|
||||
if (!list.length) return '<div class="empty">无提示项</div>'
|
||||
return list.map(function (n) {
|
||||
return '<div class="note note-' + esc(n.level || 'info') + '">' +
|
||||
(n.title ? '<div class="note-title">' + esc(n.title) + '</div>' : '') +
|
||||
(n.text ? '<div class="note-text">' + esc(n.text) + '</div>' : '') +
|
||||
'</div>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function timelineHtml(data) {
|
||||
if (!isPlainObject(data) || !isArr(data.days)) return '<div class="empty">无时序数据</div>'
|
||||
return data.days.map(function (day) {
|
||||
var html = '<div class="day">'
|
||||
html += '<div class="day-head"><span class="day-label">' + esc(day.label || '') + '</span>' +
|
||||
(day.date ? '<span class="day-date">' + esc(day.date) + '</span>' : '') + '</div>'
|
||||
html += '<div class="tablebox"><table class="tbl"><thead><tr>' +
|
||||
'<th class="col-time">时间</th><th>安排</th><th class="col-kind">类型</th>' +
|
||||
'</tr></thead><tbody>'
|
||||
;(isArr(day.rows) ? day.rows : []).forEach(function (r) {
|
||||
var hard = r.kind === 'hard'
|
||||
html += '<tr' + (hard ? ' class="row-hard"' : '') + '>'
|
||||
html += '<td class="col-time">' + esc(r.time || '') + '</td>'
|
||||
html += '<td><div class="row-title">' + esc(r.title || '') + '</div>' +
|
||||
(r.note ? '<div class="cell-note">' + esc(r.note) + '</div>' : '') + '</td>'
|
||||
html += '<td class="col-kind">' +
|
||||
'<span class="kind' + (hard ? ' kind-hard' : '') + '">' +
|
||||
esc(KIND_LABEL[r.kind] || r.kind || '') + '</span>' +
|
||||
(r.activity ? '<span class="kind kind-act">' + esc(ACTIVITY_LABEL[r.activity] || r.activity) + '</span>' : '') +
|
||||
'</td>'
|
||||
html += '</tr>'
|
||||
})
|
||||
html += '</tbody></table></div></div>'
|
||||
return html
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function cardHtml(card, spec, ctx) {
|
||||
var id = card.id || card.anchor_id || ''
|
||||
var html = '<article class="card"' + (id ? ' id="card_' + esc(id) + '"' : '') + '>'
|
||||
html += '<h4 class="card-title">' + esc(card.title || '') + '</h4>'
|
||||
if (card.subtitle) html += '<div class="card-sub">' + esc(card.subtitle) + '</div>'
|
||||
|
||||
if (isArr(card.badges) && card.badges.length) {
|
||||
html += '<div class="card-badges">' + card.badges.map(function (b) {
|
||||
var tone = toneClass(b.tone)
|
||||
return '<span class="badge' + (tone ? ' badge-' + tone : '') + '">' + esc(b.text || '') + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
|
||||
if (isPlainObject(card.image) && card.image.url) {
|
||||
var raw = String(card.image.url)
|
||||
var embedded = ctx.images && ctx.images[raw]
|
||||
var src = embedded || absUrl(raw, ctx.baseUrl)
|
||||
var cap = card.image.caption || ''
|
||||
var dis = card.image.disclaimer || (spec && spec.image_disclaimer_default) || ''
|
||||
html += '<figure>'
|
||||
if (embedded) {
|
||||
html += '<img src="' + esc(src) + '" alt="' + esc(cap) + '" />'
|
||||
} else {
|
||||
html += '<div class="img-missing">配图未内嵌:' + esc(raw) + '</div>'
|
||||
}
|
||||
html += '<figcaption class="figcap">' + (cap ? esc(cap) + ' · ' : '') + esc(dis) + '</figcaption>'
|
||||
html += '</figure>'
|
||||
}
|
||||
|
||||
if (isArr(card.fields) && card.fields.length) {
|
||||
html += '<div class="fields">' + card.fields.map(function (f) {
|
||||
return '<div class="field">' +
|
||||
'<div class="field-label">' + esc(f.label || '') + '</div>' +
|
||||
'<div class="field-value">' + factHtml(f.value, spec) +
|
||||
(f.hint ? '<div class="field-hint">' + esc(f.hint) + '</div>' : '') +
|
||||
'</div></div>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
|
||||
if (isArr(card.bullets) && card.bullets.length) {
|
||||
html += '<ul class="bullets">' + card.bullets.map(function (b) {
|
||||
return '<li>' + esc(b) + '</li>'
|
||||
}).join('') + '</ul>'
|
||||
}
|
||||
|
||||
html += '</article>'
|
||||
return html
|
||||
}
|
||||
|
||||
function cardsHtml(data, spec, section, ctx) {
|
||||
var list = isArr(data) ? data : []
|
||||
if (!list.length) return '<div class="empty">无节点数据</div>'
|
||||
var dayOf = function (c) { return c.day === undefined || c.day === null ? 0 : Number(c.day) }
|
||||
|
||||
if (!(section && section.group_by === 'day')) {
|
||||
return '<div class="card-grid">' + list.map(function (c) { return cardHtml(c, spec, ctx) }).join('') + '</div>'
|
||||
}
|
||||
|
||||
var days = []
|
||||
list.forEach(function (c) {
|
||||
var d = dayOf(c)
|
||||
if (days.indexOf(d) < 0) days.push(d)
|
||||
})
|
||||
days.sort(function (a, b) { return a - b })
|
||||
return days.map(function (d) {
|
||||
return '<div class="group"><h3 class="group-title">' + esc(dayName(d)) + '</h3>' +
|
||||
'<div class="card-grid">' +
|
||||
list.filter(function (c) { return dayOf(c) === d })
|
||||
.map(function (c) { return cardHtml(c, spec, ctx) }).join('') +
|
||||
'</div></div>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function tableHtml(data, section) {
|
||||
var table = null
|
||||
if (isArr(data)) {
|
||||
table = pickById(data, section && section.pick_id)
|
||||
if (!table && section && section.pick_id) return '<div class="empty">缺少表格:' + esc(section.pick_id) + '</div>'
|
||||
if (!table) table = data[0]
|
||||
} else {
|
||||
table = data
|
||||
}
|
||||
if (!isPlainObject(table)) return '<div class="empty">无表格数据</div>'
|
||||
|
||||
var columns = isArr(table.columns) ? table.columns : []
|
||||
var rows = isArr(table.rows) ? table.rows : []
|
||||
var hl = isArr(table.highlight_row_keys) ? table.highlight_row_keys : []
|
||||
|
||||
var html = '<div class="tablebox">'
|
||||
if (table.title) html += '<div class="table-title">' + esc(table.title) + '</div>'
|
||||
html += '<table class="tbl"><thead><tr>'
|
||||
columns.forEach(function (c) {
|
||||
html += '<th' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') +
|
||||
(c.width ? ' style="width:' + esc(c.width) + '"' : '') + '>' + esc(c.label || c.key) + '</th>'
|
||||
})
|
||||
html += '</tr></thead><tbody>'
|
||||
rows.forEach(function (r) {
|
||||
html += '<tr' + (hl.indexOf(r._key) >= 0 ? ' class="row-hl"' : '') + '>'
|
||||
columns.forEach(function (c) {
|
||||
var v = r[c.key]
|
||||
html += '<td' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') + '>' +
|
||||
esc(v === null || v === undefined ? '—' : v) + '</td>'
|
||||
})
|
||||
html += '</tr>'
|
||||
})
|
||||
html += '</tbody></table></div>'
|
||||
if (table.note) html += '<div class="tbl-note">' + esc(table.note) + '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function costHtml(data) {
|
||||
if (!isPlainObject(data)) return '<div class="empty">无费用数据</div>'
|
||||
var items = isArr(data.items) ? data.items : []
|
||||
var html = '<div class="tablebox"><table class="tbl"><thead><tr>' +
|
||||
'<th>项目</th><th class="al-right">金额(' + esc(data.currency || 'CNY') + ')</th><th>口径</th>' +
|
||||
'</tr></thead><tbody>'
|
||||
items.forEach(function (it) {
|
||||
html += '<tr><td>' + esc(it.label || '') + '</td>' +
|
||||
'<td class="al-right">' + esc(it.amount === undefined ? '—' : it.amount) + '</td>' +
|
||||
'<td class="cell-note">' + esc(it.note || it.unit || '') + '</td></tr>'
|
||||
})
|
||||
html += '</tbody>'
|
||||
if (data.total !== undefined && data.total !== null) {
|
||||
html += '<tfoot><tr><td>合计</td><td class="al-right">' + esc(data.total) + '</td><td></td></tr></tfoot>'
|
||||
}
|
||||
html += '</table></div>'
|
||||
if (data.note) html += '<div class="tbl-note">' + esc(data.note) + '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function checklistHtml(data, spec) {
|
||||
var list = isArr(data) ? data : []
|
||||
if (!list.length) return '<div class="empty">无待核实项</div>'
|
||||
return '<ol class="checklist">' + list.map(function (c) {
|
||||
return '<li class="check-item">' +
|
||||
'<span class="check-text">' + esc(c.text || '') + '</span>' +
|
||||
(c.confidence ? '<span class="badge badge-' + esc(c.confidence) + '">' + esc(badgeText(spec, c.confidence)) + '</span>' : '') +
|
||||
(c.verify ? '<span class="verify">核实:' + esc(c.verify) + '</span>' : '') +
|
||||
'</li>'
|
||||
}).join('') + '</ol>'
|
||||
}
|
||||
|
||||
/* ---------------- 地图 block(内联 SVG,不联网、不用 Leaflet) ---------------- */
|
||||
|
||||
function mapSvgHtml(markers, routes) {
|
||||
var W = 1000
|
||||
var PAD = 70
|
||||
var ROW_H = 176
|
||||
|
||||
var dayOf = function (m) { return m.day === undefined || m.day === null ? 0 : Number(m.day) }
|
||||
var days = []
|
||||
markers.forEach(function (m) {
|
||||
var d = dayOf(m)
|
||||
if (days.indexOf(d) < 0) days.push(d)
|
||||
})
|
||||
days.sort(function (a, b) { return a - b })
|
||||
var hasRowLabel = days.length > 1
|
||||
var ROW_Y0 = hasRowLabel ? 92 : 62
|
||||
|
||||
var pos = {}
|
||||
var labels = ''
|
||||
var maxY = ROW_Y0
|
||||
days.forEach(function (d, rowIdx) {
|
||||
var rowMarks = markers.filter(function (m) { return dayOf(m) === d })
|
||||
var y = ROW_Y0 + rowIdx * ROW_H
|
||||
maxY = y
|
||||
var step = rowMarks.length > 1 ? (W - PAD * 2) / (rowMarks.length - 1) : 0
|
||||
rowMarks.forEach(function (m, i) {
|
||||
pos[m.id] = { x: Math.round((PAD + i * step) * 10) / 10, y: y }
|
||||
})
|
||||
if (hasRowLabel) {
|
||||
labels += '<text x="' + (W / 2) + '" y="' + (y - 50) + '" text-anchor="middle" font-size="12" fill="#6b7280">' +
|
||||
esc(dayNameForMap(d)) + '</text>'
|
||||
}
|
||||
})
|
||||
|
||||
var lines = ''
|
||||
routes.forEach(function (r) {
|
||||
var ids = isArr(r.marker_ids) ? r.marker_ids : []
|
||||
for (var i = 0; i < ids.length - 1; i++) {
|
||||
var a = pos[ids[i]]
|
||||
var b = pos[ids[i + 1]]
|
||||
if (!a || !b) continue
|
||||
if (a.y !== b.y) continue // 跨天不连线,避免斜穿两行
|
||||
if (a.x === b.x && a.y === b.y) continue
|
||||
lines += '<line x1="' + a.x + '" y1="' + a.y + '" x2="' + b.x + '" y2="' + b.y + '"' +
|
||||
' stroke="' + esc(r.color || '#cbd5e1') + '"' +
|
||||
' stroke-width="' + (r.kind === 'drive' ? 3 : 4) + '"' +
|
||||
(r.dashed ? ' stroke-dasharray="6 6"' : '') +
|
||||
' stroke-linecap="round"></line>'
|
||||
}
|
||||
})
|
||||
|
||||
var nodes = ''
|
||||
markers.forEach(function (m) {
|
||||
var p = pos[m.id]
|
||||
if (!p) return
|
||||
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
|
||||
nodes += '<g><title>' + esc((seq ? seq + ' ' : '') + (m.name || '')) + '</title>' +
|
||||
'<circle cx="' + p.x + '" cy="' + p.y + '" r="13" fill="' + esc(pinColor(m.kind)) +
|
||||
'" stroke="#fff" stroke-width="2.5"></circle>' +
|
||||
'<text x="' + p.x + '" y="' + p.y + '" text-anchor="middle" dominant-baseline="central"' +
|
||||
' font-size="11" font-weight="700" fill="#fff">' + esc(seq) + '</text></g>'
|
||||
})
|
||||
|
||||
var H = maxY + 62
|
||||
return '<svg class="map-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="行程点位顺序示意图">' +
|
||||
labels +
|
||||
(lines ? '<g>' + lines + '</g>' : '') +
|
||||
(nodes ? '<g>' + nodes + '</g>' : '') +
|
||||
'</svg>'
|
||||
}
|
||||
|
||||
function mapLegendHtml(routes) {
|
||||
if (!isArr(routes) || !routes.length) return ''
|
||||
return '<div class="legend">' + routes.map(function (r) {
|
||||
var color = r.color || '#4b5563'
|
||||
var style = r.dashed
|
||||
? 'background:repeating-linear-gradient(90deg,' + color + ' 0 6px,transparent 6px 12px)'
|
||||
: 'background:' + color
|
||||
return '<span class="legend-item"><span class="swatch" style="' + esc(style) + '"></span>' +
|
||||
esc(r.name || r.id || '') + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
|
||||
function mapHtml(data, spec) {
|
||||
if (!isPlainObject(data) || !isArr(data.markers) || !data.markers.length) {
|
||||
return '<div class="empty">无地图数据</div>'
|
||||
}
|
||||
var markers = data.markers.slice().sort(function (a, b) {
|
||||
var x = a.seq === undefined || a.seq === null ? 9999 : Number(a.seq)
|
||||
var y = b.seq === undefined || b.seq === null ? 9999 : Number(b.seq)
|
||||
return x - y
|
||||
})
|
||||
var html = '<div class="map-wrap">'
|
||||
html += mapSvgHtml(markers, isArr(data.routes) ? data.routes : [])
|
||||
html += mapLegendHtml(data.routes)
|
||||
html += '<div class="map-note">' +
|
||||
esc(data.disclaimer || (spec && spec.map_route_disclaimer_default) || DEFAULT_MAP_NOTE) + '</div>'
|
||||
html += '<div class="points">' + markers.map(function (m) {
|
||||
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
|
||||
return '<div class="point"><span class="num" style="background:' + esc(pinColor(m.kind)) + '">' +
|
||||
esc(seq) + '</span><span>' + esc(m.name || '') + '</span></div>'
|
||||
}).join('') + '</div>'
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
/* ---------------- 顶层组装 ---------------- */
|
||||
|
||||
var BLOCKS = {
|
||||
overview: function (data, spec, section, ctx) { return overviewHtml(data, spec, ctx) },
|
||||
notes: function (data, spec, section) { return notesHtml(data, section) },
|
||||
timeline: function (data) { return timelineHtml(data) },
|
||||
cards: function (data, spec, section, ctx) { return cardsHtml(data, spec, section, ctx) },
|
||||
map: function (data, spec) { return mapHtml(data, spec) },
|
||||
table: function (data, spec, section) { return tableHtml(data, section) },
|
||||
cost: function (data) { return costHtml(data) },
|
||||
checklist: function (data, spec) { return checklistHtml(data, spec) }
|
||||
}
|
||||
|
||||
function sectionHtml(section, payload, spec, ctx) {
|
||||
var id = section.id || ('sec_' + section.type)
|
||||
var data = section.data ? resolvePath(payload, section.data) : payload
|
||||
var fn = BLOCKS[section.type]
|
||||
var html = '<section class="section" id="' + esc(id) + '">'
|
||||
if (section.title) {
|
||||
html += '<div class="sec-head"><h2 class="sec-title">' + esc(section.title) + '</h2>' +
|
||||
'<a class="sec-top" href="#top">回到顶部</a></div>'
|
||||
}
|
||||
html += (typeof fn === 'function')
|
||||
? fn(data, spec, section, ctx)
|
||||
: '<div class="empty">未知 block 类型:' + esc(section.type) + '</div>'
|
||||
html += '</section>'
|
||||
return html
|
||||
}
|
||||
|
||||
function indexHtml(items) {
|
||||
return '<nav class="index">' + items.map(function (it) {
|
||||
return '<a href="#' + esc(it.id) + '">' + esc(it.title) + '</a>'
|
||||
}).join('') + '</nav>'
|
||||
}
|
||||
|
||||
function footHtml(ctx) {
|
||||
var meta = ctx.meta || {}
|
||||
var bits = ['游山玩水 · 思想实验室']
|
||||
if (meta.model) bits.push('模型 ' + meta.model)
|
||||
if (meta.elapsed_ms) bits.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
|
||||
if (metaBadges(meta).length) {
|
||||
var warns = isArr(meta.warnings) ? meta.warnings : []
|
||||
bits.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
|
||||
}
|
||||
if (ctx.exportedAt) bits.push('导出于 ' + ctx.exportedAt)
|
||||
return '<div class="doc-foot">' + esc(bits.join(' | ')) + '<br>' +
|
||||
'本报告内容由模型生成,含标注为「待确认 / 需核实」的信息,出行前请自行核实。</div>'
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 payload + report_spec 拼成一份完整的单文件 HTML(纯字符串,无副作用)
|
||||
* @param {object} payload 后端返回的行程数据
|
||||
* @param {object} spec 技能包的 report_spec
|
||||
* @param {object} opts { css, images, meta, title, kicker, chip, exportedAt, baseUrl }
|
||||
* @returns {string} 完整 HTML 文本
|
||||
*/
|
||||
function build(payload, spec, opts) {
|
||||
spec = spec || {}
|
||||
opts = opts || {}
|
||||
var sections = isArr(spec.sections) ? spec.sections : []
|
||||
if (!sections.length) {
|
||||
throw new Error('report_spec 里没有 sections —— 没有渲染描述就导不出文档')
|
||||
}
|
||||
|
||||
var ctx = {
|
||||
images: opts.images || {},
|
||||
meta: opts.meta || {},
|
||||
baseUrl: opts.baseUrl || '',
|
||||
kicker: opts.kicker || '思想实验室 · 游山玩水',
|
||||
chip: opts.chip || '行程报告',
|
||||
exportedAt: opts.exportedAt || ''
|
||||
}
|
||||
var summary = isPlainObject(payload && payload.summary) ? payload.summary : null
|
||||
var title = opts.title || (summary && summary.title) || '行程报告'
|
||||
|
||||
var heroHtml = ''
|
||||
var bodyParts = []
|
||||
var indexItems = []
|
||||
|
||||
sections.forEach(function (section) {
|
||||
if (!section || !section.type) return
|
||||
if (section.title) indexItems.push({ id: section.id || ('sec_' + section.type), title: section.title })
|
||||
|
||||
if (section.type === 'overview' && !heroHtml) {
|
||||
var ovData = section.data ? resolvePath(payload, section.data) : payload
|
||||
heroHtml = '<header class="hero" id="' + esc(section.id || 'sec_overview') + '">' +
|
||||
overviewHtml(ovData, spec, ctx) + '</header>'
|
||||
return
|
||||
}
|
||||
bodyParts.push(sectionHtml(section, payload, spec, ctx))
|
||||
})
|
||||
|
||||
// 技能包若没给 overview 区块,就用 payload.summary 兜底一个头图,避免文档没有标题
|
||||
if (!heroHtml && summary) {
|
||||
heroHtml = '<header class="hero" id="sec_overview">' + overviewHtml(summary, spec, ctx) + '</header>'
|
||||
}
|
||||
|
||||
var html = '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n'
|
||||
html += '<meta charset="utf-8" />\n'
|
||||
html += '<meta name="viewport" content="width=device-width, initial-scale=1" />\n'
|
||||
html += '<title>' + esc(title) + ' · 行程报告</title>\n'
|
||||
html += '<!-- 由 TRAE-Toolbox · 思想实验室「游山玩水」导出' +
|
||||
(ctx.exportedAt ? '(' + esc(ctx.exportedAt) + ')' : '') + ',单文件、可离线查看 -->\n'
|
||||
html += '<style>\n' + (opts.css || '') + '\n</style>\n'
|
||||
html += '</head>\n<body>\n'
|
||||
html += '<div class="page" id="top">\n'
|
||||
if (heroHtml) html += heroHtml + '\n'
|
||||
if (spec.show_index !== false && indexItems.length) html += indexHtml(indexItems) + '\n'
|
||||
if (bodyParts.length) html += bodyParts.join('\n') + '\n'
|
||||
html += footHtml(ctx) + '\n'
|
||||
html += '</div>\n</body>\n</html>\n'
|
||||
return html
|
||||
}
|
||||
|
||||
/* ---------------- 图片内嵌(转 base64,保证单文件离线可看) ---------------- */
|
||||
|
||||
function collectImageUrls(payload) {
|
||||
var seen = {}
|
||||
var out = []
|
||||
;(function walk(node, depth) {
|
||||
if (depth > 12 || node === null || typeof node !== 'object') return
|
||||
if (isArr(node)) {
|
||||
node.forEach(function (x) { walk(x, depth + 1) })
|
||||
return
|
||||
}
|
||||
if (isPlainObject(node.image) && typeof node.image.url === 'string' && node.image.url) {
|
||||
if (!seen[node.image.url]) { seen[node.image.url] = 1; out.push(node.image.url) }
|
||||
}
|
||||
Object.keys(node).forEach(function (k) { walk(node[k], depth + 1) })
|
||||
})(payload, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function toDataUrl(url, baseUrl) {
|
||||
return fetch(absUrl(url, baseUrl))
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
return r.blob()
|
||||
})
|
||||
.then(function (blob) {
|
||||
if (blob.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error('单张超过 ' + Math.round(MAX_IMAGE_BYTES / 1024 / 1024) + 'MB,已跳过')
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
var fr = new FileReader()
|
||||
fr.onload = function () { resolve({ dataUri: String(fr.result), bytes: blob.size }) }
|
||||
fr.onerror = function () { reject(new Error('读取图片失败')) }
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function embedImages(payload) {
|
||||
var baseUrl = (typeof location !== 'undefined' && location.href) ? location.href : ''
|
||||
var urls = collectImageUrls(payload)
|
||||
var stats = { total: urls.length, embedded: 0, failed: 0, bytes: 0 }
|
||||
var warnings = []
|
||||
var map = {}
|
||||
if (!urls.length || typeof fetch !== 'function' || typeof FileReader === 'undefined') {
|
||||
return Promise.resolve({ map: map, baseUrl: baseUrl, stats: stats, warnings: warnings })
|
||||
}
|
||||
return Promise.all(urls.map(function (u) {
|
||||
return toDataUrl(u, baseUrl).then(function (d) {
|
||||
map[u] = d.dataUri
|
||||
stats.embedded++
|
||||
stats.bytes += d.bytes
|
||||
}, function (err) {
|
||||
stats.failed++
|
||||
warnings.push('配图未内嵌(' + (err && err.message || '获取失败') + '):' + u)
|
||||
})
|
||||
})).then(function () {
|
||||
return { map: map, baseUrl: baseUrl, stats: stats, warnings: warnings }
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 样式读取 / 文件落盘 ---------------- */
|
||||
|
||||
var cssCache = null
|
||||
|
||||
function loadCss(cssUrl) {
|
||||
if (typeof fetch !== 'function') return Promise.reject(new Error('当前环境不支持导出(无 fetch)'))
|
||||
if (cssCache) return Promise.resolve(cssCache)
|
||||
return fetch(cssUrl, { cache: 'force-cache' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('读取样式失败:HTTP ' + r.status + '(' + cssUrl + ')')
|
||||
return r.text()
|
||||
})
|
||||
.then(function (txt) {
|
||||
if (!txt || txt.indexOf('.hero') < 0) throw new Error('样式文件内容异常:' + cssUrl)
|
||||
cssCache = txt
|
||||
return cssCache
|
||||
})
|
||||
}
|
||||
|
||||
function saveBlob(html, filename) {
|
||||
var blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
var url = URL.createObjectURL(blob)
|
||||
var a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(function () { try { URL.revokeObjectURL(url) } catch (e) {} }, 10000)
|
||||
}
|
||||
|
||||
function defaultFilename(payload) {
|
||||
var s = isPlainObject(payload && payload.summary) ? payload.summary : {}
|
||||
var name = String(s.title || '行程报告')
|
||||
.replace(/[\\/:*?"<>|\r\n\t]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!name) name = '行程报告'
|
||||
if (name.length > 60) name = name.slice(0, 60)
|
||||
return name + '-行程报告.html'
|
||||
}
|
||||
|
||||
/* ---------------- 对外入口 ---------------- */
|
||||
|
||||
/**
|
||||
* 导出并触发下载(浏览器)
|
||||
* @returns {Promise<{ok, filename, bytes, images, warnings}>}
|
||||
*/
|
||||
function download(payload, spec, opts) {
|
||||
opts = opts || {}
|
||||
var cssUrl = opts.cssUrl || (SELF_DIR + 'tc_export.css')
|
||||
return Promise.all([loadCss(cssUrl), embedImages(payload)])
|
||||
.then(function (res) {
|
||||
var html = build(payload, spec, {
|
||||
css: res[0],
|
||||
images: res[1].map,
|
||||
baseUrl: res[1].baseUrl,
|
||||
meta: opts.meta,
|
||||
title: opts.title,
|
||||
kicker: opts.kicker,
|
||||
chip: opts.chip,
|
||||
exportedAt: opts.exportedAt || nowBeijing()
|
||||
})
|
||||
var filename = opts.filename || defaultFilename(payload)
|
||||
saveBlob(html, filename)
|
||||
var warnings = res[1].warnings.slice()
|
||||
return {
|
||||
ok: true,
|
||||
filename: filename,
|
||||
bytes: utf8Length(html),
|
||||
images: res[1].stats,
|
||||
sections: (isArr(spec && spec.sections) ? spec.sections.length : 0),
|
||||
warnings: warnings
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var api = {
|
||||
build: build,
|
||||
download: download,
|
||||
defaultFilename: defaultFilename,
|
||||
collectImageUrls: collectImageUrls
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') window.TCExport = api
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
||||
})()
|
||||
@@ -0,0 +1,652 @@
|
||||
/* ============================================================
|
||||
* tc_render.js —— 游山玩水 · 通用汇报渲染器(阶段一离线版)
|
||||
*
|
||||
* 设计原则(插件式 / 低耦合):
|
||||
* 1. 本文件完全独立:不 import、不 require、不引用工具箱任何其他模块或公共库。
|
||||
* 2. 只认 report_spec 里的 8 种 block 类型 + 数据路径,不认识任何业务字段
|
||||
* (不知道什么叫"浴场""徒步""停车"),换 skill 只换 report_spec,本文件一行不改。
|
||||
* 3. 地图双通道:有 Leaflet 就用 Leaflet,没有就用内置 SVG 降级渲染,
|
||||
* 保证 Leaflet 加载失败时页面照样能看点线关系。
|
||||
* 4. 删除本文件不影响任何其他 lab / tool。
|
||||
*
|
||||
* 依赖:无(Leaflet 为可选增强,通过全局 window.L 探测)
|
||||
* ============================================================ */
|
||||
(function (global) {
|
||||
'use strict'
|
||||
|
||||
/* ---------------- 可调参数(改这里即可换瓦片源) ---------------- */
|
||||
const MAP_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const MAP_TILE_ATTR = '© OpenStreetMap contributors'
|
||||
const MAP_FALLBACK_DISCLAIMER = '点位顺序示意,非真实道路/轨迹'
|
||||
|
||||
const DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
|
||||
const FALLBACK_IMAGE_TEXT = '配图未生成'
|
||||
|
||||
const mapRegistry = []
|
||||
|
||||
/* ---------------- 基础工具 ---------------- */
|
||||
|
||||
function h(tag, attrs, children) {
|
||||
const el = document.createElement(tag)
|
||||
if (attrs) {
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
const v = attrs[k]
|
||||
if (v === null || v === undefined || v === false) return
|
||||
if (k === 'class') el.className = v
|
||||
else if (k === 'text') el.textContent = v
|
||||
else if (k === 'html') el.innerHTML = v
|
||||
else if (k.indexOf('on') === 0 && typeof v === 'function') el.addEventListener(k.slice(2), v)
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v)
|
||||
else el.setAttribute(k, v === true ? '' : String(v))
|
||||
})
|
||||
}
|
||||
appendChildren(el, children)
|
||||
return el
|
||||
}
|
||||
|
||||
function appendChildren(el, children) {
|
||||
if (children === null || children === undefined) return
|
||||
if (Array.isArray(children)) {
|
||||
children.forEach(function (c) { appendChildren(el, c) })
|
||||
return
|
||||
}
|
||||
if (children instanceof Node) { el.appendChild(children); return }
|
||||
el.appendChild(document.createTextNode(String(children)))
|
||||
}
|
||||
|
||||
function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild) }
|
||||
|
||||
function resolvePath(root, path) {
|
||||
if (!path) return root
|
||||
const p = String(path).replace(/^\$\.?/, '')
|
||||
if (!p) return root
|
||||
return p.split('.').reduce(function (acc, key) {
|
||||
if (acc === null || acc === undefined) return undefined
|
||||
return acc[key]
|
||||
}, root)
|
||||
}
|
||||
|
||||
function pickById(arr, id) {
|
||||
if (!Array.isArray(arr)) return null
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === id) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) }
|
||||
|
||||
function badgeText(spec, level) {
|
||||
const map = (spec && spec.confidence_badge) || DEFAULT_BADGE
|
||||
return map[level] || DEFAULT_BADGE[level] || level
|
||||
}
|
||||
|
||||
/* ---------------- 置信度字段渲染(通用) ---------------- */
|
||||
|
||||
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
|
||||
function renderFact(value, spec) {
|
||||
if (isPlainObject(value)) {
|
||||
const box = h('span', { class: 'tcr-fact' })
|
||||
box.appendChild(h('span', { class: 'tcr-fact-text', text: value.text === undefined ? '' : String(value.text) }))
|
||||
if (value.confidence) {
|
||||
box.appendChild(h('span', {
|
||||
class: 'tcr-badge tcr-badge-' + value.confidence,
|
||||
text: badgeText(spec, value.confidence),
|
||||
title: value.verify ? ('核实方式:' + value.verify) : ''
|
||||
}))
|
||||
}
|
||||
if (value.verify) {
|
||||
box.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + value.verify }))
|
||||
}
|
||||
return box
|
||||
}
|
||||
return h('span', { class: 'tcr-fact-text', text: value === null || value === undefined ? '—' : String(value) })
|
||||
}
|
||||
|
||||
/* ---------------- 各 block 渲染 ---------------- */
|
||||
|
||||
function renderOverview(data, spec) {
|
||||
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无概览数据' })
|
||||
const box = h('div', { class: 'tcr-overview' })
|
||||
if (data.title) box.appendChild(h('h2', { class: 'tcr-ov-title', text: data.title }))
|
||||
if (data.one_liner) box.appendChild(h('p', { class: 'tcr-ov-line', text: data.one_liner }))
|
||||
if (Array.isArray(data.tags) && data.tags.length) {
|
||||
const tagRow = h('div', { class: 'tcr-tags' })
|
||||
data.tags.forEach(function (t) { tagRow.appendChild(h('span', { class: 'tcr-tag', text: t })) })
|
||||
box.appendChild(tagRow)
|
||||
}
|
||||
if (Array.isArray(data.stats) && data.stats.length) {
|
||||
const grid = h('div', { class: 'tcr-stats' })
|
||||
data.stats.forEach(function (s) {
|
||||
const cell = h('div', { class: 'tcr-stat' })
|
||||
cell.appendChild(h('div', { class: 'tcr-stat-label', text: s.label || '' }))
|
||||
cell.appendChild(h('div', { class: 'tcr-stat-value', text: s.value === undefined ? '' : String(s.value) }))
|
||||
if (s.hint) cell.appendChild(h('div', { class: 'tcr-stat-hint', text: s.hint }))
|
||||
grid.appendChild(cell)
|
||||
})
|
||||
box.appendChild(grid)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
function renderTimeline(data, spec) {
|
||||
if (!isPlainObject(data) || !Array.isArray(data.days)) return h('div', { class: 'tcr-empty', text: '无时序数据' })
|
||||
const box = h('div', { class: 'tcr-timeline' })
|
||||
data.days.forEach(function (day) {
|
||||
const dayBox = h('div', { class: 'tcr-day' })
|
||||
const head = h('div', { class: 'tcr-day-head' })
|
||||
head.appendChild(h('span', { class: 'tcr-day-label', text: day.label || '' }))
|
||||
if (day.date) head.appendChild(h('span', { class: 'tcr-day-date', text: day.date }))
|
||||
dayBox.appendChild(head)
|
||||
|
||||
const table = h('table', { class: 'tcr-table tcr-timeline-table' })
|
||||
const thead = h('thead', null, h('tr', null, [
|
||||
h('th', { class: 'tcr-col-time', text: '时间' }),
|
||||
h('th', { text: '安排' }),
|
||||
h('th', { class: 'tcr-col-kind', text: '类型' })
|
||||
]))
|
||||
table.appendChild(thead)
|
||||
const tbody = h('tbody')
|
||||
const rows = Array.isArray(day.rows) ? day.rows : []
|
||||
rows.forEach(function (r) {
|
||||
const tr = h('tr', { class: r.kind === 'hard' ? 'tcr-row-hard' : '' })
|
||||
tr.appendChild(h('td', { class: 'tcr-col-time', text: r.time || '' }))
|
||||
const td = h('td')
|
||||
td.appendChild(h('div', { class: 'tcr-row-title', text: r.title || '' }))
|
||||
if (r.note) td.appendChild(h('div', { class: 'tcr-row-note', text: r.note }))
|
||||
tr.appendChild(td)
|
||||
const kindCell = h('td', { class: 'tcr-col-kind' })
|
||||
kindCell.appendChild(h('span', {
|
||||
class: 'tcr-kind tcr-kind-' + (r.kind || 'soft'),
|
||||
text: kindLabel(r.kind)
|
||||
}))
|
||||
if (r.activity) {
|
||||
kindCell.appendChild(h('span', { class: 'tcr-kind tcr-kind-act', text: activityLabel(r.activity) }))
|
||||
}
|
||||
tr.appendChild(kindCell)
|
||||
tbody.appendChild(tr)
|
||||
})
|
||||
table.appendChild(tbody)
|
||||
dayBox.appendChild(table)
|
||||
box.appendChild(dayBox)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
function kindLabel(kind) {
|
||||
const map = { hard: '硬时间点', soft: '常规' }
|
||||
return map[kind] || (kind || '')
|
||||
}
|
||||
|
||||
// 活动类型与 kind(时间点性质)是两个正交字段,分开显示
|
||||
function activityLabel(activity) {
|
||||
const map = {
|
||||
move: '移动', eat: '吃饭', hike: '徒步',
|
||||
shop: '采买', sleep: '过夜', walk: '步行'
|
||||
}
|
||||
return map[activity] || (activity || '')
|
||||
}
|
||||
|
||||
function renderCards(data, spec, section) {
|
||||
const list = Array.isArray(data) ? data : []
|
||||
if (!list.length) return h('div', { class: 'tcr-empty', text: '无节点数据' })
|
||||
const box = h('div', { class: 'tcr-cards' })
|
||||
|
||||
const grouped = section && section.group_by === 'day'
|
||||
if (!grouped) {
|
||||
list.forEach(function (c) { box.appendChild(renderCard(c, spec)) })
|
||||
return box
|
||||
}
|
||||
const days = []
|
||||
list.forEach(function (c) {
|
||||
const d = c.day === undefined ? 0 : c.day
|
||||
if (days.indexOf(d) < 0) days.push(d)
|
||||
})
|
||||
days.sort(function (a, b) { return a - b })
|
||||
days.forEach(function (d) {
|
||||
const group = h('div', { class: 'tcr-card-group' })
|
||||
group.appendChild(h('h3', { class: 'tcr-group-title', text: d === 1 ? '第一天' : (d === 2 ? '第二天' : '其他') }))
|
||||
const grid = h('div', { class: 'tcr-card-grid' })
|
||||
list.forEach(function (c) {
|
||||
const cd = c.day === undefined ? 0 : c.day
|
||||
if (cd === d) grid.appendChild(renderCard(c, spec))
|
||||
})
|
||||
group.appendChild(grid)
|
||||
box.appendChild(group)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
function renderCard(card, spec) {
|
||||
const el = h('article', { class: 'tcr-card', id: 'card_' + (card.id || card.anchor_id || '') })
|
||||
const head = h('div', { class: 'tcr-card-head' })
|
||||
const titles = h('div', { class: 'tcr-card-titles' })
|
||||
titles.appendChild(h('h4', { class: 'tcr-card-title', text: card.title || '' }))
|
||||
if (card.subtitle) titles.appendChild(h('div', { class: 'tcr-card-sub', text: card.subtitle }))
|
||||
head.appendChild(titles)
|
||||
if (Array.isArray(card.badges) && card.badges.length) {
|
||||
const bs = h('div', { class: 'tcr-badges' })
|
||||
card.badges.forEach(function (b) {
|
||||
bs.appendChild(h('span', { class: 'tcr-badge tcr-tone-' + (b.tone || 'plain'), text: b.text || '' }))
|
||||
})
|
||||
head.appendChild(bs)
|
||||
}
|
||||
el.appendChild(head)
|
||||
|
||||
if (card.image && card.image.url) {
|
||||
const fig = h('figure', { class: 'tcr-figure' })
|
||||
const img = h('img', { class: 'tcr-img', src: card.image.url, alt: card.image.caption || '', loading: 'lazy' })
|
||||
img.addEventListener('error', function () {
|
||||
clear(fig)
|
||||
fig.appendChild(h('div', { class: 'tcr-img-missing', text: FALLBACK_IMAGE_TEXT + '(' + (card.image.caption || '') + ')' }))
|
||||
fig.appendChild(h('figcaption', { class: 'tcr-figcap', text: card.image.disclaimer || '' }))
|
||||
})
|
||||
fig.appendChild(img)
|
||||
const cap = h('figcaption', { class: 'tcr-figcap' })
|
||||
if (card.image.caption) cap.appendChild(h('span', { text: card.image.caption + ' · ' }))
|
||||
cap.appendChild(h('span', { class: 'tcr-figcap-note', text: card.image.disclaimer || '' }))
|
||||
fig.appendChild(cap)
|
||||
el.appendChild(fig)
|
||||
}
|
||||
|
||||
if (Array.isArray(card.fields) && card.fields.length) {
|
||||
const dl = h('div', { class: 'tcr-fields' })
|
||||
card.fields.forEach(function (f) {
|
||||
const row = h('div', { class: 'tcr-field' })
|
||||
row.appendChild(h('div', { class: 'tcr-field-label', text: f.label || '' }))
|
||||
const val = h('div', { class: 'tcr-field-value' })
|
||||
val.appendChild(renderFact(f.value, spec))
|
||||
if (f.hint) val.appendChild(h('div', { class: 'tcr-field-hint', text: f.hint }))
|
||||
row.appendChild(val)
|
||||
dl.appendChild(row)
|
||||
})
|
||||
el.appendChild(dl)
|
||||
}
|
||||
|
||||
if (Array.isArray(card.bullets) && card.bullets.length) {
|
||||
const ul = h('ul', { class: 'tcr-bullets' })
|
||||
card.bullets.forEach(function (b) { ul.appendChild(h('li', { text: b })) })
|
||||
el.appendChild(ul)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function renderTable(data, spec, section) {
|
||||
let table = null
|
||||
if (Array.isArray(data)) {
|
||||
table = pickById(data, section.pick_id)
|
||||
if (!table && section.pick_id) return h('div', { class: 'tcr-empty', text: '缺少表格:' + section.pick_id })
|
||||
if (!table) table = data[0]
|
||||
} else {
|
||||
table = data
|
||||
}
|
||||
if (!isPlainObject(table)) return h('div', { class: 'tcr-empty', text: '无表格数据' })
|
||||
|
||||
const box = h('div', { class: 'tcr-tablebox' })
|
||||
if (table.title) box.appendChild(h('div', { class: 'tcr-table-title', text: table.title }))
|
||||
|
||||
const columns = Array.isArray(table.columns) ? table.columns : []
|
||||
const rows = Array.isArray(table.rows) ? table.rows : []
|
||||
const hl = Array.isArray(table.highlight_row_keys) ? table.highlight_row_keys : []
|
||||
|
||||
const el = h('table', { class: 'tcr-table' })
|
||||
const tr = h('tr')
|
||||
columns.forEach(function (c) {
|
||||
tr.appendChild(h('th', {
|
||||
class: c.align ? ('tcr-al-' + c.align) : '',
|
||||
style: c.width ? { width: c.width } : null,
|
||||
text: c.label || c.key
|
||||
}))
|
||||
})
|
||||
el.appendChild(h('thead', null, tr))
|
||||
|
||||
const tbody = h('tbody')
|
||||
rows.forEach(function (r) {
|
||||
const row = h('tr', { class: hl.indexOf(r._key) >= 0 ? 'tcr-row-hl' : '' })
|
||||
columns.forEach(function (c) {
|
||||
const v = r[c.key]
|
||||
const td = h('td', { class: c.align ? ('tcr-al-' + c.align) : '' })
|
||||
if (v === null || v === undefined) td.textContent = '—'
|
||||
else td.textContent = String(v)
|
||||
row.appendChild(td)
|
||||
})
|
||||
tbody.appendChild(row)
|
||||
})
|
||||
el.appendChild(tbody)
|
||||
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
|
||||
if (table.note) box.appendChild(h('div', { class: 'tcr-table-note', text: table.note }))
|
||||
return box
|
||||
}
|
||||
|
||||
function renderCost(data, spec) {
|
||||
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无费用数据' })
|
||||
const box = h('div', { class: 'tcr-cost' })
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
const el = h('table', { class: 'tcr-table' })
|
||||
el.appendChild(h('thead', null, h('tr', null, [
|
||||
h('th', { text: '项目' }),
|
||||
h('th', { class: 'tcr-al-right', text: '金额(' + (data.currency || 'CNY') + ')' }),
|
||||
h('th', { text: '口径' })
|
||||
])))
|
||||
const tbody = h('tbody')
|
||||
items.forEach(function (it) {
|
||||
const row = h('tr')
|
||||
row.appendChild(h('td', { text: it.label || '' }))
|
||||
row.appendChild(h('td', { class: 'tcr-al-right', text: it.amount === undefined ? '—' : String(it.amount) }))
|
||||
row.appendChild(h('td', { class: 'tcr-cell-note', text: it.note || it.unit || '' }))
|
||||
tbody.appendChild(row)
|
||||
})
|
||||
el.appendChild(tbody)
|
||||
if (data.total !== undefined) {
|
||||
const tf = h('tfoot')
|
||||
const tr = h('tr', { class: 'tcr-row-hl' })
|
||||
tr.appendChild(h('td', { text: '合计' }))
|
||||
tr.appendChild(h('td', { class: 'tcr-al-right', text: String(data.total) }))
|
||||
tr.appendChild(h('td', { text: '' }))
|
||||
tf.appendChild(tr)
|
||||
el.appendChild(tf)
|
||||
}
|
||||
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
|
||||
if (data.note) box.appendChild(h('div', { class: 'tcr-table-note', text: data.note }))
|
||||
return box
|
||||
}
|
||||
|
||||
function renderChecklist(data, spec) {
|
||||
const list = Array.isArray(data) ? data : []
|
||||
if (!list.length) return h('div', { class: 'tcr-empty', text: '无待核实项' })
|
||||
const ol = h('ol', { class: 'tcr-checklist' })
|
||||
list.forEach(function (c) {
|
||||
const li = h('li', { class: 'tcr-check-item' })
|
||||
li.appendChild(h('span', { class: 'tcr-check-text', text: c.text || '' }))
|
||||
if (c.confidence) {
|
||||
li.appendChild(h('span', { class: 'tcr-badge tcr-badge-' + c.confidence, text: badgeText(spec, c.confidence) }))
|
||||
}
|
||||
if (c.verify) li.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + c.verify }))
|
||||
ol.appendChild(li)
|
||||
})
|
||||
return ol
|
||||
}
|
||||
|
||||
function renderNotes(data, spec, section) {
|
||||
let list = Array.isArray(data) ? data : []
|
||||
if (section && Array.isArray(section.filter_levels) && section.filter_levels.length) {
|
||||
const allow = section.filter_levels
|
||||
list = list.filter(function (n) { return allow.indexOf(n.level) >= 0 })
|
||||
}
|
||||
if (!list.length) return h('div', { class: 'tcr-empty', text: '无提示项' })
|
||||
const box = h('div', { class: 'tcr-notes' })
|
||||
list.forEach(function (n) {
|
||||
const el = h('div', { class: 'tcr-note tcr-note-' + (n.level || 'info') })
|
||||
el.appendChild(h('div', { class: 'tcr-note-title', text: n.title || '' }))
|
||||
if (n.text) el.appendChild(h('div', { class: 'tcr-note-text', text: n.text }))
|
||||
box.appendChild(el)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
/* ---------------- 地图 block(Leaflet 优先,SVG 降级) ---------------- */
|
||||
|
||||
function renderMap(data, spec) {
|
||||
if (!isPlainObject(data) || !Array.isArray(data.markers)) {
|
||||
return h('div', { class: 'tcr-empty', text: '无地图数据' })
|
||||
}
|
||||
const box = h('div', { class: 'tcr-mapbox' })
|
||||
const holder = h('div', { class: 'tcr-map-holder' })
|
||||
box.appendChild(holder)
|
||||
const legend = renderMapLegend(data)
|
||||
if (legend) box.appendChild(legend)
|
||||
const dis = data.disclaimer || (spec && spec.map_route_disclaimer_default) || MAP_FALLBACK_DISCLAIMER
|
||||
box.appendChild(h('div', { class: 'tcr-map-note', text: dis }))
|
||||
mapRegistry.push({ el: holder, data: data })
|
||||
return box
|
||||
}
|
||||
|
||||
function renderMapLegend(data) {
|
||||
const routes = Array.isArray(data.routes) ? data.routes : []
|
||||
if (!routes.length) return null
|
||||
const box = h('div', { class: 'tcr-map-legend' })
|
||||
routes.forEach(function (r) {
|
||||
const item = h('span', { class: 'tcr-legend-item' })
|
||||
const swatch = h('span', { class: 'tcr-legend-swatch' + (r.dashed ? ' tcr-dashed' : '') })
|
||||
if (r.color) swatch.style.background = r.color
|
||||
if (r.dashed) {
|
||||
swatch.style.background = 'repeating-linear-gradient(90deg,' + (r.color || '#666') + ' 0 6px,transparent 6px 12px)'
|
||||
}
|
||||
item.appendChild(swatch)
|
||||
item.appendChild(h('span', { text: r.name || r.id }))
|
||||
box.appendChild(item)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
function initMaps() {
|
||||
mapRegistry.forEach(function (reg) {
|
||||
try {
|
||||
if (global.L && typeof global.L.map === 'function') drawLeaflet(reg.el, reg.data)
|
||||
else drawSvgFallback(reg.el, reg.data)
|
||||
} catch (e) {
|
||||
try { drawSvgFallback(reg.el, reg.data) } catch (e2) {
|
||||
clear(reg.el)
|
||||
reg.el.appendChild(h('div', { class: 'tcr-empty', text: '地图渲染失败' }))
|
||||
}
|
||||
}
|
||||
})
|
||||
mapRegistry.length = 0
|
||||
}
|
||||
|
||||
function drawLeaflet(el, data) {
|
||||
const L = global.L
|
||||
clear(el)
|
||||
const map = L.map(el, { zoomControl: true, attributionControl: true })
|
||||
L.tileLayer(MAP_TILE_URL, { maxZoom: 18, attribution: MAP_TILE_ATTR }).addTo(map)
|
||||
|
||||
const markers = data.markers || []
|
||||
const byId = {}
|
||||
const latlngs = []
|
||||
markers.forEach(function (m) {
|
||||
if (typeof m.lat !== 'number' || typeof m.lng !== 'number') return
|
||||
byId[m.id] = m
|
||||
latlngs.push([m.lat, m.lng])
|
||||
const icon = L.divIcon({
|
||||
className: 'tcr-pin-wrap',
|
||||
html: '<span class="tcr-pin tcr-pin-' + (m.kind || 'spot') + '">' + (m.seq === undefined ? '' : m.seq) + '</span>',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11]
|
||||
})
|
||||
const marker = L.marker([m.lat, m.lng], { icon: icon, title: m.name || '' }).addTo(map)
|
||||
marker.bindPopup('<b>' + escapeHtml(m.name || '') + '</b>' +
|
||||
(m.note ? '<br/>' + escapeHtml(m.note) : '') +
|
||||
(m.coord_precision === 'approx' ? '<br/><i>坐标为概略值</i>' : ''))
|
||||
})
|
||||
|
||||
;(data.routes || []).forEach(function (r) {
|
||||
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
|
||||
.map(function (m) { return [m.lat, m.lng] })
|
||||
if (pts.length < 2) return
|
||||
const line = L.polyline(pts, {
|
||||
color: r.color || '#2f6fed',
|
||||
weight: r.kind === 'drive' ? 3 : 4,
|
||||
dashArray: r.dashed ? '6 6' : null,
|
||||
opacity: 0.9
|
||||
}).addTo(map)
|
||||
if (r.note) line.bindTooltip(r.name + ' · ' + r.note, { sticky: true })
|
||||
else line.bindTooltip(r.name || '', { sticky: true })
|
||||
})
|
||||
|
||||
if (latlngs.length) {
|
||||
try {
|
||||
map.fitBounds(L.latLngBounds(latlngs).pad(0.15))
|
||||
} catch (e) {
|
||||
map.setView(data.center || latlngs[0], data.zoom || 9)
|
||||
}
|
||||
} else {
|
||||
map.setView(data.center || [31.95, 121.15], data.zoom || 9)
|
||||
}
|
||||
setTimeout(function () { try { map.invalidateSize() } catch (e) {} }, 60)
|
||||
}
|
||||
|
||||
function drawSvgFallback(el, data) {
|
||||
clear(el)
|
||||
const markers = (data.markers || []).filter(function (m) {
|
||||
return typeof m.lat === 'number' && typeof m.lng === 'number'
|
||||
})
|
||||
const W = 860, H = 420, PAD = 34
|
||||
const svgNS = 'http://www.w3.org/2000/svg'
|
||||
const svg = document.createElementNS(svgNS, 'svg')
|
||||
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H)
|
||||
svg.setAttribute('class', 'tcr-svg-map')
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||
svg.appendChild(mkSvg('rect', { x: 0, y: 0, width: W, height: H, class: 'tcr-svg-bg' }))
|
||||
|
||||
if (!markers.length) {
|
||||
el.appendChild(svg)
|
||||
el.appendChild(h('div', { class: 'tcr-empty', text: '无可绘制点位' }))
|
||||
return
|
||||
}
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
markers.forEach(function (m) {
|
||||
minLat = Math.min(minLat, m.lat); maxLat = Math.max(maxLat, m.lat)
|
||||
minLng = Math.min(minLng, m.lng); maxLng = Math.max(maxLng, m.lng)
|
||||
})
|
||||
const spanLat = Math.max(maxLat - minLat, 0.01)
|
||||
const spanLng = Math.max(maxLng - minLng, 0.01)
|
||||
function px(m) {
|
||||
const x = PAD + ((m.lng - minLng) / spanLng) * (W - PAD * 2)
|
||||
const y = H - PAD - ((m.lat - minLat) / spanLat) * (H - PAD * 2)
|
||||
return { x: x, y: y }
|
||||
}
|
||||
|
||||
const byId = {}
|
||||
markers.forEach(function (m) { byId[m.id] = m })
|
||||
|
||||
;(data.routes || []).forEach(function (r) {
|
||||
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
|
||||
if (pts.length < 2) return
|
||||
const d = pts.map(function (m, i) {
|
||||
const p = px(m)
|
||||
return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)
|
||||
}).join(' ')
|
||||
svg.appendChild(mkSvg('path', {
|
||||
d: d,
|
||||
fill: 'none',
|
||||
stroke: r.color || '#2f6fed',
|
||||
'stroke-width': r.kind === 'drive' ? 2.5 : 3.2,
|
||||
'stroke-dasharray': r.dashed ? '6 6' : 'none',
|
||||
'stroke-linejoin': 'round',
|
||||
class: 'tcr-svg-line'
|
||||
}))
|
||||
})
|
||||
|
||||
markers.forEach(function (m) {
|
||||
const p = px(m)
|
||||
svg.appendChild(mkSvg('circle', { cx: p.x, cy: p.y, r: 9, fill: pinColor(m.kind), stroke: '#fff', 'stroke-width': 2 }))
|
||||
const t = mkSvg('text', {
|
||||
x: p.x, y: p.y + 3.4, 'text-anchor': 'middle',
|
||||
class: 'tcr-svg-seq', fill: '#fff'
|
||||
})
|
||||
t.textContent = m.seq === undefined ? '' : String(m.seq)
|
||||
svg.appendChild(t)
|
||||
const label = mkSvg('text', { x: p.x + 12, y: p.y + 4, class: 'tcr-svg-label' })
|
||||
label.textContent = m.name || ''
|
||||
svg.appendChild(label)
|
||||
})
|
||||
|
||||
el.appendChild(svg)
|
||||
el.appendChild(h('div', { class: 'tcr-map-fallback-hint', text: '未加载到 Leaflet,已用内置 SVG 降级绘制点位顺序' }))
|
||||
}
|
||||
|
||||
function mkSvg(tag, attrs) {
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||
Object.keys(attrs || {}).forEach(function (k) {
|
||||
if (attrs[k] === null || attrs[k] === undefined) return
|
||||
el.setAttribute(k, String(attrs[k]))
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
function pinColor(kind) {
|
||||
const map = {
|
||||
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98', parking: '#c2701a',
|
||||
hike: '#2e9e5b', district: '#b8860b', food: '#e0673a', bathhouse: '#1f8a70',
|
||||
shop: '#5a67d8', sleep: '#2d3748', walk: '#8a5cf6', breakfast: '#c98a00', spot: '#0ea5e9'
|
||||
}
|
||||
return map[kind] || '#4b5563'
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 顶层渲染 ---------------- */
|
||||
|
||||
const BLOCKS = {
|
||||
overview: renderOverview,
|
||||
timeline: renderTimeline,
|
||||
cards: renderCards,
|
||||
map: renderMap,
|
||||
table: renderTable,
|
||||
cost: renderCost,
|
||||
checklist: renderChecklist,
|
||||
notes: renderNotes
|
||||
}
|
||||
|
||||
function renderSection(section, payload, spec) {
|
||||
const data = section.data ? resolvePath(payload, section.data) : payload
|
||||
const fn = BLOCKS[section.type]
|
||||
const wrap = h('section', { class: 'tcr-section', id: section.id || ('sec_' + section.type) })
|
||||
if (section.title) {
|
||||
const head = h('div', { class: 'tcr-section-head' })
|
||||
head.appendChild(h('h3', { class: 'tcr-section-title', text: section.title }))
|
||||
head.appendChild(h('a', { class: 'tcr-section-top', href: '#tcr_top', text: '回到顶部' }))
|
||||
wrap.appendChild(head)
|
||||
}
|
||||
if (typeof fn !== 'function') {
|
||||
wrap.appendChild(h('div', { class: 'tcr-empty', text: '未知 block 类型:' + section.type }))
|
||||
return wrap
|
||||
}
|
||||
wrap.appendChild(fn(data, spec, section))
|
||||
return wrap
|
||||
}
|
||||
|
||||
function renderIndex(spec) {
|
||||
const sections = (spec.sections || []).filter(function (s) { return s.title })
|
||||
if (!sections.length) return null
|
||||
const nav = h('nav', { class: 'tcr-index' })
|
||||
sections.forEach(function (s) {
|
||||
nav.appendChild(h('a', { class: 'tcr-index-item', href: '#' + (s.id || ('sec_' + s.type)), text: s.title }))
|
||||
})
|
||||
return nav
|
||||
}
|
||||
|
||||
function render(payload, spec, mount) {
|
||||
if (!mount) throw new Error('tc_render: 缺少挂载节点')
|
||||
spec = spec || {}
|
||||
const sections = Array.isArray(spec.sections) ? spec.sections : []
|
||||
// 空 spec 是「静默留白」的经典成因:不报错、不渲染,页面一片空白、无从排查。
|
||||
// 宁可主动抛错,让调用方把「渲染描述没传过来」直接摊在页面上。
|
||||
if (!sections.length) {
|
||||
throw new Error('report_spec 里没有 sections —— 后端没把渲染描述传过来,或技能包的 report_spec.json 为空')
|
||||
}
|
||||
clear(mount)
|
||||
if (spec.show_index) {
|
||||
const idx = renderIndex(spec)
|
||||
if (idx) mount.appendChild(idx)
|
||||
}
|
||||
sections.forEach(function (section) {
|
||||
mount.appendChild(renderSection(section, payload || {}, spec))
|
||||
})
|
||||
// 地图必须等节点入 DOM 后再初始化
|
||||
if (global.requestAnimationFrame) global.requestAnimationFrame(initMaps)
|
||||
else setTimeout(initMaps, 0)
|
||||
}
|
||||
|
||||
global.TCRender = {
|
||||
render: render,
|
||||
resolvePath: resolvePath,
|
||||
pickById: pickById,
|
||||
MAP_TILE_URL: MAP_TILE_URL
|
||||
}
|
||||
})(window)
|
||||
@@ -0,0 +1,193 @@
|
||||
/* ============================================================
|
||||
* tc_report.css —— 游山玩水 · 报告样式(只服务 tc_render.js)
|
||||
*
|
||||
* 为什么单独一份:
|
||||
* tc_render.js 是「通用汇报渲染器」,它生成的节点类名统一用 tcr- 前缀。
|
||||
* 页面上还有表单(.tc-field / .tc-badge / .tc-note 等页面级类名),
|
||||
* 两套类名如果共用 tc- 前缀就会互相污染(例如 .tc-field 表单要 flex 纵向、
|
||||
* 报告卡片里要 grid 两列),所以报告一律走 tcr- 独立命名空间。
|
||||
*
|
||||
* 使用方式:任何页面只要引用本文件 + tc_render.js,就能渲染任意 skill 的报告。
|
||||
* 依赖:无(下面自带 :root 变量兜底,不引用工具箱任何其他样式文件)
|
||||
* 删除本文件只会让报告掉样式,不会影响本 lab 之外的任何 tool / lab。
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
--tcr-bg: #f6f7f9;
|
||||
--tcr-card: #ffffff;
|
||||
--tcr-line: #e5e7eb;
|
||||
--tcr-text: #1f2328;
|
||||
--tcr-sub: #6b7280;
|
||||
--tcr-accent: #2f6fed;
|
||||
}
|
||||
|
||||
/* ---------------- 区块骨架 ---------------- */
|
||||
|
||||
.tcr-index {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px;
|
||||
background: #fff; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 8px;
|
||||
}
|
||||
.tcr-index-item {
|
||||
font-size: 12px; color: var(--tcr-accent); text-decoration: none;
|
||||
padding: 3px 8px; border-radius: 6px; background: #f0f4ff;
|
||||
}
|
||||
.tcr-index-item:hover { background: #e2eaff; }
|
||||
|
||||
.tcr-section {
|
||||
background: var(--tcr-card); border: 1px solid var(--tcr-line);
|
||||
border-radius: 10px; padding: 16px 16px 18px; margin-bottom: 16px;
|
||||
}
|
||||
.tcr-section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||
.tcr-section-title { font-size: 16px; margin: 0; }
|
||||
.tcr-section-top { font-size: 12px; color: var(--tcr-sub); text-decoration: none; }
|
||||
.tcr-empty { color: var(--tcr-sub); font-size: 13px; padding: 8px 0; }
|
||||
|
||||
/* ---------------- 概览 ---------------- */
|
||||
|
||||
.tcr-ov-title { margin: 0 0 6px; font-size: 18px; }
|
||||
.tcr-ov-line { margin: 0 0 10px; color: #374151; }
|
||||
.tcr-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
.tcr-tag { font-size: 12px; background: #eef2f7; color: #374151; border-radius: 999px; padding: 2px 10px; }
|
||||
.tcr-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
||||
.tcr-stat { background: #fafbfc; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 10px; }
|
||||
.tcr-stat-label { font-size: 12px; color: var(--tcr-sub); }
|
||||
.tcr-stat-value { font-size: 16px; font-weight: 600; margin: 2px 0; }
|
||||
.tcr-stat-hint { font-size: 11px; color: var(--tcr-sub); line-height: 1.45; }
|
||||
|
||||
/* ---------------- 表格 ---------------- */
|
||||
|
||||
.tcr-table-scroll { overflow-x: auto; }
|
||||
table.tcr-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; }
|
||||
table.tcr-table th, table.tcr-table td {
|
||||
border: 1px solid var(--tcr-line); padding: 7px 9px; text-align: left; vertical-align: top;
|
||||
}
|
||||
table.tcr-table thead th { background: #f3f5f8; font-weight: 600; white-space: nowrap; }
|
||||
.tcr-al-right { text-align: right !important; }
|
||||
.tcr-al-center { text-align: center !important; }
|
||||
.tcr-row-hl td { background: #eef4ff; }
|
||||
.tcr-row-hard td { background: #fff7ed; }
|
||||
.tcr-col-time { white-space: nowrap; color: #374151; width: 108px; }
|
||||
.tcr-col-kind { width: 138px; }
|
||||
.tcr-row-title { font-weight: 500; }
|
||||
.tcr-row-note { font-size: 12px; color: var(--tcr-sub); }
|
||||
.tcr-cell-note { color: var(--tcr-sub); font-size: 12px; }
|
||||
.tcr-kind {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
||||
background: #eef2f7; color: #4b5563; white-space: nowrap;
|
||||
}
|
||||
.tcr-kind-hard { background: #fdece8; color: #b42318; font-weight: 600; }
|
||||
.tcr-kind-act { background: #eef2f7; color: #374151; margin-left: 4px; }
|
||||
|
||||
.tcr-table-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
||||
.tcr-table-note { font-size: 12px; color: var(--tcr-sub); margin-top: 8px; }
|
||||
|
||||
/* ---------------- 时序分组 ---------------- */
|
||||
|
||||
.tcr-day { margin-bottom: 16px; }
|
||||
.tcr-day-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; }
|
||||
.tcr-day-label { font-weight: 600; }
|
||||
.tcr-day-date { font-size: 12px; color: var(--tcr-sub); }
|
||||
|
||||
/* ---------------- 卡片 ---------------- */
|
||||
|
||||
.tcr-card-group { margin-bottom: 14px; }
|
||||
.tcr-group-title { font-size: 14px; margin: 6px 0 10px; color: #374151; }
|
||||
.tcr-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
.tcr-card { border: 1px solid var(--tcr-line); border-radius: 10px; padding: 12px; background: #fff; }
|
||||
.tcr-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; }
|
||||
.tcr-card-title { font-size: 15px; margin: 0; }
|
||||
.tcr-card-sub { font-size: 12px; color: var(--tcr-sub); }
|
||||
.tcr-badges { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
|
||||
.tcr-badge {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
||||
background: #eef2f7; color: #4b5563; white-space: nowrap; cursor: default;
|
||||
}
|
||||
.tcr-badge-high { background: #e8f6ee; color: #1a7a45; }
|
||||
.tcr-badge-medium { background: #fff5e0; color: #8a6100; }
|
||||
.tcr-badge-low { background: #fdece8; color: #b42318; }
|
||||
.tcr-tone-info { background: #e8f0fe; color: #1d4ed8; }
|
||||
.tcr-tone-good { background: #e8f6ee; color: #1a7a45; }
|
||||
.tcr-tone-warn { background: #fff5e0; color: #8a6100; }
|
||||
.tcr-tone-danger { background: #fdece8; color: #b42318; }
|
||||
.tcr-tone-plain { background: #eef2f7; color: #4b5563; }
|
||||
|
||||
/* ---------------- 配图 ---------------- */
|
||||
|
||||
.tcr-figure { margin: 10px 0 4px; }
|
||||
.tcr-img { width: 100%; height: 150px; object-fit: cover; border-radius: 8px; display: block; background: #f0f2f5; }
|
||||
.tcr-img-missing {
|
||||
height: 90px; display: flex; align-items: center; justify-content: center;
|
||||
background: #f3f5f8; border: 1px dashed var(--tcr-line); border-radius: 8px;
|
||||
color: var(--tcr-sub); font-size: 12px;
|
||||
}
|
||||
.tcr-figcap { font-size: 11px; color: var(--tcr-sub); margin-top: 4px; }
|
||||
.tcr-figcap-note { color: #b42318; }
|
||||
|
||||
/* ---------------- 卡片字段 / 事实 ---------------- */
|
||||
|
||||
.tcr-fields { margin-top: 10px; }
|
||||
.tcr-field {
|
||||
display: grid; grid-template-columns: 96px 1fr; gap: 6px;
|
||||
padding: 5px 0; border-top: 1px dashed #eef0f3;
|
||||
}
|
||||
.tcr-field:first-child { border-top: none; }
|
||||
.tcr-field-label { color: var(--tcr-sub); font-size: 12px; }
|
||||
.tcr-field-value { font-size: 13px; }
|
||||
.tcr-field-hint { font-size: 11px; color: var(--tcr-sub); }
|
||||
.tcr-verify { display: block; font-size: 11px; color: #8a6100; margin-top: 2px; }
|
||||
.tcr-bullets { margin: 10px 0 0; padding-left: 18px; font-size: 13px; }
|
||||
.tcr-bullets li { margin-bottom: 3px; }
|
||||
|
||||
/* ---------------- 地图 ---------------- */
|
||||
|
||||
.tcr-map-holder { height: 420px; border-radius: 8px; overflow: hidden; background: #eef1f4; border: 1px solid var(--tcr-line); }
|
||||
.tcr-map-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; font-size: 12px; color: #374151; }
|
||||
.tcr-legend-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.tcr-legend-swatch { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: #666; }
|
||||
.tcr-map-note { font-size: 12px; color: var(--tcr-sub); margin-top: 6px; }
|
||||
.tcr-map-fallback-hint { font-size: 12px; color: #8a6100; margin-top: 6px; }
|
||||
.tcr-svg-map { width: 100%; height: auto; display: block; }
|
||||
.tcr-svg-bg { fill: #f2f5f8; }
|
||||
.tcr-svg-seq { font-size: 10px; font-weight: 700; }
|
||||
.tcr-svg-label { font-size: 11px; fill: #374151; }
|
||||
.tcr-pin-wrap { background: transparent; border: none; }
|
||||
.tcr-pin {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px; border-radius: 50%; color: #fff; font-size: 11px;
|
||||
font-weight: 700; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
||||
background: #4b5563;
|
||||
}
|
||||
.tcr-pin-start { background: #2f6fed; }
|
||||
.tcr-pin-end { background: #d14343; }
|
||||
.tcr-pin-hike { background: #2e9e5b; }
|
||||
.tcr-pin-district { background: #b8860b; }
|
||||
.tcr-pin-food { background: #e0673a; }
|
||||
.tcr-pin-bathhouse { background: #1f8a70; }
|
||||
.tcr-pin-shop { background: #5a67d8; }
|
||||
.tcr-pin-sleep { background: #2d3748; }
|
||||
.tcr-pin-walk { background: #8a5cf6; }
|
||||
.tcr-pin-breakfast { background: #c98a00; }
|
||||
.tcr-pin-parking { background: #c2701a; }
|
||||
.tcr-pin-spot { background: #0ea5e9; }
|
||||
.tcr-pin-waypoint { background: #8a8f98; }
|
||||
|
||||
/* ---------------- 提示 ---------------- */
|
||||
|
||||
.tcr-note { border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid; }
|
||||
.tcr-note-title { font-weight: 600; margin-bottom: 2px; font-size: 13px; }
|
||||
.tcr-note-text { font-size: 13px; color: #374151; }
|
||||
.tcr-note-info { background: #f2f7ff; border-color: #cfe0ff; }
|
||||
.tcr-note-warn { background: #fffaf0; border-color: #f0dcae; }
|
||||
.tcr-note-danger { background: #fff5f4; border-color: #f5c9c3; }
|
||||
|
||||
/* ---------------- 待核实清单 ---------------- */
|
||||
|
||||
.tcr-checklist { margin: 0; padding-left: 20px; font-size: 13px; }
|
||||
.tcr-check-item { margin-bottom: 7px; }
|
||||
.tcr-check-text { margin-right: 6px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tcr-card-grid { grid-template-columns: 1fr; }
|
||||
.tcr-field { grid-template-columns: 82px 1fr; }
|
||||
.tcr-map-holder { height: 320px; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||
stroke="#2f6fed" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img"
|
||||
aria-label="上海周边高铁游周末两天一晚">
|
||||
<title>上海周边高铁游周末两天一晚</title>
|
||||
<!-- 高铁:流线车头 + 车窗 + 轨道 -->
|
||||
<path d="M6.5 4.5h11a2 2 0 0 1 1.95 1.55l1.05 5.2A2 2 0 0 1 18.55 13H5.45a2 2 0 0 1-1.95-2.25l1.05-5.2A2 2 0 0 1 6.5 4.5Z" />
|
||||
<path d="M5.45 13 4.2 16.6M18.55 13l1.25 3.6" />
|
||||
<path d="M4 19.5h16" />
|
||||
<circle cx="9" cy="9" r="1.1" fill="#2f6fed" stroke="none" />
|
||||
<circle cx="15" cy="9" r="1.1" fill="#2f6fed" stroke="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 701 B |
@@ -0,0 +1,768 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
============================================================
|
||||
sh_2d_rail_hike/index.html —— 高铁游技能页(技能私有)
|
||||
|
||||
【隔离用意 · 请后续开发者先读这段】
|
||||
本页面从 sh_2d_car_hike/index.html 复制而来(用户已确认「复制现有再改」),
|
||||
复制后它只服务【本 skill】。它自带自己的表单/结果/导出按钮/交互弹窗,
|
||||
自己的渲染器 tc_render.js、自己的样式 tc_report.css、自己的导出器 tc_export.js。
|
||||
本 skill 的接口前缀是 /api/thought_lab/travel_citytour/skills/sh_2d_rail_hike
|
||||
(由 lab 层挂载,鉴权已由 lab 层包好,本页不写任何鉴权逻辑)。
|
||||
|
||||
请勿把它与自驾 skill 的页面「抽公共组件」合并;本 lab 的原则是每个 skill 各存一份
|
||||
可自由改的页面,宁可重复,也不要一处改动牵连全体。要新增 skill,请复制整个
|
||||
skills/<skill_id>/ 目录后在副本里改。
|
||||
|
||||
唯一允许向上引用的只有 lab 级第三方库:../../assets/vendor/leaflet/
|
||||
(无业务语义的 vendor,不涉及任何 skill 的业务代码)
|
||||
============================================================
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>上海周边高铁游周末两天一晚 · 游山玩水</title>
|
||||
<style>
|
||||
/* 本页样式全部以 tc- 前缀隔离,不引用其他 skill / lab / tool 的任何样式 */
|
||||
:root {
|
||||
--tc-bg:#f6f7f9; --tc-card:#fff; --tc-line:#e5e7eb; --tc-text:#1f2328;
|
||||
--tc-sub:#6b7280; --tc-accent:#2f6fed;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--tc-bg); color:var(--tc-text);
|
||||
font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; }
|
||||
.tc-page { max-width:1080px; margin:0 auto; padding:20px 16px 72px; }
|
||||
.tc-top { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
|
||||
.tc-top h1 { font-size:20px; margin:0; }
|
||||
.tc-sub { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-back { font-size:12px; color:var(--tc-accent); text-decoration:none; }
|
||||
.tc-back:hover { text-decoration:underline; }
|
||||
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
|
||||
border-radius:8px; padding:8px 12px; margin-bottom:14px; }
|
||||
.tc-meta b { color:var(--tc-text); }
|
||||
.tc-note { border-radius:8px; padding:10px 12px; font-size:13px; border:1px solid; margin-bottom:12px; }
|
||||
.tc-note-info { background:#f2f7ff; border-color:#cfe0ff; }
|
||||
.tc-note-warn { background:#fffaf0; border-color:#f0dcae; }
|
||||
.tc-note-danger { background:#fdece8; border-color:#f3c0b6; }
|
||||
.tc-note b { display:block; margin-bottom:2px; }
|
||||
.tc-note ol { margin:6px 0 0; padding-left:20px; }
|
||||
.tc-note ol li { margin-bottom:3px; }
|
||||
|
||||
.tc-block { background:var(--tc-card); border:1px solid var(--tc-line); border-radius:10px;
|
||||
padding:16px; margin-bottom:16px; }
|
||||
.tc-block-title { font-size:15px; margin:0 0 4px; }
|
||||
.tc-block-hint { font-size:12px; color:var(--tc-sub); margin-bottom:12px; }
|
||||
|
||||
/* 表单 */
|
||||
.tc-form { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px; }
|
||||
.tc-field { display:flex; flex-direction:column; gap:4px; }
|
||||
.tc-label { font-size:12px; color:#374151; font-weight:500; }
|
||||
.tc-label .tc-req { color:#b42318; margin-left:2px; }
|
||||
.tc-input, .tc-select {
|
||||
font-size:13px; padding:7px 9px; border:1px solid var(--tc-line); border-radius:8px;
|
||||
background:#fff; color:var(--tc-text); font-family:inherit;
|
||||
}
|
||||
.tc-input:focus, .tc-select:focus { outline:none; border-color:var(--tc-accent); box-shadow:0 0 0 3px #e8f0fe; }
|
||||
.tc-hint { font-size:11px; color:var(--tc-sub); }
|
||||
.tc-affects { font-size:11px; color:#8a6100; }
|
||||
|
||||
.tc-actions { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:14px; }
|
||||
.tc-btn { display:inline-block; text-decoration:none; font-size:13px; border:none; cursor:pointer;
|
||||
background:var(--tc-accent); color:#fff; border-radius:8px; padding:9px 18px; font-family:inherit; }
|
||||
.tc-btn:hover:not(:disabled) { background:#2560d8; }
|
||||
.tc-btn:disabled { background:#c3cddb; cursor:not-allowed; }
|
||||
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
|
||||
.tc-btn-ghost:hover { background:#f0f4ff; }
|
||||
.tc-gen-status { font-size:12px; color:var(--tc-sub); }
|
||||
|
||||
.tc-empty { color:var(--tc-sub); font-size:13px; padding:8px 0; }
|
||||
|
||||
/* 结果区徽标 */
|
||||
.tc-skill-tags { display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.tc-badge { font-size:11px; padding:1px 7px; border-radius:999px; background:#eef2f7; color:#4b5563; }
|
||||
.tc-badge-info { background:#e8f0fe; color:#1d4ed8; }
|
||||
.tc-badge-warn { background:#fff5e0; color:#8a6100; }
|
||||
.tc-badge-danger { background:#fdece8; color:#b42318; }
|
||||
.tc-badge-good { background:#e8f6ee; color:#1a7a45; }
|
||||
|
||||
/* 交互弹窗 */
|
||||
.tc-modal-mask { position:fixed; inset:0; background:rgba(15,20,30,.45); display:none;
|
||||
align-items:center; justify-content:center; z-index:9000; padding:16px; }
|
||||
.tc-modal-mask.tc-show { display:flex; }
|
||||
.tc-modal { background:#fff; border-radius:12px; max-width:820px; width:100%;
|
||||
max-height:86vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
.tc-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:10px;
|
||||
padding:14px 16px; border-bottom:1px solid var(--tc-line); }
|
||||
.tc-modal-title { font-size:15px; font-weight:600; margin:0; }
|
||||
.tc-modal-sub { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-modal-body { padding:14px 16px; overflow:auto; }
|
||||
.tc-modal-foot { padding:10px 16px; border-top:1px solid var(--tc-line);
|
||||
display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.tc-kv { display:grid; grid-template-columns:112px 1fr; gap:4px 10px; font-size:13px; margin-bottom:10px; }
|
||||
.tc-kv-k { color:var(--tc-sub); }
|
||||
.tc-kv-v { word-break:break-all; }
|
||||
.tc-pre { background:#f7f8fa; border:1px solid var(--tc-line); border-radius:8px; padding:10px;
|
||||
font:12px/1.5 Consolas,Monaco,monospace; white-space:pre-wrap; word-break:break-all;
|
||||
max-height:280px; overflow:auto; margin:0; }
|
||||
.tc-sec-label { font-size:12px; font-weight:600; margin:12px 0 6px; color:#374151; }
|
||||
.tc-state { font-size:12px; padding:1px 8px; border-radius:999px; display:inline-block; }
|
||||
.tc-state-ok { background:#e8f6ee; color:#1a7a45; }
|
||||
.tc-state-error { background:#fdece8; color:#b42318; }
|
||||
.tc-state-pending { background:#eef2f7; color:#4b5563; }
|
||||
|
||||
/* Toast */
|
||||
.tc-toasts { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:9500;
|
||||
display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; }
|
||||
.tc-toast { font-size:13px; color:#fff; border-radius:8px; padding:8px 16px;
|
||||
box-shadow:0 4px 14px rgba(0,0,0,.18); }
|
||||
.tc-toast-info { background:#2f6fed; }
|
||||
.tc-toast-success { background:#1a7a45; }
|
||||
.tc-toast-error { background:#b42318; }
|
||||
|
||||
.tc-result-head { display:flex; justify-content:space-between; align-items:baseline; gap:10px;
|
||||
flex-wrap:wrap; margin-bottom:10px; }
|
||||
.tc-result-title { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
|
||||
@media (max-width:640px){ .tc-kv{ grid-template-columns:88px 1fr; } .tc-block{ padding:13px; } }
|
||||
</style>
|
||||
<!-- 报告样式单独一份:类名统一 tcr- 前缀,与上面的表单/页面样式完全隔离 -->
|
||||
<link rel="stylesheet" href="./tc_report.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="tc-page" id="tcr_top">
|
||||
<div class="tc-top">
|
||||
<h1>上海周边高铁游周末两天一晚</h1>
|
||||
<span class="tc-sub">游山玩水 · 单 skill 页</span>
|
||||
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
|
||||
</div>
|
||||
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
|
||||
|
||||
<div class="tc-note tc-note-danger" id="tc_auth_note" style="display:none">
|
||||
<b>缺少导航通行凭证(nav_gate),接口被拒绝</b>
|
||||
本工具的<b>页面</b>允许同源导航进入,但<b>接口</b>只认 nav_gate cookie,所以会出现「页面能打开、接口全是 401」。按下面做一次即可恢复:
|
||||
<ol>
|
||||
<li>从<b>首页九宫格</b>点「游山玩水」卡片进来(必须经过首页,它才会种 cookie)</li>
|
||||
<li>全程用<b>同一个地址</b>:<code>localhost</code> 与 <code>127.0.0.1</code> 的 cookie 不通用,中途切换域名就会失联</li>
|
||||
<li>nav_gate 是<b>会话级 cookie</b>,关闭浏览器后失效,需要重新从首页进一次</li>
|
||||
</ol>
|
||||
<div class="tc-actions">
|
||||
<a class="tc-btn" href="/">重新从首页进入</a>
|
||||
<button class="tc-btn tc-btn-ghost" id="tc_auth_retry">我已重新进入,重试</button>
|
||||
</div>
|
||||
<div id="tc_auth_diag"></div>
|
||||
</div>
|
||||
|
||||
<div class="tc-note tc-note-warn" id="tc_slow_note" style="display:none">
|
||||
<b>生成较慢,请勿关闭页面</b>
|
||||
整份行程(12 个节点 + 5 张表,含市内交通方案)需要模型输出 1.4-1.8 万 token,实测约 6-10 分钟。页面会显示已用时间,完成后自动渲染。
|
||||
</div>
|
||||
|
||||
<div class="tc-note tc-note-info">
|
||||
<b>两条本技能的固定规则,先说在前面</b>
|
||||
① <b>不给具体车次号</b>:AI 无法查 12306,写出来的车次号一定是编的,所以报告只给「建议时段 + 车站 + 历时区间 + 参考票价区间」,<b>实际车次请以 12306 为准</b>。<br>
|
||||
② <b>返程死线 21:00</b>:周日必须下午出发,最晚 21:00 抵达上海(周一要上班,晚了没地铁)。若班次偏晚,报告会提示你改早班次。
|
||||
</div>
|
||||
|
||||
<div class="tc-block">
|
||||
<h2 class="tc-block-title">1 · 填写行程参数</h2>
|
||||
<div class="tc-block-hint" id="tc_form_hint">正在读取问答定义…</div>
|
||||
<div class="tc-form" id="tc_form"></div>
|
||||
<div class="tc-actions">
|
||||
<button class="tc-btn" id="tc_gen" disabled>生成行程</button>
|
||||
<button class="tc-btn tc-btn-ghost" id="tc_gen_last" title="不重新生成,直接载入上一次的结果(一次生成要 6-10 分钟)">载入最近一次生成</button>
|
||||
<span class="tc-gen-status" id="tc_gen_status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tc_result"></div>
|
||||
|
||||
<div class="tc-note tc-note-info">
|
||||
<b>打不开 / 提示未授权?</b>
|
||||
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接把链接粘贴到地址栏会被拒绝(无同源来源)。
|
||||
<br><a href="./sample/index.html">查看离线样例:苏州(不烧 token,直接看报告长什么样)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
|
||||
<link rel="stylesheet" href="../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
|
||||
<script src="../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
|
||||
<script src="./tc_render.js"></script>
|
||||
<!-- 单文件 HTML 导出器:纯前端拼字符串 + 内联样式/图片,导出不消耗任何 token -->
|
||||
<script src="./tc_export.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict'
|
||||
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
|
||||
var API = '/api/thought_lab/travel_citytour/skills/sh_2d_rail_hike'
|
||||
var state = { uiMode: 'debug', meta: null, generating: false, timer: null, lastResult: null }
|
||||
|
||||
var $ = function (id) { return document.getElementById(id) }
|
||||
|
||||
/* ---------- 通用 UI 组件(双模式:debug / normal) ---------- */
|
||||
|
||||
function nowBeijing() {
|
||||
try {
|
||||
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
|
||||
} catch (e) { return new Date().toLocaleString() }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s === undefined || s === null ? '' : s)
|
||||
.replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] })
|
||||
}
|
||||
|
||||
function toast(text, kind) {
|
||||
var box = $('tc_toasts')
|
||||
if (!box) {
|
||||
box = document.createElement('div')
|
||||
box.className = 'tc-toasts'
|
||||
box.id = 'tc_toasts'
|
||||
document.body.appendChild(box)
|
||||
}
|
||||
var el = document.createElement('div')
|
||||
el.className = 'tc-toast tc-toast-' + (kind || 'info')
|
||||
el.textContent = text
|
||||
box.appendChild(el)
|
||||
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el) }, 2000)
|
||||
}
|
||||
|
||||
/* 导航通行凭证缺失(页面守卫认 Referer、接口守卫只认 cookie)时给出明确指引 */
|
||||
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
|
||||
function showAuthNote() {
|
||||
var el = $('tc_auth_note')
|
||||
if (el) el.style.display = ''
|
||||
// 顺手拉一次 lab 级公开诊断接口(注册在鉴权守卫之前),把「到底缺什么」直接摊在页面上
|
||||
// 注意:诊断接口归 lab 层所有(红线),本 skill 不重复实现
|
||||
fetch('/api/thought_lab/travel_citytour/auth/diag?t=' + Date.now(), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (d) {
|
||||
var box = $('tc_auth_diag')
|
||||
if (!box) return
|
||||
box.innerHTML = '<div class="tc-sec-label">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
|
||||
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
|
||||
})
|
||||
.catch(function () {})
|
||||
}
|
||||
|
||||
/* 交互详情弹窗:完整记录触发动作 / 时间 / 接口 / 方法 / 参数 / 返回 / 错误 / 耗时 / 状态 */
|
||||
var interactionTimer = null
|
||||
function showInteractionDialog(rec) {
|
||||
var mask = $('tc_modal')
|
||||
var okState = rec.state === 'ok'
|
||||
var canAutoClose = okState
|
||||
var autoSec = state.uiMode === 'debug' ? 3 : 2
|
||||
|
||||
mask.querySelector('.tc-modal-title').textContent = rec.action
|
||||
mask.querySelector('.tc-modal-head .tc-modal-sub').textContent =
|
||||
state.uiMode === 'debug' ? '研发调试模式 · 完整交互详情' : '交互详情'
|
||||
|
||||
var body = mask.querySelector('.tc-modal-body')
|
||||
body.innerHTML =
|
||||
'<div class="tc-kv">' +
|
||||
'<div class="tc-kv-k">当前状态</div><div class="tc-kv-v"><span class="tc-state tc-state-' +
|
||||
(rec.state === 'ok' ? 'ok' : (rec.state === 'pending' ? 'pending' : 'error')) + '">' +
|
||||
esc(rec.state === 'ok' ? '成功' : (rec.state === 'pending' ? '进行中' : '失败')) + '</span></div>' +
|
||||
'<div class="tc-kv-k">触发动作</div><div class="tc-kv-v">' + esc(rec.action) + '</div>' +
|
||||
'<div class="tc-kv-k">时间</div><div class="tc-kv-v">' + esc(rec.time) + '(北京时间)</div>' +
|
||||
'<div class="tc-kv-k">接口地址</div><div class="tc-kv-v">' + esc(rec.method + ' ' + rec.url) + '</div>' +
|
||||
'<div class="tc-kv-k">耗时</div><div class="tc-kv-v">' + esc(rec.elapsed) + '</div>' +
|
||||
'<div class="tc-kv-k">结果摘要</div><div class="tc-kv-v">' + esc(rec.summary || '—') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="tc-sec-label">请求参数</div><pre class="tc-pre">' + esc(rec.request || '(无)') + '</pre>' +
|
||||
'<div class="tc-sec-label">返回结果</div><pre class="tc-pre">' + esc(rec.response || '(无)') + '</pre>' +
|
||||
(rec.error ? '<div class="tc-sec-label">错误信息</div><pre class="tc-pre">' + esc(rec.error) + '</pre>' : '')
|
||||
|
||||
mask.querySelector('.tc-modal-foot .tc-gen-status').textContent = canAutoClose
|
||||
? ('窗口将在 ' + autoSec + 's 后自动关闭') : '出现异常,窗口不会自动关闭,便于排查'
|
||||
|
||||
mask.classList.add('tc-show')
|
||||
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
|
||||
if (canAutoClose) {
|
||||
var left = autoSec
|
||||
interactionTimer = setInterval(function () {
|
||||
left--
|
||||
var tip = mask.querySelector('.tc-modal-foot .tc-gen-status')
|
||||
if (tip) tip.textContent = '窗口将在 ' + left + 's 后自动关闭'
|
||||
if (left <= 0) { closeInteractionDialog(); }
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
function closeInteractionDialog() {
|
||||
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
|
||||
var mask = $('tc_modal')
|
||||
if (mask) mask.classList.remove('tc-show')
|
||||
}
|
||||
|
||||
/* 统一包装一次后台交互:记录细节 + 按模式决定弹窗还是 Toast */
|
||||
function runTrackedInteraction(opt) {
|
||||
var t0 = Date.now()
|
||||
var rec = {
|
||||
action: opt.action, method: opt.method || 'GET', url: opt.url,
|
||||
request: opt.request ? JSON.stringify(opt.request, null, 2) : '', time: nowBeijing()
|
||||
}
|
||||
if (opt.showPending && state.uiMode === 'debug') {
|
||||
rec.state = 'pending'; rec.elapsed = '—'; rec.summary = '请求已发出,等待返回…'
|
||||
showInteractionDialog(rec)
|
||||
}
|
||||
return opt.run().then(function (res) {
|
||||
rec.state = 'ok'
|
||||
rec.elapsed = (Date.now() - t0) + ' ms'
|
||||
rec.summary = opt.summary ? opt.summary(res) : '成功'
|
||||
rec.response = JSON.stringify(opt.responseOf ? opt.responseOf(res) : res, null, 2)
|
||||
if (state.uiMode === 'debug') showInteractionDialog(rec)
|
||||
else toast(rec.summary, 'success')
|
||||
return res
|
||||
}).catch(function (err) {
|
||||
rec.state = 'error'
|
||||
rec.elapsed = (Date.now() - t0) + ' ms'
|
||||
rec.summary = '失败'
|
||||
rec.error = (err && err.message) || String(err)
|
||||
if (err && err.detail) rec.response = JSON.stringify(err.detail, null, 2)
|
||||
// 失败在任何模式下都强提示,不降级、不自动关闭
|
||||
showInteractionDialog(rec)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
function requestJson(url, options) {
|
||||
var opt = Object.assign({ cache: 'no-store' }, options || {})
|
||||
var timeoutMs = opt.timeoutMs || 0
|
||||
delete opt.timeoutMs
|
||||
var timer = null
|
||||
if (timeoutMs && window.AbortController) {
|
||||
var ctrl = new AbortController()
|
||||
opt.signal = ctrl.signal
|
||||
timer = setTimeout(function () { ctrl.abort() }, timeoutMs)
|
||||
}
|
||||
var clear = function () { if (timer) { clearTimeout(timer); timer = null } }
|
||||
return fetch(url + '?t=' + Date.now(), opt)
|
||||
.then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null
|
||||
try { j = JSON.parse(t) } catch (e) {}
|
||||
if (!r.ok) {
|
||||
var err = new Error((j && (j.error || (j.hint ? j.error + ':' + j.hint : j.error))) || ('HTTP ' + r.status))
|
||||
err.detail = j || t.slice(0, 400)
|
||||
throw err
|
||||
}
|
||||
if (!j) throw new Error('返回不是合法 JSON')
|
||||
return j
|
||||
})
|
||||
})
|
||||
.then(function (j) { clear(); return j }, function (e) {
|
||||
clear()
|
||||
if (e && e.name === 'AbortError') {
|
||||
// 长请求经 8975 反代存在「服务端已返回、客户端收不到」的情况,此前界面会无限停在「生成中」
|
||||
var err = new Error('等待 ' + Math.round(timeoutMs / 60000) + ' 分钟仍未收到响应。服务端可能已经生成完成,请点「载入最近一次生成」查看')
|
||||
err.detail = { timeout_ms: timeoutMs, url: url }
|
||||
throw err
|
||||
}
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 启动:读本技能元信息(含 ui_mode / qa / report_spec / destinations) ---------- */
|
||||
|
||||
function loadMeta() {
|
||||
return runTrackedInteraction({
|
||||
action: '读取技能元信息', method: 'GET', url: API + '/meta',
|
||||
run: function () { return requestJson(API + '/meta') },
|
||||
summary: function (r) {
|
||||
return 'ui_mode=' + r.ui_mode + ',模型=' + r.ai_model +
|
||||
',' + (r.report_spec && r.report_spec.sections || []).length + ' 个区块'
|
||||
},
|
||||
responseOf: function (r) {
|
||||
return {
|
||||
ok: r.ok, ui_mode: r.ui_mode, ai_model: r.ai_model, ai_ready: r.ai_ready,
|
||||
skill: r.skill, sections: (r.report_spec && r.report_spec.sections || []).length,
|
||||
destinations: (r.destinations || []).map(function (d) { return d.name })
|
||||
}
|
||||
}
|
||||
}).then(function (r) {
|
||||
state.meta = r
|
||||
state.uiMode = r.ui_mode || 'debug'
|
||||
var s = r.skill || {}
|
||||
$('tc_meta').innerHTML =
|
||||
'ui_mode:<b>' + esc(state.uiMode) + '</b>' +
|
||||
' · 技能:<b>' + esc(s.name || '') + '</b>(v' + esc(s.version || '') + ')' +
|
||||
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
|
||||
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
|
||||
$('tc_slow_note').style.display = ''
|
||||
renderForm(r)
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 渲染问答表单(按本技能 qa.json 动态生成,不写死字段) ---------- */
|
||||
|
||||
function renderForm(meta) {
|
||||
var qa = meta.qa || {}
|
||||
var host = $('tc_form')
|
||||
host.innerHTML = ''
|
||||
var s = meta.skill || {}
|
||||
$('tc_form_hint').textContent = '当前技能:' + (s.name || '') + '(v' + (s.version || '') + ')· '
|
||||
+ (s.anchors_count || 0) + ' 个骨架槽位 · 带 * 为必填。'
|
||||
|
||||
var dest = qa.destination_input || {}
|
||||
var destSuggestions = (meta.destinations || []).map(function (d) { return d.name }).join(' / ')
|
||||
addField(host, {
|
||||
key: 'destination', label: dest.label || '目的地',
|
||||
type: 'text', required: dest.required !== false,
|
||||
placeholder: dest.placeholder || '例如:南通如东',
|
||||
hint: (dest.hint || '') + (destSuggestions ? '(本技能已跑过:' + destSuggestions + ')' : '')
|
||||
})
|
||||
|
||||
;(qa.questions || []).forEach(function (q) {
|
||||
addField(host, {
|
||||
key: q.key, label: q.label || q.key, type: q.type || 'text',
|
||||
required: q.required !== false, default: q.default, options: q.options,
|
||||
min: q.min, max: q.max, hint: q.hint || '',
|
||||
affects: q.affects || []
|
||||
})
|
||||
})
|
||||
|
||||
var btn = $('tc_gen')
|
||||
btn.disabled = false
|
||||
btn.textContent = '生成行程'
|
||||
$('tc_gen_status').textContent = '参数填好后点右侧按钮;生成需 6-8 分钟'
|
||||
}
|
||||
|
||||
function addField(host, f) {
|
||||
var wrap = document.createElement('div')
|
||||
wrap.className = 'tc-field'
|
||||
var lab = document.createElement('label')
|
||||
lab.className = 'tc-label'
|
||||
lab.textContent = f.label
|
||||
if (f.required) {
|
||||
var st = document.createElement('span'); st.className = 'tc-req'; st.textContent = '*'
|
||||
lab.appendChild(st)
|
||||
}
|
||||
wrap.appendChild(lab)
|
||||
|
||||
var input
|
||||
if (f.type === 'select') {
|
||||
input = document.createElement('select')
|
||||
;(f.options || []).forEach(function (o) {
|
||||
var op = document.createElement('option')
|
||||
op.value = o.value
|
||||
op.textContent = o.label || o.value
|
||||
input.appendChild(op)
|
||||
})
|
||||
} else {
|
||||
input = document.createElement('input')
|
||||
input.className = 'tc-input'
|
||||
input.type = (f.type === 'number' ? 'number' : (f.type === 'date' ? 'date' : (f.type === 'time' ? 'time' : 'text')))
|
||||
if (f.placeholder) input.placeholder = f.placeholder
|
||||
if (f.min !== undefined && f.min !== null) input.min = f.min
|
||||
if (f.max !== undefined && f.max !== null) input.max = f.max
|
||||
}
|
||||
if (f.type === 'select') input.className = 'tc-select'
|
||||
input.id = 'qa_' + f.key
|
||||
if (f.default !== undefined && f.default !== null && f.default !== '') input.value = f.default
|
||||
wrap.appendChild(input)
|
||||
|
||||
if (f.hint) {
|
||||
var h = document.createElement('div'); h.className = 'tc-hint'; h.textContent = f.hint
|
||||
wrap.appendChild(h)
|
||||
}
|
||||
if (f.affects && f.affects.length) {
|
||||
var a = document.createElement('div'); a.className = 'tc-affects'
|
||||
a.textContent = '影响:' + f.affects.join('、')
|
||||
wrap.appendChild(a)
|
||||
}
|
||||
host.appendChild(wrap)
|
||||
}
|
||||
|
||||
function collectAnswers() {
|
||||
var qa = (state.meta && state.meta.qa) || {}
|
||||
var out = {}
|
||||
var keys = ['destination'].concat((qa.questions || []).map(function (q) { return q.key }))
|
||||
keys.forEach(function (k) {
|
||||
var el = $('qa_' + k)
|
||||
if (!el) return
|
||||
var v = String(el.value || '').trim()
|
||||
if (v === '') return
|
||||
var q = (qa.questions || []).filter(function (x) { return x.key === k })[0]
|
||||
out[k] = (q && q.type === 'number') ? Number(v) : v
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- 生成 ---------- */
|
||||
|
||||
function startTimer() {
|
||||
var t0 = Date.now()
|
||||
state.timer = setInterval(function () {
|
||||
var s = Math.floor((Date.now() - t0) / 1000)
|
||||
$('tc_gen_status').textContent = '生成中… 已用 ' + s + ' 秒(整份行程实测约 3-5 分钟,请勿关闭页面)'
|
||||
}, 1000)
|
||||
}
|
||||
function stopTimer() {
|
||||
if (state.timer) { clearInterval(state.timer); state.timer = null }
|
||||
}
|
||||
|
||||
/* 载入最近一次生成:不重新烧 4 分钟,直接看上一次落盘的结果(本技能私有 data/) */
|
||||
function onLoadLast() {
|
||||
var btn = $('tc_gen_last')
|
||||
var old = btn.textContent
|
||||
btn.disabled = true
|
||||
btn.textContent = '载入中…'
|
||||
runTrackedInteraction({
|
||||
action: '载入最近一次生成', method: 'GET', url: API + '/last',
|
||||
run: function () { return requestJson(API + '/last') },
|
||||
summary: function (r) {
|
||||
return '已载入:' + ((r.payload && r.payload.summary && r.payload.summary.title) || '未命名行程')
|
||||
},
|
||||
responseOf: function (r) {
|
||||
return {
|
||||
ok: r.ok, meta: r.meta,
|
||||
check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) }
|
||||
}
|
||||
}
|
||||
}).then(function (r) {
|
||||
if (renderResultSafe(r, '载入成功,但渲染失败(问题在前端渲染器)')) toast('已载入最近一次生成', 'success')
|
||||
}).catch(function (e) {
|
||||
if (isUnauthorized(e)) showAuthNote()
|
||||
$('tc_result').innerHTML = '<div class="tc-note tc-note-warn"><b>载入失败</b>:' + esc((e && e.message) || String(e)) + '</div>'
|
||||
}).then(function () {
|
||||
btn.disabled = false
|
||||
btn.textContent = old
|
||||
})
|
||||
}
|
||||
|
||||
function onGenerate() {
|
||||
if (state.generating) return
|
||||
var answers = collectAnswers()
|
||||
if (!answers.destination) { toast('请先填写目的地', 'error'); return }
|
||||
|
||||
state.generating = true
|
||||
var btn = $('tc_gen')
|
||||
btn.disabled = true
|
||||
btn.textContent = '生成中…'
|
||||
startTimer()
|
||||
|
||||
runTrackedInteraction({
|
||||
action: '生成行程(AI)',
|
||||
method: 'POST',
|
||||
url: API + '/generate',
|
||||
request: { answers: answers },
|
||||
showPending: true,
|
||||
run: function () {
|
||||
return requestJson(API + '/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: answers }),
|
||||
timeoutMs: 8 * 60 * 1000
|
||||
})
|
||||
},
|
||||
summary: function (r) {
|
||||
var c = r.check || {}
|
||||
// 有结构问题时如实说明(结果已被保留,可查看/导出),不要一律报「生成成功」
|
||||
var errs = c.errors || []
|
||||
return (errs.length ? '已生成但有 ' + errs.length + ' 处结构问题:' : '生成成功:') +
|
||||
(r.check && r.check.stats ? r.check.stats.cards : '?') + ' 张卡片,' +
|
||||
(c.warnings ? c.warnings.length : 0) + ' 条提醒,耗时 ' + Math.round((r.meta && r.meta.elapsed_ms || 0) / 1000) + 's'
|
||||
},
|
||||
responseOf: function (r) {
|
||||
return {
|
||||
ok: r.ok, meta: r.meta, check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) },
|
||||
payload_summary: r.payload && r.payload.summary,
|
||||
destination_override: r.destination_override
|
||||
}
|
||||
}
|
||||
}).then(function (r) {
|
||||
if (renderResultSafe(r, '生成成功,但渲染失败(数据是好的,问题在前端渲染器)')) {
|
||||
var nErr = (r.check && r.check.errors) || []
|
||||
// 有结构问题时不用绿色成功提示,避免「看起来一切正常」的错觉
|
||||
if (nErr.length) toast('已生成,但有 ' + nErr.length + ' 处结构问题(见下方红色提示)', 'error')
|
||||
else toast('行程已生成并渲染', 'success')
|
||||
}
|
||||
}).catch(function (err) {
|
||||
if (isUnauthorized(err)) showAuthNote()
|
||||
$('tc_gen_status').textContent = '生成失败:' + (err && err.message || '')
|
||||
}).then(function () {
|
||||
state.generating = false
|
||||
stopTimer()
|
||||
btn.disabled = false
|
||||
btn.textContent = '重新生成'
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染必须永远有反馈:异常时把错误摊在页面上,绝不留白、绝不静默
|
||||
function renderResultSafe(r, title) {
|
||||
try {
|
||||
renderResult(r)
|
||||
return true
|
||||
} catch (e) {
|
||||
$('tc_result').innerHTML =
|
||||
'<div class="tc-note tc-note-warn"><b>' + esc(title) + '</b><br>' +
|
||||
'· 错误:' + esc((e && e.message) || String(e)) + '<br>' +
|
||||
'· 位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) + '</div>'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(r) {
|
||||
var host = $('tc_result')
|
||||
var payload = r.payload
|
||||
var spec = r.report_spec || {}
|
||||
// 导出按钮吃的是这份「已经生成好」的结果:导出不再调用 AI,不消耗 token
|
||||
state.lastResult = r
|
||||
var head = document.createElement('div')
|
||||
head.className = 'tc-result-head'
|
||||
var warns = (r.check && r.check.warnings) || []
|
||||
// 结构问题(errors):结果已被服务端保留(不再整份丢弃),这里必须醒目告知,而不是假装一切正常
|
||||
var errs = (r.check && r.check.errors) || []
|
||||
head.innerHTML =
|
||||
'<div class="tc-result-title">' +
|
||||
'<h2 class="tc-block-title" style="margin:0">2 · 生成结果</h2>' +
|
||||
'<button class="tc-btn tc-btn-ghost" id="tc_export" style="padding:5px 12px"' +
|
||||
' title="把下面这份结果导出成一个独立的 HTML 单文件,离线随时查看(不重新生成、不消耗 token)">导出至 HTML</button>' +
|
||||
'</div>' +
|
||||
'<div class="tc-skill-tags">' +
|
||||
'<span class="tc-badge tc-badge-info">模型 ' + esc((r.meta && r.meta.model) || '') + '</span>' +
|
||||
'<span class="tc-badge tc-badge-info">耗时 ' + Math.round(((r.meta && r.meta.elapsed_ms) || 0) / 1000) + 's</span>' +
|
||||
(r.meta && r.meta.is_new_destination ? '<span class="tc-badge tc-badge-warn">首个新目的地</span>' : '<span class="tc-badge tc-badge-good">技能包内已有覆盖</span>') +
|
||||
(errs.length
|
||||
? '<span class="tc-badge tc-badge-danger">' + errs.length + ' 处结构问题</span>'
|
||||
: (warns.length
|
||||
? '<span class="tc-badge tc-badge-warn">' + warns.length + ' 条校验提醒</span>'
|
||||
: '<span class="tc-badge tc-badge-good">结构校验通过</span>')) +
|
||||
'</div>'
|
||||
host.innerHTML = ''
|
||||
host.appendChild(head)
|
||||
|
||||
var exportBtn = $('tc_export')
|
||||
if (exportBtn) {
|
||||
if (window.TCExport) exportBtn.addEventListener('click', onExport)
|
||||
else { exportBtn.disabled = true; exportBtn.title = '导出器未加载(tc_export.js 未成功执行)' }
|
||||
}
|
||||
|
||||
if (errs.length) {
|
||||
var e = document.createElement('div')
|
||||
e.className = 'tc-note tc-note-danger'
|
||||
e.innerHTML = '<b>本次结果有 ' + errs.length + ' 处结构问题(前 12 条)—— 已保留,未丢弃</b>' +
|
||||
errs.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>') +
|
||||
'<br>下方内容仍可正常查看与导出。若这些报错其实是「校验器误判」,' +
|
||||
'请把这段文字发给 AI 定位(不必重新生成);若确属内容缺失,再点「重新生成」。'
|
||||
host.appendChild(e)
|
||||
}
|
||||
|
||||
if (warns.length) {
|
||||
var w = document.createElement('div')
|
||||
w.className = 'tc-note tc-note-warn'
|
||||
w.innerHTML = '<b>结构校验提醒(' + warns.length + ' 条,前 12 条)</b>' +
|
||||
warns.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>')
|
||||
host.appendChild(w)
|
||||
}
|
||||
|
||||
var mount = document.createElement('div')
|
||||
mount.id = 'tc_report'
|
||||
host.appendChild(mount)
|
||||
if (!window.TCRender) {
|
||||
mount.innerHTML = '<div class="tc-empty">渲染器未加载(tc_render.js 未成功执行)</div>'
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.TCRender.render(payload, spec, mount)
|
||||
} catch (e) {
|
||||
// 渲染中断时保留已渲染部分,并把错误摊在页面上 —— 绝不静默留白
|
||||
var box = document.createElement('div')
|
||||
box.className = 'tc-note tc-note-warn'
|
||||
box.innerHTML = '<b>渲染中断</b>:' + esc((e && e.message) || String(e)) +
|
||||
'<br>位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) +
|
||||
'<br>以上为已成功渲染的部分。把这行错误发给 AI 即可定位。'
|
||||
mount.appendChild(box)
|
||||
}
|
||||
}
|
||||
|
||||
/* 导出至 HTML:把当前这份结果拼成「单文件离线文档」并下载。
|
||||
全程纯前端(读同目录 tc_export.css + 内嵌配图),不调用任何后端接口、不消耗 token。 */
|
||||
function onExport() {
|
||||
var r = state.lastResult
|
||||
if (!r) { toast('还没有可导出的结果,先生成或载入一次', 'error'); return }
|
||||
if (!window.TCExport) { toast('导出器未加载(tc_export.js)', 'error'); return }
|
||||
|
||||
var btn = $('tc_export')
|
||||
if (btn && btn.disabled) return
|
||||
var old = btn ? btn.textContent : ''
|
||||
if (btn) { btn.disabled = true; btn.textContent = '导出中…' }
|
||||
|
||||
var payload = r.payload || {}
|
||||
var summary = payload.summary || {}
|
||||
var sections = (r.report_spec && r.report_spec.sections) || []
|
||||
var cards = Array.isArray(payload.cards) ? payload.cards : []
|
||||
var markers = (payload.map && payload.map.markers) || []
|
||||
|
||||
runTrackedInteraction({
|
||||
action: '导出至 HTML(单文件·离线可看)', method: 'LOCAL', url: './tc_export.css → blob 下载',
|
||||
request: {
|
||||
title: summary.title || '(无标题)',
|
||||
sections: sections.length,
|
||||
cards: cards.length,
|
||||
markers: markers.length,
|
||||
note: '内联样式 + 内嵌配图 + 内联 SVG 地图;不调用 AI、不消耗 token'
|
||||
},
|
||||
showPending: true,
|
||||
run: function () { return window.TCExport.download(payload, r.report_spec || {}, { meta: r.meta }) },
|
||||
summary: function (res) {
|
||||
return '已导出 ' + res.filename + '(' + Math.round(res.bytes / 1024) + ' KB,' + res.sections + ' 个区块' +
|
||||
(res.images && res.images.total ? ',配图内嵌 ' + res.images.embedded + '/' + res.images.total : '') + ')'
|
||||
},
|
||||
responseOf: function (res) { return res }
|
||||
}).then(function (res) {
|
||||
toast('已导出:' + res.filename, 'success')
|
||||
}).catch(function (e) {
|
||||
toast('导出失败:' + ((e && e.message) || ''), 'error')
|
||||
}).then(function () {
|
||||
if (btn) { btn.disabled = false; btn.textContent = old }
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
|
||||
// 交互弹窗骨架
|
||||
var mask = document.createElement('div')
|
||||
mask.className = 'tc-modal-mask'
|
||||
mask.id = 'tc_modal'
|
||||
mask.innerHTML =
|
||||
'<div class="tc-modal">' +
|
||||
'<div class="tc-modal-head">' +
|
||||
'<div><h3 class="tc-modal-title">交互详情</h3><div class="tc-modal-sub"></div></div>' +
|
||||
'<button class="tc-btn tc-btn-ghost" id="tc_modal_close" style="padding:5px 12px">关闭</button>' +
|
||||
'</div>' +
|
||||
'<div class="tc-modal-body"></div>' +
|
||||
'<div class="tc-modal-foot">' +
|
||||
'<button class="tc-btn tc-btn-ghost" id="tc_modal_copy" style="padding:5px 12px">复制完整交互日志</button>' +
|
||||
'<span class="tc-gen-status"></span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
document.body.appendChild(mask)
|
||||
|
||||
$('tc_modal_close').addEventListener('click', closeInteractionDialog)
|
||||
mask.addEventListener('click', function (e) { if (e.target === mask) closeInteractionDialog() })
|
||||
$('tc_modal_copy').addEventListener('click', function () {
|
||||
var txt = mask.querySelector('.tc-modal-body').innerText
|
||||
var done = function () { toast('交互日志已复制', 'success') }
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done, done)
|
||||
else {
|
||||
var ta = document.createElement('textarea')
|
||||
ta.value = txt; document.body.appendChild(ta); ta.select()
|
||||
try { document.execCommand('copy'); done() } catch (e) { toast('复制失败', 'error') }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
})
|
||||
|
||||
$('tc_gen').addEventListener('click', onGenerate)
|
||||
$('tc_gen_last').addEventListener('click', onLoadLast)
|
||||
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
|
||||
|
||||
loadMeta().catch(function (e) {
|
||||
if (isUnauthorized(e)) showAuthNote()
|
||||
$('tc_form_hint').textContent = '技能元信息加载失败:' + (e && e.message || '')
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
============================================================
|
||||
sh_2d_rail_hike/sample/index.html —— 本技能的离线样例页(技能私有)
|
||||
|
||||
用途:不烧 token、不需要后端,直接打开就能看到本技能的报告长什么样。
|
||||
做法:读同目录的 sample/suzhou_2026-10-17.json(手工样例)+ report_spec.json,
|
||||
交给本 skill 的渲染器 tc_render.js 渲染。
|
||||
|
||||
【隔离用意】
|
||||
1. 本页与样例数据都归【本 skill】私有:样例放在 skills/sh_2d_rail_hike/sample/。
|
||||
这是隔离后修正过的口径 —— 以前样例放在 lab 级共享 sample/ 目录,A skill 的样例
|
||||
会被 B skill 引用,容易串味。
|
||||
2. report_spec.json 这里是技能包那份的【副本】,只为让本页能独立打开。
|
||||
后端生成时用的是 skills/sh_2d_rail_hike/report_spec.json(真源),不是本副本。
|
||||
改真源后请同步本副本,否则本页展示的区块顺序会和实际生成结果不一致。
|
||||
3. 唯一向上引用的只有 lab 级第三方库 ../../../assets/vendor/leaflet/(无业务语义的 vendor)。
|
||||
============================================================
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>苏州古城水岸 · 两日高铁轻旅行|游山玩水(离线样例)</title>
|
||||
<style>
|
||||
/* 页面级样式,与报告样式(tcr- 前缀)完全隔离,避免互相污染 */
|
||||
:root {
|
||||
--tc-bg: #f6f7f9;
|
||||
--tc-card: #ffffff;
|
||||
--tc-line: #e5e7eb;
|
||||
--tc-text: #1f2328;
|
||||
--tc-sub: #6b7280;
|
||||
--tc-accent: #2f6fed;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-text);
|
||||
font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.tc-page { max-width: 1080px; margin: 0 auto; padding: 20px 16px 64px; }
|
||||
.tc-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.tc-top h1 { font-size: 20px; margin: 0; }
|
||||
.tc-top .tc-mode { font-size: 12px; color: var(--tc-sub); }
|
||||
.tc-back { font-size: 12px; color: var(--tc-accent); text-decoration: none; }
|
||||
.tc-back:hover { text-decoration: underline; }
|
||||
.tc-meta {
|
||||
font-size: 12px; color: var(--tc-sub); background: #fff;
|
||||
border: 1px solid var(--tc-line); border-radius: 8px; padding: 8px 12px; margin: 12px 0 14px;
|
||||
}
|
||||
.tc-meta b { color: var(--tc-text); font-weight: 600; }
|
||||
.tc-disclaimer {
|
||||
background: #fff8e6; border: 1px solid #f0d79a; color: #7a5a12;
|
||||
border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 18px;
|
||||
}
|
||||
.tc-error { background: #fff5f4; border: 1px solid #f5c9c3; color: #b42318; border-radius: 8px; padding: 14px; font-size: 13px; }
|
||||
.tc-error code { background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 4px; }
|
||||
</style>
|
||||
<!-- 报告样式与渲染器都归本 skill 私有(同目录一份);改本 skill 不影响其他 skill -->
|
||||
<link rel="stylesheet" href="../tc_report.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="tc-page" id="tcr_top">
|
||||
<div class="tc-top">
|
||||
<h1>苏州古城水岸 · 两日高铁轻旅行</h1>
|
||||
<span class="tc-mode">游山玩水 · 技能「上海周边高铁游周末两天一晚」· 离线样例</span>
|
||||
<a class="tc-back" href="../index.html">← 返回本技能</a>
|
||||
</div>
|
||||
<div class="tc-meta" id="tc_meta">正在载入样例数据…</div>
|
||||
<div class="tc-disclaimer">
|
||||
<b>这是效果验证样例:</b>由技能包规则手工编写,用来验证渲染器/导出器能正确处理本技能的新词表
|
||||
(共享单车 / 网约车 / 车站 / 住宿)与「每处可导航地址」。<b>其中的高铁时段、票价、店铺、酒店、
|
||||
共享单车禁停区均属示意</b>,真实行程以 AI 生成 + 12306 核实为准。
|
||||
</div>
|
||||
<div id="tc_mount"></div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
|
||||
<link rel="stylesheet" href="../../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
|
||||
<script src="../../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
|
||||
<script src="../tc_render.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var mount = document.getElementById('tc_mount')
|
||||
var metaBox = document.getElementById('tc_meta')
|
||||
|
||||
function fail(msg, err) {
|
||||
mount.innerHTML = ''
|
||||
var box = document.createElement('div')
|
||||
box.className = 'tc-error'
|
||||
box.innerHTML = '<b>' + msg + '</b><br/>' + (err ? String(err.message || err) : '')
|
||||
+ '<br/>请通过首页九宫格进入本工具后再打开本页(静态资源受导航鉴权保护)。'
|
||||
mount.appendChild(box)
|
||||
metaBox.textContent = '载入失败'
|
||||
}
|
||||
|
||||
// 本技能样例的问答摘要(字段名取自本 skill 的 qa.json,与自驾 skill 完全不同)
|
||||
var LABELS = {
|
||||
departure_window: { sat_early: '周六早出发', sat_morning: '周六上午出发', fri_night: '周五晚出发(夜车)' },
|
||||
lodging_mode: { hotel: '酒店/民宿', bathhouse: '高级洗浴(带留宿)', both: '两者都给' },
|
||||
budget_level: { economy: '经济', comfort: '舒适', quality: '品质' },
|
||||
luggage: { backpack: '轻装(背包)', suitcase: '带拉杆箱' },
|
||||
hike_intensity: { easy: '轻松(约1h)', medium: '中等(约1.5h)', strong: '稍强(约2h)' }
|
||||
}
|
||||
function label(key, val) {
|
||||
var m = LABELS[key]
|
||||
if (m && m[val]) return m[val]
|
||||
return val === undefined || val === null || val === '' ? '—' : String(val)
|
||||
}
|
||||
|
||||
function renderMeta(payload) {
|
||||
var m = payload.meta || {}
|
||||
var d = m.destination || {}
|
||||
var qa = m.qa || {}
|
||||
metaBox.innerHTML =
|
||||
'skill:<b>' + (m.skill_name || '—') + '</b>(v' + (m.skill_version || '—') + ')' +
|
||||
' · 目的地:<b>' + (d.name || '—') + '</b>' + (d.admin ? '(' + d.admin + ')' : '') +
|
||||
' · 出发:<b>' + label('departure_window', qa.departure_window) + '</b>' +
|
||||
'(' + (qa.departure_date || '—') + ' 自 ' + (qa.departure_station || '—') + ')' +
|
||||
' · 过夜:<b>' + label('lodging_mode', qa.lodging_mode) + '</b>' +
|
||||
' · 档次:<b>' + label('budget_level', qa.budget_level) + '</b>' +
|
||||
' · 行李:<b>' + label('luggage', qa.luggage) + '</b>' +
|
||||
' · 徒步:<b>' + label('hike_intensity', qa.hike_intensity) + '</b>' +
|
||||
' · 人数:<b>' + (qa.headcount === undefined ? '—' : qa.headcount) + '</b>' +
|
||||
' · 生成方式:<b>' + (m.generator || '—') + '</b> · 生成时间:<b>' + (m.generated_at || '—') + '</b>(北京时间)'
|
||||
}
|
||||
|
||||
if (!window.TCRender) { fail('渲染器未加载', null); return }
|
||||
|
||||
var files = ['./suzhou_2026-10-17.json', './report_spec.json']
|
||||
Promise.all(files.map(function (u) {
|
||||
return fetch(u + '?t=' + Date.now(), { cache: 'no-store' }).then(function (r) {
|
||||
if (!r.ok) throw new Error(u + ' HTTP ' + r.status)
|
||||
return r.json()
|
||||
})
|
||||
})).then(function (res) {
|
||||
var payload = res[0]
|
||||
var spec = res[1]
|
||||
window.TCRender.render(payload, spec, mount)
|
||||
renderMeta(payload)
|
||||
}).catch(function (e) {
|
||||
fail('样例数据载入失败', e)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端渲染逻辑不用改结构。",
|
||||
"_isolation_comment": "【隔离用意】本文件是【本 skill 私有的渲染描述】。本 skill 复用了与 sh_2d_car_hike 相同的 8 种 block 类型(overview/timeline/cards/map/table/cost/checklist/notes),这是刻意为之 —— block 类型是『展示形态』的抽象,够通用;而『车次表』『市内交通表』这类业务差异,靠 table 里的列定义与 pick_id 表达即可,不必新增 block 类型。所以本 skill 的渲染器只需改自己的字典(activity 中文名、marker 颜色),不必改渲染结构。这是隔离后最省力的定制方式。若将来某个 skill 真需要全新展示形态(例如航班时刻轴),那时再为它单独加 block,不影响本文件。",
|
||||
"version": "1.1.1",
|
||||
|
||||
"block_types_comment": "通用渲染器支持的 8 种 block;本 skill 未新增 block 类型",
|
||||
"block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"],
|
||||
|
||||
"layout_comment": "页面骨架:单列纵向",
|
||||
"layout": "single_column",
|
||||
"show_index_comment": "是否在页首显示区块锚点导航",
|
||||
"show_index": true,
|
||||
|
||||
"sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项",
|
||||
"sections": [
|
||||
{ "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" },
|
||||
|
||||
{ "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] },
|
||||
|
||||
{ "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" },
|
||||
|
||||
{ "id": "sec_cards", "type": "cards", "title": "节点详情(含导航地址)", "data": "$.cards", "group_by": "day" },
|
||||
|
||||
{ "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" },
|
||||
|
||||
{ "id": "sec_train_out", "type": "table", "title": "去程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_out_options" },
|
||||
|
||||
{ "id": "sec_train_back", "type": "table", "title": "返程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_back_options" },
|
||||
|
||||
{ "id": "sec_city_transport", "type": "table", "title": "市内交通方案 · 共享单车 / 网约车", "data": "$.tables", "pick_id": "city_transport" },
|
||||
|
||||
{ "id": "sec_lodging", "type": "table", "title": "住宿候选对比", "data": "$.tables", "pick_id": "lodging_compare" },
|
||||
|
||||
{ "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" },
|
||||
|
||||
{ "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" },
|
||||
|
||||
{ "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" },
|
||||
|
||||
{ "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" }
|
||||
],
|
||||
|
||||
"confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)",
|
||||
"confidence_badge": {
|
||||
"high": "已核实",
|
||||
"medium": "待确认",
|
||||
"low": "需核实"
|
||||
},
|
||||
"image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景",
|
||||
"image_disclaimer_default": "AI 示意图,非实景",
|
||||
"map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹;坐标按概略值处理,导航请以导航地址为准"
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"_comment": "【离线样例 · 手工编写,仅用于渲染验证与产物预览】本文件是 sh_2d_rail_hike 的一份完整 payload 示例,用来:(1) 验证渲染器/导出器能正确处理本 skill 的新词表(共享单车/网约车/车站/住宿)与 address 字段;(2) 让用户在不想烧 token 时也能先看报告长什么样。注意:本样例中的高铁时段、票价、店铺、酒店、共享单车禁停区等均属【示意】,不代表真实情况,真实行程以 AI 生成 + 12306 核实为准。",
|
||||
"meta": {
|
||||
"skill_id": "sh_2d_rail_hike",
|
||||
"skill_name": "上海周边高铁游周末两天一晚",
|
||||
"skill_version": "1.1.1",
|
||||
"schema_version": "1.1.1",
|
||||
"destination": {
|
||||
"key": "suzhou",
|
||||
"name": "苏州",
|
||||
"admin": "江苏省苏州市",
|
||||
"center": [31.299, 120.585]
|
||||
},
|
||||
"qa": {
|
||||
"destination": "苏州",
|
||||
"departure_station": "上海虹桥站",
|
||||
"departure_window": "sat_morning",
|
||||
"departure_date": "2026-10-17",
|
||||
"lodging_mode": "bathhouse",
|
||||
"budget_level": "comfort",
|
||||
"headcount": 1,
|
||||
"luggage": "backpack",
|
||||
"hike_intensity": "medium"
|
||||
},
|
||||
"generated_at": "2026-09-30 10:20:00",
|
||||
"generator": "manual_sample",
|
||||
"disclaimer": "本样例内容含标注为「待确认 / 需核实」的信息,出行前请按『待核实清单』自行复核。"
|
||||
},
|
||||
|
||||
"summary": {
|
||||
"title": "苏州古城水岸 · 两日高铁轻旅行",
|
||||
"one_liner": "虹桥半小时直达:环古城河步道走一段,下午赶在闭园前看拙政园或苏博,晚饭选本地人爱去的馆子,饭后把 19:00-21:00 交给平江路夜景与观前街夜市;泡个本地大浴场留宿一夜,隔天金鸡湖环湖收尾再返沪。",
|
||||
"tags": ["高铁半小时", "古城水岸", "白天景点", "夜逛闹市", "本地人饭馆", "洗浴留宿"],
|
||||
"stats": [
|
||||
{ "label": "去程车程", "value": "约 25 分钟", "hint": "上海虹桥 → 苏州站" },
|
||||
{ "label": "徒步合计", "value": "约 3 小时", "hint": "两段各约 1.5 小时,均为平路" },
|
||||
{ "label": "周六夜夜逛", "value": "19:00-21:00", "hint": "平江路夜景 + 观前街夜市,自由活动" },
|
||||
{ "label": "返程死线", "value": "21:00 前抵沪", "hint": "周日下午出发,为地铁末班留余量" }
|
||||
]
|
||||
},
|
||||
|
||||
"timeline": {
|
||||
"days": [
|
||||
{
|
||||
"label": "第一天",
|
||||
"date": "2026-10-17(周六)",
|
||||
"rows": [
|
||||
{ "time": "07:40", "title": "抵达上海虹桥站,安检进站", "anchor_id": "train_out", "kind": "soft", "activity": "train", "note": "大站建议提前 40 分钟到,含安检与检票" },
|
||||
{ "time": "08:20", "title": "乘高铁赴苏州站(约 25 分钟)", "anchor_id": "train_out", "kind": "soft", "activity": "train", "note": "建议时段 08:00-09:30;实际车次以 12306 为准" },
|
||||
{ "time": "09:00", "title": "苏州站出站,扫码取共享单车", "anchor_id": "city_mobility", "kind": "soft", "activity": "bike", "note": "站前广场有指定单车停放区;带拉杆箱请改打车" },
|
||||
{ "time": "09:20", "title": "骑行至酒店寄存行李", "anchor_id": "station_to_lodging", "kind": "soft", "activity": "bike", "note": "约 3km,骑行 15 分钟;14:00 后方可入住,先寄存" },
|
||||
{ "time": "12:00", "title": "平江路北段午饭(本地人爱去 + 评分 4.4)", "anchor_id": "lunch_d1", "kind": "soft", "activity": "eat", "note": "临河本地苏帮菜小馆,人均约 90 元;非游客主街、本地评价占比高" },
|
||||
{ "time": "14:00", "title": "环古城河步道徒步(相门—平门段)", "anchor_id": "hike_1", "kind": "soft", "activity": "hike", "note": "平路水岸,累计爬升约 0" },
|
||||
{ "time": "15:40", "title": "拙政园 / 苏州博物馆(白天开放,务必赶在最后入场前)", "anchor_id": "spot_d1", "kind": "soft", "activity": "walk", "note": "拙政园约 16:30 停止入场、苏博约 16:00 停止入馆;徒步结束后直接前往" },
|
||||
{ "time": "17:30", "title": "观前街晚饭(本地人爱去 + 评分 4.3,19:00 前吃完)", "anchor_id": "dinner_d1", "kind": "soft", "activity": "eat", "note": "巷内本地家常菜馆,与午饭菜系区分;吃完步行 10 分钟即可开始夜逛" },
|
||||
{ "time": "19:00", "title": "夜逛:平江路夜景 + 观前街夜市(自由活动)", "anchor_id": "night_stroll", "kind": "soft", "activity": "stroll", "note": "可选去处 ≥3 个,不强制顺序;地铁末班约 22:30,返回可打车" },
|
||||
{ "time": "21:20", "title": "返回住处 · 入住本地大型浴场(留宿)", "anchor_id": "lodging_night", "kind": "hard", "activity": "checkin", "note": "不晚于 22:30 抵达;需确认是否可过夜" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "第二天",
|
||||
"date": "2026-10-18(周日)",
|
||||
"rows": [
|
||||
{ "time": "07:20", "title": "住处附近本地早点", "anchor_id": "breakfast_d2", "kind": "soft", "activity": "eat", "note": "面馆 6:30 开门" },
|
||||
{ "time": "08:40", "title": "打车至金鸡湖,环湖步道徒步", "anchor_id": "hike_2", "kind": "soft", "activity": "hike", "note": "带装备建议打车,约 8km、20 分钟" },
|
||||
{ "time": "10:40", "title": "虎丘(与昨天景点去重,白天开放)", "anchor_id": "spot_d2", "kind": "soft", "activity": "walk", "note": "距苏州站约 6km,返程前需留足车程" },
|
||||
{ "time": "13:20", "title": "取行李,打车赴苏州站(返程起点)", "anchor_id": "train_back", "kind": "soft", "activity": "taxi", "note": "返程前一站;留出 40 分钟安检检票缓冲" },
|
||||
{ "time": "14:30", "title": "乘高铁返沪(约 25 分钟)", "anchor_id": "train_back", "kind": "hard", "activity": "train", "note": "周日必须下午出发,最晚 21:00 抵沪;实际车次以 12306 为准" },
|
||||
{ "time": "15:10", "title": "抵达上海虹桥站,地铁返家", "anchor_id": "train_back", "kind": "soft", "activity": "metro", "note": "返程终点;虹桥 2/10/17 号线,末班约 22:30 后,距 21:00 死线尚早" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"cards": [
|
||||
{
|
||||
"id": "card_train_out", "anchor_id": "train_out", "day": 1,
|
||||
"title": "去程高铁 · 时段与车站",
|
||||
"subtitle": "虹桥 → 苏州站,班次极密",
|
||||
"badges": [{ "text": "建议时段 08:00-09:30", "tone": "info" }],
|
||||
"fields": [
|
||||
{ "label": "出发站", "value": "上海虹桥站" },
|
||||
{ "label": "到达站", "value": "苏州站" },
|
||||
{ "label": "建议时段", "value": { "text": "08:00-09:30(班次密集,高峰约 10 分钟一班)", "confidence": "low", "verify": "车次与时刻以 12306 实时查询为准" } },
|
||||
{ "label": "历时", "value": { "text": "23-35 分钟", "confidence": "low", "verify": "以 12306 查询结果为准" } },
|
||||
{ "label": "二等座参考票价", "value": { "text": "¥40-50", "confidence": "low", "verify": "票价按区间示意,实际以 12306 为准" } },
|
||||
{ "label": "进站缓冲", "value": "虹桥为大站,建议提前 40 分钟到站" },
|
||||
{ "label": "导航", "value": "上海市闵行区申贵路1500号 上海虹桥站" }
|
||||
],
|
||||
"bullets": [
|
||||
"本技能不给具体车次号(模型无法查 12306,写出来必然是编的)",
|
||||
"苏州站出站后即为地铁 2/4 号线,站前广场设共享单车停放区"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_city_mobility", "anchor_id": "city_mobility", "day": 1,
|
||||
"title": "市内交通方案 · 共享单车 + 网约车",
|
||||
"subtitle": "苏州老城禁停区较多,先看清再骑",
|
||||
"badges": [{ "text": "预估市内交通约 ¥45", "tone": "info" }],
|
||||
"fields": [
|
||||
{ "label": "策略", "value": "3km 内优先共享单车;超过 5km 或带行李时打车;老城核心区一律步行" },
|
||||
{ "label": "共享单车", "value": { "text": "美团 / 哈啰 / 青桔均可,扫码开锁;需确认当地在运营区", "confidence": "medium", "verify": "打开对应 App 查看是否显示可租车辆" } },
|
||||
{ "label": "禁停区", "value": { "text": "平江路、观前街、拙政园周边大片区域禁行禁停,须停到外围指定停车点", "confidence": "low", "verify": "以 App 内显示的 P 点为准,出发前查看" } },
|
||||
{ "label": "网约车上车点", "value": { "text": "苏州站北广场西侧临时停靠点;平江路口禁停,建议在干将东路侧上车", "confidence": "low", "verify": "叫车后以司机约定的上车点为准" } },
|
||||
{ "label": "预估费用", "value": "共享单车按 1.5 元/15 分钟计;网约车起步约 ¥10,全程约 ¥45" },
|
||||
{ "label": "行李约束", "value": "本次为轻装背包,可骑车;若改带拉杆箱则全程改打车并先寄存" },
|
||||
{ "label": "导航", "value": "苏州市姑苏区干将东路(平江路口西侧临时停靠点,网约车常用上车点)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_station_to_lodging", "anchor_id": "station_to_lodging", "day": 1,
|
||||
"title": "出站接驳 + 行李寄存",
|
||||
"subtitle": "先寄存,再开逛",
|
||||
"fields": [
|
||||
{ "label": "接驳方式", "value": "苏州站 → 酒店约 3km,共享单车 15 分钟 / 网约车 10 分钟" },
|
||||
{ "label": "顺序", "value": "多数酒店 14:00 后才可入住,早到必须先免费寄存行李再出行" },
|
||||
{ "label": "导航", "value": { "text": "苏州市姑苏区临顿路(观前街南段)酒店集群", "confidence": "medium", "verify": "具体酒店以实际预订为准" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_lunch_d1", "anchor_id": "lunch_d1", "day": 1,
|
||||
"title": "第一天午饭 · 平江路北段",
|
||||
"subtitle": "本地人爱去 + 评分好,人均约 ¥90",
|
||||
"fields": [
|
||||
{ "label": "推荐业态", "value": { "text": "平江路北段临河的本地苏帮菜小馆(苏式汤面、松鼠鳜鱼小份、时令河鲜)", "confidence": "low", "verify": "不编造店名;到店前用高德/大众点评搜索『平江路 苏帮菜』按评分挑选" } },
|
||||
{ "label": "为什么是本地人爱去", "value": { "text": "位于平江路北段非游客主街、以本地招牌菜(苏式汤面)为主、本地评价占比高", "confidence": "low", "verify": "查看大众点评评价里本地用户占比与复购提及" } },
|
||||
{ "label": "评分", "value": { "text": "大众点评约 4.3-4.5,评价量数千条级", "confidence": "low", "verify": "以大众点评/高德实时显示为准(评分会随时间变化)" } },
|
||||
{ "label": "人均", "value": "约 ¥90(单人点面 + 一菜即可)" },
|
||||
{ "label": "到达方式", "value": "从寄存点步行约 12 分钟;沿途为步行街区,不可骑入" },
|
||||
{ "label": "导航", "value": "苏州市姑苏区平江路历史街区北段(白塔东路入口)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_hike_1", "anchor_id": "hike_1", "day": 1,
|
||||
"title": "第一段徒步 · 环古城河步道(相门—平门段)",
|
||||
"subtitle": "平路水岸,约 1.5 小时",
|
||||
"badges": [{ "text": "平路 · 累计爬升约 0", "tone": "warn" }],
|
||||
"fields": [
|
||||
{ "label": "起点", "value": "相门城墙遗址(苏州市姑苏区干将东路相门桥)" },
|
||||
{ "label": "终点", "value": "平门桥(苏州市姑苏区人民路北端)" },
|
||||
{ "label": "方式", "value": "单程走,不走回头路;全程沿河,约 4.5km" },
|
||||
{ "label": "耗时", "value": "约 1.5 小时(按 3km/h 慢走折算,含驻足看景)" },
|
||||
{ "label": "路面", "value": "塑胶步道 + 石板路,平坦好走,适合运动鞋" },
|
||||
{ "label": "补水点", "value": "沿途有便利店与驿站,可买水" },
|
||||
{ "label": "到达起点", "value": "从午饭点步行约 15 分钟;若骑车,须停在相门桥南侧指定停车点" },
|
||||
{ "label": "导航", "value": "江苏省苏州市姑苏区干将东路 相门桥(相门城墙遗址)" }
|
||||
],
|
||||
"bullets": [
|
||||
"已触发地形降级 terrain_flat:苏州无山,本段为平路徒步,累计爬升约 0,运动量按时长与步数计",
|
||||
"想出汗可提高配速或走两个来回"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_spot_d1", "anchor_id": "spot_d1", "day": 1,
|
||||
"title": "周六下午 · 拙政园 / 苏州博物馆(白天开放)",
|
||||
"subtitle": "走完步道直接去,务必赶在最后入场前",
|
||||
"badges": [{ "text": "白天开放 · 需赶在停止入场前", "tone": "warn" }],
|
||||
"fields": [
|
||||
{ "label": "看点", "value": "拙政园为中国四大名园之一,明代造园代表;苏州博物馆由贝聿铭设计,建筑本身就是看点" },
|
||||
{ "label": "开放时间", "value": { "text": "拙政园旺季约 7:30-17:30;苏博约 9:00-17:00(周一闭馆)", "confidence": "low", "verify": "以官方公众号公布时间为准" } },
|
||||
{ "label": "最后入场时间", "value": { "text": "拙政园约 16:30 停止入场;苏博约 16:00 停止入馆(闭馆前 30-60 分钟停止入场,这比闭馆时间更关键)", "confidence": "low", "verify": "官方公众号或购票页公示「停止入场时间」" } },
|
||||
{ "label": "时间校验", "value": "徒步 15:30 结束 → 打车/步行约 10 分钟到东北街 → 约 15:40 抵达,仍有约 50 分钟入场余量" },
|
||||
{ "label": "门票", "value": { "text": "拙政园旺季约 ¥70-90;苏博免费", "confidence": "low", "verify": "以官方渠道为准" } },
|
||||
{ "label": "预约", "value": { "text": "苏州博物馆需提前在官方渠道预约,旺季名额紧张", "confidence": "low", "verify": "出发前 3-7 天在官方公众号预约" } },
|
||||
{ "label": "与次日去重", "value": "第二天 spot_d2 改用虎丘或盘门景区,不与此处重复" },
|
||||
{ "label": "导航", "value": "江苏省苏州市姑苏区东北街 拙政园/苏州博物馆" }
|
||||
],
|
||||
"bullets": ["本槽位核心是「白天开放」:若当天来不及,必须改选更近的景点或缩短徒步,绝不允许安排到门口已停止入场"]
|
||||
},
|
||||
{
|
||||
"id": "card_dinner_d1", "anchor_id": "dinner_d1", "day": 1,
|
||||
"title": "第一天晚饭 · 观前街(本地人爱去 + 评分好)",
|
||||
"subtitle": "与午饭换菜系,19:00 前吃完,直接接夜逛",
|
||||
"fields": [
|
||||
{ "label": "推荐业态", "value": { "text": "观前街巷内本地家常菜馆(与午饭的苏帮菜区分,可选苏式面/馄饨/砂锅)", "confidence": "low", "verify": "不编造店名;按大众点评本地评价占比挑选" } },
|
||||
{ "label": "为什么是本地人爱去", "value": { "text": "位于观前街巷内而非主街、以本地家常菜为主、本地评价占比高", "confidence": "low", "verify": "查看大众点评评价里本地用户占比" } },
|
||||
{ "label": "评分", "value": { "text": "大众点评约 4.3-4.5,评价量千条级", "confidence": "low", "verify": "以大众点评/高德实时显示为准" } },
|
||||
{ "label": "营业时间", "value": { "text": "多数至 21:00-22:00", "confidence": "medium", "verify": "到店前电话或 App 确认打烊时间" } },
|
||||
{ "label": "时间约束", "value": "必须 19:00 前吃完,把 19:00-21:00 留给夜逛;不得选上菜慢或长队排队的店" },
|
||||
{ "label": "到夜逛起点", "value": "出门即观前街夜市,步行 0 分钟;平江路夜景步行约 10 分钟" },
|
||||
{ "label": "人均", "value": "约 ¥80" },
|
||||
{ "label": "导航", "value": "江苏省苏州市姑苏区观前街(玄妙观东侧巷内)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_night_stroll", "anchor_id": "night_stroll", "day": 1,
|
||||
"title": "周六夜 · 夜逛闹市(自由活动,19:00-21:00)",
|
||||
"subtitle": "平江路夜景 + 观前街夜市,不强制顺序,自己挑",
|
||||
"badges": [{ "text": "自由活动 · 约 2 小时", "tone": "info" }],
|
||||
"fields": [
|
||||
{ "label": "为什么有这一段", "value": "用户反馈过:原来 18:00 吃完晚饭就直接回住处,19:00-21:00 空着太浪费。所以晚饭提前,这两小时用来夜逛" },
|
||||
{ "label": "可逛范围", "value": "闹市步行街 + 历史街区夜景(平江路夜里挂灯,观前街为夜间商圈)" },
|
||||
{ "label": "可选去处 1", "value": "平江路夜间段(临河灯笼夜景,安静适合拍照)— 江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)" },
|
||||
{ "label": "可选去处 2", "value": "观前街夜间商圈(玄妙观前广场,热闹、小吃多)— 江苏省苏州市姑苏区观前街 玄妙观" },
|
||||
{ "label": "可选去处 3", "value": "山塘街夜景(七里山塘,需打车约 10 分钟)— 江苏省苏州市姑苏区山塘街(渡僧桥入口)" },
|
||||
{ "label": "夜间是否开放", "value": { "text": "平江路与观前街为开放街区,夜间常开;沿街店铺多数营业至 21:00-22:00", "confidence": "low", "verify": "夜市与灯光开放时间会随季节调整,出发前确认" } },
|
||||
{ "label": "夜间交通", "value": { "text": "苏州地铁 2/4 号线末班约 22:30 前后;该时段网约车可正常叫到;共享单车夜间可用但老城禁停区仍生效", "confidence": "medium", "verify": "地铁末班时刻以官方 App 为准" } },
|
||||
{ "label": "返回住处", "value": "夜逛区域距住处约 1km,步行 12 分钟;打车约 8 分钟 / ¥12" },
|
||||
{ "label": "安全提示", "value": "夜间人流较多,注意随身财物;沿河路段注意照明较暗处" },
|
||||
{ "label": "导航", "value": "夜逛主区域:江苏省苏州市姑苏区平江路历史街区(南入口:干将东路);另两处见上方可选去处" }
|
||||
],
|
||||
"bullets": [
|
||||
"自由活动性质:以上 3 处任选,不要求走完;累了随时可回住处",
|
||||
"本段结束时间控制在 21:00 左右,回住处后仍有充裕时间洗漱休息"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_lodging_night", "anchor_id": "lodging_night", "day": 1,
|
||||
"title": "住宿 · 本地大型浴场(留宿)",
|
||||
"subtitle": "已按你的选择:高级洗浴带留宿",
|
||||
"badges": [{ "text": "硬时间点:不晚于 22:30 抵达", "tone": "warn" }],
|
||||
"fields": [
|
||||
{ "label": "形态", "value": "高级洗浴(带留宿)——按 lodging_mode=bathhouse 执行" },
|
||||
{ "label": "规模依据", "value": { "text": "营业面积通常在数千平方米量级,本地评价量级高(本地人复购为主)", "confidence": "low", "verify": "出发前电话确认营业面积、是否可过夜、是否需预约" } },
|
||||
{ "label": "能否过夜", "value": { "text": "部分浴场仅营业到凌晨,不可留宿;必须电话确认 24 小时营业或有安静休息区", "confidence": "low", "verify": "致电前台确认『能否过夜、是否有独立休息区』" } },
|
||||
{ "label": "男女分区", "value": { "text": "通常分男女浴区,休息大厅为公共区域", "confidence": "medium", "verify": "到店确认" } },
|
||||
{ "label": "人均", "value": "约 ¥120-180(含过夜)" },
|
||||
{ "label": "次日寄存", "value": "次日离店后一般可免费寄存行李,便于继续游玩;需前台确认" },
|
||||
{ "label": "导航", "value": { "text": "苏州市姑苏区观前街周边大型洗浴中心(以实际选定门店为准)", "confidence": "low", "verify": "选店后请以门店地址导航" } }
|
||||
],
|
||||
"bullets": ["已排除社区小浴室:本条要求规模较大、档次中上"]
|
||||
},
|
||||
{
|
||||
"id": "card_breakfast_d2", "anchor_id": "breakfast_d2", "day": 2,
|
||||
"title": "第二天早饭 · 本地面馆",
|
||||
"subtitle": "7 点前已营业",
|
||||
"fields": [
|
||||
{ "label": "方案", "value": "店内吃:本地苏式面馆(枫镇大肉面/焖肉面时令)" },
|
||||
{ "label": "开门时间", "value": { "text": "多数面馆 6:00-6:30 开门", "confidence": "medium", "verify": "出发前一晚确认次日开门时间" } },
|
||||
{ "label": "距离", "value": "从住处步行约 8 分钟" },
|
||||
{ "label": "导航", "value": "苏州市姑苏区观前街周边(临顿路沿线本地面馆)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_hike_2", "anchor_id": "hike_2", "day": 2,
|
||||
"title": "第二段徒步 · 金鸡湖环湖步道",
|
||||
"subtitle": "与昨天的古城水岸形成对比:现代湖景",
|
||||
"badges": [{ "text": "平路 · 累计爬升约 0", "tone": "warn" }],
|
||||
"fields": [
|
||||
{ "label": "起点", "value": "金鸡湖西岸 · 东方之门前广场(苏州市工业园区星港街)" },
|
||||
{ "label": "终点", "value": "湖东 · 摩天轮乐园段,或原路折返" },
|
||||
{ "label": "方式", "value": "环湖步道,可走一段折返;全程约 5km" },
|
||||
{ "label": "耗时", "value": "约 1.5 小时" },
|
||||
{ "label": "路面", "value": "木栈道 + 铺装步道,平坦" },
|
||||
{ "label": "到达起点", "value": "从住处打车约 20 分钟 / ¥25(约 8km,带装备建议打车)" },
|
||||
{ "label": "导航", "value": "江苏省苏州市工业园区星港街 东方之门(金鸡湖西岸)" }
|
||||
],
|
||||
"bullets": ["已触发地形降级 terrain_flat:本段同属平路,运动量按时长与步数计"]
|
||||
},
|
||||
{
|
||||
"id": "card_spot_d2", "anchor_id": "spot_d2", "day": 2,
|
||||
"title": "返程前穿插 · 虎丘(与昨天景点去重)",
|
||||
"subtitle": "白天开放,与苏州站顺路",
|
||||
"fields": [
|
||||
{ "label": "看点", "value": "虎丘有「吴中第一名胜」之称,云岩寺塔(虎丘塔)为斜塔代表,历史可上溯春秋" },
|
||||
{ "label": "去重说明", "value": "第一天的 spot_d1 已用拙政园/苏州博物馆,本处改用虎丘,两处不重复" },
|
||||
{ "label": "开放时间", "value": { "text": "约 7:30-17:30(旺季),闭园前 30-60 分钟停止入场", "confidence": "low", "verify": "以官方公众号公布时间为准" } },
|
||||
{ "label": "门票", "value": { "text": "旺季约 ¥70-80", "confidence": "low", "verify": "以官方渠道为准" } },
|
||||
{ "label": "预约", "value": { "text": "通常无需预约,但旺季建议线上购票", "confidence": "low", "verify": "官方公众号购票页确认" } },
|
||||
{ "label": "衔接返程", "value": "虎丘距苏州站约 6km,打车约 20 分钟;返程前务必留足车程 + 40 分钟安检缓冲" },
|
||||
{ "label": "导航", "value": "江苏省苏州市姑苏区虎丘山门内8号 虎丘" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_train_back", "anchor_id": "train_back", "day": 2,
|
||||
"title": "返程高铁 · 周日 14:30 发车,21:00 前抵沪",
|
||||
"subtitle": "死线约束:周日必须下午返",
|
||||
"badges": [{ "text": "21:00 前必须抵沪", "tone": "danger" }],
|
||||
"fields": [
|
||||
{ "label": "出发站", "value": "苏州站" },
|
||||
{ "label": "到达站", "value": "上海虹桥站" },
|
||||
{ "label": "建议时段", "value": { "text": "14:00-17:00 发车(越早越稳)", "confidence": "low", "verify": "车次与时刻以 12306 实时查询为准" } },
|
||||
{ "label": "历时", "value": { "text": "23-35 分钟", "confidence": "low", "verify": "以 12306 查询结果为准" } },
|
||||
{ "label": "二等座参考票价", "value": { "text": "¥40-50", "confidence": "low", "verify": "票价按区间示意,实际以 12306 为准" } },
|
||||
{ "label": "到站缓冲", "value": "从最后一个景点到苏州站约 15 分钟,另留 40 分钟安检检票" },
|
||||
{ "label": "抵沪后回家", "value": "虹桥站地铁 2/10/17 号线,末班约 22:30 后;本次 15:10 抵沪,时间充裕" },
|
||||
{ "label": "导航", "value": "江苏省苏州市姑苏区苏站路27号 苏州站" }
|
||||
],
|
||||
"bullets": [
|
||||
"周日下午为返程高峰,票源紧张且车站人多,建议提前购票",
|
||||
"本技能不给具体车次号;请以 12306 为准并核对发到时刻"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"map": {
|
||||
"center": [31.299, 120.585],
|
||||
"zoom": 12,
|
||||
"markers": [
|
||||
{ "id": "m_hongqiao", "name": "上海虹桥站", "kind": "start", "lat": 31.1948, "lng": 121.325, "coord_precision": "approx", "day": 1, "seq": 1, "address": "上海市闵行区申贵路1500号 上海虹桥站", "note": "去程出发站,提前 40 分钟到" },
|
||||
{ "id": "m_suzhou_station", "name": "苏州站", "kind": "train_station", "lat": 31.326, "lng": 120.605, "coord_precision": "approx", "day": 1, "seq": 2, "address": "江苏省苏州市姑苏区苏站路27号 苏州站", "note": "去程到达站 / 返程出发站" },
|
||||
{ "id": "m_bike_pickup", "name": "共享单车停放区(站前广场)", "kind": "bike", "lat": 31.3245, "lng": 120.607, "coord_precision": "approx", "day": 1, "seq": 3, "address": "江苏省苏州市姑苏区苏站路 苏州站北广场共享单车停放区", "note": "扫码取车;以 App 显示的可租车辆为准" },
|
||||
{ "id": "m_hotel", "name": "住宿(观前街/临顿路一带)", "kind": "lodging", "lat": 31.3096, "lng": 120.6226, "coord_precision": "approx", "day": 1, "seq": 4, "address": "江苏省苏州市姑苏区临顿路(观前街南段)", "note": "先寄存行李再出行" },
|
||||
{ "id": "m_lunch", "name": "午饭 · 平江路北段", "kind": "food", "lat": 31.3152, "lng": 120.6289, "coord_precision": "approx", "day": 1, "seq": 5, "address": "江苏省苏州市姑苏区平江路历史街区北段(白塔东路入口)", "note": "步行街区,不可骑入;本地人爱去 + 评分 4.4" },
|
||||
{ "id": "m_hike1_start", "name": "相门城墙遗址(徒步起点)", "kind": "hike", "lat": 31.3167, "lng": 120.6385, "coord_precision": "approx", "day": 1, "seq": 6, "address": "江苏省苏州市姑苏区干将东路 相门桥", "note": "单车须停南侧指定停车点" },
|
||||
{ "id": "m_hike1_end", "name": "平门桥(徒步终点)", "kind": "hike", "lat": 31.3238, "lng": 120.6148, "coord_precision": "approx", "day": 1, "seq": 7, "address": "江苏省苏州市姑苏区人民路北端 平门桥", "note": "单程 4.5km,平路" },
|
||||
{ "id": "m_spot_d1", "name": "拙政园 / 苏州博物馆(白天开放)", "kind": "spot", "lat": 31.3242, "lng": 120.6294, "coord_precision": "approx", "day": 1, "seq": 8, "address": "江苏省苏州市姑苏区东北街 拙政园/苏州博物馆", "note": "务必赶在停止入场前(拙政园约 16:30、苏博约 16:00);苏博需预约" },
|
||||
{ "id": "m_dinner", "name": "晚饭 · 观前街巷内", "kind": "food", "lat": 31.3109, "lng": 120.6231, "coord_precision": "approx", "day": 1, "seq": 9, "address": "江苏省苏州市姑苏区观前街(玄妙观东侧巷内)", "note": "本地人爱去 + 评分 4.3;须 19:00 前吃完" },
|
||||
{ "id": "m_pingjiang", "name": "平江路夜景区(夜逛主区域)", "kind": "night_market", "lat": 31.3162, "lng": 120.6275, "coord_precision": "approx", "day": 1, "seq": 10, "address": "江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)", "note": "夜逛自由活动;沿河灯光夜景;禁停共享单车" },
|
||||
{ "id": "m_guangqian", "name": "观前街夜市 · 玄妙观", "kind": "district", "lat": 31.3105, "lng": 120.6248, "coord_precision": "approx", "day": 1, "seq": 11, "address": "江苏省苏州市姑苏区观前街 玄妙观", "note": "夜逛可选去处 2;夜间商圈,小吃多" },
|
||||
{ "id": "m_bathhouse", "name": "住宿 · 大型浴场(留宿)", "kind": "bathhouse", "lat": 31.3082, "lng": 120.6301, "coord_precision": "approx", "day": 1, "seq": 12, "address": "江苏省苏州市姑苏区观前街周边大型洗浴中心", "note": "需确认能否过夜;夜逛结束后返回" },
|
||||
{ "id": "m_breakfast", "name": "早饭 · 临顿路面馆", "kind": "food", "lat": 31.3088, "lng": 120.6262, "coord_precision": "approx", "day": 2, "seq": 13, "address": "江苏省苏州市姑苏区临顿路沿线本地面馆", "note": "6:30 前已开门" },
|
||||
{ "id": "m_taxi_call", "name": "网约车上车点(干将东路侧)", "kind": "taxi_point", "lat": 31.3121, "lng": 120.6282, "coord_precision": "approx", "day": 2, "seq": 14, "address": "江苏省苏州市姑苏区干将东路(平江路口西侧临时停靠点)", "note": "平江路口禁停,在此上车" },
|
||||
{ "id": "m_jinji_lake", "name": "金鸡湖西岸 · 东方之门(徒步起点)", "kind": "hike", "lat": 31.3175, "lng": 120.6742, "coord_precision": "approx", "day": 2, "seq": 15, "address": "江苏省苏州市工业园区星港街 东方之门", "note": "湖景步道,平坦" },
|
||||
{ "id": "m_motianlun", "name": "金鸡湖东岸 · 摩天轮乐园段", "kind": "hike", "lat": 31.3155, "lng": 120.7219, "coord_precision": "approx", "day": 2, "seq": 16, "address": "江苏省苏州市工业园区华池街 金鸡湖摩天轮乐园", "note": "徒步终点,可折返" },
|
||||
{ "id": "m_huqiu", "name": "虎丘(或用盘门景区替代)", "kind": "spot", "lat": 31.3376, "lng": 120.5756, "coord_precision": "approx", "day": 2, "seq": 17, "address": "江苏省苏州市姑苏区虎丘山门内8号 虎丘", "note": "与第一天 spot_d1(拙政园/苏博)去重;距苏州站约 6km" },
|
||||
{ "id": "m_back_station", "name": "苏州站(返程)", "kind": "end", "lat": 31.326, "lng": 120.605, "coord_precision": "approx", "day": 2, "seq": 18, "address": "江苏省苏州市姑苏区苏站路27号 苏州站", "note": "留 40 分钟缓冲" },
|
||||
{ "id": "m_hongqiao_back", "name": "上海虹桥站(抵沪)", "kind": "end", "lat": 31.1948, "lng": 121.325, "coord_precision": "approx", "day": 2, "seq": 19, "address": "上海市闵行区申贵路1500号 上海虹桥站", "note": "地铁 2/10/17 号线返家" }
|
||||
],
|
||||
"routes": [
|
||||
{ "id": "r_train_out", "name": "去程高铁", "kind": "train", "marker_ids": ["m_hongqiao", "m_suzhou_station"], "color": "#1d4ed8", "dashed": true, "note": "点位顺序示意,非真实铁路线;实际车次以 12306 为准" },
|
||||
{ "id": "r_bike_to_hotel", "name": "共享单车接驳", "kind": "bike", "marker_ids": ["m_suzhou_station", "m_bike_pickup", "m_hotel"], "color": "#0ea5e9", "dashed": false, "note": "点位顺序示意,非真实骑行路径" },
|
||||
{ "id": "r_hike_1", "name": "环古城河步道", "kind": "hike", "marker_ids": ["m_hike1_start", "m_hike1_end"], "color": "#2e9e5b", "dashed": false, "note": "点位顺序示意,非真实道路/轨迹" },
|
||||
{ "id": "r_to_spot_d1", "name": "徒步终点 → 白天景点", "kind": "stroll", "marker_ids": ["m_hike1_end", "m_spot_d1"], "color": "#059669", "dashed": false, "note": "点位顺序示意,非真实道路;步行约 10 分钟,须赶在停止入场前" },
|
||||
{ "id": "r_night_stroll", "name": "夜逛动线(自由活动)", "kind": "stroll", "marker_ids": ["m_dinner", "m_guangqian", "m_pingjiang"], "color": "#7c2d92", "dashed": false, "note": "点位顺序示意,非真实道路;夜逛自由活动,不强制顺序,可随时返回住处" },
|
||||
{ "id": "r_taxi_jinji", "name": "网约车至金鸡湖", "kind": "taxi", "marker_ids": ["m_taxi_call", "m_jinji_lake"], "color": "#f59e0b", "dashed": true, "note": "点位顺序示意,非真实行驶路径" },
|
||||
{ "id": "r_hike_2", "name": "金鸡湖环湖步道", "kind": "hike", "marker_ids": ["m_jinji_lake", "m_motianlun"], "color": "#2e9e5b", "dashed": false, "note": "点位顺序示意,非真实道路/轨迹" },
|
||||
{ "id": "r_train_back", "name": "返程高铁", "kind": "train", "marker_ids": ["m_back_station", "m_hongqiao_back"], "color": "#d14343", "dashed": true, "note": "点位顺序示意,非真实铁路线;最晚 21:00 抵沪" }
|
||||
]
|
||||
},
|
||||
|
||||
"tables": [
|
||||
{
|
||||
"id": "train_out_options",
|
||||
"title": "去程高铁 · 时段与车站",
|
||||
"columns": [
|
||||
{ "key": "window", "label": "建议时段" },
|
||||
{ "key": "from_station", "label": "出发站" },
|
||||
{ "key": "to_station", "label": "到达站" },
|
||||
{ "key": "duration", "label": "历时", "align": "right" },
|
||||
{ "key": "fare", "label": "二等座参考票价", "align": "right" },
|
||||
{ "key": "note", "label": "备注" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "out_early", "window": "07:30-08:30", "from_station": "上海虹桥站", "to_station": "苏州站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "最早到,第一天时间最充裕(推荐)" },
|
||||
{ "_key": "out_mid", "window": "08:30-09:30", "from_station": "上海虹桥站", "to_station": "苏州站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "本次选用;到站后仍可从容吃午饭" }
|
||||
],
|
||||
"note": "本表为时段建议,非具体车次。本技能不给车次号(模型无法查询 12306,写出的车次号必然是编造的);车次、时刻与票价请以 12306 实时查询为准。"
|
||||
},
|
||||
{
|
||||
"id": "train_back_options",
|
||||
"title": "返程高铁 · 时段与车站(死线 21:00 抵沪)",
|
||||
"columns": [
|
||||
{ "key": "window", "label": "建议时段" },
|
||||
{ "key": "from_station", "label": "出发站" },
|
||||
{ "key": "to_station", "label": "到达站" },
|
||||
{ "key": "duration", "label": "历时", "align": "right" },
|
||||
{ "key": "fare", "label": "二等座参考票价", "align": "right" },
|
||||
{ "key": "note", "label": "备注" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "back_mid", "window": "14:00-15:30", "from_station": "苏州站", "to_station": "上海虹桥站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "本次选用;抵沪后地铁余量充足(推荐)" },
|
||||
{ "_key": "back_late", "window": "16:00-17:00", "from_station": "苏州站", "to_station": "上海虹桥站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "仍可满足 21:00 前抵沪,但余量变小;周日下午票源紧张" }
|
||||
],
|
||||
"highlight_row_keys": ["back_mid"],
|
||||
"note": "本表为时段建议,非具体车次。周日必须下午出发,最晚 21:00 抵达上海(周一上班,晚了没地铁)。周日下午返程高峰,建议提前购票。"
|
||||
},
|
||||
{
|
||||
"id": "city_transport",
|
||||
"title": "市内交通方案 · 共享单车 / 网约车",
|
||||
"columns": [
|
||||
{ "key": "leg", "label": "路段" },
|
||||
{ "key": "mode", "label": "方式" },
|
||||
{ "key": "distance", "label": "距离/时长", "align": "right" },
|
||||
{ "key": "cost", "label": "预估费用", "align": "right" },
|
||||
{ "key": "tip", "label": "提示" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "t1", "leg": "苏州站 → 酒店", "mode": "共享单车", "distance": "约 3km / 15 分钟", "cost": "¥1.5-3", "tip": "站前广场有指定停放区;带拉杆箱改打车" },
|
||||
{ "_key": "t2", "leg": "酒店 → 平江路", "mode": "步行", "distance": "约 1km / 12 分钟", "cost": "¥0", "tip": "步行街禁停单车,直接走过去" },
|
||||
{ "_key": "t3", "leg": "平江路 → 相门(徒步起点)", "mode": "步行", "distance": "约 1.2km / 15 分钟", "cost": "¥0", "tip": "若骑车须停相门桥南侧指定点" },
|
||||
{ "_key": "t4", "leg": "观前街 → 浴场", "mode": "步行", "distance": "约 800m / 10 分钟", "cost": "¥0", "tip": "夜间城区路况好,步行即可" },
|
||||
{ "_key": "t5", "leg": "住处 → 金鸡湖", "mode": "网约车", "distance": "约 8km / 20 分钟", "cost": "约 ¥25", "tip": "超过 5km 建议打车;上车点用干将东路侧" },
|
||||
{ "_key": "t6", "leg": "金鸡湖 → 拙政园/苏博", "mode": "网约车", "distance": "约 9km / 22 分钟", "cost": "约 ¥28", "tip": "跨区,打车最稳" },
|
||||
{ "_key": "t7", "leg": "拙政园 → 苏州站(返程)", "mode": "网约车", "distance": "约 4km / 15 分钟", "cost": "约 ¥15", "tip": "留 40 分钟安检检票缓冲,务必准时" }
|
||||
],
|
||||
"note": "禁停区范围与网约车上车点属易变信息,出行前请以共享单车 App 内显示的 P 点、以及叫车后司机约定的上车点为准。共享单车按 1.5 元/15 分钟估算。"
|
||||
},
|
||||
{
|
||||
"id": "lodging_compare",
|
||||
"title": "住宿候选对比",
|
||||
"columns": [
|
||||
{ "key": "name", "label": "候选" },
|
||||
{ "key": "type", "label": "类型" },
|
||||
{ "key": "price", "label": "价格区间", "align": "right" },
|
||||
{ "key": "overnight", "label": "能否过夜" },
|
||||
{ "key": "note", "label": "备注" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "l1", "name": "观前街周边大型洗浴中心", "type": "洗浴留宿(已选)", "price": "¥120-180", "overnight": "需确认", "note": "须电话确认 24 小时营业或有安静休息区;含过夜" },
|
||||
{ "_key": "l2", "name": "临顿路连锁酒店", "type": "酒店(备选)", "price": "¥380-520", "overnight": "可", "note": "14:00 入住 / 12:00 退房,多数含早,可免费寄存" },
|
||||
{ "_key": "l3", "name": "平江路民宿", "type": "民宿(备选)", "price": "¥450-700", "overnight": "可", "note": "位置好但多为老宅改造,隔音一般;需确认能否寄存" }
|
||||
],
|
||||
"highlight_row_keys": ["l1"],
|
||||
"note": "已按你的选择优先『高级洗浴留宿』;档次与能否过夜属主观/易变信息,务必出发前电话核实。"
|
||||
},
|
||||
{
|
||||
"id": "food_summary",
|
||||
"title": "三餐候选一览",
|
||||
"columns": [
|
||||
{ "key": "meal", "label": "餐次" },
|
||||
{ "key": "area", "label": "位置" },
|
||||
{ "key": "type", "label": "业态" },
|
||||
{ "key": "per_capita", "label": "人均", "align": "right" },
|
||||
{ "key": "access", "label": "到达方式" }
|
||||
],
|
||||
"rows": [
|
||||
{ "_key": "f1", "meal": "D1 午餐", "area": "平江路北段", "type": "苏帮菜小馆(临河)", "per_capita": "¥90", "access": "步行 12 分钟" },
|
||||
{ "_key": "f2", "meal": "D1 晚餐", "area": "观前街", "type": "本地家常菜", "per_capita": "¥80", "access": "步行 15 分钟" },
|
||||
{ "_key": "f3", "meal": "D2 早餐", "area": "临顿路", "type": "苏式面馆", "per_capita": "¥25", "access": "步行 8 分钟" }
|
||||
],
|
||||
"note": "按单人出行设计;不编造店名,到店前用高德/大众点评按当前位置挑选本地评价占比高的店。"
|
||||
}
|
||||
],
|
||||
|
||||
"cost": {
|
||||
"currency": "CNY",
|
||||
"items": [
|
||||
{ "label": "往返高铁票(二等座,按区间中值 ×2)", "amount": 90, "note": "¥45×2,实际以 12306 为准" },
|
||||
{ "label": "住宿(洗浴留宿,含过夜)", "amount": 150, "unit": "晚", "note": "单人间基准,档次以门店为准" },
|
||||
{ "label": "市内交通 · 共享单车", "amount": 5, "note": "按 1.5 元/15 分钟估" },
|
||||
{ "label": "市内交通 · 网约车", "amount": 70, "note": "金鸡湖往返 + 返程赴站" },
|
||||
{ "label": "餐饮(2 正餐 + 1 早餐)", "amount": 195, "note": "按人均估算" },
|
||||
{ "label": "门票(拙政园,如选苏博则免)", "amount": 80, "note": "以官方票价为准" }
|
||||
],
|
||||
"total": 590,
|
||||
"note": "高铁票价按区间中值估算,实际以 12306 为准;住宿与门票以实际预订为准。"
|
||||
},
|
||||
|
||||
"checklist": [
|
||||
{ "anchor_id": "train_out", "field": "train_schedule", "kind": "must", "confidence": "low", "text": "出发前在 12306 核对去程车次、发车时刻与到达时刻(本报告不给车次号)", "verify": "12306 App 或官网查询 上海虹桥 → 苏州站" },
|
||||
{ "anchor_id": "train_back", "field": "train_schedule", "kind": "must", "confidence": "low", "text": "核对返程车次,确认最晚 21:00 前能抵达上海虹桥(返程死线)", "verify": "12306 App 查询 苏州站 → 上海虹桥,优先选 17:00 前发车" },
|
||||
{ "anchor_id": "train_out", "field": "train_fare", "kind": "optional", "confidence": "low", "text": "确认二等座实际票价(报告仅给 ¥40-50 区间)", "verify": "12306 下单页显示价格" },
|
||||
{ "anchor_id": "lodging_night", "field": "bathhouse_overnight_ok", "kind": "must", "confidence": "low", "text": "电话确认所选浴场是否真能过夜(部分仅营业到凌晨)", "verify": "致电前台:是否 24 小时营业、有无安静休息区、是否需预约" },
|
||||
{ "anchor_id": "station_to_lodging", "field": "opening_hours", "kind": "optional", "confidence": "low", "text": "确认所选住宿能否提前免费寄存行李(多数 14:00 后才可入住)", "verify": "预订时备注早到;或致电前台确认行李寄存政策" },
|
||||
{ "anchor_id": "lodging_night", "field": "bathhouse_grade", "kind": "optional", "confidence": "low", "text": "确认浴场规模与卫生(营业面积、开业年限、本地评价量级)", "verify": "大众点评查看门店信息与近期评价" },
|
||||
{ "anchor_id": "city_mobility", "field": "bike_no_park_zone", "kind": "must", "confidence": "low", "text": "确认平江路/观前街周边的共享单车禁停范围与合法还车点", "verify": "打开美团/哈啰 App 查看 P 点分布" },
|
||||
{ "anchor_id": "city_mobility", "field": "taxi_pickup_point", "kind": "optional", "confidence": "low", "text": "确认平江路口等禁停路段的网约车实际可上车位置", "verify": "叫车后与司机确认上车点" },
|
||||
{ "anchor_id": "spot_d1", "field": "opening_hours", "kind": "must", "confidence": "low", "text": "确认拙政园/苏博当日的开放时间与「最后入场时间」(拙政园约 16:30、苏博约 16:00 停止入场,比闭馆时间更关键)", "verify": "官方公众号或购票页公示的停止入场时间" },
|
||||
{ "anchor_id": "spot_d1", "field": "reservation_required", "kind": "must", "confidence": "low", "text": "苏州博物馆需提前预约(旺季名额紧张);若选拙政园需确认门票", "verify": "苏州博物馆官方公众号预约" },
|
||||
{ "anchor_id": "spot_d2", "field": "opening_hours", "kind": "optional", "confidence": "low", "text": "确认虎丘当日开放时间与停止入场时间(返程前需留足车程)", "verify": "官方公众号或官网公布时间" },
|
||||
{ "anchor_id": "lunch_d1", "field": "restaurant_rating", "kind": "must", "confidence": "low", "text": "核对午饭餐馆的评分与评价数(「本地人爱去 + 评分好」两条都要成立)", "verify": "大众点评/高德按「平江路 苏帮菜」筛选,看评分与本地评价占比" },
|
||||
{ "anchor_id": "dinner_d1", "field": "restaurant_rating", "kind": "must", "confidence": "low", "text": "核对晚饭餐馆的评分、评价数与打烊时间(须 19:00 前能吃完)", "verify": "大众点评/高德筛选,并致电确认营业时段" },
|
||||
{ "anchor_id": "night_stroll", "field": "night_market_open", "kind": "must", "confidence": "low", "text": "确认平江路/观前街夜间开放情况与沿街店铺打烊时间(会随季节调整)", "verify": "出发前查当地文旅公众号或致电街区管理方" },
|
||||
{ "anchor_id": "night_stroll", "field": "opening_hours", "kind": "optional", "confidence": "medium", "text": "确认苏州地铁 2/4 号线末班时刻(夜逛结束返程用)", "verify": "苏州地铁官方 App 或站内公示" },
|
||||
{ "anchor_id": "breakfast_d2", "field": "opening_hours", "kind": "optional", "confidence": "medium", "text": "确认面馆次日开门时间(7 点前是否已营业)", "verify": "前一晚现场确认或致电" }
|
||||
],
|
||||
|
||||
"notes": [
|
||||
{
|
||||
"level": "danger",
|
||||
"title": "返程死线:周日 21:00 前必须抵达上海",
|
||||
"text": "周日务必下午出发返程,最晚 21:00 抵达上海,为地铁末班留出余量(周一要上班)。本次建议 14:00-16:00 发车;若所选班次偏晚请改早班次。抵沪后如接近末班时间,请直接打车回家。"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"title": "本技能不给高铁车次号",
|
||||
"text": "AI 无法查询 12306,写出的车次号必然是编造的,会误导你赶错车。因此报告只给「建议时段 + 车站 + 历时区间 + 参考票价区间」。出发前请在 12306 核对车次与发到时刻。"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"title": "降级说明:地形平坦处理",
|
||||
"text": "苏州为平原水乡、无山。两段徒步均按 terrain_flat 规则执行:环古城河步道与金鸡湖环湖步道均为平路水岸,累计爬升约 0。已明确标注「平路徒步」,未冒充山地徒步效果。想出汗可提高配速或折返走两趟。"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"title": "共享单车禁停区提示",
|
||||
"text": "平江路、观前街、拙政园一带为共享单车禁行/禁停重点区域。请在干将东路、白塔东路等外围指定停车点还车后再步行进入,否则可能产生调度费。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "周六夜怎么安排:19:00-21:00 是自由活动",
|
||||
"text": "晚饭安排在 17:30 左右,吃完正好接上夜逛。平江路夜间段(临河灯笼夜景,安静适合拍照)与观前街夜市(热闹、小吃多)相距约 1.5km,可任选或都逛;想走远一点可打车去七里山塘看夜景。不强制顺序,累了随时回住处。逛街时请留意沿街店铺打烊时间(多数 21:00-22:00)。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "两顿饭都按「本地人爱去 + 评分好」筛选",
|
||||
"text": "午饭(平江路北段苏帮菜)与晚饭(观前街本地家常菜)都要求同时满足两条:本地人爱去(非游客主街、本地招牌菜、本地评价占比高)与评分好(大众点评约 4.3-4.5)。报告未指定具体店名,避免编造;到店前请用大众点评/高德按当前位置筛选,评分与打烊时间以实时显示为准。"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"title": "季节提示",
|
||||
"text": "2026 年 10 月中旬苏州气温约 15-23℃,秋高气爽,适合步行。平江路早晚温差较大,建议带一件薄外套。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/* ============================================================
|
||||
* sh_2d_rail_hike/tc_export.css —— 高铁游 · 导出文档样式(只服务本 skill 的 tc_export.js)
|
||||
*
|
||||
* 定位:这是【导出的单文件 HTML】用的样式,不在页面上加载。
|
||||
* tc_export.js 导出时会把本文件全文读出来,内联进 <style>,
|
||||
* 这样导出的 .html 双击即可离线查看(不依赖网络、不依赖本工具)。
|
||||
*
|
||||
* 【隔离用意 · 请后续开发者先读这段】
|
||||
* 本文件从 sh_2d_car_hike/tc_export.css 复制而来(用户已确认「复制现有再改」)。
|
||||
* 复制后只服务【本 skill】的导出文档。相对原版刻意的改动:
|
||||
* 点位清单由「单行名字」改成「名字 + 导航地址 + 高德/百度链接」(.point-body 等新类)。
|
||||
* 本 skill 内部另有一份 tc_report.css 服务页面渲染,两者类名不同、互不引用 ——
|
||||
* 一个管页面、一个管导出文件,各自独立。要新增 skill,请复制本文件后再改。
|
||||
* ============================================================ */
|
||||
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#f4f5f7;
|
||||
--card:#ffffff;
|
||||
--line:#e6e8ec;
|
||||
--line-soft:#f0f1f4;
|
||||
--text:#1f2328;
|
||||
--sub:#6b7280;
|
||||
--accent:#2f6fed;
|
||||
--radius:14px;
|
||||
}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
margin:0;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei","PingFang SC","Hiragino Sans GB",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
text-rendering:optimizeLegibility;
|
||||
}
|
||||
.page{max-width:1100px;margin:0 auto;padding:28px 18px 80px}
|
||||
|
||||
/* ---------- HERO / 行程概览 ---------- */
|
||||
.hero{
|
||||
position:relative;overflow:hidden;border-radius:18px;padding:30px 30px 26px;
|
||||
color:#fff;
|
||||
background:radial-gradient(1100px 460px at 10% -12%,#3f7bf0 0%,#2456c9 44%,#1b3d94 100%);
|
||||
box-shadow:0 16px 40px rgba(27,61,148,.24);
|
||||
margin-bottom:18px;
|
||||
}
|
||||
.hero::after{
|
||||
content:"";position:absolute;right:-90px;top:-110px;width:360px;height:360px;border-radius:50%;
|
||||
background:radial-gradient(circle,rgba(255,255,255,.18),transparent 62%);
|
||||
pointer-events:none;
|
||||
}
|
||||
.hero-inner{position:relative;z-index:1}
|
||||
.kicker{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;letter-spacing:.08em;color:rgba(255,255,255,.76);margin-bottom:12px}
|
||||
.kicker .chip{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:2px 10px;letter-spacing:.02em;color:#fff}
|
||||
.hero h1{margin:0 0 9px;font-size:26px;line-height:1.32;letter-spacing:.01em;font-weight:700}
|
||||
.lede{margin:0 0 16px;max-width:780px;font-size:14px;line-height:1.75;color:rgba(255,255,255,.87)}
|
||||
.hero-tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
|
||||
.hero-tags span{font-size:12px;padding:3px 11px;border-radius:999px;background:rgba(255,255,255,.13);border:1px solid rgba(255,255,255,.24)}
|
||||
.hero-meta{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
|
||||
.hero-meta .mb{font-size:11.5px;padding:3px 10px;border-radius:999px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:rgba(255,255,255,.94)}
|
||||
.hero-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
|
||||
.hstat{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.18);border-radius:12px;padding:12px 14px}
|
||||
.hstat .l{font-size:11.5px;color:rgba(255,255,255,.72)}
|
||||
.hstat .v{font-size:17px;font-weight:700;margin:4px 0 3px;font-variant-numeric:tabular-nums}
|
||||
.hstat .v.sm{font-size:14px;line-height:1.5}
|
||||
.hstat .h{font-size:11px;line-height:1.55;color:rgba(255,255,255,.66)}
|
||||
|
||||
/* ---------- 目录 ---------- */
|
||||
.index{
|
||||
position:sticky;top:10px;z-index:30;
|
||||
display:flex;flex-wrap:wrap;gap:6px;padding:9px;margin-bottom:18px;
|
||||
background:rgba(255,255,255,.9);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(10px);
|
||||
backdrop-filter:saturate(180%) blur(10px);
|
||||
border:1px solid var(--line);border-radius:14px;
|
||||
box-shadow:0 6px 18px rgba(15,23,42,.06);
|
||||
}
|
||||
.index a{font-size:12.5px;text-decoration:none;color:#2f6fed;background:#f0f4ff;padding:4px 10px;border-radius:8px;transition:background .15s,color .15s}
|
||||
.index a:hover{background:#e2eaff}
|
||||
|
||||
/* ---------- 区块 ---------- */
|
||||
.section{
|
||||
background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:20px 20px 22px;margin-bottom:16px;box-shadow:0 1px 2px rgba(16,24,40,.03);
|
||||
}
|
||||
.sec-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}
|
||||
.sec-title{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:700;margin:0;letter-spacing:.01em}
|
||||
.sec-title::before{content:"";width:4px;height:17px;border-radius:3px;background:linear-gradient(180deg,#2f6fed,#5b8df6)}
|
||||
.sec-top{font-size:12px;color:var(--sub);text-decoration:none;white-space:nowrap;transition:color .15s}
|
||||
.sec-top:hover{color:var(--accent)}
|
||||
.empty{color:var(--sub);font-size:13px;padding:8px 0}
|
||||
|
||||
/* ---------- 提示块 ---------- */
|
||||
.note{border-radius:10px;padding:12px 14px;margin-bottom:10px;border:1px solid}
|
||||
.note:last-child{margin-bottom:0}
|
||||
.note-title{font-weight:600;font-size:13.5px;margin-bottom:3px}
|
||||
.note-text{font-size:13px;line-height:1.72;color:#374151}
|
||||
.note-warn{background:#fffaf0;border-color:#f0dcae}
|
||||
.note-info{background:#f2f7ff;border-color:#cfe0ff}
|
||||
.note-danger{background:#fff5f4;border-color:#f5c9c3}
|
||||
|
||||
/* ---------- 表格 ---------- */
|
||||
.tablebox{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||||
.table-title{font-size:13px;font-weight:600;margin-bottom:9px;color:#374151}
|
||||
table.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff}
|
||||
table.tbl th,table.tbl td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
|
||||
table.tbl thead th{background:#f7f8fa;font-weight:600;white-space:nowrap;color:#374151}
|
||||
table.tbl tbody tr:hover td{background:#fafbfc}
|
||||
table.tbl tbody tr.row-hl td{background:#eef4ff}
|
||||
table.tbl tbody tr.row-hard td{background:#fff7ed}
|
||||
table.tbl tfoot td{font-weight:700;background:#f2f6ff;font-size:13.5px}
|
||||
.col-time{white-space:nowrap;color:#374151;width:104px;font-variant-numeric:tabular-nums}
|
||||
.col-kind{width:150px}
|
||||
.row-title{font-weight:500}
|
||||
.al-right{text-align:right !important;font-variant-numeric:tabular-nums}
|
||||
.cell-note{color:var(--sub);font-size:12px}
|
||||
.tbl-note{font-size:12px;color:var(--sub);margin-top:9px}
|
||||
|
||||
/* ---------- 类型标签 ---------- */
|
||||
.kind{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#f3f4f6;color:#6b7280;white-space:nowrap}
|
||||
.kind-act{background:#eef2ff;color:#4f46e5;margin-left:4px}
|
||||
.kind-hard{background:#fdece8;color:#b42318;font-weight:600}
|
||||
|
||||
/* ---------- 置信度徽标 ---------- */
|
||||
.badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#eef2f7;color:#4b5563;white-space:nowrap;vertical-align:1px}
|
||||
.badge-high{background:#e8f6ee;color:#1a7a45}
|
||||
.badge-medium{background:#fff5e0;color:#8a6100}
|
||||
.badge-low{background:#fdece8;color:#b42318}
|
||||
.badge-info{background:#e8f0fe;color:#1d4ed8}
|
||||
.badge-good{background:#e8f6ee;color:#1a7a45}
|
||||
.badge-warn{background:#fff5e0;color:#8a6100}
|
||||
.badge-danger{background:#fdece8;color:#b42318}
|
||||
|
||||
/* ---------- 时序 ---------- */
|
||||
.day{margin-bottom:18px}
|
||||
.day:last-child{margin-bottom:0}
|
||||
.day-head{display:flex;align-items:baseline;gap:8px;margin-bottom:7px}
|
||||
.day-label{font-weight:700;font-size:14px}
|
||||
.day-date{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||
|
||||
/* ---------- 卡片 ---------- */
|
||||
.group{margin-bottom:18px}
|
||||
.group:last-child{margin-bottom:0}
|
||||
.group-title{display:flex;align-items:center;gap:9px;font-size:14px;font-weight:700;color:#374151;margin:0 0 11px}
|
||||
.group-title::after{content:"";flex:1;height:1px;background:var(--line-soft)}
|
||||
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px}
|
||||
.card{border:1px solid var(--line);border-radius:12px;padding:14px 15px 15px;background:#fff;transition:box-shadow .18s,border-color .18s}
|
||||
.card:hover{box-shadow:0 8px 24px rgba(16,24,40,.08);border-color:#d7dce5}
|
||||
.card-title{font-size:14.5px;font-weight:700;margin:0;line-height:1.45}
|
||||
.card-sub{font-size:12px;color:var(--sub);margin-top:2px}
|
||||
.card-badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
|
||||
.fields{margin-top:11px}
|
||||
.field{display:grid;grid-template-columns:98px 1fr;gap:8px;padding:7px 0;border-top:1px dashed #eceef2}
|
||||
.field:first-child{border-top:0;padding-top:0}
|
||||
.field-label{color:var(--sub);font-size:12px;line-height:1.6}
|
||||
.field-value{font-size:13px;line-height:1.66}
|
||||
.field-hint{font-size:11px;color:var(--sub);margin-top:3px;line-height:1.55}
|
||||
.fact-text{margin-right:4px}
|
||||
.verify{display:block;font-size:11px;color:#8a6100;margin-top:3px;line-height:1.55}
|
||||
.bullets{margin:11px 0 0;padding-left:19px;font-size:13px}
|
||||
.bullets li{margin-bottom:4px}
|
||||
.bullets li::marker{color:#9aa3af}
|
||||
.card figure{margin:9px 0 0}
|
||||
.card figure img{width:100%;height:auto;display:block;border-radius:9px;border:1px solid var(--line)}
|
||||
.figcap{font-size:11px;color:var(--sub);margin-top:6px;line-height:1.55}
|
||||
.img-missing{font-size:12px;color:var(--sub);background:#f7f8fa;border:1px dashed var(--line);border-radius:9px;padding:14px;text-align:center}
|
||||
|
||||
/* ---------- 点位与路线 ---------- */
|
||||
.map-wrap{background:#fafbfc;border:1px solid var(--line);border-radius:12px;padding:16px 14px}
|
||||
.map-svg{width:100%;height:auto;display:block}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;font-size:12px;color:#374151}
|
||||
.legend-item{display:inline-flex;align-items:center;gap:6px}
|
||||
.swatch{width:20px;height:4px;border-radius:2px;display:inline-block}
|
||||
.map-note{font-size:12px;color:var(--sub);margin-top:10px}
|
||||
.points{display:grid;grid-template-columns:repeat(auto-fill,minmax(228px,1fr));gap:7px;margin-top:15px;border-top:1px dashed #e9ebef;padding-top:15px}
|
||||
.point{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;color:#374151}
|
||||
.point .num{flex:none;width:20px;height:20px;border-radius:50%;color:#fff;font-size:10.5px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}
|
||||
/* 【本技能新增】点位清单里的导航信息:名字 + 地址 + 高德/百度链接 */
|
||||
.point-body{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.point-name{line-height:1.45}
|
||||
.point-addr{color:#6b7280;font-size:11.5px;line-height:1.5}
|
||||
.point-nav{color:#2f6fed;text-decoration:none;font-size:11.5px}
|
||||
.point-nav:hover{text-decoration:underline}
|
||||
|
||||
/* ---------- 待核实清单 ---------- */
|
||||
.checklist{margin:0;padding:0;list-style:none;counter-reset:c}
|
||||
.check-item{counter-increment:c;position:relative;padding:10px 0 10px 36px;border-top:1px dashed #eceef2;font-size:13px;line-height:1.65}
|
||||
.check-item:first-child{border-top:0;padding-top:4px}
|
||||
.check-item::before{
|
||||
content:counter(c);position:absolute;left:0;top:11px;width:22px;height:22px;border-radius:50%;
|
||||
background:#eef2ff;color:#2f6fed;font-size:11px;font-weight:700;
|
||||
display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.check-item:first-child::before{top:5px}
|
||||
.check-text{margin-right:8px}
|
||||
|
||||
/* ---------- 页脚 ---------- */
|
||||
.doc-foot{text-align:center;font-size:12px;color:var(--sub);padding:22px 0 0;line-height:1.8}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
@media (max-width:640px){
|
||||
.page{padding:16px 12px 60px}
|
||||
.hero{padding:22px 18px 20px;border-radius:14px}
|
||||
.hero h1{font-size:21px}
|
||||
.lede{font-size:13.5px}
|
||||
.card-grid{grid-template-columns:1fr}
|
||||
.field{grid-template-columns:84px 1fr}
|
||||
.sec-title{font-size:15px}
|
||||
.index{top:0;border-radius:0;margin:0 -12px 16px;border-left:0;border-right:0}
|
||||
}
|
||||
|
||||
/* ---------- 打印 ---------- */
|
||||
@media print{
|
||||
body{background:#fff}
|
||||
.page{max-width:none;padding:0}
|
||||
.index{display:none}
|
||||
.section,.card,.hero,.note{box-shadow:none}
|
||||
.card,.section,.note,.day{break-inside:avoid}
|
||||
.hero{background:#1b3d94 !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
table.tbl{font-size:11.5px}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
/* ============================================================
|
||||
* sh_2d_rail_hike/tc_export.js —— 高铁游 · 单文件 HTML 导出器(技能私有)
|
||||
*
|
||||
* 干什么:把后端已经生成好的 payload + 技能包的 report_spec,在【浏览器本地】
|
||||
* 拼成一份完整的、自包含的 .html 文档并下载。纯前端、零 AI 调用,
|
||||
* 导出一次不消耗任何 token。
|
||||
*
|
||||
* 怎么做到「单文件、离线可看」:
|
||||
* 1. 样式内联:把同目录 tc_export.css 全文读出来塞进 <style>,不引用外部 css。
|
||||
* 2. 图片内嵌:卡片的配图就地 fetch 成 base64 data URI 塞进 <img>,不引用外部图。
|
||||
* 3. 地图内联:不用 Leaflet(它要联网拉瓦片),改用内置 SVG 画点位顺序示意图。
|
||||
* → 导出的文件双击即可打开,断网也能看。
|
||||
*
|
||||
* 【隔离用意 · 请后续开发者先读这段】
|
||||
* 本文件从 sh_2d_car_hike/tc_export.js 复制而来(用户已确认「复制现有再改」)。
|
||||
* 复制后只服务【本 skill】。相对原版刻意的改动:
|
||||
* (1) ACTIVITY_LABEL / PIN_COLORS 扩充为本技能词表(单车/网约车/地铁/高铁/入住/车站/住宿)
|
||||
* (2) 点位清单里直接展示「导航地址」,并给高德/百度搜索链接 ——
|
||||
* 导出的离线文件里也能一键查到怎么走,这是本 skill 用户的核心诉求
|
||||
* 为什么不与自驾 skill 共用?同上:共用意味着本 skill 想加导航链接就得改别人的文件。
|
||||
* 要新增 skill,请复制本文件后再改。本文件与 tc_render.js 也各写一套(一个拼字符串给文件看、
|
||||
* 一个建 DOM 给页面看),互不依赖:tc_render.js 挂了,导出照样能用。
|
||||
*
|
||||
* 依赖:无。tc_export.css 由本文件在导出时读取并内联。
|
||||
* ============================================================ */
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
/* ---------------- 常量 ---------------- */
|
||||
|
||||
var DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
|
||||
var KIND_LABEL = { hard: '硬时间点', soft: '常规' }
|
||||
// 【本技能私有词表】与同目录 tc_render.js 保持一致;比自驾 skill 多
|
||||
// 逛街夜游/游览景点/共享单车/网约车/地铁/高铁/入住寄存/休息
|
||||
var ACTIVITY_LABEL = {
|
||||
walk: '步行', stroll: '逛街夜游', spot: '游览景点', bike: '共享单车', taxi: '网约车',
|
||||
metro: '地铁', train: '高铁', eat: '吃饭', hike: '徒步', shop: '采买',
|
||||
checkin: '入住寄存', rest: '休息'
|
||||
}
|
||||
var DEFAULT_MAP_NOTE = '点位顺序示意,非真实道路/轨迹'
|
||||
var MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
// 【本技能私有词表】点位配色:无 parking(本技能无自驾),
|
||||
// 新增 train_station 车站 / lodging 住宿 / bike 单车点 / taxi_point 网约车上车点 /
|
||||
// night_market 夜市夜逛区(v1.1.0)
|
||||
var PIN_COLORS = {
|
||||
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98',
|
||||
train_station: '#1d4ed8', lodging: '#7c3aed', bathhouse: '#1f8a70',
|
||||
district: '#b8860b', food: '#e0673a', hike: '#2e9e5b',
|
||||
bike: '#0ea5e9', taxi_point: '#f59e0b', night_market: '#7c2d92',
|
||||
spot: '#059669', shop: '#5a67d8', walk: '#8a5cf6'
|
||||
}
|
||||
|
||||
// 本脚本所在目录:用来定位同目录的 tc_export.css
|
||||
// 必须在「脚本加载时」取(异步回调里 document.currentScript 已经是 null)
|
||||
var SELF_DIR = (function () {
|
||||
try {
|
||||
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
|
||||
return String(document.currentScript.src).replace(/[^/]*$/, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
return ''
|
||||
})()
|
||||
|
||||
/* ---------------- 基础工具 ---------------- */
|
||||
|
||||
function esc(s) {
|
||||
return String(s === undefined || s === null ? '' : s)
|
||||
.replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
})
|
||||
}
|
||||
|
||||
function isArr(v) { return Array.isArray(v) }
|
||||
function isPlainObject(v) { return !!v && typeof v === 'object' && !isArr(v) }
|
||||
|
||||
function resolvePath(root, path) {
|
||||
if (!path) return root
|
||||
var p = String(path).replace(/^\$\.?/, '')
|
||||
if (!p) return root
|
||||
var cur = root
|
||||
var keys = p.split('.')
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (cur === null || cur === undefined) return undefined
|
||||
cur = cur[keys[i]]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
function pickById(arr, id) {
|
||||
if (!isArr(arr)) return null
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === id) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function badgeText(spec, level) {
|
||||
var map = (spec && spec.confidence_badge) || DEFAULT_BADGE
|
||||
return map[level] || DEFAULT_BADGE[level] || level
|
||||
}
|
||||
|
||||
function pinColor(kind) { return PIN_COLORS[kind] || '#4b5563' }
|
||||
|
||||
function dayName(d) {
|
||||
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : '其他'))
|
||||
}
|
||||
|
||||
function dayNameForMap(d) {
|
||||
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : ''))
|
||||
}
|
||||
|
||||
function toneClass(tone) {
|
||||
var map = { info: 'info', warn: 'warn', danger: 'danger', good: 'good', high: 'high', medium: 'medium', low: 'low' }
|
||||
return map[tone] || ''
|
||||
}
|
||||
|
||||
function absUrl(u, baseUrl) {
|
||||
try { return new URL(String(u), baseUrl || undefined).href } catch (e) { return String(u) }
|
||||
}
|
||||
|
||||
function nowBeijing() {
|
||||
try {
|
||||
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
|
||||
} catch (e) { return new Date().toLocaleString() }
|
||||
}
|
||||
|
||||
function utf8Length(s) {
|
||||
try { return new Blob([s]).size } catch (e) { return String(s).length }
|
||||
}
|
||||
|
||||
/* ---------------- 事实 / 置信度 ---------------- */
|
||||
|
||||
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
|
||||
function factHtml(value, spec) {
|
||||
if (isPlainObject(value)) {
|
||||
var out = '<span class="fact-text">' + esc(value.text === undefined ? '' : value.text) + '</span>'
|
||||
if (value.confidence) {
|
||||
out += '<span class="badge badge-' + esc(value.confidence) + '">' + esc(badgeText(spec, value.confidence)) + '</span>'
|
||||
}
|
||||
if (value.verify) out += '<span class="verify">核实:' + esc(value.verify) + '</span>'
|
||||
return out
|
||||
}
|
||||
return '<span class="fact-text">' + esc(value === null || value === undefined ? '—' : value) + '</span>'
|
||||
}
|
||||
|
||||
/* ---------------- 各 block 渲染(返回 HTML 字符串) ---------------- */
|
||||
|
||||
function metaBadges(meta) {
|
||||
var out = []
|
||||
if (!meta) return out
|
||||
var keys = Object.keys(meta)
|
||||
if (!keys.length) return out
|
||||
if (meta.model) out.push('模型 ' + meta.model)
|
||||
if (meta.elapsed_ms) out.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
|
||||
if (Object.prototype.hasOwnProperty.call(meta, 'is_new_destination')) {
|
||||
out.push(meta.is_new_destination ? '首个新目的地' : '技能包内已有覆盖')
|
||||
}
|
||||
var warns = isArr(meta.warnings) ? meta.warnings : null
|
||||
if (warns) out.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
|
||||
return out
|
||||
}
|
||||
|
||||
function overviewHtml(data, spec, ctx) {
|
||||
if (!isPlainObject(data)) return '<div class="hero-inner"><div class="empty">无概览数据</div></div>'
|
||||
var html = '<div class="hero-inner">'
|
||||
html += '<div class="kicker">' + esc(ctx.kicker) + '<span class="chip">' + esc(ctx.chip) + '</span></div>'
|
||||
if (data.title) html += '<h1>' + esc(data.title) + '</h1>'
|
||||
if (data.one_liner) html += '<p class="lede">' + esc(data.one_liner) + '</p>'
|
||||
if (isArr(data.tags) && data.tags.length) {
|
||||
html += '<div class="hero-tags">' + data.tags.map(function (t) {
|
||||
return '<span>' + esc(t) + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
var mbs = metaBadges(ctx.meta)
|
||||
if (mbs.length) {
|
||||
html += '<div class="hero-meta">' + mbs.map(function (t) {
|
||||
return '<span class="mb">' + esc(t) + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
if (isArr(data.stats) && data.stats.length) {
|
||||
html += '<div class="hero-stats">' + data.stats.map(function (s) {
|
||||
var v = s.value === undefined || s.value === null ? '' : String(s.value)
|
||||
return '<div class="hstat">' +
|
||||
'<div class="l">' + esc(s.label || '') + '</div>' +
|
||||
'<div class="v' + (v.length > 9 ? ' sm' : '') + '">' + esc(v) + '</div>' +
|
||||
(s.hint ? '<div class="h">' + esc(s.hint) + '</div>' : '') +
|
||||
'</div>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function notesHtml(data, section) {
|
||||
var list = isArr(data) ? data.slice() : []
|
||||
if (section && isArr(section.filter_levels) && section.filter_levels.length) {
|
||||
list = list.filter(function (n) { return section.filter_levels.indexOf(n.level) >= 0 })
|
||||
}
|
||||
if (!list.length) return '<div class="empty">无提示项</div>'
|
||||
return list.map(function (n) {
|
||||
return '<div class="note note-' + esc(n.level || 'info') + '">' +
|
||||
(n.title ? '<div class="note-title">' + esc(n.title) + '</div>' : '') +
|
||||
(n.text ? '<div class="note-text">' + esc(n.text) + '</div>' : '') +
|
||||
'</div>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function timelineHtml(data) {
|
||||
if (!isPlainObject(data) || !isArr(data.days)) return '<div class="empty">无时序数据</div>'
|
||||
return data.days.map(function (day) {
|
||||
var html = '<div class="day">'
|
||||
html += '<div class="day-head"><span class="day-label">' + esc(day.label || '') + '</span>' +
|
||||
(day.date ? '<span class="day-date">' + esc(day.date) + '</span>' : '') + '</div>'
|
||||
html += '<div class="tablebox"><table class="tbl"><thead><tr>' +
|
||||
'<th class="col-time">时间</th><th>安排</th><th class="col-kind">类型</th>' +
|
||||
'</tr></thead><tbody>'
|
||||
;(isArr(day.rows) ? day.rows : []).forEach(function (r) {
|
||||
var hard = r.kind === 'hard'
|
||||
html += '<tr' + (hard ? ' class="row-hard"' : '') + '>'
|
||||
html += '<td class="col-time">' + esc(r.time || '') + '</td>'
|
||||
html += '<td><div class="row-title">' + esc(r.title || '') + '</div>' +
|
||||
(r.note ? '<div class="cell-note">' + esc(r.note) + '</div>' : '') + '</td>'
|
||||
html += '<td class="col-kind">' +
|
||||
'<span class="kind' + (hard ? ' kind-hard' : '') + '">' +
|
||||
esc(KIND_LABEL[r.kind] || r.kind || '') + '</span>' +
|
||||
(r.activity ? '<span class="kind kind-act">' + esc(ACTIVITY_LABEL[r.activity] || r.activity) + '</span>' : '') +
|
||||
'</td>'
|
||||
html += '</tr>'
|
||||
})
|
||||
html += '</tbody></table></div></div>'
|
||||
return html
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function cardHtml(card, spec, ctx) {
|
||||
var id = card.id || card.anchor_id || ''
|
||||
var html = '<article class="card"' + (id ? ' id="card_' + esc(id) + '"' : '') + '>'
|
||||
html += '<h4 class="card-title">' + esc(card.title || '') + '</h4>'
|
||||
if (card.subtitle) html += '<div class="card-sub">' + esc(card.subtitle) + '</div>'
|
||||
|
||||
if (isArr(card.badges) && card.badges.length) {
|
||||
html += '<div class="card-badges">' + card.badges.map(function (b) {
|
||||
var tone = toneClass(b.tone)
|
||||
return '<span class="badge' + (tone ? ' badge-' + tone : '') + '">' + esc(b.text || '') + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
|
||||
if (isPlainObject(card.image) && card.image.url) {
|
||||
var raw = String(card.image.url)
|
||||
var embedded = ctx.images && ctx.images[raw]
|
||||
var src = embedded || absUrl(raw, ctx.baseUrl)
|
||||
var cap = card.image.caption || ''
|
||||
var dis = card.image.disclaimer || (spec && spec.image_disclaimer_default) || ''
|
||||
html += '<figure>'
|
||||
if (embedded) {
|
||||
html += '<img src="' + esc(src) + '" alt="' + esc(cap) + '" />'
|
||||
} else {
|
||||
html += '<div class="img-missing">配图未内嵌:' + esc(raw) + '</div>'
|
||||
}
|
||||
html += '<figcaption class="figcap">' + (cap ? esc(cap) + ' · ' : '') + esc(dis) + '</figcaption>'
|
||||
html += '</figure>'
|
||||
}
|
||||
|
||||
if (isArr(card.fields) && card.fields.length) {
|
||||
html += '<div class="fields">' + card.fields.map(function (f) {
|
||||
return '<div class="field">' +
|
||||
'<div class="field-label">' + esc(f.label || '') + '</div>' +
|
||||
'<div class="field-value">' + factHtml(f.value, spec) +
|
||||
(f.hint ? '<div class="field-hint">' + esc(f.hint) + '</div>' : '') +
|
||||
'</div></div>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
|
||||
if (isArr(card.bullets) && card.bullets.length) {
|
||||
html += '<ul class="bullets">' + card.bullets.map(function (b) {
|
||||
return '<li>' + esc(b) + '</li>'
|
||||
}).join('') + '</ul>'
|
||||
}
|
||||
|
||||
html += '</article>'
|
||||
return html
|
||||
}
|
||||
|
||||
function cardsHtml(data, spec, section, ctx) {
|
||||
var list = isArr(data) ? data : []
|
||||
if (!list.length) return '<div class="empty">无节点数据</div>'
|
||||
var dayOf = function (c) { return c.day === undefined || c.day === null ? 0 : Number(c.day) }
|
||||
|
||||
if (!(section && section.group_by === 'day')) {
|
||||
return '<div class="card-grid">' + list.map(function (c) { return cardHtml(c, spec, ctx) }).join('') + '</div>'
|
||||
}
|
||||
|
||||
var days = []
|
||||
list.forEach(function (c) {
|
||||
var d = dayOf(c)
|
||||
if (days.indexOf(d) < 0) days.push(d)
|
||||
})
|
||||
days.sort(function (a, b) { return a - b })
|
||||
return days.map(function (d) {
|
||||
return '<div class="group"><h3 class="group-title">' + esc(dayName(d)) + '</h3>' +
|
||||
'<div class="card-grid">' +
|
||||
list.filter(function (c) { return dayOf(c) === d })
|
||||
.map(function (c) { return cardHtml(c, spec, ctx) }).join('') +
|
||||
'</div></div>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function tableHtml(data, section) {
|
||||
var table = null
|
||||
if (isArr(data)) {
|
||||
table = pickById(data, section && section.pick_id)
|
||||
if (!table && section && section.pick_id) return '<div class="empty">缺少表格:' + esc(section.pick_id) + '</div>'
|
||||
if (!table) table = data[0]
|
||||
} else {
|
||||
table = data
|
||||
}
|
||||
if (!isPlainObject(table)) return '<div class="empty">无表格数据</div>'
|
||||
|
||||
var columns = isArr(table.columns) ? table.columns : []
|
||||
var rows = isArr(table.rows) ? table.rows : []
|
||||
var hl = isArr(table.highlight_row_keys) ? table.highlight_row_keys : []
|
||||
|
||||
var html = '<div class="tablebox">'
|
||||
if (table.title) html += '<div class="table-title">' + esc(table.title) + '</div>'
|
||||
html += '<table class="tbl"><thead><tr>'
|
||||
columns.forEach(function (c) {
|
||||
html += '<th' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') +
|
||||
(c.width ? ' style="width:' + esc(c.width) + '"' : '') + '>' + esc(c.label || c.key) + '</th>'
|
||||
})
|
||||
html += '</tr></thead><tbody>'
|
||||
rows.forEach(function (r) {
|
||||
html += '<tr' + (hl.indexOf(r._key) >= 0 ? ' class="row-hl"' : '') + '>'
|
||||
columns.forEach(function (c) {
|
||||
var v = r[c.key]
|
||||
html += '<td' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') + '>' +
|
||||
esc(v === null || v === undefined ? '—' : v) + '</td>'
|
||||
})
|
||||
html += '</tr>'
|
||||
})
|
||||
html += '</tbody></table></div>'
|
||||
if (table.note) html += '<div class="tbl-note">' + esc(table.note) + '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function costHtml(data) {
|
||||
if (!isPlainObject(data)) return '<div class="empty">无费用数据</div>'
|
||||
var items = isArr(data.items) ? data.items : []
|
||||
var html = '<div class="tablebox"><table class="tbl"><thead><tr>' +
|
||||
'<th>项目</th><th class="al-right">金额(' + esc(data.currency || 'CNY') + ')</th><th>口径</th>' +
|
||||
'</tr></thead><tbody>'
|
||||
items.forEach(function (it) {
|
||||
html += '<tr><td>' + esc(it.label || '') + '</td>' +
|
||||
'<td class="al-right">' + esc(it.amount === undefined ? '—' : it.amount) + '</td>' +
|
||||
'<td class="cell-note">' + esc(it.note || it.unit || '') + '</td></tr>'
|
||||
})
|
||||
html += '</tbody>'
|
||||
if (data.total !== undefined && data.total !== null) {
|
||||
html += '<tfoot><tr><td>合计</td><td class="al-right">' + esc(data.total) + '</td><td></td></tr></tfoot>'
|
||||
}
|
||||
html += '</table></div>'
|
||||
if (data.note) html += '<div class="tbl-note">' + esc(data.note) + '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function checklistHtml(data, spec) {
|
||||
var list = isArr(data) ? data : []
|
||||
if (!list.length) return '<div class="empty">无待核实项</div>'
|
||||
return '<ol class="checklist">' + list.map(function (c) {
|
||||
return '<li class="check-item">' +
|
||||
'<span class="check-text">' + esc(c.text || '') + '</span>' +
|
||||
(c.confidence ? '<span class="badge badge-' + esc(c.confidence) + '">' + esc(badgeText(spec, c.confidence)) + '</span>' : '') +
|
||||
(c.verify ? '<span class="verify">核实:' + esc(c.verify) + '</span>' : '') +
|
||||
'</li>'
|
||||
}).join('') + '</ol>'
|
||||
}
|
||||
|
||||
/* ---------------- 地图 block(内联 SVG,不联网、不用 Leaflet) ---------------- */
|
||||
|
||||
function mapSvgHtml(markers, routes) {
|
||||
var W = 1000
|
||||
var PAD = 70
|
||||
var ROW_H = 176
|
||||
|
||||
var dayOf = function (m) { return m.day === undefined || m.day === null ? 0 : Number(m.day) }
|
||||
var days = []
|
||||
markers.forEach(function (m) {
|
||||
var d = dayOf(m)
|
||||
if (days.indexOf(d) < 0) days.push(d)
|
||||
})
|
||||
days.sort(function (a, b) { return a - b })
|
||||
var hasRowLabel = days.length > 1
|
||||
var ROW_Y0 = hasRowLabel ? 92 : 62
|
||||
|
||||
var pos = {}
|
||||
var labels = ''
|
||||
var maxY = ROW_Y0
|
||||
days.forEach(function (d, rowIdx) {
|
||||
var rowMarks = markers.filter(function (m) { return dayOf(m) === d })
|
||||
var y = ROW_Y0 + rowIdx * ROW_H
|
||||
maxY = y
|
||||
var step = rowMarks.length > 1 ? (W - PAD * 2) / (rowMarks.length - 1) : 0
|
||||
rowMarks.forEach(function (m, i) {
|
||||
pos[m.id] = { x: Math.round((PAD + i * step) * 10) / 10, y: y }
|
||||
})
|
||||
if (hasRowLabel) {
|
||||
labels += '<text x="' + (W / 2) + '" y="' + (y - 50) + '" text-anchor="middle" font-size="12" fill="#6b7280">' +
|
||||
esc(dayNameForMap(d)) + '</text>'
|
||||
}
|
||||
})
|
||||
|
||||
var lines = ''
|
||||
routes.forEach(function (r) {
|
||||
var ids = isArr(r.marker_ids) ? r.marker_ids : []
|
||||
for (var i = 0; i < ids.length - 1; i++) {
|
||||
var a = pos[ids[i]]
|
||||
var b = pos[ids[i + 1]]
|
||||
if (!a || !b) continue
|
||||
if (a.y !== b.y) continue // 跨天不连线,避免斜穿两行
|
||||
if (a.x === b.x && a.y === b.y) continue
|
||||
lines += '<line x1="' + a.x + '" y1="' + a.y + '" x2="' + b.x + '" y2="' + b.y + '"' +
|
||||
' stroke="' + esc(r.color || '#cbd5e1') + '"' +
|
||||
' stroke-width="' + (r.kind === 'drive' ? 3 : 4) + '"' +
|
||||
(r.dashed ? ' stroke-dasharray="6 6"' : '') +
|
||||
' stroke-linecap="round"></line>'
|
||||
}
|
||||
})
|
||||
|
||||
var nodes = ''
|
||||
markers.forEach(function (m) {
|
||||
var p = pos[m.id]
|
||||
if (!p) return
|
||||
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
|
||||
nodes += '<g><title>' + esc((seq ? seq + ' ' : '') + (m.name || '')) + '</title>' +
|
||||
'<circle cx="' + p.x + '" cy="' + p.y + '" r="13" fill="' + esc(pinColor(m.kind)) +
|
||||
'" stroke="#fff" stroke-width="2.5"></circle>' +
|
||||
'<text x="' + p.x + '" y="' + p.y + '" text-anchor="middle" dominant-baseline="central"' +
|
||||
' font-size="11" font-weight="700" fill="#fff">' + esc(seq) + '</text></g>'
|
||||
})
|
||||
|
||||
var H = maxY + 62
|
||||
return '<svg class="map-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="行程点位顺序示意图">' +
|
||||
labels +
|
||||
(lines ? '<g>' + lines + '</g>' : '') +
|
||||
(nodes ? '<g>' + nodes + '</g>' : '') +
|
||||
'</svg>'
|
||||
}
|
||||
|
||||
function mapLegendHtml(routes) {
|
||||
if (!isArr(routes) || !routes.length) return ''
|
||||
return '<div class="legend">' + routes.map(function (r) {
|
||||
var color = r.color || '#4b5563'
|
||||
var style = r.dashed
|
||||
? 'background:repeating-linear-gradient(90deg,' + color + ' 0 6px,transparent 6px 12px)'
|
||||
: 'background:' + color
|
||||
return '<span class="legend-item"><span class="swatch" style="' + esc(style) + '"></span>' +
|
||||
esc(r.name || r.id || '') + '</span>'
|
||||
}).join('') + '</div>'
|
||||
}
|
||||
|
||||
function mapHtml(data, spec) {
|
||||
if (!isPlainObject(data) || !isArr(data.markers) || !data.markers.length) {
|
||||
return '<div class="empty">无地图数据</div>'
|
||||
}
|
||||
var markers = data.markers.slice().sort(function (a, b) {
|
||||
var x = a.seq === undefined || a.seq === null ? 9999 : Number(a.seq)
|
||||
var y = b.seq === undefined || b.seq === null ? 9999 : Number(b.seq)
|
||||
return x - y
|
||||
})
|
||||
var html = '<div class="map-wrap">'
|
||||
html += mapSvgHtml(markers, isArr(data.routes) ? data.routes : [])
|
||||
html += mapLegendHtml(data.routes)
|
||||
html += '<div class="map-note">' +
|
||||
esc(data.disclaimer || (spec && spec.map_route_disclaimer_default) || DEFAULT_MAP_NOTE) + '</div>'
|
||||
// 【本技能专属】点位清单:不只列名字,还要把「导航地址」摊开,并给高德/百度搜索链接。
|
||||
// 导出的文件是离线单文件,用户在手机上打开时可以直接点链接去搜路线 —— 这正是
|
||||
// 本 skill 用户的核心诉求(每到一处都有具体地点,方便导航)。
|
||||
html += '<div class="points">' + markers.map(function (m) {
|
||||
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
|
||||
var addr = String(m.address || '').trim()
|
||||
var q = encodeURIComponent(addr || m.name || '')
|
||||
var nav = q
|
||||
? ' <a class="point-nav" target="_blank" rel="noopener" href="https://uri.amap.com/search?keyword=' + esc(q) + '">高德</a>' +
|
||||
'<a class="point-nav" target="_blank" rel="noopener" href="https://map.baidu.com/search/' + esc(q) + '">百度</a>'
|
||||
: ''
|
||||
return '<div class="point"><span class="num" style="background:' + esc(pinColor(m.kind)) + '">' +
|
||||
esc(seq) + '</span><span class="point-body"><span class="point-name">' + esc(m.name || '') + '</span>' +
|
||||
(addr ? '<span class="point-addr">' + esc(addr) + '</span>' : '') + nav + '</span></div>'
|
||||
}).join('') + '</div>'
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
/* ---------------- 顶层组装 ---------------- */
|
||||
|
||||
var BLOCKS = {
|
||||
overview: function (data, spec, section, ctx) { return overviewHtml(data, spec, ctx) },
|
||||
notes: function (data, spec, section) { return notesHtml(data, section) },
|
||||
timeline: function (data) { return timelineHtml(data) },
|
||||
cards: function (data, spec, section, ctx) { return cardsHtml(data, spec, section, ctx) },
|
||||
map: function (data, spec) { return mapHtml(data, spec) },
|
||||
table: function (data, spec, section) { return tableHtml(data, section) },
|
||||
cost: function (data) { return costHtml(data) },
|
||||
checklist: function (data, spec) { return checklistHtml(data, spec) }
|
||||
}
|
||||
|
||||
function sectionHtml(section, payload, spec, ctx) {
|
||||
var id = section.id || ('sec_' + section.type)
|
||||
var data = section.data ? resolvePath(payload, section.data) : payload
|
||||
var fn = BLOCKS[section.type]
|
||||
var html = '<section class="section" id="' + esc(id) + '">'
|
||||
if (section.title) {
|
||||
html += '<div class="sec-head"><h2 class="sec-title">' + esc(section.title) + '</h2>' +
|
||||
'<a class="sec-top" href="#top">回到顶部</a></div>'
|
||||
}
|
||||
html += (typeof fn === 'function')
|
||||
? fn(data, spec, section, ctx)
|
||||
: '<div class="empty">未知 block 类型:' + esc(section.type) + '</div>'
|
||||
html += '</section>'
|
||||
return html
|
||||
}
|
||||
|
||||
function indexHtml(items) {
|
||||
return '<nav class="index">' + items.map(function (it) {
|
||||
return '<a href="#' + esc(it.id) + '">' + esc(it.title) + '</a>'
|
||||
}).join('') + '</nav>'
|
||||
}
|
||||
|
||||
function footHtml(ctx) {
|
||||
var meta = ctx.meta || {}
|
||||
var bits = ['游山玩水 · 思想实验室']
|
||||
if (meta.model) bits.push('模型 ' + meta.model)
|
||||
if (meta.elapsed_ms) bits.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
|
||||
if (metaBadges(meta).length) {
|
||||
var warns = isArr(meta.warnings) ? meta.warnings : []
|
||||
bits.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
|
||||
}
|
||||
if (ctx.exportedAt) bits.push('导出于 ' + ctx.exportedAt)
|
||||
return '<div class="doc-foot">' + esc(bits.join(' | ')) + '<br>' +
|
||||
'本报告内容由模型生成,含标注为「待确认 / 需核实」的信息,出行前请自行核实。</div>'
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 payload + report_spec 拼成一份完整的单文件 HTML(纯字符串,无副作用)
|
||||
* @param {object} payload 后端返回的行程数据
|
||||
* @param {object} spec 技能包的 report_spec
|
||||
* @param {object} opts { css, images, meta, title, kicker, chip, exportedAt, baseUrl }
|
||||
* @returns {string} 完整 HTML 文本
|
||||
*/
|
||||
function build(payload, spec, opts) {
|
||||
spec = spec || {}
|
||||
opts = opts || {}
|
||||
var sections = isArr(spec.sections) ? spec.sections : []
|
||||
if (!sections.length) {
|
||||
throw new Error('report_spec 里没有 sections —— 没有渲染描述就导不出文档')
|
||||
}
|
||||
|
||||
var ctx = {
|
||||
images: opts.images || {},
|
||||
meta: opts.meta || {},
|
||||
baseUrl: opts.baseUrl || '',
|
||||
kicker: opts.kicker || '思想实验室 · 游山玩水',
|
||||
chip: opts.chip || '行程报告',
|
||||
exportedAt: opts.exportedAt || ''
|
||||
}
|
||||
var summary = isPlainObject(payload && payload.summary) ? payload.summary : null
|
||||
var title = opts.title || (summary && summary.title) || '行程报告'
|
||||
|
||||
var heroHtml = ''
|
||||
var bodyParts = []
|
||||
var indexItems = []
|
||||
|
||||
sections.forEach(function (section) {
|
||||
if (!section || !section.type) return
|
||||
if (section.title) indexItems.push({ id: section.id || ('sec_' + section.type), title: section.title })
|
||||
|
||||
if (section.type === 'overview' && !heroHtml) {
|
||||
var ovData = section.data ? resolvePath(payload, section.data) : payload
|
||||
heroHtml = '<header class="hero" id="' + esc(section.id || 'sec_overview') + '">' +
|
||||
overviewHtml(ovData, spec, ctx) + '</header>'
|
||||
return
|
||||
}
|
||||
bodyParts.push(sectionHtml(section, payload, spec, ctx))
|
||||
})
|
||||
|
||||
// 技能包若没给 overview 区块,就用 payload.summary 兜底一个头图,避免文档没有标题
|
||||
if (!heroHtml && summary) {
|
||||
heroHtml = '<header class="hero" id="sec_overview">' + overviewHtml(summary, spec, ctx) + '</header>'
|
||||
}
|
||||
|
||||
var html = '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n'
|
||||
html += '<meta charset="utf-8" />\n'
|
||||
html += '<meta name="viewport" content="width=device-width, initial-scale=1" />\n'
|
||||
html += '<title>' + esc(title) + ' · 行程报告</title>\n'
|
||||
html += '<!-- 由 TRAE-Toolbox · 思想实验室「游山玩水」导出' +
|
||||
(ctx.exportedAt ? '(' + esc(ctx.exportedAt) + ')' : '') + ',单文件、可离线查看 -->\n'
|
||||
html += '<style>\n' + (opts.css || '') + '\n</style>\n'
|
||||
html += '</head>\n<body>\n'
|
||||
html += '<div class="page" id="top">\n'
|
||||
if (heroHtml) html += heroHtml + '\n'
|
||||
if (spec.show_index !== false && indexItems.length) html += indexHtml(indexItems) + '\n'
|
||||
if (bodyParts.length) html += bodyParts.join('\n') + '\n'
|
||||
html += footHtml(ctx) + '\n'
|
||||
html += '</div>\n</body>\n</html>\n'
|
||||
return html
|
||||
}
|
||||
|
||||
/* ---------------- 图片内嵌(转 base64,保证单文件离线可看) ---------------- */
|
||||
|
||||
function collectImageUrls(payload) {
|
||||
var seen = {}
|
||||
var out = []
|
||||
;(function walk(node, depth) {
|
||||
if (depth > 12 || node === null || typeof node !== 'object') return
|
||||
if (isArr(node)) {
|
||||
node.forEach(function (x) { walk(x, depth + 1) })
|
||||
return
|
||||
}
|
||||
if (isPlainObject(node.image) && typeof node.image.url === 'string' && node.image.url) {
|
||||
if (!seen[node.image.url]) { seen[node.image.url] = 1; out.push(node.image.url) }
|
||||
}
|
||||
Object.keys(node).forEach(function (k) { walk(node[k], depth + 1) })
|
||||
})(payload, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function toDataUrl(url, baseUrl) {
|
||||
return fetch(absUrl(url, baseUrl))
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
return r.blob()
|
||||
})
|
||||
.then(function (blob) {
|
||||
if (blob.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error('单张超过 ' + Math.round(MAX_IMAGE_BYTES / 1024 / 1024) + 'MB,已跳过')
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
var fr = new FileReader()
|
||||
fr.onload = function () { resolve({ dataUri: String(fr.result), bytes: blob.size }) }
|
||||
fr.onerror = function () { reject(new Error('读取图片失败')) }
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function embedImages(payload) {
|
||||
var baseUrl = (typeof location !== 'undefined' && location.href) ? location.href : ''
|
||||
var urls = collectImageUrls(payload)
|
||||
var stats = { total: urls.length, embedded: 0, failed: 0, bytes: 0 }
|
||||
var warnings = []
|
||||
var map = {}
|
||||
if (!urls.length || typeof fetch !== 'function' || typeof FileReader === 'undefined') {
|
||||
return Promise.resolve({ map: map, baseUrl: baseUrl, stats: stats, warnings: warnings })
|
||||
}
|
||||
return Promise.all(urls.map(function (u) {
|
||||
return toDataUrl(u, baseUrl).then(function (d) {
|
||||
map[u] = d.dataUri
|
||||
stats.embedded++
|
||||
stats.bytes += d.bytes
|
||||
}, function (err) {
|
||||
stats.failed++
|
||||
warnings.push('配图未内嵌(' + (err && err.message || '获取失败') + '):' + u)
|
||||
})
|
||||
})).then(function () {
|
||||
return { map: map, baseUrl: baseUrl, stats: stats, warnings: warnings }
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 样式读取 / 文件落盘 ---------------- */
|
||||
|
||||
var cssCache = null
|
||||
|
||||
function loadCss(cssUrl) {
|
||||
if (typeof fetch !== 'function') return Promise.reject(new Error('当前环境不支持导出(无 fetch)'))
|
||||
if (cssCache) return Promise.resolve(cssCache)
|
||||
return fetch(cssUrl, { cache: 'force-cache' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('读取样式失败:HTTP ' + r.status + '(' + cssUrl + ')')
|
||||
return r.text()
|
||||
})
|
||||
.then(function (txt) {
|
||||
if (!txt || txt.indexOf('.hero') < 0) throw new Error('样式文件内容异常:' + cssUrl)
|
||||
cssCache = txt
|
||||
return cssCache
|
||||
})
|
||||
}
|
||||
|
||||
function saveBlob(html, filename) {
|
||||
var blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
var url = URL.createObjectURL(blob)
|
||||
var a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(function () { try { URL.revokeObjectURL(url) } catch (e) {} }, 10000)
|
||||
}
|
||||
|
||||
function defaultFilename(payload) {
|
||||
var s = isPlainObject(payload && payload.summary) ? payload.summary : {}
|
||||
var name = String(s.title || '行程报告')
|
||||
.replace(/[\\/:*?"<>|\r\n\t]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!name) name = '行程报告'
|
||||
if (name.length > 60) name = name.slice(0, 60)
|
||||
return name + '-行程报告.html'
|
||||
}
|
||||
|
||||
/* ---------------- 对外入口 ---------------- */
|
||||
|
||||
/**
|
||||
* 导出并触发下载(浏览器)
|
||||
* @returns {Promise<{ok, filename, bytes, images, warnings}>}
|
||||
*/
|
||||
function download(payload, spec, opts) {
|
||||
opts = opts || {}
|
||||
var cssUrl = opts.cssUrl || (SELF_DIR + 'tc_export.css')
|
||||
return Promise.all([loadCss(cssUrl), embedImages(payload)])
|
||||
.then(function (res) {
|
||||
var html = build(payload, spec, {
|
||||
css: res[0],
|
||||
images: res[1].map,
|
||||
baseUrl: res[1].baseUrl,
|
||||
meta: opts.meta,
|
||||
title: opts.title,
|
||||
kicker: opts.kicker,
|
||||
chip: opts.chip,
|
||||
exportedAt: opts.exportedAt || nowBeijing()
|
||||
})
|
||||
var filename = opts.filename || defaultFilename(payload)
|
||||
saveBlob(html, filename)
|
||||
var warnings = res[1].warnings.slice()
|
||||
return {
|
||||
ok: true,
|
||||
filename: filename,
|
||||
bytes: utf8Length(html),
|
||||
images: res[1].stats,
|
||||
sections: (isArr(spec && spec.sections) ? spec.sections.length : 0),
|
||||
warnings: warnings
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var api = {
|
||||
build: build,
|
||||
download: download,
|
||||
defaultFilename: defaultFilename,
|
||||
collectImageUrls: collectImageUrls
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') window.TCExport = api
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
||||
})()
|
||||
@@ -0,0 +1,701 @@
|
||||
/* ============================================================
|
||||
* sh_2d_rail_hike/tc_render.js —— 高铁游 · 报告渲染器(技能私有)
|
||||
*
|
||||
* 设计原则(插件式 / 低耦合):
|
||||
* 1. 本文件完全独立:不 import、不 require、不引用工具箱任何其他模块或公共库。
|
||||
* 2. 只认 report_spec 里的 8 种 block 类型 + 数据路径,不认识任何业务字段。
|
||||
* 3. 地图双通道:有 Leaflet 就用 Leaflet,没有就用内置 SVG 降级渲染。
|
||||
*
|
||||
* 【隔离用意 · 请后续开发者先读这段】
|
||||
* 本文件从 sh_2d_car_hike/tc_render.js 复制而来(用户已确认「复制现有再改」方案),
|
||||
* 复制后它只服务【本 skill】。相对原版刻意的三处改动,全部由本 skill 的业务特点决定:
|
||||
* (1) ACTIVITY_LABEL 扩充:新增 共享单车/网约车/地铁/高铁/入住寄存/休息
|
||||
* (2) PIN_COLORS 扩充:新增 train_station/lodging/bike/taxi_point 四种点位配色
|
||||
* (3) 地图弹窗新增「导航地址 + 一键打开高德/百度」——本 skill 用户的核心诉求:
|
||||
* 每到一处都有具体地点,方便直接导航
|
||||
* 为什么不去改自驾 skill 那份来共用?因为一旦共用,本 skill 想加导航链接就得动到
|
||||
* 自驾 skill 的渲染逻辑,风险互相传导。这就是本 lab「skill 全栈隔离」的取舍:
|
||||
* 宁可各存一份,也不要跨 skill 的共用渲染器。要新增 skill,请复制本文件后再改。
|
||||
*
|
||||
* 依赖:无(Leaflet 为可选增强,通过全局 window.L 探测)
|
||||
* ============================================================ */
|
||||
(function (global) {
|
||||
'use strict'
|
||||
|
||||
/* ---------------- 可调参数(改这里即可换瓦片源) ---------------- */
|
||||
const MAP_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const MAP_TILE_ATTR = '© OpenStreetMap contributors'
|
||||
const MAP_FALLBACK_DISCLAIMER = '点位顺序示意,非真实道路/轨迹'
|
||||
|
||||
const DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
|
||||
const FALLBACK_IMAGE_TEXT = '配图未生成'
|
||||
|
||||
const mapRegistry = []
|
||||
|
||||
/* ---------------- 基础工具 ---------------- */
|
||||
|
||||
function h(tag, attrs, children) {
|
||||
const el = document.createElement(tag)
|
||||
if (attrs) {
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
const v = attrs[k]
|
||||
if (v === null || v === undefined || v === false) return
|
||||
if (k === 'class') el.className = v
|
||||
else if (k === 'text') el.textContent = v
|
||||
else if (k === 'html') el.innerHTML = v
|
||||
else if (k.indexOf('on') === 0 && typeof v === 'function') el.addEventListener(k.slice(2), v)
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v)
|
||||
else el.setAttribute(k, v === true ? '' : String(v))
|
||||
})
|
||||
}
|
||||
appendChildren(el, children)
|
||||
return el
|
||||
}
|
||||
|
||||
function appendChildren(el, children) {
|
||||
if (children === null || children === undefined) return
|
||||
if (Array.isArray(children)) {
|
||||
children.forEach(function (c) { appendChildren(el, c) })
|
||||
return
|
||||
}
|
||||
if (children instanceof Node) { el.appendChild(children); return }
|
||||
el.appendChild(document.createTextNode(String(children)))
|
||||
}
|
||||
|
||||
function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild) }
|
||||
|
||||
function resolvePath(root, path) {
|
||||
if (!path) return root
|
||||
const p = String(path).replace(/^\$\.?/, '')
|
||||
if (!p) return root
|
||||
return p.split('.').reduce(function (acc, key) {
|
||||
if (acc === null || acc === undefined) return undefined
|
||||
return acc[key]
|
||||
}, root)
|
||||
}
|
||||
|
||||
function pickById(arr, id) {
|
||||
if (!Array.isArray(arr)) return null
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === id) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) }
|
||||
|
||||
function badgeText(spec, level) {
|
||||
const map = (spec && spec.confidence_badge) || DEFAULT_BADGE
|
||||
return map[level] || DEFAULT_BADGE[level] || level
|
||||
}
|
||||
|
||||
/* ---------------- 置信度字段渲染(通用) ---------------- */
|
||||
|
||||
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
|
||||
function renderFact(value, spec) {
|
||||
if (isPlainObject(value)) {
|
||||
const box = h('span', { class: 'tcr-fact' })
|
||||
box.appendChild(h('span', { class: 'tcr-fact-text', text: value.text === undefined ? '' : String(value.text) }))
|
||||
if (value.confidence) {
|
||||
box.appendChild(h('span', {
|
||||
class: 'tcr-badge tcr-badge-' + value.confidence,
|
||||
text: badgeText(spec, value.confidence),
|
||||
title: value.verify ? ('核实方式:' + value.verify) : ''
|
||||
}))
|
||||
}
|
||||
if (value.verify) {
|
||||
box.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + value.verify }))
|
||||
}
|
||||
return box
|
||||
}
|
||||
return h('span', { class: 'tcr-fact-text', text: value === null || value === undefined ? '—' : String(value) })
|
||||
}
|
||||
|
||||
/* ---------------- 各 block 渲染 ---------------- */
|
||||
|
||||
function renderOverview(data, spec) {
|
||||
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无概览数据' })
|
||||
const box = h('div', { class: 'tcr-overview' })
|
||||
if (data.title) box.appendChild(h('h2', { class: 'tcr-ov-title', text: data.title }))
|
||||
if (data.one_liner) box.appendChild(h('p', { class: 'tcr-ov-line', text: data.one_liner }))
|
||||
if (Array.isArray(data.tags) && data.tags.length) {
|
||||
const tagRow = h('div', { class: 'tcr-tags' })
|
||||
data.tags.forEach(function (t) { tagRow.appendChild(h('span', { class: 'tcr-tag', text: t })) })
|
||||
box.appendChild(tagRow)
|
||||
}
|
||||
if (Array.isArray(data.stats) && data.stats.length) {
|
||||
const grid = h('div', { class: 'tcr-stats' })
|
||||
data.stats.forEach(function (s) {
|
||||
const cell = h('div', { class: 'tcr-stat' })
|
||||
cell.appendChild(h('div', { class: 'tcr-stat-label', text: s.label || '' }))
|
||||
cell.appendChild(h('div', { class: 'tcr-stat-value', text: s.value === undefined ? '' : String(s.value) }))
|
||||
if (s.hint) cell.appendChild(h('div', { class: 'tcr-stat-hint', text: s.hint }))
|
||||
grid.appendChild(cell)
|
||||
})
|
||||
box.appendChild(grid)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
function renderTimeline(data, spec) {
|
||||
if (!isPlainObject(data) || !Array.isArray(data.days)) return h('div', { class: 'tcr-empty', text: '无时序数据' })
|
||||
const box = h('div', { class: 'tcr-timeline' })
|
||||
data.days.forEach(function (day) {
|
||||
const dayBox = h('div', { class: 'tcr-day' })
|
||||
const head = h('div', { class: 'tcr-day-head' })
|
||||
head.appendChild(h('span', { class: 'tcr-day-label', text: day.label || '' }))
|
||||
if (day.date) head.appendChild(h('span', { class: 'tcr-day-date', text: day.date }))
|
||||
dayBox.appendChild(head)
|
||||
|
||||
const table = h('table', { class: 'tcr-table tcr-timeline-table' })
|
||||
const thead = h('thead', null, h('tr', null, [
|
||||
h('th', { class: 'tcr-col-time', text: '时间' }),
|
||||
h('th', { text: '安排' }),
|
||||
h('th', { class: 'tcr-col-kind', text: '类型' })
|
||||
]))
|
||||
table.appendChild(thead)
|
||||
const tbody = h('tbody')
|
||||
const rows = Array.isArray(day.rows) ? day.rows : []
|
||||
rows.forEach(function (r) {
|
||||
const tr = h('tr', { class: r.kind === 'hard' ? 'tcr-row-hard' : '' })
|
||||
tr.appendChild(h('td', { class: 'tcr-col-time', text: r.time || '' }))
|
||||
const td = h('td')
|
||||
td.appendChild(h('div', { class: 'tcr-row-title', text: r.title || '' }))
|
||||
if (r.note) td.appendChild(h('div', { class: 'tcr-row-note', text: r.note }))
|
||||
tr.appendChild(td)
|
||||
const kindCell = h('td', { class: 'tcr-col-kind' })
|
||||
kindCell.appendChild(h('span', {
|
||||
class: 'tcr-kind tcr-kind-' + (r.kind || 'soft'),
|
||||
text: kindLabel(r.kind)
|
||||
}))
|
||||
if (r.activity) {
|
||||
kindCell.appendChild(h('span', { class: 'tcr-kind tcr-kind-act', text: activityLabel(r.activity) }))
|
||||
}
|
||||
tr.appendChild(kindCell)
|
||||
tbody.appendChild(tr)
|
||||
})
|
||||
table.appendChild(tbody)
|
||||
dayBox.appendChild(table)
|
||||
box.appendChild(dayBox)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
function kindLabel(kind) {
|
||||
const map = { hard: '硬时间点', soft: '常规' }
|
||||
return map[kind] || (kind || '')
|
||||
}
|
||||
|
||||
// 活动类型与 kind(时间点性质)是两个正交字段,分开显示
|
||||
// 【本技能私有词表】比自驾 skill 多:stroll 逛街/夜逛 / spot 游览景点 / bike 共享单车 /
|
||||
// taxi 网约车 / metro 地铁 / train 高铁 / checkin 入住寄存 / rest 休息。改这里只影响本 skill。
|
||||
function activityLabel(activity) {
|
||||
const map = {
|
||||
walk: '步行', stroll: '逛街夜游', spot: '游览景点', bike: '共享单车', taxi: '网约车',
|
||||
metro: '地铁', train: '高铁', eat: '吃饭', hike: '徒步', shop: '采买',
|
||||
checkin: '入住寄存', rest: '休息'
|
||||
}
|
||||
return map[activity] || (activity || '')
|
||||
}
|
||||
|
||||
function renderCards(data, spec, section) {
|
||||
const list = Array.isArray(data) ? data : []
|
||||
if (!list.length) return h('div', { class: 'tcr-empty', text: '无节点数据' })
|
||||
const box = h('div', { class: 'tcr-cards' })
|
||||
|
||||
const grouped = section && section.group_by === 'day'
|
||||
if (!grouped) {
|
||||
list.forEach(function (c) { box.appendChild(renderCard(c, spec)) })
|
||||
return box
|
||||
}
|
||||
const days = []
|
||||
list.forEach(function (c) {
|
||||
const d = c.day === undefined ? 0 : c.day
|
||||
if (days.indexOf(d) < 0) days.push(d)
|
||||
})
|
||||
days.sort(function (a, b) { return a - b })
|
||||
days.forEach(function (d) {
|
||||
const group = h('div', { class: 'tcr-card-group' })
|
||||
group.appendChild(h('h3', { class: 'tcr-group-title', text: d === 1 ? '第一天' : (d === 2 ? '第二天' : '其他') }))
|
||||
const grid = h('div', { class: 'tcr-card-grid' })
|
||||
list.forEach(function (c) {
|
||||
const cd = c.day === undefined ? 0 : c.day
|
||||
if (cd === d) grid.appendChild(renderCard(c, spec))
|
||||
})
|
||||
group.appendChild(grid)
|
||||
box.appendChild(group)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
function renderCard(card, spec) {
|
||||
const el = h('article', { class: 'tcr-card', id: 'card_' + (card.id || card.anchor_id || '') })
|
||||
const head = h('div', { class: 'tcr-card-head' })
|
||||
const titles = h('div', { class: 'tcr-card-titles' })
|
||||
titles.appendChild(h('h4', { class: 'tcr-card-title', text: card.title || '' }))
|
||||
if (card.subtitle) titles.appendChild(h('div', { class: 'tcr-card-sub', text: card.subtitle }))
|
||||
head.appendChild(titles)
|
||||
if (Array.isArray(card.badges) && card.badges.length) {
|
||||
const bs = h('div', { class: 'tcr-badges' })
|
||||
card.badges.forEach(function (b) {
|
||||
bs.appendChild(h('span', { class: 'tcr-badge tcr-tone-' + (b.tone || 'plain'), text: b.text || '' }))
|
||||
})
|
||||
head.appendChild(bs)
|
||||
}
|
||||
el.appendChild(head)
|
||||
|
||||
if (card.image && card.image.url) {
|
||||
const fig = h('figure', { class: 'tcr-figure' })
|
||||
const img = h('img', { class: 'tcr-img', src: card.image.url, alt: card.image.caption || '', loading: 'lazy' })
|
||||
img.addEventListener('error', function () {
|
||||
clear(fig)
|
||||
fig.appendChild(h('div', { class: 'tcr-img-missing', text: FALLBACK_IMAGE_TEXT + '(' + (card.image.caption || '') + ')' }))
|
||||
fig.appendChild(h('figcaption', { class: 'tcr-figcap', text: card.image.disclaimer || '' }))
|
||||
})
|
||||
fig.appendChild(img)
|
||||
const cap = h('figcaption', { class: 'tcr-figcap' })
|
||||
if (card.image.caption) cap.appendChild(h('span', { text: card.image.caption + ' · ' }))
|
||||
cap.appendChild(h('span', { class: 'tcr-figcap-note', text: card.image.disclaimer || '' }))
|
||||
fig.appendChild(cap)
|
||||
el.appendChild(fig)
|
||||
}
|
||||
|
||||
if (Array.isArray(card.fields) && card.fields.length) {
|
||||
const dl = h('div', { class: 'tcr-fields' })
|
||||
card.fields.forEach(function (f) {
|
||||
const row = h('div', { class: 'tcr-field' })
|
||||
row.appendChild(h('div', { class: 'tcr-field-label', text: f.label || '' }))
|
||||
const val = h('div', { class: 'tcr-field-value' })
|
||||
val.appendChild(renderFact(f.value, spec))
|
||||
if (f.hint) val.appendChild(h('div', { class: 'tcr-field-hint', text: f.hint }))
|
||||
row.appendChild(val)
|
||||
dl.appendChild(row)
|
||||
})
|
||||
el.appendChild(dl)
|
||||
}
|
||||
|
||||
if (Array.isArray(card.bullets) && card.bullets.length) {
|
||||
const ul = h('ul', { class: 'tcr-bullets' })
|
||||
card.bullets.forEach(function (b) { ul.appendChild(h('li', { text: b })) })
|
||||
el.appendChild(ul)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function renderTable(data, spec, section) {
|
||||
let table = null
|
||||
if (Array.isArray(data)) {
|
||||
table = pickById(data, section.pick_id)
|
||||
if (!table && section.pick_id) return h('div', { class: 'tcr-empty', text: '缺少表格:' + section.pick_id })
|
||||
if (!table) table = data[0]
|
||||
} else {
|
||||
table = data
|
||||
}
|
||||
if (!isPlainObject(table)) return h('div', { class: 'tcr-empty', text: '无表格数据' })
|
||||
|
||||
const box = h('div', { class: 'tcr-tablebox' })
|
||||
if (table.title) box.appendChild(h('div', { class: 'tcr-table-title', text: table.title }))
|
||||
|
||||
const columns = Array.isArray(table.columns) ? table.columns : []
|
||||
const rows = Array.isArray(table.rows) ? table.rows : []
|
||||
const hl = Array.isArray(table.highlight_row_keys) ? table.highlight_row_keys : []
|
||||
|
||||
const el = h('table', { class: 'tcr-table' })
|
||||
const tr = h('tr')
|
||||
columns.forEach(function (c) {
|
||||
tr.appendChild(h('th', {
|
||||
class: c.align ? ('tcr-al-' + c.align) : '',
|
||||
style: c.width ? { width: c.width } : null,
|
||||
text: c.label || c.key
|
||||
}))
|
||||
})
|
||||
el.appendChild(h('thead', null, tr))
|
||||
|
||||
const tbody = h('tbody')
|
||||
rows.forEach(function (r) {
|
||||
const row = h('tr', { class: hl.indexOf(r._key) >= 0 ? 'tcr-row-hl' : '' })
|
||||
columns.forEach(function (c) {
|
||||
const v = r[c.key]
|
||||
const td = h('td', { class: c.align ? ('tcr-al-' + c.align) : '' })
|
||||
if (v === null || v === undefined) td.textContent = '—'
|
||||
else td.textContent = String(v)
|
||||
row.appendChild(td)
|
||||
})
|
||||
tbody.appendChild(row)
|
||||
})
|
||||
el.appendChild(tbody)
|
||||
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
|
||||
if (table.note) box.appendChild(h('div', { class: 'tcr-table-note', text: table.note }))
|
||||
return box
|
||||
}
|
||||
|
||||
function renderCost(data, spec) {
|
||||
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无费用数据' })
|
||||
const box = h('div', { class: 'tcr-cost' })
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
const el = h('table', { class: 'tcr-table' })
|
||||
el.appendChild(h('thead', null, h('tr', null, [
|
||||
h('th', { text: '项目' }),
|
||||
h('th', { class: 'tcr-al-right', text: '金额(' + (data.currency || 'CNY') + ')' }),
|
||||
h('th', { text: '口径' })
|
||||
])))
|
||||
const tbody = h('tbody')
|
||||
items.forEach(function (it) {
|
||||
const row = h('tr')
|
||||
row.appendChild(h('td', { text: it.label || '' }))
|
||||
row.appendChild(h('td', { class: 'tcr-al-right', text: it.amount === undefined ? '—' : String(it.amount) }))
|
||||
row.appendChild(h('td', { class: 'tcr-cell-note', text: it.note || it.unit || '' }))
|
||||
tbody.appendChild(row)
|
||||
})
|
||||
el.appendChild(tbody)
|
||||
if (data.total !== undefined) {
|
||||
const tf = h('tfoot')
|
||||
const tr = h('tr', { class: 'tcr-row-hl' })
|
||||
tr.appendChild(h('td', { text: '合计' }))
|
||||
tr.appendChild(h('td', { class: 'tcr-al-right', text: String(data.total) }))
|
||||
tr.appendChild(h('td', { text: '' }))
|
||||
tf.appendChild(tr)
|
||||
el.appendChild(tf)
|
||||
}
|
||||
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
|
||||
if (data.note) box.appendChild(h('div', { class: 'tcr-table-note', text: data.note }))
|
||||
return box
|
||||
}
|
||||
|
||||
function renderChecklist(data, spec) {
|
||||
const list = Array.isArray(data) ? data : []
|
||||
if (!list.length) return h('div', { class: 'tcr-empty', text: '无待核实项' })
|
||||
const ol = h('ol', { class: 'tcr-checklist' })
|
||||
list.forEach(function (c) {
|
||||
const li = h('li', { class: 'tcr-check-item' })
|
||||
li.appendChild(h('span', { class: 'tcr-check-text', text: c.text || '' }))
|
||||
if (c.confidence) {
|
||||
li.appendChild(h('span', { class: 'tcr-badge tcr-badge-' + c.confidence, text: badgeText(spec, c.confidence) }))
|
||||
}
|
||||
if (c.verify) li.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + c.verify }))
|
||||
ol.appendChild(li)
|
||||
})
|
||||
return ol
|
||||
}
|
||||
|
||||
function renderNotes(data, spec, section) {
|
||||
let list = Array.isArray(data) ? data : []
|
||||
if (section && Array.isArray(section.filter_levels) && section.filter_levels.length) {
|
||||
const allow = section.filter_levels
|
||||
list = list.filter(function (n) { return allow.indexOf(n.level) >= 0 })
|
||||
}
|
||||
if (!list.length) return h('div', { class: 'tcr-empty', text: '无提示项' })
|
||||
const box = h('div', { class: 'tcr-notes' })
|
||||
list.forEach(function (n) {
|
||||
const el = h('div', { class: 'tcr-note tcr-note-' + (n.level || 'info') })
|
||||
el.appendChild(h('div', { class: 'tcr-note-title', text: n.title || '' }))
|
||||
if (n.text) el.appendChild(h('div', { class: 'tcr-note-text', text: n.text }))
|
||||
box.appendChild(el)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
/* ---------------- 地图 block(Leaflet 优先,SVG 降级) ---------------- */
|
||||
|
||||
function renderMap(data, spec) {
|
||||
if (!isPlainObject(data) || !Array.isArray(data.markers)) {
|
||||
return h('div', { class: 'tcr-empty', text: '无地图数据' })
|
||||
}
|
||||
const box = h('div', { class: 'tcr-mapbox' })
|
||||
const holder = h('div', { class: 'tcr-map-holder' })
|
||||
box.appendChild(holder)
|
||||
const legend = renderMapLegend(data)
|
||||
if (legend) box.appendChild(legend)
|
||||
const dis = data.disclaimer || (spec && spec.map_route_disclaimer_default) || MAP_FALLBACK_DISCLAIMER
|
||||
box.appendChild(h('div', { class: 'tcr-map-note', text: dis }))
|
||||
mapRegistry.push({ el: holder, data: data })
|
||||
return box
|
||||
}
|
||||
|
||||
function renderMapLegend(data) {
|
||||
const routes = Array.isArray(data.routes) ? data.routes : []
|
||||
if (!routes.length) return null
|
||||
const box = h('div', { class: 'tcr-map-legend' })
|
||||
routes.forEach(function (r) {
|
||||
const item = h('span', { class: 'tcr-legend-item' })
|
||||
const swatch = h('span', { class: 'tcr-legend-swatch' + (r.dashed ? ' tcr-dashed' : '') })
|
||||
if (r.color) swatch.style.background = r.color
|
||||
if (r.dashed) {
|
||||
swatch.style.background = 'repeating-linear-gradient(90deg,' + (r.color || '#666') + ' 0 6px,transparent 6px 12px)'
|
||||
}
|
||||
item.appendChild(swatch)
|
||||
item.appendChild(h('span', { text: r.name || r.id }))
|
||||
box.appendChild(item)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
function initMaps() {
|
||||
mapRegistry.forEach(function (reg) {
|
||||
try {
|
||||
if (global.L && typeof global.L.map === 'function') drawLeaflet(reg.el, reg.data)
|
||||
else drawSvgFallback(reg.el, reg.data)
|
||||
} catch (e) {
|
||||
try { drawSvgFallback(reg.el, reg.data) } catch (e2) {
|
||||
clear(reg.el)
|
||||
reg.el.appendChild(h('div', { class: 'tcr-empty', text: '地图渲染失败' }))
|
||||
}
|
||||
}
|
||||
})
|
||||
mapRegistry.length = 0
|
||||
}
|
||||
|
||||
/* 【本技能专属】单个点位的弹窗内容:名称 + 备注 + 导航地址 + 一键打开高德/百度。
|
||||
用户诉求是「每到一处都有具体地点,方便我导航」,所以地址必须显眼、可复制、可点开。
|
||||
|
||||
坐标与地址的关系(重要):AI 给出的经纬度一律按「概略值」处理(契约要求 coord_precision=approx),
|
||||
真实导航请以 address 为准。所以这里把 address 放在主位,坐标仅作兜底的跳转参数。
|
||||
高德 uri API 能接受「坐标或地址关键字」,这里优先用地址关键字,避免坐标偏移把用户带错。 */
|
||||
function markerPopupHtml(m) {
|
||||
const name = m.name || ''
|
||||
const addr = String(m.address || '').trim()
|
||||
const parts = ['<b>' + escapeHtml(name) + '</b>']
|
||||
if (addr) parts.push('<br/><span class="tcr-pop-addr">' + escapeHtml(addr) + '</span>')
|
||||
if (m.note) parts.push('<br/><span class="tcr-pop-note">' + escapeHtml(m.note) + '</span>')
|
||||
if (m.coord_precision === 'approx') parts.push('<br/><i class="tcr-pop-dim">坐标为概略值,导航请用上面的地址</i>')
|
||||
|
||||
const links = []
|
||||
const q = encodeURIComponent(addr || name)
|
||||
if (q) {
|
||||
// 高德:关键字搜索(最稳,不受坐标系偏移影响)
|
||||
links.push('<a class="tcr-pop-nav" target="_blank" rel="noopener" href="https://uri.amap.com/search?keyword=' + q + '">高德搜</a>')
|
||||
// 百度:同样按关键字搜索
|
||||
links.push('<a class="tcr-pop-nav" target="_blank" rel="noopener" href="https://map.baidu.com/search/' + q + '">百度搜</a>')
|
||||
}
|
||||
if (typeof m.lat === 'number' && typeof m.lng === 'number') {
|
||||
// 坐标直达(作为参考;因 AI 坐标是概略值,可能落在附近而非精确点上)
|
||||
links.push('<a class="tcr-pop-nav" target="_blank" rel="noopener" href="https://uri.amap.com/marker?position='
|
||||
+ m.lng + ',' + m.lat + '&name=' + encodeURIComponent(name) + '">坐标定位</a>')
|
||||
}
|
||||
if (links.length) parts.push('<br/><span class="tcr-pop-navs">' + links.join(' · ') + '</span>')
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function drawLeaflet(el, data) {
|
||||
const L = global.L
|
||||
clear(el)
|
||||
const map = L.map(el, { zoomControl: true, attributionControl: true })
|
||||
L.tileLayer(MAP_TILE_URL, { maxZoom: 18, attribution: MAP_TILE_ATTR }).addTo(map)
|
||||
|
||||
const markers = data.markers || []
|
||||
const byId = {}
|
||||
const latlngs = []
|
||||
markers.forEach(function (m) {
|
||||
if (typeof m.lat !== 'number' || typeof m.lng !== 'number') return
|
||||
byId[m.id] = m
|
||||
latlngs.push([m.lat, m.lng])
|
||||
const icon = L.divIcon({
|
||||
className: 'tcr-pin-wrap',
|
||||
html: '<span class="tcr-pin tcr-pin-' + (m.kind || 'spot') + '">' + (m.seq === undefined ? '' : m.seq) + '</span>',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11]
|
||||
})
|
||||
const marker = L.marker([m.lat, m.lng], { icon: icon, title: m.name || '' }).addTo(map)
|
||||
marker.bindPopup(markerPopupHtml(m))
|
||||
})
|
||||
|
||||
;(data.routes || []).forEach(function (r) {
|
||||
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
|
||||
.map(function (m) { return [m.lat, m.lng] })
|
||||
if (pts.length < 2) return
|
||||
const line = L.polyline(pts, {
|
||||
color: r.color || '#2f6fed',
|
||||
weight: r.kind === 'drive' ? 3 : 4,
|
||||
dashArray: r.dashed ? '6 6' : null,
|
||||
opacity: 0.9
|
||||
}).addTo(map)
|
||||
if (r.note) line.bindTooltip(r.name + ' · ' + r.note, { sticky: true })
|
||||
else line.bindTooltip(r.name || '', { sticky: true })
|
||||
})
|
||||
|
||||
if (latlngs.length) {
|
||||
try {
|
||||
map.fitBounds(L.latLngBounds(latlngs).pad(0.15))
|
||||
} catch (e) {
|
||||
map.setView(data.center || latlngs[0], data.zoom || 9)
|
||||
}
|
||||
} else {
|
||||
map.setView(data.center || [31.95, 121.15], data.zoom || 9)
|
||||
}
|
||||
setTimeout(function () { try { map.invalidateSize() } catch (e) {} }, 60)
|
||||
}
|
||||
|
||||
function drawSvgFallback(el, data) {
|
||||
clear(el)
|
||||
const markers = (data.markers || []).filter(function (m) {
|
||||
return typeof m.lat === 'number' && typeof m.lng === 'number'
|
||||
})
|
||||
const W = 860, H = 420, PAD = 34
|
||||
const svgNS = 'http://www.w3.org/2000/svg'
|
||||
const svg = document.createElementNS(svgNS, 'svg')
|
||||
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H)
|
||||
svg.setAttribute('class', 'tcr-svg-map')
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||
svg.appendChild(mkSvg('rect', { x: 0, y: 0, width: W, height: H, class: 'tcr-svg-bg' }))
|
||||
|
||||
if (!markers.length) {
|
||||
el.appendChild(svg)
|
||||
el.appendChild(h('div', { class: 'tcr-empty', text: '无可绘制点位' }))
|
||||
return
|
||||
}
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
markers.forEach(function (m) {
|
||||
minLat = Math.min(minLat, m.lat); maxLat = Math.max(maxLat, m.lat)
|
||||
minLng = Math.min(minLng, m.lng); maxLng = Math.max(maxLng, m.lng)
|
||||
})
|
||||
const spanLat = Math.max(maxLat - minLat, 0.01)
|
||||
const spanLng = Math.max(maxLng - minLng, 0.01)
|
||||
function px(m) {
|
||||
const x = PAD + ((m.lng - minLng) / spanLng) * (W - PAD * 2)
|
||||
const y = H - PAD - ((m.lat - minLat) / spanLat) * (H - PAD * 2)
|
||||
return { x: x, y: y }
|
||||
}
|
||||
|
||||
const byId = {}
|
||||
markers.forEach(function (m) { byId[m.id] = m })
|
||||
|
||||
;(data.routes || []).forEach(function (r) {
|
||||
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
|
||||
if (pts.length < 2) return
|
||||
const d = pts.map(function (m, i) {
|
||||
const p = px(m)
|
||||
return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)
|
||||
}).join(' ')
|
||||
svg.appendChild(mkSvg('path', {
|
||||
d: d,
|
||||
fill: 'none',
|
||||
stroke: r.color || '#2f6fed',
|
||||
'stroke-width': r.kind === 'drive' ? 2.5 : 3.2,
|
||||
'stroke-dasharray': r.dashed ? '6 6' : 'none',
|
||||
'stroke-linejoin': 'round',
|
||||
class: 'tcr-svg-line'
|
||||
}))
|
||||
})
|
||||
|
||||
markers.forEach(function (m) {
|
||||
const p = px(m)
|
||||
svg.appendChild(mkSvg('circle', { cx: p.x, cy: p.y, r: 9, fill: pinColor(m.kind), stroke: '#fff', 'stroke-width': 2 }))
|
||||
const t = mkSvg('text', {
|
||||
x: p.x, y: p.y + 3.4, 'text-anchor': 'middle',
|
||||
class: 'tcr-svg-seq', fill: '#fff'
|
||||
})
|
||||
t.textContent = m.seq === undefined ? '' : String(m.seq)
|
||||
svg.appendChild(t)
|
||||
const label = mkSvg('text', { x: p.x + 12, y: p.y + 4, class: 'tcr-svg-label' })
|
||||
label.textContent = m.name || ''
|
||||
// 【本技能专属】SVG 降级模式下也用 <title> 挂上导航地址,鼠标悬停即可看到
|
||||
const tip = mkSvg('title', {})
|
||||
tip.textContent = (m.name || '') + (m.address ? '|' + m.address : '')
|
||||
label.appendChild(tip)
|
||||
svg.appendChild(label)
|
||||
})
|
||||
|
||||
el.appendChild(svg)
|
||||
el.appendChild(h('div', { class: 'tcr-map-fallback-hint', text: '未加载到 Leaflet,已用内置 SVG 降级绘制点位顺序' }))
|
||||
}
|
||||
|
||||
function mkSvg(tag, attrs) {
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||
Object.keys(attrs || {}).forEach(function (k) {
|
||||
if (attrs[k] === null || attrs[k] === undefined) return
|
||||
el.setAttribute(k, String(attrs[k]))
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
// 【本技能私有词表】点位配色。比自驾 skill 去掉 parking(无自驾),
|
||||
// 新增 train_station 车站 / lodging 住宿 / bike 单车点 / taxi_point 网约车上车点 /
|
||||
// night_market 夜市夜逛区(v1.1.0,用于周六夜夜逛)
|
||||
function pinColor(kind) {
|
||||
const map = {
|
||||
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98',
|
||||
train_station: '#1d4ed8', lodging: '#7c3aed', bathhouse: '#1f8a70',
|
||||
district: '#b8860b', food: '#e0673a', hike: '#2e9e5b',
|
||||
bike: '#0ea5e9', taxi_point: '#f59e0b', night_market: '#7c2d92',
|
||||
spot: '#059669', shop: '#5a67d8', walk: '#8a5cf6'
|
||||
}
|
||||
return map[kind] || '#4b5563'
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 顶层渲染 ---------------- */
|
||||
|
||||
const BLOCKS = {
|
||||
overview: renderOverview,
|
||||
timeline: renderTimeline,
|
||||
cards: renderCards,
|
||||
map: renderMap,
|
||||
table: renderTable,
|
||||
cost: renderCost,
|
||||
checklist: renderChecklist,
|
||||
notes: renderNotes
|
||||
}
|
||||
|
||||
function renderSection(section, payload, spec) {
|
||||
const data = section.data ? resolvePath(payload, section.data) : payload
|
||||
const fn = BLOCKS[section.type]
|
||||
const wrap = h('section', { class: 'tcr-section', id: section.id || ('sec_' + section.type) })
|
||||
if (section.title) {
|
||||
const head = h('div', { class: 'tcr-section-head' })
|
||||
head.appendChild(h('h3', { class: 'tcr-section-title', text: section.title }))
|
||||
head.appendChild(h('a', { class: 'tcr-section-top', href: '#tcr_top', text: '回到顶部' }))
|
||||
wrap.appendChild(head)
|
||||
}
|
||||
if (typeof fn !== 'function') {
|
||||
wrap.appendChild(h('div', { class: 'tcr-empty', text: '未知 block 类型:' + section.type }))
|
||||
return wrap
|
||||
}
|
||||
wrap.appendChild(fn(data, spec, section))
|
||||
return wrap
|
||||
}
|
||||
|
||||
function renderIndex(spec) {
|
||||
const sections = (spec.sections || []).filter(function (s) { return s.title })
|
||||
if (!sections.length) return null
|
||||
const nav = h('nav', { class: 'tcr-index' })
|
||||
sections.forEach(function (s) {
|
||||
nav.appendChild(h('a', { class: 'tcr-index-item', href: '#' + (s.id || ('sec_' + s.type)), text: s.title }))
|
||||
})
|
||||
return nav
|
||||
}
|
||||
|
||||
function render(payload, spec, mount) {
|
||||
if (!mount) throw new Error('tc_render: 缺少挂载节点')
|
||||
spec = spec || {}
|
||||
const sections = Array.isArray(spec.sections) ? spec.sections : []
|
||||
// 空 spec 是「静默留白」的经典成因:不报错、不渲染,页面一片空白、无从排查。
|
||||
// 宁可主动抛错,让调用方把「渲染描述没传过来」直接摊在页面上。
|
||||
if (!sections.length) {
|
||||
throw new Error('report_spec 里没有 sections —— 后端没把渲染描述传过来,或技能包的 report_spec.json 为空')
|
||||
}
|
||||
clear(mount)
|
||||
if (spec.show_index) {
|
||||
const idx = renderIndex(spec)
|
||||
if (idx) mount.appendChild(idx)
|
||||
}
|
||||
sections.forEach(function (section) {
|
||||
mount.appendChild(renderSection(section, payload || {}, spec))
|
||||
})
|
||||
// 地图必须等节点入 DOM 后再初始化
|
||||
if (global.requestAnimationFrame) global.requestAnimationFrame(initMaps)
|
||||
else setTimeout(initMaps, 0)
|
||||
}
|
||||
|
||||
global.TCRender = {
|
||||
render: render,
|
||||
resolvePath: resolvePath,
|
||||
pickById: pickById,
|
||||
MAP_TILE_URL: MAP_TILE_URL
|
||||
}
|
||||
})(window)
|
||||
@@ -0,0 +1,210 @@
|
||||
/* ============================================================
|
||||
* sh_2d_rail_hike/tc_report.css —— 高铁游 · 报告样式(只服务本 skill 的 tc_render.js)
|
||||
*
|
||||
* 【隔离用意 · 请后续开发者先读这段】
|
||||
* 本文件从 sh_2d_car_hike/tc_report.css 复制而来(用户已确认「复制现有再改」)。
|
||||
* 复制后它只服务【本 skill】的报告。相对原版刻意的改动:
|
||||
* (1) 新增本技能的点位配色类(.tcr-pin-train_station / -lodging / -bike / -taxi_point)
|
||||
* (2) 新增地图弹窗里的导航样式(.tcr-pop-addr / .tcr-pop-nav …)
|
||||
* 为什么不与自驾 skill 共用一份 CSS?因为本 skill 想改配色或加导航样式时,
|
||||
* 共用就意味着动到别人的文件、可能弄坏别人的报告。宁可有这份重复,也不要跨 skill 耦合。
|
||||
* 要新增 skill,请复制本文件后再改。
|
||||
*
|
||||
* 使用方式:本 skill 的页面引用本文件 + 同目录 tc_render.js,即可渲染本 skill 的报告。
|
||||
* 依赖:无(下面自带 :root 变量兜底,不引用工具箱任何其他样式文件)
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
--tcr-bg: #f6f7f9;
|
||||
--tcr-card: #ffffff;
|
||||
--tcr-line: #e5e7eb;
|
||||
--tcr-text: #1f2328;
|
||||
--tcr-sub: #6b7280;
|
||||
--tcr-accent: #2f6fed;
|
||||
}
|
||||
|
||||
/* ---------------- 区块骨架 ---------------- */
|
||||
|
||||
.tcr-index {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px;
|
||||
background: #fff; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 8px;
|
||||
}
|
||||
.tcr-index-item {
|
||||
font-size: 12px; color: var(--tcr-accent); text-decoration: none;
|
||||
padding: 3px 8px; border-radius: 6px; background: #f0f4ff;
|
||||
}
|
||||
.tcr-index-item:hover { background: #e2eaff; }
|
||||
|
||||
.tcr-section {
|
||||
background: var(--tcr-card); border: 1px solid var(--tcr-line);
|
||||
border-radius: 10px; padding: 16px 16px 18px; margin-bottom: 16px;
|
||||
}
|
||||
.tcr-section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||
.tcr-section-title { font-size: 16px; margin: 0; }
|
||||
.tcr-section-top { font-size: 12px; color: var(--tcr-sub); text-decoration: none; }
|
||||
.tcr-empty { color: var(--tcr-sub); font-size: 13px; padding: 8px 0; }
|
||||
|
||||
/* ---------------- 概览 ---------------- */
|
||||
|
||||
.tcr-ov-title { margin: 0 0 6px; font-size: 18px; }
|
||||
.tcr-ov-line { margin: 0 0 10px; color: #374151; }
|
||||
.tcr-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
.tcr-tag { font-size: 12px; background: #eef2f7; color: #374151; border-radius: 999px; padding: 2px 10px; }
|
||||
.tcr-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
||||
.tcr-stat { background: #fafbfc; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 10px; }
|
||||
.tcr-stat-label { font-size: 12px; color: var(--tcr-sub); }
|
||||
.tcr-stat-value { font-size: 16px; font-weight: 600; margin: 2px 0; }
|
||||
.tcr-stat-hint { font-size: 11px; color: var(--tcr-sub); line-height: 1.45; }
|
||||
|
||||
/* ---------------- 表格 ---------------- */
|
||||
|
||||
.tcr-table-scroll { overflow-x: auto; }
|
||||
table.tcr-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; }
|
||||
table.tcr-table th, table.tcr-table td {
|
||||
border: 1px solid var(--tcr-line); padding: 7px 9px; text-align: left; vertical-align: top;
|
||||
}
|
||||
table.tcr-table thead th { background: #f3f5f8; font-weight: 600; white-space: nowrap; }
|
||||
.tcr-al-right { text-align: right !important; }
|
||||
.tcr-al-center { text-align: center !important; }
|
||||
.tcr-row-hl td { background: #eef4ff; }
|
||||
.tcr-row-hard td { background: #fff7ed; }
|
||||
.tcr-col-time { white-space: nowrap; color: #374151; width: 108px; }
|
||||
.tcr-col-kind { width: 138px; }
|
||||
.tcr-row-title { font-weight: 500; }
|
||||
.tcr-row-note { font-size: 12px; color: var(--tcr-sub); }
|
||||
.tcr-cell-note { color: var(--tcr-sub); font-size: 12px; }
|
||||
.tcr-kind {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
||||
background: #eef2f7; color: #4b5563; white-space: nowrap;
|
||||
}
|
||||
.tcr-kind-hard { background: #fdece8; color: #b42318; font-weight: 600; }
|
||||
.tcr-kind-act { background: #eef2f7; color: #374151; margin-left: 4px; }
|
||||
|
||||
.tcr-table-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
||||
.tcr-table-note { font-size: 12px; color: var(--tcr-sub); margin-top: 8px; }
|
||||
|
||||
/* ---------------- 时序分组 ---------------- */
|
||||
|
||||
.tcr-day { margin-bottom: 16px; }
|
||||
.tcr-day-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; }
|
||||
.tcr-day-label { font-weight: 600; }
|
||||
.tcr-day-date { font-size: 12px; color: var(--tcr-sub); }
|
||||
|
||||
/* ---------------- 卡片 ---------------- */
|
||||
|
||||
.tcr-card-group { margin-bottom: 14px; }
|
||||
.tcr-group-title { font-size: 14px; margin: 6px 0 10px; color: #374151; }
|
||||
.tcr-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
.tcr-card { border: 1px solid var(--tcr-line); border-radius: 10px; padding: 12px; background: #fff; }
|
||||
.tcr-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; }
|
||||
.tcr-card-title { font-size: 15px; margin: 0; }
|
||||
.tcr-card-sub { font-size: 12px; color: var(--tcr-sub); }
|
||||
.tcr-badges { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
|
||||
.tcr-badge {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
||||
background: #eef2f7; color: #4b5563; white-space: nowrap; cursor: default;
|
||||
}
|
||||
.tcr-badge-high { background: #e8f6ee; color: #1a7a45; }
|
||||
.tcr-badge-medium { background: #fff5e0; color: #8a6100; }
|
||||
.tcr-badge-low { background: #fdece8; color: #b42318; }
|
||||
.tcr-tone-info { background: #e8f0fe; color: #1d4ed8; }
|
||||
.tcr-tone-good { background: #e8f6ee; color: #1a7a45; }
|
||||
.tcr-tone-warn { background: #fff5e0; color: #8a6100; }
|
||||
.tcr-tone-danger { background: #fdece8; color: #b42318; }
|
||||
.tcr-tone-plain { background: #eef2f7; color: #4b5563; }
|
||||
|
||||
/* ---------------- 配图 ---------------- */
|
||||
|
||||
.tcr-figure { margin: 10px 0 4px; }
|
||||
.tcr-img { width: 100%; height: 150px; object-fit: cover; border-radius: 8px; display: block; background: #f0f2f5; }
|
||||
.tcr-img-missing {
|
||||
height: 90px; display: flex; align-items: center; justify-content: center;
|
||||
background: #f3f5f8; border: 1px dashed var(--tcr-line); border-radius: 8px;
|
||||
color: var(--tcr-sub); font-size: 12px;
|
||||
}
|
||||
.tcr-figcap { font-size: 11px; color: var(--tcr-sub); margin-top: 4px; }
|
||||
.tcr-figcap-note { color: #b42318; }
|
||||
|
||||
/* ---------------- 卡片字段 / 事实 ---------------- */
|
||||
|
||||
.tcr-fields { margin-top: 10px; }
|
||||
.tcr-field {
|
||||
display: grid; grid-template-columns: 96px 1fr; gap: 6px;
|
||||
padding: 5px 0; border-top: 1px dashed #eef0f3;
|
||||
}
|
||||
.tcr-field:first-child { border-top: none; }
|
||||
.tcr-field-label { color: var(--tcr-sub); font-size: 12px; }
|
||||
.tcr-field-value { font-size: 13px; }
|
||||
.tcr-field-hint { font-size: 11px; color: var(--tcr-sub); }
|
||||
.tcr-verify { display: block; font-size: 11px; color: #8a6100; margin-top: 2px; }
|
||||
.tcr-bullets { margin: 10px 0 0; padding-left: 18px; font-size: 13px; }
|
||||
.tcr-bullets li { margin-bottom: 3px; }
|
||||
|
||||
/* ---------------- 地图 ---------------- */
|
||||
|
||||
.tcr-map-holder { height: 420px; border-radius: 8px; overflow: hidden; background: #eef1f4; border: 1px solid var(--tcr-line); }
|
||||
.tcr-map-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; font-size: 12px; color: #374151; }
|
||||
.tcr-legend-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.tcr-legend-swatch { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: #666; }
|
||||
.tcr-map-note { font-size: 12px; color: var(--tcr-sub); margin-top: 6px; }
|
||||
.tcr-map-fallback-hint { font-size: 12px; color: #8a6100; margin-top: 6px; }
|
||||
.tcr-svg-map { width: 100%; height: auto; display: block; }
|
||||
.tcr-svg-bg { fill: #f2f5f8; }
|
||||
.tcr-svg-seq { font-size: 10px; font-weight: 700; }
|
||||
.tcr-svg-label { font-size: 11px; fill: #374151; }
|
||||
.tcr-pin-wrap { background: transparent; border: none; }
|
||||
.tcr-pin {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px; border-radius: 50%; color: #fff; font-size: 11px;
|
||||
font-weight: 700; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
||||
background: #4b5563;
|
||||
}
|
||||
.tcr-pin-start { background: #2f6fed; }
|
||||
.tcr-pin-end { background: #d14343; }
|
||||
.tcr-pin-hike { background: #2e9e5b; }
|
||||
.tcr-pin-district { background: #b8860b; }
|
||||
.tcr-pin-food { background: #e0673a; }
|
||||
.tcr-pin-bathhouse { background: #1f8a70; }
|
||||
.tcr-pin-shop { background: #5a67d8; }
|
||||
.tcr-pin-sleep { background: #2d3748; }
|
||||
.tcr-pin-walk { background: #8a5cf6; }
|
||||
.tcr-pin-breakfast { background: #c98a00; }
|
||||
.tcr-pin-parking { background: #c2701a; }
|
||||
.tcr-pin-spot { background: #0ea5e9; }
|
||||
.tcr-pin-waypoint { background: #8a8f98; }
|
||||
/* 【本技能新增配色】车站 / 住宿 / 共享单车站点 / 网约车上车点 / 夜市夜逛区 */
|
||||
.tcr-pin-train_station { background: #1d4ed8; }
|
||||
.tcr-pin-lodging { background: #7c3aed; }
|
||||
.tcr-pin-bike { background: #0ea5e9; }
|
||||
.tcr-pin-taxi_point { background: #f59e0b; }
|
||||
.tcr-pin-night_market { background: #7c2d92; }
|
||||
|
||||
/* ---------------- 地图弹窗里的导航信息(本技能专属) ---------------- */
|
||||
|
||||
.tcr-pop-addr { color: #1f2328; font-size: 12px; }
|
||||
.tcr-pop-note { color: #6b7280; font-size: 12px; }
|
||||
.tcr-pop-dim { color: #8a6100; font-size: 11px; }
|
||||
.tcr-pop-navs { display: inline-block; margin-top: 4px; font-size: 12px; }
|
||||
.tcr-pop-nav { color: #2f6fed; text-decoration: none; }
|
||||
.tcr-pop-nav:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------------- 提示 ---------------- */
|
||||
|
||||
.tcr-note { border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid; }
|
||||
.tcr-note-title { font-weight: 600; margin-bottom: 2px; font-size: 13px; }
|
||||
.tcr-note-text { font-size: 13px; color: #374151; }
|
||||
.tcr-note-info { background: #f2f7ff; border-color: #cfe0ff; }
|
||||
.tcr-note-warn { background: #fffaf0; border-color: #f0dcae; }
|
||||
.tcr-note-danger { background: #fff5f4; border-color: #f5c9c3; }
|
||||
|
||||
/* ---------------- 待核实清单 ---------------- */
|
||||
|
||||
.tcr-checklist { margin: 0; padding-left: 20px; font-size: 13px; }
|
||||
.tcr-check-item { margin-bottom: 7px; }
|
||||
.tcr-check-text { margin-right: 6px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tcr-card-grid { grid-template-columns: 1fr; }
|
||||
.tcr-field { grid-template-columns: 82px 1fr; }
|
||||
.tcr-map-holder { height: 320px; }
|
||||
}
|
||||