Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
> **版本规范**: Major.架构变更 | Minor.新功能 | Patch.bug修复/数据更新
> 每次发版打对应 git tag(如 `git tag -a v2.1.0`)。

## 2026-08-22 - 修复主站与编辑器交互竞态

### 变更内容
- `web/app.js` — 将动态内联点击事件改为 CSP 兼容的事件委托。
- `web/index.html`、`tests/test_tour_ai_layout_markup.js` — 登录前后只显示已上线入口,并保持菜单顺序一致。
- `web/editor/src/NewEditorApp.tsx`、`hooks/useRoute.ts` — 启用路线钩子、传递城市上下文并取消过期请求。
- `web/editor/src/components/Wizard/HotelsStep.tsx` — 切城时取消旧酒店请求。
- `web/editor/src/stores/itineraryStore.ts` — 总天数减少时裁剪多余日期。
- `tests/test_reviewed_frontend_contracts.js`、`itineraryStore.test.ts` — 增加 CSP、路线、酒店和日期回归测试。
- `scripts/verify_ui.js` — 将过时 Demo 验证更新为当前游客结构化规划与流式结果渲染链路。
- `web/editor-dist/` — 从当前源码重新构建发布产物。

### 原因
- 防止 CSP 阻断动态按钮、路线钩子未挂载、城市上下文丢失、旧异步响应覆盖新状态以及缩短行程后保留旧日期。

### 影响范围
- 影响主站动态交互和 React 编辑器的路线、酒店、日期状态;不定义完整的跨城市逐日路线语义。

## 2026-08-22 - 保证 POI 管理更新的事务一致性

### 变更内容
Expand Down
117 changes: 75 additions & 42 deletions scripts/verify_ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,58 +27,91 @@ async function main() {
});
page.on("pageerror", (error) => errors.push(error.message));

await page.goto(url, { waitUntil: "networkidle" });
await page.click("#loadExampleButton");
await page.click("button.primary-action");
await page.waitForSelector(".overview-section", { timeout: 10000 });

await page.click('[data-stage="compare"]');
await page.waitForSelector(".comparison-panel", { timeout: 10000 });

await page.click('[data-stage="itinerary"]');
await page.waitForSelector(".visual-panel", { timeout: 10000 });

await page.click('[data-stage="debug"]');
await page.waitForSelector(".debug-panel", { timeout: 10000 });

await page.click('[data-stage="tools"]');
await page.waitForSelector(".inspector:not([hidden])", { timeout: 10000 });

const routeNodes = await page.locator(".route-node").count();
const timelineStops = await page.locator(".timeline-stop").count();
const comparisonCards = await page.locator(".comparison-card").count();
const beamSteps = await page.locator(".beam-step").count();
const paretoDebugItems = await page.locator(".pareto-debug span").count();
const diversityMetrics = await page.locator(".diversity-metrics span").count();
const searchContributionItems = await page.locator("#searchOutput .score-breakdown span").count();
const fixtureItinerary = {
city: "长沙",
summary: "UI smoke fixture",
days: [1, 2].map((day) => ({
day,
summary: `第 ${day} 天`,
stops: [1, 2].map((stop) => ({
poi_id: `ui-smoke-${day}-${stop}`,
poi_name: `测试地点 ${day}-${stop}`,
poi_type: stop === 1 ? "attraction" : "restaurant",
area: "测试区域",
start_time: stop === 1 ? "09:00" : "12:00",
end_time: stop === 1 ? "10:30" : "13:00",
visit_duration_minutes: stop === 1 ? 90 : 60,
travel_minutes_from_previous: stop === 1 ? 0 : 15,
})),
})),
};
await page.route("**/agent/plan-structured", async (route) => {
const stream = [
`data: ${JSON.stringify({ type: "session", session_id: "ui-smoke-session" })}`,
`data: ${JSON.stringify({ type: "itinerary", itinerary: fixtureItinerary })}`,
"",
].join("\n");
await route.fulfill({
status: 200,
contentType: "text/event-stream; charset=utf-8",
body: stream,
});
});

await browser.close();
let city = "";
let dayCount = 0;
let stopCount = 0;
let sidebarItemCount = 0;
let serviceStatus = "";
try {
await page.goto(url, { waitUntil: "networkidle" });
await page.click("#guestBtn");
await page.waitForSelector("#mainApp:not([hidden])", { timeout: 10000 });
await page.waitForSelector("#formCityGrid .city-card", { timeout: 10000 });
const changsha = page.locator('#formCityGrid .city-card[data-city="长沙"]');
const cityCard = await changsha.count()
? changsha
: page.locator("#formCityGrid .city-card").first();
city = await cityCard.getAttribute("data-city") || "";
await cityCard.click();
await page.click('#formDaysGroup .day-btn[data-value="2"]');
const planResponse = page.waitForResponse(
(response) => response.url().includes("/agent/plan-structured"),
{ timeout: 60000 },
);
await page.click("#formSubmitBtn");
const response = await planResponse;
if (!response.ok()) {
throw new Error(`Structured planning request failed with HTTP ${response.status()}`);
}
await page.waitForSelector("#agentResult:not([hidden]) .agent-day", { timeout: 60000 });
dayCount = await page.locator("#agentResult .agent-day").count();
stopCount = await page.locator("#agentResult .agent-stop").count();
sidebarItemCount = await page.locator("#sidebar .sidebar-item").count();
serviceStatus = await page.locator("#serviceStatus").innerText();
} finally {
await browser.close();
}

