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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# fix: 登录成功后回到登录前原页面(backurl)

问题:未登录打开 `/practice?scenario=<slug>` 等受保护页面,被重定向到登录页,登录成功后固定跳首页,原入口(指定题目、会话链接)丢失。

- `ProtectedRoutes` 守卫:跳登录页时携带 `?redirect=<原路径+查询参数>`(URL 编码)。
- `LoginPage`:登录成功后回跳 `redirect`;`safeRedirect` 只接受站内路径——拒绝外链、协议相对路径 `//` 与登录页自身,参数缺失回 `/`。
- 测试:LoginPage 单测新增 4 条(缺参回首页、带查询回指定题、会话路径保留、拒绝外链);渐进式 E2E 新增「未登录 → 登录 → 回指定题」全流程(5 引擎矩阵全过)。
- 影响面:所有受保护页面(练习/复习/历史/我的)未登录访问都获得同样的回跳能力;已登录用户行为不变。
1 change: 1 addition & 0 deletions docs/changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、反馈多图原件和隔离的标准答案重点讲解。 |
Expand Down
1 change: 1 addition & 0 deletions docs/业务/8-渐进式场景练习.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

## 交互行为(当前实现)
- **入口**:`/practice?scenario=<slug>` 精确选题。题目不存在/已归档/无权访问 → 明确「不可用」提示,不回退随机题;不产生 Session。
- **登录回跳**:未登录打开入口时跳登录页并携带原路径(`?redirect=`),登录成功后回到该题;只接受站内路径,拒绝外链。
- **会话创建**:点录音开始才创建真实 Practice Session(同一次开始动作带同一 `requestId`,网络重试/重复提交服务端幂等,只产生一个 Session);创建成功后进入规范 Session URL,刷新恢复同一会话。
- **提示区**:仅已创建的渐进式 Session 显示;「给我一点提示」→「再给一点提示」→ 用尽后显示「已显示全部提示」。一次一条,已显示提示累计保留(重说不清空);刷新后按服务端计数恢复前缀;不自动弹出、不自动显示。
- **作答与结果**:提示不影响任务完成判定与评分口径——hints 文本和提示计数永不进入纠错/标准答案的模型请求;结果页不标注「使用了提示」。Attempt 记录作答当时的 `hintCount`(服务端复制,客户端不可伪造),仅用于分层分析。
Expand Down
28 changes: 24 additions & 4 deletions web/e2e/progressive.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "场景不存在或不可用" } });
Expand Down Expand Up @@ -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);
});
9 changes: 7 additions & 2 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,7 +19,12 @@ import "./styles/practice-preferences.css";

function ProtectedRoutes() {
const { user } = useUser();
if (!user) return <Navigate to="/login" replace />;
const location = useLocation();
if (!user) {
// 带原始路径(含 ?scenario= 等查询参数)进登录页,登录成功后回原处
const from = location.pathname + location.search;
return <Navigate to={`/login?redirect=${encodeURIComponent(from)}`} replace />;
}
return <Layout />;
}

Expand Down
12 changes: 10 additions & 2 deletions web/src/pages/LoginPage.jsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 {
Expand Down
56 changes: 50 additions & 6 deletions web/src/pages/LoginPage.test.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,16 +13,30 @@ vi.mock("../api/client.js", () => ({
},
}));

function renderLogin() {
// 登录成功后的落点探针:把当前路径+查询渲染出来供断言
function LandedProbe() {
const location = useLocation();
return <div data-testid="landed">{location.pathname + location.search}</div>;
}

function renderLogin(initialEntry = "/login") {
return render(
<MemoryRouter>
<MemoryRouter initialEntries={[initialEntry]}>
<UserProvider>
<LoginPage />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<LandedProbe />} />
</Routes>
</UserProvider>
</MemoryRouter>,
);
}

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();
Expand Down Expand Up @@ -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();
}
});
});
Loading