diff --git a/CHANGELOG.md b/CHANGELOG.md index 236728c..42db9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 管理更新的事务一致性 ### 变更内容 diff --git a/scripts/verify_ui.js b/scripts/verify_ui.js index bafb28a..37ec0a5 100644 --- a/scripts/verify_ui.js +++ b/scripts/verify_ui.js @@ -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) => { diff --git a/tests/test_reviewed_frontend_contracts.js b/tests/test_reviewed_frontend_contracts.js new file mode 100644 index 0000000..b5747b6 --- /dev/null +++ b/tests/test_reviewed_frontend_contracts.js @@ -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."); diff --git a/tests/test_tour_ai_layout_markup.js b/tests/test_tour_ai_layout_markup.js index 44927a9..b7972df 100644 --- a/tests/test_tour_ai_layout_markup.js +++ b/tests/test_tour_ai_layout_markup.js @@ -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( + /