From 1e02c7835ea7822983a36769351853ea8256cd58 Mon Sep 17 00:00:00 2001 From: zhongyingmin Date: Thu, 27 Aug 2026 10:35:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- src/App.tsx | 37 -------- src/main.tsx | 3 + src/utils/assets.ts | 72 ++++++++------- src/utils/yiban.ts | 45 +++++++++- tests/e2e/course-flow.spec.ts | 164 +++++++++++++++++----------------- 6 files changed, 173 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index c607a98..5dab328 100644 --- a/README.md +++ b/README.md @@ -333,17 +333,17 @@ import { Overlay } from "@/components/common"; setOpen(false)} - backgroundImage="dialog/overlay-bg.png" + backgroundImage="url(./images/dialog-bg.png)" backgroundClassName="bg-cover bg-center" contentClassName="flex items-center justify-center" > - 课程提示 + 课程提示 ; ``` 说明: -- `backgroundImage`: 传入 `src/assets/images` 下的图片路径,组件内部会通过 `getBackgroundImage` 处理。 +- `backgroundImage`: 传入原始 CSS `background-image` 值(例如 `url(./images/dialog-bg.png)` 或 `linear-gradient(...)`),组件不参与路径处理;引用 public 资源时可用 `getPublicPath` 拼接相对路径。 - `backgroundClassName`: 使用 Tailwind class 控制背景层样式,例如 `bg-cover`、`bg-center`、渐变、透明度等。 - `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。 - `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。 diff --git a/src/App.tsx b/src/App.tsx index adad0c5..a62d9ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,6 @@ import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas"; import { OrientationGuard } from "@/course-runtime/orientation"; import { Navigation } from "./components/business"; import { registerAppPortalRoot } from "./components/common/canvasPortal"; -import { useUserStore } from "./stores"; -import { isVideoAjaxAvailable } from "./utils"; function CourseShell() { const { portalContainer } = useCourseCanvas(); @@ -25,41 +23,6 @@ function CourseShell() { } function App() { - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const courseId = params.get("courseId") ?? params.get("courseid"); - const uid = params.get("uid"); - const forceYiban = params.get("yiban") === "1"; - const hasYibanParams = !!(courseId || uid); - const hasVideoAjax = isVideoAjaxAvailable(); - const isYibanEnv = import.meta.env.DEV - ? hasYibanParams && forceYiban - : hasYibanParams; - const storageMode = isYibanEnv && hasVideoAjax ? "yiban" : "session"; - const userStore = useUserStore.getState(); - - userStore.setYibanConnectionStatus( - storageMode === "yiban" ? "connected" : "disconnected", - ); - if (courseId) userStore.setCourseId(courseId); - if (uid) userStore.setUserInfo({ userId: uid }); - - console.info( - storageMode === "yiban" - ? "[yiban] initialized" - : "[yiban] use session storage fallback", - { - courseId, - uid, - isDev: import.meta.env.DEV, - forceYiban, - hasYibanParams, - hasVideoAjax, - storageMode, - }, - ); - }, []); - return ( <> diff --git a/src/main.tsx b/src/main.tsx index 73a7fb2..f1b289f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,9 @@ import { RouterProvider } from "react-router-dom"; import "./utils/vconsole"; import "./styles/index.css"; import { router } from "./router/index.tsx"; +import { initializeYibanSession } from "./utils/yiban"; + +initializeYibanSession(); const rootElement = document.getElementById("root"); if (!rootElement) { diff --git a/src/utils/assets.ts b/src/utils/assets.ts index 7172e02..9889cc4 100644 --- a/src/utils/assets.ts +++ b/src/utils/assets.ts @@ -1,47 +1,59 @@ // 图片资源映射 - 使用静态导入确保构建时包含所有图片 const imageModules = import.meta.glob( "../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}", - { - eager: true, - }, + { eager: true }, ); -// 访问静态图片 - 使用静态导入确保构建后路径正确 -export const getStaticImage = (path: string) => { - try { - // 确保路径以正确的格式结尾 - const imagePath = - path.endsWith(".png") || - path.endsWith(".jpg") || - path.endsWith(".jpeg") || - path.endsWith(".gif") || - path.endsWith(".svg") || - path.endsWith(".webp") - ? `../assets/images/${path}` - : `../assets/images/${path}.png`; +const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]; - // 使用静态导入的模块 - const module = imageModules[imagePath]; - if (module && typeof module === "object" && "default" in module) { - return (module as { default: string }).default; - } +/** + * 把传入路径规范化为相对 src/assets/images/ 的路径 + * 兼容 `src/assets/images/foo.png`、`assets/images/foo.png`、`/foo.png`、`foo.png` 等写法 + */ +function normalizeImagePath(path: string): string { + return path + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .replace(/^(?:src\/)?assets\/images\//, ""); +} - // 回退到 URL 方式 - return new URL(imagePath, import.meta.url).href; - } catch (error) { - console.error("Failed to load image:", path, error); - return ""; +/** + * 访问静态图片(src/assets/images/ 下的构建期资源) + * 未命中时返回空字符串并告警,调用方据此回退 + */ +export const getStaticImage = (path: string): string => { + const normalized = normalizeImagePath(path); + const candidate = IMAGE_EXTENSIONS.some((ext) => normalized.endsWith(ext)) + ? normalized + : `${normalized}.png`; + + const module = imageModules[`../assets/images/${candidate}`] as + | { default: string } + | undefined; + + if (module && typeof module.default === "string") { + return module.default; } + + console.warn( + `[assets] 未找到图片 "${path}",请确认文件位于 src/assets/images/ 下并已提交。`, + ); + return ""; }; -// 获取背景图片的专用函数 -export const getBackgroundImage = (path: string) => { +/** + * 获取背景图片的 CSS url 值 + * 找不到时返回 "none" + */ +export const getBackgroundImage = (path: string): string => { const imageUrl = getStaticImage(path); return imageUrl ? `url(${imageUrl})` : "none"; }; -// 获取 public 文件夹中的资源路径(用于 Word 文档等) -export const getPublicPath = (path: string) => { +/** + * 获取 public 文件夹中的资源路径(用于 Word 文档等非构建期资源) + */ +export const getPublicPath = (path: string): string => { // 移除开头的斜杠(如果有) const cleanPath = path.startsWith("/") ? path.slice(1) : path; // 使用 Vite 的 BASE_URL 环境变量来处理基础路径 diff --git a/src/utils/yiban.ts b/src/utils/yiban.ts index dbeb33d..4f6e287 100644 --- a/src/utils/yiban.ts +++ b/src/utils/yiban.ts @@ -40,6 +40,45 @@ function warnVideoAjaxUnavailable(description: string) { }); } +/** + * 从 URL 参数初始化易班会话状态 + * 在 React 渲染前调用,确保页面首次读取进度时 courseId / userId 已写入 store + */ +export function initializeYibanSession() { + const params = new URLSearchParams(window.location.search); + const courseId = params.get("courseId") ?? params.get("courseid"); + const uid = params.get("uid"); + const forceYiban = params.get("yiban") === "1"; + const hasYibanParams = !!(courseId || uid); + const hasVideoAjax = isVideoAjaxAvailable(); + const isYibanEnv = import.meta.env.DEV + ? hasYibanParams && forceYiban + : hasYibanParams; + const storageMode = isYibanEnv && hasVideoAjax ? "yiban" : "session"; + const userStore = useUserStore.getState(); + + userStore.setYibanConnectionStatus( + storageMode === "yiban" ? "connected" : "disconnected", + ); + if (courseId) userStore.setCourseId(courseId); + if (uid) userStore.setUserInfo({ userId: uid }); + + console.info( + storageMode === "yiban" + ? "[yiban] initialized" + : "[yiban] use session storage fallback", + { + courseId, + uid, + isDev: import.meta.env.DEV, + forceYiban, + hasYibanParams, + hasVideoAjax, + storageMode, + }, + ); +} + /** * 获取 VideoAjax 实例 */ @@ -228,10 +267,7 @@ export async function getUserStorage( if (backend === "session") { const itemValue = sessionStorage.getItem(getStorageKey(lessonId, key)); - return itemValue === undefined || - itemValue === null || - itemValue === "null" || - itemValue === "" + return itemValue === null || itemValue === "null" || itemValue === "" ? { status: "empty", content: "" } : { status: "success", content: itemValue }; } @@ -466,6 +502,7 @@ export async function saveUserAnswer(params: { return false; } + // 易班 API 以 getUserAnswer 写入答题记录,方法名与「保存」语义相反,属平台既有命名 const result = await yibanStatusRequest({ method: (callback) => getVideoAjax().getUserAnswer( diff --git a/tests/e2e/course-flow.spec.ts b/tests/e2e/course-flow.spec.ts index 813d5a9..a100c3a 100644 --- a/tests/e2e/course-flow.spec.ts +++ b/tests/e2e/course-flow.spec.ts @@ -1,4 +1,42 @@ -import { expect, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; + +async function dragWithPointer( + page: Page, + source: Locator, + target: Locator, +): Promise { + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + + if (!sourceBox || !targetBox) { + throw new Error("Drag source or target is not visible"); + } + + const sourcePosition = { + x: sourceBox.x + sourceBox.width / 2, + y: sourceBox.y + sourceBox.height / 2, + }; + const targetPosition = { + x: targetBox.x + targetBox.width / 2, + y: targetBox.y + targetBox.height / 2, + }; + + await page.mouse.move(sourcePosition.x, sourcePosition.y); + await page.mouse.down(); + await page.mouse.move(sourcePosition.x + 10, sourcePosition.y, { steps: 5 }); + await page.waitForTimeout(150); + await page.mouse.move(targetPosition.x, targetPosition.y, { steps: 20 }); + await page.waitForTimeout(150); + await page.mouse.up(); +} + +function getResource(page: Page, title: string): Locator { + return page.getByRole("button", { name: new RegExp(title) }); +} + +function getCategory(page: Page, title: string): Locator { + return page.getByRole("heading", { name: title, exact: true }).locator(".."); +} test.describe("CourseDemo connected flow", () => { test.beforeEach(async ({ page }) => { @@ -67,104 +105,70 @@ test.describe("CourseDemo connected flow", () => { await expect(page.locator("text=先阅读资源卡片")).not.toBeVisible(); }); - // TODO: @dnd-kit drag automation is unreliable in Playwright. - // Manual verification confirms drag-drop works correctly in real browsers. - // Skip these tests until a stable automation approach is found. - test.skip("drag and drop resource classification", async ({ page }) => { - // Wait for resources to load - await expect(page.locator('[data-guide="course-resources"]')).toBeVisible(); + test("drag and drop resource classification", async ({ page }) => { + const policyResource = getResource(page, "资助政策微课"); + const policyZone = getCategory(page, "政策学习"); - // Find first resource card (资助政策微课) - const policyResource = page - .locator('[data-guide="course-resources"]') - .locator("text=资助政策微课") - .first(); await expect(policyResource).toBeVisible(); - - // Find the policy-learning drop zone - const policyZone = page.locator("text=政策学习").first(); await expect(policyZone).toBeVisible(); + await dragWithPointer(page, policyResource, policyZone); - // Use Playwright's native dragTo which works better with dnd-kit - await policyResource.dragTo(policyZone, { - force: true, - timeout: 10000, - }); - await page.waitForTimeout(1000); - - // Success toast should appear (exact match with dynamic title) - await expect(page.locator("text=/分类正确/")).toBeVisible(); - await page.waitForTimeout(300); - - // Progress should update - const progressText = page.locator("text=/已完成 \\d+\\/6/"); - await expect(progressText).toBeVisible(); + await expect(page.getByText("分类正确:资助政策微课")).toBeVisible(); + await expect(page.getByText("已完成 1/6")).toBeVisible(); + await expect(policyResource).not.toBeVisible(); }); - test.skip("wrong category shows error toast", async ({ page }) => { - await expect(page.locator('[data-guide="course-resources"]')).toBeVisible(); - - // Try to drop policy resource into practice (wrong category) - const policyResource = page - .locator('[data-guide="course-resources"]') - .locator("text=资助政策微课") - .first(); - const wrongZone = page.locator("text=互动实践").first(); + test("wrong category shows error toast", async ({ page }) => { + const policyResource = getResource(page, "资助政策微课"); + const wrongZone = getCategory(page, "互动实践"); await expect(policyResource).toBeVisible(); await expect(wrongZone).toBeVisible(); + await dragWithPointer(page, policyResource, wrongZone); - // Use Playwright's native dragTo - await policyResource.dragTo(wrongZone, { - force: true, - timeout: 10000, - }); - await page.waitForTimeout(1000); - - // Error toast should appear (exact match with dynamic title) - await expect(page.locator("text=/不属于这个分类/")).toBeVisible(); + await expect( + page.getByText("“资助政策微课”不属于这个分类,再想一想。"), + ).toBeVisible(); + await expect(page.getByText("已完成 0/6")).toBeVisible(); + await expect(policyResource).toBeVisible(); }); test("completion overlay appears after all resources placed", async ({ page, }) => { - // This would require placing all 6 resources correctly, which is slow - // Instead we'll just verify the reset button works - await page.click("text=重新开始"); - await page.waitForTimeout(500); + const placements = [ + ["资助政策微课", "政策学习"], + ["申请流程图", "政策学习"], + ["诚信申请案例", "互动实践"], + ["材料自查清单", "互动实践"], + ["常见问题答疑", "学习支持"], + ["校园咨询渠道", "学习支持"], + ] as const; - // Toast should confirm reset - await expect(page.locator("text=练习已重置")).toBeVisible(); + for (const [resourceTitle, categoryTitle] of placements) { + await dragWithPointer( + page, + getResource(page, resourceTitle), + getCategory(page, categoryTitle), + ); + } + + await expect(page.getByText("资源地图已建立")).toBeVisible(); + await expect(page.getByText("已完成 6/6")).toBeVisible(); + await expect(page.getByText("100%")).toBeVisible(); }); - test.skip("persists progress (session fallback)", async ({ - page, - context, - }) => { - // Place one resource - const resource = page - .locator('[data-guide="course-resources"]') - .locator("text=资助政策微课") - .first(); - const zone = page.locator("text=政策学习").first(); + test("persists progress with session fallback", async ({ page }) => { + await dragWithPointer( + page, + getResource(page, "资助政策微课"), + getCategory(page, "政策学习"), + ); + await expect(page.getByText("已完成 1/6")).toBeVisible(); - await expect(resource).toBeVisible(); - await expect(zone).toBeVisible(); - - // Use Playwright's native dragTo - await resource.dragTo(zone, { - force: true, - timeout: 10000, - }); - await page.waitForTimeout(1000); - - // Reload page await page.reload(); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(1500); - - // Progress should show 1/6 - await expect(page.locator("text=已完成 1/6")).toBeVisible(); - await expect(page.locator("text=进度已恢复")).toBeVisible(); + await expect(page.getByText("进度已恢复")).toBeVisible(); + await expect(page.getByText("已完成 1/6")).toBeVisible(); + await expect(getResource(page, "资助政策微课")).not.toBeVisible(); }); });