diff --git "a/docs/changelog/20260827-090646-fix-\347\231\273\345\275\225\345\220\216\345\233\236\350\267\263\345\216\237\351\241\265\351\235\242backurl.md" "b/docs/changelog/20260827-090646-fix-\347\231\273\345\275\225\345\220\216\345\233\236\350\267\263\345\216\237\351\241\265\351\235\242backurl.md" new file mode 100644 index 0000000..6c9428d --- /dev/null +++ "b/docs/changelog/20260827-090646-fix-\347\231\273\345\275\225\345\220\216\345\233\236\350\267\263\345\216\237\351\241\265\351\235\242backurl.md" @@ -0,0 +1,8 @@ +# fix: 登录成功后回到登录前原页面(backurl) + +问题:未登录打开 `/practice?scenario=` 等受保护页面,被重定向到登录页,登录成功后固定跳首页,原入口(指定题目、会话链接)丢失。 + +- `ProtectedRoutes` 守卫:跳登录页时携带 `?redirect=<原路径+查询参数>`(URL 编码)。 +- `LoginPage`:登录成功后回跳 `redirect`;`safeRedirect` 只接受站内路径——拒绝外链、协议相对路径 `//` 与登录页自身,参数缺失回 `/`。 +- 测试:LoginPage 单测新增 4 条(缺参回首页、带查询回指定题、会话路径保留、拒绝外链);渐进式 E2E 新增「未登录 → 登录 → 回指定题」全流程(5 引擎矩阵全过)。 +- 影响面:所有受保护页面(练习/复习/历史/我的)未登录访问都获得同样的回跳能力;已登录用户行为不变。 diff --git a/docs/changelog/README.md b/docs/changelog/README.md index fba745c..14671ac 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -6,6 +6,7 @@ | 时间 | 文件 | 摘要 | |---|---|---| +| 2026-08-27 09:06:46 | [20260827-090646-fix-登录后回跳原页面backurl.md](20260827-090646-fix-登录后回跳原页面backurl.md) | 未登录访问受保护页面时,登录成功后回到原入口(含指定题目查询参数),外链拒绝。 | | 2026-08-27 08:30:34 | [20260827-083034-feat-渐进式场景提示-实现与试点就绪.md](20260827-083034-feat-渐进式场景提示-实现与试点就绪.md) | 渐进式场景提示代码与试点内容就绪:slug 精确入口、按需提示、幂等创建、评分隔离、10 题审核稿;生产激活待分批投放。 | | 2026-08-26 15:27:01 | [20260826-152701-chore-渐进式场景提示实施规格.md](20260826-152701-chore-渐进式场景提示实施规格.md) | 新增 agent-ready 渐进提示规格,分开代码交付、生产激活与产品试点验收。 | | 2026-08-24 22:18:31 | [20260824-221831-feat-独立Attempt与反馈图片.md](20260824-221831-feat-独立Attempt与反馈图片.md) | 独立 Attempt、反馈多图原件和隔离的标准答案重点讲解。 | diff --git "a/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" "b/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" index 63f004a..0761a3e 100644 --- "a/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" +++ "b/docs/\344\270\232\345\212\241/8-\346\270\220\350\277\233\345\274\217\345\234\272\346\231\257\347\273\203\344\271\240.md" @@ -14,6 +14,7 @@ ## 交互行为(当前实现) - **入口**:`/practice?scenario=` 精确选题。题目不存在/已归档/无权访问 → 明确「不可用」提示,不回退随机题;不产生 Session。 +- **登录回跳**:未登录打开入口时跳登录页并携带原路径(`?redirect=`),登录成功后回到该题;只接受站内路径,拒绝外链。 - **会话创建**:点录音开始才创建真实 Practice Session(同一次开始动作带同一 `requestId`,网络重试/重复提交服务端幂等,只产生一个 Session);创建成功后进入规范 Session URL,刷新恢复同一会话。 - **提示区**:仅已创建的渐进式 Session 显示;「给我一点提示」→「再给一点提示」→ 用尽后显示「已显示全部提示」。一次一条,已显示提示累计保留(重说不清空);刷新后按服务端计数恢复前缀;不自动弹出、不自动显示。 - **作答与结果**:提示不影响任务完成判定与评分口径——hints 文本和提示计数永不进入纠错/标准答案的模型请求;结果页不标注「使用了提示」。Attempt 记录作答当时的 `hintCount`(服务端复制,客户端不可伪造),仅用于分层分析。 diff --git a/web/e2e/progressive.spec.js b/web/e2e/progressive.spec.js index dd190e3..de48f24 100644 --- a/web/e2e/progressive.spec.js +++ b/web/e2e/progressive.spec.js @@ -55,22 +55,25 @@ const SESSION = { attempts: [], }; -async function setup(page, { slugOk = true, revealed = 0 } = {}) { +async function setup(page, { slugOk = true, revealed = 0, loggedIn = true } = {}) { const calls = { createSession: 0, reveals: [] }; await page.route("https://fonts.googleapis.com/**", (route) => route.fulfill({ body: "", contentType: "text/css" })); await page.route("https://fonts.gstatic.com/**", (route) => route.abort()); - await page.addInitScript((user) => { - localStorage.setItem("english-speak-user", JSON.stringify(user)); + await page.addInitScript(({ user, loggedIn }) => { + if (loggedIn) localStorage.setItem("english-speak-user", JSON.stringify(user)); localStorage.setItem("speakup_lang", "en"); localStorage.setItem( `speakup-practice-preferences:${user.userId}`, JSON.stringify({ level: "daily", purpose: "travel" }), ); - }, USER); + }, { user: USER, loggedIn }); page.on("dialog", (dialog) => dialog.dismiss()); await page.route("**/api/**", async (route) => { const { pathname } = new URL(route.request().url()); const method = route.request().method(); + if (pathname === "/api/auth/login" && method === "POST") { + return route.fulfill({ json: USER }); + } if (pathname === "/api/scenarios/by-slug/prog-e2e") { if (!slugOk) { return route.fulfill({ status: 404, json: { detail: "场景不存在或不可用" } }); @@ -148,3 +151,20 @@ test("刷新后按服务端计数恢复已显示提示", async ({ page }) => { await expect(page.getByText("能麻烦你重新做一杯热的吗?")).toHaveCount(0); await expect(page.getByRole("button", { name: "Give me another hint" })).toBeVisible(); }); + +test("未登录:指定题目 URL 经登录保留,登录后回到原题", async ({ page }) => { + await setup(page, { loggedIn: false }); + await page.goto("/practice?scenario=prog-e2e", { waitUntil: "domcontentloaded" }); + + // 守卫跳登录页,原路径(含 ?scenario=)编码进 redirect 参数 + await expect(page).toHaveURL(/\/login\?redirect=%2Fpractice%3Fscenario%3Dprog-e2e/); + + await page.getByPlaceholder("138 0000 0000").fill("13800002222"); + await page.getByRole("button", { name: "Enter" }).click(); + + // 登录后回到指定题目,内容精确 + await expect(page).toHaveURL(/\/practice\?scenario=prog-e2e/); + await expect(page.getByText("店员把你的热拿铁做成了冰拿铁。").first()).toBeVisible(); + await expect(page.getByText("礼貌说明问题,请店员重做。").first()).toBeVisible(); + await expect(page.getByText("说明饮品做错了")).toHaveCount(0); +}); diff --git a/web/src/App.jsx b/web/src/App.jsx index 549014d..3e8d505 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; import { UserProvider } from "./context/UserContext.jsx"; import { useUser } from "./context/useUser.js"; import { LanguageProvider } from "./i18n/index.jsx"; @@ -19,7 +19,12 @@ import "./styles/practice-preferences.css"; function ProtectedRoutes() { const { user } = useUser(); - if (!user) return ; + const location = useLocation(); + if (!user) { + // 带原始路径(含 ?scenario= 等查询参数)进登录页,登录成功后回原处 + const from = location.pathname + location.search; + return ; + } return ; } diff --git a/web/src/pages/LoginPage.jsx b/web/src/pages/LoginPage.jsx index 48dc236..c85b905 100644 --- a/web/src/pages/LoginPage.jsx +++ b/web/src/pages/LoginPage.jsx @@ -1,14 +1,22 @@ import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useUser } from "../context/useUser.js"; import { useT } from "../i18n/useI18n.js"; +// 登录成功后回跳:只接受站内路径,拒绝外链/协议相对路径/登录页自身 +function safeRedirect(value) { + if (!value || !/^\/(?!\/)/.test(value)) return "/"; + if (value === "/login" || value.startsWith("/login?") || value.startsWith("/login/")) return "/"; + return value; +} + export default function LoginPage() { const [phone, setPhone] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const { login } = useUser(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const t = useT(); const valid = /^1\d{10}$/.test(phone); @@ -23,7 +31,7 @@ export default function LoginPage() { setError(""); try { await login(phone); - navigate("/"); + navigate(safeRedirect(searchParams.get("redirect"))); } catch { setError(t("login.errorLoginFailed")); } finally { diff --git a/web/src/pages/LoginPage.test.jsx b/web/src/pages/LoginPage.test.jsx index 4534f50..1c4a0bb 100644 --- a/web/src/pages/LoginPage.test.jsx +++ b/web/src/pages/LoginPage.test.jsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, Routes, Route, useLocation } from "react-router-dom"; import LoginPage from "./LoginPage.jsx"; import { UserProvider } from "../context/UserContext.jsx"; @@ -13,16 +13,30 @@ vi.mock("../api/client.js", () => ({ }, })); -function renderLogin() { +// 登录成功后的落点探针:把当前路径+查询渲染出来供断言 +function LandedProbe() { + const location = useLocation(); + return
{location.pathname + location.search}
; +} + +function renderLogin(initialEntry = "/login") { return render( - + - + + } /> + } /> + , ); } +async function loginWithValidPhone() { + await userEvent.type(screen.getByPlaceholderText("138 0000 0000"), "13800001234"); + await userEvent.click(screen.getByRole("button", { name: /Enter/ })); +} + describe("LoginPage", () => { beforeEach(() => { localStorage.clear(); @@ -59,8 +73,38 @@ describe("LoginPage", () => { it("calls the login API on submit", async () => { renderLogin(); const { api } = await import("../api/client.js"); - await userEvent.type(screen.getByPlaceholderText("138 0000 0000"), "13800001234"); - await userEvent.click(screen.getByRole("button", { name: /Enter/ })); + await loginWithValidPhone(); expect(api.login).toHaveBeenCalledWith("13800001234"); }); + + it("无 redirect 参数时登录后回首页", async () => { + renderLogin(); + await loginWithValidPhone(); + expect(await screen.findByTestId("landed")).toHaveTextContent(/^\/$/); + }); + + it("登录后回到 redirect 指定的原始页面(含查询参数)", async () => { + renderLogin("/login?redirect=%2Fpractice%3Fscenario%3Ddelivery-missing-dish-claim"); + await loginWithValidPhone(); + expect(await screen.findByTestId("landed")).toHaveTextContent(/^\/practice\?scenario=delivery-missing-dish-claim$/); + }); + + it("redirect 会话路径也完整保留", async () => { + renderLogin("/login?redirect=%2Fpractice%2Fsess_abc"); + await loginWithValidPhone(); + expect(await screen.findByTestId("landed")).toHaveTextContent(/^\/practice\/sess_abc$/); + }); + + it("拒绝外链、协议相对路径与登录页自身", async () => { + for (const bad of [ + "https%3A%2F%2Fevil.example.com", + "%2F%2Fevil.example.com", + "%2Flogin%3Fredirect%3D%252F", + ]) { + const view = renderLogin(`/login?redirect=${bad}`); + await loginWithValidPhone(); + expect(await screen.findByTestId("landed")).toHaveTextContent(/^\/$/); + view.unmount(); + } + }); });