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();
});
});