forked from zhongyidian/yx-react-template
fix: 修复课程运行时初始化与拖拽测试
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d77281ff0d
commit
1e02c7835e
6 files changed
+171
-152
No files matched your search
@@ -333,17 +333,17 @@ import { Overlay } from "@/components/common";
|
|||||||
<Overlay
|
<Overlay
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
backgroundImage="dialog/overlay-bg.png"
|
backgroundImage="url(./images/dialog-bg.png)"
|
||||||
backgroundClassName="bg-cover bg-center"
|
backgroundClassName="bg-cover bg-center"
|
||||||
contentClassName="flex items-center justify-center"
|
contentClassName="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<img src="/images/dialog.png" alt="课程提示" />
|
<img src="./images/dialog.png" alt="课程提示" />
|
||||||
</Overlay>;
|
</Overlay>;
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `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`、渐变、透明度等。
|
- `backgroundClassName`: 使用 Tailwind class 控制背景层样式,例如 `bg-cover`、`bg-center`、渐变、透明度等。
|
||||||
- `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。
|
- `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。
|
||||||
- `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。
|
- `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。
|
||||||
|
|||||||
-37
@@ -5,8 +5,6 @@ import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
|
|||||||
import { OrientationGuard } from "@/course-runtime/orientation";
|
import { OrientationGuard } from "@/course-runtime/orientation";
|
||||||
import { Navigation } from "./components/business";
|
import { Navigation } from "./components/business";
|
||||||
import { registerAppPortalRoot } from "./components/common/canvasPortal";
|
import { registerAppPortalRoot } from "./components/common/canvasPortal";
|
||||||
import { useUserStore } from "./stores";
|
|
||||||
import { isVideoAjaxAvailable } from "./utils";
|
|
||||||
|
|
||||||
function CourseShell() {
|
function CourseShell() {
|
||||||
const { portalContainer } = useCourseCanvas();
|
const { portalContainer } = useCourseCanvas();
|
||||||
@@ -25,41 +23,6 @@ function CourseShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<OrientationGuard />
|
<OrientationGuard />
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { RouterProvider } from "react-router-dom";
|
|||||||
import "./utils/vconsole";
|
import "./utils/vconsole";
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
import { router } from "./router/index.tsx";
|
import { router } from "./router/index.tsx";
|
||||||
|
import { initializeYibanSession } from "./utils/yiban";
|
||||||
|
|
||||||
|
initializeYibanSession();
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|||||||
+41
-29
@@ -1,47 +1,59 @@
|
|||||||
// 图片资源映射 - 使用静态导入确保构建时包含所有图片
|
// 图片资源映射 - 使用静态导入确保构建时包含所有图片
|
||||||
const imageModules = import.meta.glob(
|
const imageModules = import.meta.glob(
|
||||||
"../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}",
|
"../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}",
|
||||||
{
|
{ eager: true },
|
||||||
eager: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 访问静态图片 - 使用静态导入确保构建后路径正确
|
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
||||||
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 module = imageModules[imagePath];
|
* 把传入路径规范化为相对 src/assets/images/ 的路径
|
||||||
if (module && typeof module === "object" && "default" in module) {
|
* 兼容 `src/assets/images/foo.png`、`assets/images/foo.png`、`/foo.png`、`foo.png` 等写法
|
||||||
return (module as { default: string }).default;
|
*/
|
||||||
|
function normalizeImagePath(path: string): string {
|
||||||
|
return path
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^\/+/, "")
|
||||||
|
.replace(/^(?:src\/)?assets\/images\//, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退到 URL 方式
|
/**
|
||||||
return new URL(imagePath, import.meta.url).href;
|
* 访问静态图片(src/assets/images/ 下的构建期资源)
|
||||||
} catch (error) {
|
* 未命中时返回空字符串并告警,调用方据此回退
|
||||||
console.error("Failed to load image:", path, error);
|
*/
|
||||||
|
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 "";
|
return "";
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取背景图片的专用函数
|
/**
|
||||||
export const getBackgroundImage = (path: string) => {
|
* 获取背景图片的 CSS url 值
|
||||||
|
* 找不到时返回 "none"
|
||||||
|
*/
|
||||||
|
export const getBackgroundImage = (path: string): string => {
|
||||||
const imageUrl = getStaticImage(path);
|
const imageUrl = getStaticImage(path);
|
||||||
return imageUrl ? `url(${imageUrl})` : "none";
|
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;
|
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
|
||||||
// 使用 Vite 的 BASE_URL 环境变量来处理基础路径
|
// 使用 Vite 的 BASE_URL 环境变量来处理基础路径
|
||||||
|
|||||||
+41
-4
@@ -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 实例
|
* 获取 VideoAjax 实例
|
||||||
*/
|
*/
|
||||||
@@ -228,10 +267,7 @@ export async function getUserStorage(
|
|||||||
|
|
||||||
if (backend === "session") {
|
if (backend === "session") {
|
||||||
const itemValue = sessionStorage.getItem(getStorageKey(lessonId, key));
|
const itemValue = sessionStorage.getItem(getStorageKey(lessonId, key));
|
||||||
return itemValue === undefined ||
|
return itemValue === null || itemValue === "null" || itemValue === ""
|
||||||
itemValue === null ||
|
|
||||||
itemValue === "null" ||
|
|
||||||
itemValue === ""
|
|
||||||
? { status: "empty", content: "" }
|
? { status: "empty", content: "" }
|
||||||
: { status: "success", content: itemValue };
|
: { status: "success", content: itemValue };
|
||||||
}
|
}
|
||||||
@@ -466,6 +502,7 @@ export async function saveUserAnswer(params: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 易班 API 以 getUserAnswer 写入答题记录,方法名与「保存」语义相反,属平台既有命名
|
||||||
const result = await yibanStatusRequest<unknown>({
|
const result = await yibanStatusRequest<unknown>({
|
||||||
method: (callback) =>
|
method: (callback) =>
|
||||||
getVideoAjax().getUserAnswer(
|
getVideoAjax().getUserAnswer(
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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.describe("CourseDemo connected flow", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -67,104 +105,70 @@ test.describe("CourseDemo connected flow", () => {
|
|||||||
await expect(page.locator("text=先阅读资源卡片")).not.toBeVisible();
|
await expect(page.locator("text=先阅读资源卡片")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: @dnd-kit drag automation is unreliable in Playwright.
|
test("drag and drop resource classification", async ({ page }) => {
|
||||||
// Manual verification confirms drag-drop works correctly in real browsers.
|
const policyResource = getResource(page, "资助政策微课");
|
||||||
// Skip these tests until a stable automation approach is found.
|
const policyZone = getCategory(page, "政策学习");
|
||||||
test.skip("drag and drop resource classification", async ({ page }) => {
|
|
||||||
// Wait for resources to load
|
|
||||||
await expect(page.locator('[data-guide="course-resources"]')).toBeVisible();
|
|
||||||
|
|
||||||
// Find first resource card (资助政策微课)
|
|
||||||
const policyResource = page
|
|
||||||
.locator('[data-guide="course-resources"]')
|
|
||||||
.locator("text=资助政策微课")
|
|
||||||
.first();
|
|
||||||
await expect(policyResource).toBeVisible();
|
await expect(policyResource).toBeVisible();
|
||||||
|
|
||||||
// Find the policy-learning drop zone
|
|
||||||
const policyZone = page.locator("text=政策学习").first();
|
|
||||||
await expect(policyZone).toBeVisible();
|
await expect(policyZone).toBeVisible();
|
||||||
|
await dragWithPointer(page, policyResource, policyZone);
|
||||||
|
|
||||||
// Use Playwright's native dragTo which works better with dnd-kit
|
await expect(page.getByText("分类正确:资助政策微课")).toBeVisible();
|
||||||
await policyResource.dragTo(policyZone, {
|
await expect(page.getByText("已完成 1/6")).toBeVisible();
|
||||||
force: true,
|
await expect(policyResource).not.toBeVisible();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("wrong category shows error toast", async ({ page }) => {
|
test("wrong category shows error toast", async ({ page }) => {
|
||||||
await expect(page.locator('[data-guide="course-resources"]')).toBeVisible();
|
const policyResource = getResource(page, "资助政策微课");
|
||||||
|
const wrongZone = getCategory(page, "互动实践");
|
||||||
// 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();
|
|
||||||
|
|
||||||
await expect(policyResource).toBeVisible();
|
await expect(policyResource).toBeVisible();
|
||||||
await expect(wrongZone).toBeVisible();
|
await expect(wrongZone).toBeVisible();
|
||||||
|
await dragWithPointer(page, policyResource, wrongZone);
|
||||||
|
|
||||||
// Use Playwright's native dragTo
|
await expect(
|
||||||
await policyResource.dragTo(wrongZone, {
|
page.getByText("“资助政策微课”不属于这个分类,再想一想。"),
|
||||||
force: true,
|
).toBeVisible();
|
||||||
timeout: 10000,
|
await expect(page.getByText("已完成 0/6")).toBeVisible();
|
||||||
});
|
await expect(policyResource).toBeVisible();
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Error toast should appear (exact match with dynamic title)
|
|
||||||
await expect(page.locator("text=/不属于这个分类/")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completion overlay appears after all resources placed", async ({
|
test("completion overlay appears after all resources placed", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// This would require placing all 6 resources correctly, which is slow
|
const placements = [
|
||||||
// Instead we'll just verify the reset button works
|
["资助政策微课", "政策学习"],
|
||||||
await page.click("text=重新开始");
|
["申请流程图", "政策学习"],
|
||||||
await page.waitForTimeout(500);
|
["诚信申请案例", "互动实践"],
|
||||||
|
["材料自查清单", "互动实践"],
|
||||||
|
["常见问题答疑", "学习支持"],
|
||||||
|
["校园咨询渠道", "学习支持"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
// Toast should confirm reset
|
for (const [resourceTitle, categoryTitle] of placements) {
|
||||||
await expect(page.locator("text=练习已重置")).toBeVisible();
|
await dragWithPointer(
|
||||||
});
|
|
||||||
|
|
||||||
test.skip("persists progress (session fallback)", async ({
|
|
||||||
page,
|
page,
|
||||||
context,
|
getResource(page, resourceTitle),
|
||||||
}) => {
|
getCategory(page, categoryTitle),
|
||||||
// Place one resource
|
);
|
||||||
const resource = page
|
}
|
||||||
.locator('[data-guide="course-resources"]')
|
|
||||||
.locator("text=资助政策微课")
|
|
||||||
.first();
|
|
||||||
const zone = page.locator("text=政策学习").first();
|
|
||||||
|
|
||||||
await expect(resource).toBeVisible();
|
await expect(page.getByText("资源地图已建立")).toBeVisible();
|
||||||
await expect(zone).toBeVisible();
|
await expect(page.getByText("已完成 6/6")).toBeVisible();
|
||||||
|
await expect(page.getByText("100%")).toBeVisible();
|
||||||
// Use Playwright's native dragTo
|
|
||||||
await resource.dragTo(zone, {
|
|
||||||
force: true,
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Reload page
|
test("persists progress with session fallback", async ({ page }) => {
|
||||||
|
await dragWithPointer(
|
||||||
|
page,
|
||||||
|
getResource(page, "资助政策微课"),
|
||||||
|
getCategory(page, "政策学习"),
|
||||||
|
);
|
||||||
|
await expect(page.getByText("已完成 1/6")).toBeVisible();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await expect(page.getByText("进度已恢复")).toBeVisible();
|
||||||
await page.waitForTimeout(1500);
|
await expect(page.getByText("已完成 1/6")).toBeVisible();
|
||||||
|
await expect(getResource(page, "资助政策微课")).not.toBeVisible();
|
||||||
// Progress should show 1/6
|
|
||||||
await expect(page.locator("text=已完成 1/6")).toBeVisible();
|
|
||||||
await expect(page.locator("text=进度已恢复")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user