if (errors.length > 0) {
throw new Error(`Browser console errors: ${errors.join(" | ")}`);
}
if (routeNodes < 2) {
throw new Error(`Expected route visualization nodes, got ${routeNodes}`);
}
if (timelineStops < 4) {
throw new Error(`Expected timeline stops, got ${timelineStops}`);
}
if (comparisonCards < 2) {
throw new Error(`Expected candidate comparison cards, got ${comparisonCards}`);
if (!city) {
throw new Error("Expected at least one selectable city");
}
if (beamSteps < 3) {
throw new Error(`Expected Beam Search debug steps, got ${beamSteps}`);
if (dayCount !== 2) {
throw new Error(`Expected 2 itinerary days, got ${dayCount}`);
}
if (paretoDebugItems < 1) {
throw new Error(`Expected Pareto debug evidence, got ${paretoDebugItems}`);
if (stopCount < 4) {
throw new Error(`Expected at least 4 itinerary stops, got ${stopCount}`);
}
if (diversityMetrics < 3) {
throw new Error(`Expected diversity metrics, got ${diversityMetrics}`);
if (sidebarItemCount !== 5) {
throw new Error(`Expected 5 sidebar entries, got ${sidebarItemCount}`);
}
if (searchContributionItems < 1) {
throw new Error(`Expected BM25 contribution chips, got ${searchContributionItems}`);
if (!serviceStatus.includes("POI")) {
throw new Error(`Expected POI service status, got ${serviceStatus}`);
}
console.log(`UI verification passed: ${routeNodes} route nodes, ${timelineStops} timeline stops, ${comparisonCards} comparison cards, ${beamSteps} beam steps, ${diversityMetrics} diversity metrics, ${searchContributionItems} search contributions.`);
console.log(`UI verification passed: guest planning for ${city}, ${dayCount} days, ${stopCount} stops, ${sidebarItemCount} sidebar entries.`);
}

main().catch((error) => {
Expand Down
29 changes: 29 additions & 0 deletions tests/test_reviewed_frontend_contracts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const fs = require("fs");
const path = require("path");

const root = path.join(__dirname, "..");
const app = fs.readFileSync(path.join(root, "web", "app.js"), "utf8");
const editorApp = fs.readFileSync(path.join(root, "web", "editor", "src", "NewEditorApp.tsx"), "utf8");
const routeHook = fs.readFileSync(path.join(root, "web", "editor", "src", "hooks", "useRoute.ts"), "utf8");
const hotelsStep = fs.readFileSync(path.join(root, "web", "editor", "src", "components", "Wizard", "HotelsStep.tsx"), "utf8");

if (/\sonclick\s*=\s*["']/.test(app)) {
throw new Error("Expected main app dynamic markup to avoid CSP-blocked inline event handlers.");
}
if (!app.includes('data-action="toggle-guide"') || !app.includes("data-xhs-edit-index")) {
throw new Error("Expected CSP-safe data attributes for delegated actions.");
}
if (!editorApp.includes("useRoute();")) {
throw new Error("Expected the active NewEditorApp entry to enable route loading.");
}
if (!routeHook.includes("JSON.stringify({ poi_ids: deduped, city })")) {
throw new Error("Expected batch route requests to include the selected city.");
}
if (!routeHook.includes("if (error.name === 'AbortError') return;")) {
throw new Error("Expected cancelled route requests to avoid writing stale fallback routes.");
}
if (!hotelsStep.includes("new AbortController()") || !hotelsStep.includes("controller.abort()")) {
throw new Error("Expected hotel requests to be cancelled when the selected city changes.");
}

console.log("Reviewed frontend interaction contracts are present.");
17 changes: 17 additions & 0 deletions tests/test_tour_ai_layout_markup.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,26 @@ const indexHtml = fs.readFileSync(path.join(root, "web", "index.html"), "utf8");
const styles = fs.readFileSync(path.join(root, "web", "styles.css"), "utf8");
const sidebarStyles = fs.readFileSync(path.join(root, "web", "css", "sidebar.css"), "utf8");

function extractLabels(scopePattern, labelPattern) {
const scope = indexHtml.match(scopePattern)?.[1] || "";
return [...scope.matchAll(labelPattern)].map(match => match[1].trim());
}

const authSidebarLabels = extractLabels(
/<aside class="auth-sidebar"[\s\S]*?>([\s\S]*?)<\/aside>/,
/<a[^>]*>([^<]+)<\/a>/g
);
const appSidebarLabels = extractLabels(
/<nav id="sidebar"[\s\S]*?>([\s\S]*?)<\/nav>/,
/<span class="sidebar-label">([^<]+)<\/span>/g
);
const launchedSidebarLabels = ["AI 助手", "我的行程", "路线规划", "个人中心", "联系我们"];

const expectations = [
[indexHtml.includes("auth-topbar"), "login page should include the Tour-AI style top bar"],
[indexHtml.includes("auth-sidebar"), "login page should include the Tour-AI style side navigation"],
[JSON.stringify(authSidebarLabels) === JSON.stringify(launchedSidebarLabels), "login sidebar should only show launched features"],
[JSON.stringify(appSidebarLabels) === JSON.stringify(launchedSidebarLabels), "login and app sidebars should show the same features in the same order"],
[styles.includes("--app-bg: #f3f4f6"), "global tokens should use the light gray app workspace background"],
[sidebarStyles.includes("--sidebar-width: 244px"), "sidebar should match the target layout width"],
[sidebarStyles.includes(".sidebar-shell-topbar"), "main app should have a fixed top brand bar"],
Expand Down
21 changes: 19 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1888,7 +1888,7 @@ function renderStop(stop) {
${isMeal ? `<div class="stop-area-hint">📍 建议在 <strong>${escapeHtml(stop.area)}</strong> 一带用餐</div>` : ""}
<div class="stop-reason">${escapeHtml(stop.reason) || ""}</div>
${stop.recommendation ? `<div class="stop-tip">💡 ${escapeHtml(stop.recommendation)}</div>` : ""}
${guideText ? `<div class="stop-guide"><div class="stop-guide-text" id="${uid}">${escapeHtml(shortGuide)}${needToggle ? "..." : ""}</div>${needToggle ? `<button class="stop-guide-toggle" data-full-text="${escapeHtml(guideText)}" data-short-text="${escapeHtml(shortGuide)}..." data-target="${uid}" onclick="toggleGuide(this)">展开攻略</button>` : ""}</div>` : ""}
${guideText ? `<div class="stop-guide"><div class="stop-guide-text" id="${uid}">${escapeHtml(shortGuide)}${needToggle ? "..." : ""}</div>${needToggle ? `<button class="stop-guide-toggle" data-action="toggle-guide" data-full-text="${escapeHtml(guideText)}" data-short-text="${escapeHtml(shortGuide)}..." data-target="${uid}">展开攻略</button>` : ""}</div>` : ""}
</div>
</div>
<div class="stop-actions">
Expand All @@ -1914,6 +1914,11 @@ function toggleGuide(btn) {
}
}

document.addEventListener("click", function(e) {
const guideButton = e.target.closest(".stop-guide-toggle[data-action='toggle-guide']");
if (guideButton) toggleGuide(guideButton);
});

function timeWindowLabel(status) {
const labels = {
ok: "可行",
Expand Down Expand Up @@ -4336,7 +4341,7 @@ function xhsRenderDay(idx) {
var cfg = XHS_TYPES[p.type] || {icon:"📍",bg:"#f5f5f5",color:"#666"};
var card = document.createElement("div"); card.className = "xhs-place";
card.innerHTML = '<div class="xhs-place-num">'+(i+1)+'</div>' +
'<div class="xhs-place-actions"><button class="xhs-place-action" title="编辑" onclick="xhsOpenModal('+i+')">✏️</button><button class="xhs-place-action danger" title="删除" onclick="xhsDeletePlace('+idx+','+i+')">🗑️</button></div>' +
'<div class="xhs-place-actions"><button class="xhs-place-action" title="编辑" data-xhs-edit-index="'+i+'">✏️</button><button class="xhs-place-action danger" title="删除" data-xhs-delete-day="'+idx+'" data-xhs-delete-index="'+i+'">🗑️</button></div>' +
'<div class="xhs-place-header"><h3 class="xhs-place-name">'+escapeHtml(p.name||"未命名")+'</h3><span class="xhs-place-type xhs-type-'+escapeHtml(p.type||"观光")+'">'+cfg.icon+' '+escapeHtml(p.type||"观光")+'</span></div>' +
(p.description?'<p class="xhs-place-desc">'+escapeHtml(p.description)+'</p>':'') +
'<div class="xhs-place-meta">'+(p.duration?'<span>⏱️ '+escapeHtml(p.duration)+'</span>':'')+'</div>' +
Expand All @@ -4345,6 +4350,18 @@ function xhsRenderDay(idx) {
});
}

document.addEventListener("click", function(e) {
var editButton = e.target.closest("[data-xhs-edit-index]");
if (editButton) {
xhsOpenModal(Number(editButton.dataset.xhsEditIndex));
return;
}
var deleteButton = e.target.closest("[data-xhs-delete-day][data-xhs-delete-index]");
if (deleteButton) {
xhsDeletePlace(Number(deleteButton.dataset.xhsDeleteDay), Number(deleteButton.dataset.xhsDeleteIndex));
}
});

function xhsOpenLb(idx) { xhsLbIdx=idx; document.getElementById("xhsLightbox").hidden=false; xhsUpdateLb(); }
function xhsCloseLb() { document.getElementById("xhsLightbox").hidden=true; }
function xhsLbNav(dir) { xhsLbIdx=(xhsLbIdx+dir+xhsLbImages.length)%xhsLbImages.length; xhsUpdateLb(); }
Expand Down
Loading