merge: add student demo window

dev-QQq
chenyuan 1 month ago
commit d3aaeb39b2

@ -0,0 +1,14 @@
import request from "@/utils/request";
export function createStudentDemoSession(teachingClassId) {
return request({ url: "/api/teacher/student-demo-sessions", method: "post", data: { teachingClassId } });
}
export function exchangeStudentDemoSession(ticket) {
return request({
url: "/api/student-demo-sessions/exchange",
method: "post",
data: { ticket },
headers: { isToken: false, repeatSubmit: false },
});
}

@ -1,6 +1,12 @@
import request from "@/utils/request";
import { getDemoAnswerKey, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
function demoResult(taskKey) {
try { return JSON.parse(sessionStorage.getItem(getDemoAnswerKey(taskKey)) || "null"); } catch (error) { return null; }
}
export function getStudentTrainingAnswer(taskKey, params) {
if (isStudentDemo()) return Promise.resolve({ code: 200, data: demoResult(taskKey) });
return request({
url: `/api/student-training-answers/${taskKey}`,
method: "get",
@ -9,6 +15,11 @@ export function getStudentTrainingAnswer(taskKey, params) {
}
export function saveStudentTrainingAnswer(taskKey, data) {
if (isStudentDemo()) {
const answer = { ...data, teachingClassId: getStudentDemoSession()?.userInfo?.demoTeachingClassId };
sessionStorage.setItem(getDemoAnswerKey(taskKey), JSON.stringify(answer));
return Promise.resolve({ code: 200, data: answer });
}
return request({
url: `/api/student-training-answers/${taskKey}`,
method: "post",
@ -18,6 +29,7 @@ export function saveStudentTrainingAnswer(taskKey, data) {
}
export function deleteStudentTrainingAnswer(taskKey, params) {
if (isStudentDemo()) { sessionStorage.removeItem(getDemoAnswerKey(taskKey)); return Promise.resolve({ code: 200 }); }
return request({
url: `/api/student-training-answers/${taskKey}`,
method: "delete",
@ -26,6 +38,7 @@ export function deleteStudentTrainingAnswer(taskKey, params) {
}
export function uploadStudentTrainingFile(data) {
if (isStudentDemo()) return Promise.reject(new Error("演示模式不上传文件"));
return request({
url: "/common/upload",
method: "post",

@ -8,6 +8,7 @@
-->
<template>
<section :class="mainClass">
<div v-if="isDemo" class="student-demo-banner">演示模式:{{ demoClassName }}<button @click="exitDemo">退</button></div>
<router-view v-slot="{ Component, route }">
<keep-alive :include="tagsViewStore.cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="route.path" />
@ -24,11 +25,15 @@ import iframeToggle from "./IframeToggle/index";
import useTagsViewStore from "@/store/modules/tagsView";
import { getUserInfo } from "@/utils/auth";
import useUserStore from "@/store/modules/user";
import { clearStudentDemoSession, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
const userStore = useUserStore();
const tagsViewStore = useTagsViewStore();
const route = useRoute();
const router = useRouter();
const role = ref(JSON.parse(getUserInfo()).roleId);
const isDemo = computed(() => isStudentDemo());
const demoClassName = computed(() => getStudentDemoSession()?.userInfo?.className || "教学班");
function exitDemo() { clearStudentDemoSession(); window.location.replace("/student-demo"); }
const mainClass = computed(() => {
if (Number(role.value) === 4) return "app-main";
if (Number(role.value) === 3) return "teacher-main";
@ -81,6 +86,7 @@ function useTimeRecorder() {
}
const { start, end, reset } = useTimeRecorder();
const getCurrentTeachingClassId = async () => {
if (isDemo.value) return getStudentDemoSession()?.userInfo?.demoTeachingClassId || "";
const cachedClassId = proxy.$cache.session.get("currentTeachingClassId");
if (cachedClassId) return cachedClassId;
const res = await indexApi.getCurrentTeachingClass({ userId: userStore.userInfo.userId });
@ -91,6 +97,7 @@ const getCurrentTeachingClassId = async () => {
return classId;
};
router.beforeEach(async (to, from, next) => {
if (isDemo.value) { next(); return; }
const disabledTitles = proxy.$cache.session.getJSON("disabledTitles");
const routess = Array.isArray(disabledTitles) ? disabledTitles : [];
//
@ -149,6 +156,7 @@ router.beforeEach(async (to, from, next) => {
background: url("@/assets/images/背景.webp") no-repeat;
background-size: 100% 100%;
}
.student-demo-banner{position:sticky;top:0;z-index:30;display:flex;justify-content:space-between;padding:8px 18px;color:#fff;background:#b45309}.student-demo-banner button{border:0;border-radius:4px;padding:3px 10px;cursor:pointer}
.app-main2 {
min-height: calc(100vh - 50px);
width: 100%;

@ -18,7 +18,7 @@ import useSettingsStore from "@/store/modules/settings";
import usePermissionStore from "@/store/modules/permission";
NProgress.configure({ showSpinner: false });
const whiteList = ["/login", "/register"];
const whiteList = ["/login", "/register", "/student-demo"];
router.beforeEach((to, from, next) => {
NProgress.start();

@ -134,6 +134,11 @@ export const dynamicRoutes = [
},
],
},
{
path: "/student-demo",
component: () => import("@/views/studentDemo/index.vue"),
hidden: true,
},
// 互联网产品开发基础
{
path: "/foundation",

@ -1,9 +1,10 @@
import Cookies from "js-cookie";
import { clearStudentDemoSession, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
const TokenKey = "Admin-Token";
const user = "UserInfo";
export function getToken() {
return Cookies.get(TokenKey);
return getStudentDemoSession()?.token || Cookies.get(TokenKey);
}
export function setToken(token) {
@ -11,14 +12,17 @@ export function setToken(token) {
}
export function removeToken() {
if (isStudentDemo()) return clearStudentDemoSession();
return Cookies.remove(TokenKey);
}
export function setUserInfo(userinfo) {
return Cookies.set(user, userinfo);
}
export function getUserInfo() {
return Cookies.get(user);
const demo = getStudentDemoSession();
return demo?.userInfo ? JSON.stringify(demo.userInfo) : Cookies.get(user);
}
export function removeUserInfo() {
if (isStudentDemo()) return clearStudentDemoSession();
return Cookies.remove(user);
}

@ -0,0 +1,30 @@
export const STUDENT_DEMO_STORAGE_KEY = "student-demo-session";
export function getStudentDemoSession() {
try {
const value = sessionStorage.getItem(STUDENT_DEMO_STORAGE_KEY);
return value ? JSON.parse(value) : null;
} catch (error) {
return null;
}
}
export function isStudentDemo() {
return Boolean(getStudentDemoSession()?.token);
}
export function saveStudentDemoSession(login) {
sessionStorage.setItem(STUDENT_DEMO_STORAGE_KEY, JSON.stringify({
token: login.token,
userInfo: login,
expiresAt: Date.now() + 30 * 60 * 1000,
}));
}
export function clearStudentDemoSession() {
sessionStorage.removeItem(STUDENT_DEMO_STORAGE_KEY);
}
export function getDemoAnswerKey(taskKey) {
return `student-demo-answer:${getStudentDemoSession()?.userInfo?.demoTeachingClassId || ""}:${taskKey}`;
}

@ -1,5 +1,7 @@
<template>
<aside class="training-ai-evaluation" aria-label="AI">
<section v-if="isDemo" class="evaluation-panel"><h3>演示模式</h3><p class="evaluation-panel__hint">AI </p></section>
<template v-else>
<section class="evaluation-panel">
<div class="evaluation-panel__header">
<div>
@ -56,12 +58,14 @@
</ul>
</div>
</section>
</template>
</aside>
</template>
<script setup>
import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation";
import { normalizeEvaluationReports, normalizeOperation } from "./trainingAiEvaluationReport";
import { isStudentDemo } from "@/utils/studentDemo";
const props = defineProps({
taskKey: { type: String, default: "" },
@ -74,6 +78,7 @@ const helpLoading = ref(false);
const assessmentLoading = ref(false);
const helpError = ref("");
const assessmentError = ref("");
const isDemo = computed(() => isStudentDemo());
const normalizedReports = computed(() => normalizeEvaluationReports(evaluation.value));
const help = computed(() => normalizedReports.value.help);

@ -0,0 +1,28 @@
<template>
<main class="student-demo-entry"><p>{{ message }}</p></main>
</template>
<script setup>
import { exchangeStudentDemoSession } from "@/api/studentDemo";
import { saveStudentDemoSession } from "@/utils/studentDemo";
const message = ref("正在连接教师演示会话…");
function receiveTicket(event) {
if (event.origin !== window.location.origin || event.source !== window.opener || event.data?.type !== "STUDENT_DEMO_TICKET") return;
exchangeStudentDemoSession(event.data.ticket).then((res) => {
saveStudentDemoSession(res.data);
window.removeEventListener("message", receiveTicket);
window.location.replace("/index");
}).catch(() => { message.value = "演示链接已失效,请返回教师端重新打开。"; });
}
onMounted(() => {
if (!window.opener) { message.value = "演示链接已失效,请返回教师端重新打开。"; return; }
window.addEventListener("message", receiveTicket);
window.opener.postMessage({ type: "STUDENT_DEMO_READY" }, window.location.origin);
});
onBeforeUnmount(() => window.removeEventListener("message", receiveTicket));
</script>
<style scoped>.student-demo-entry{display:grid;min-height:100vh;place-items:center;background:#06111d;color:#dff7ff;font-size:18px}</style>

@ -35,6 +35,9 @@
>
恢复本班全部默认任务
</el-button>
<el-button v-if="selectedTeachingClass" type="primary" size="small" @click="openStudentDemo">
学生端讲解
</el-button>
</div>
</div>
</div>
@ -221,6 +224,7 @@ import {
restoreClassTrainingTasksDefaults,
uploadTrainingTaskMaterial,
} from "@/api/trainingTask";
import { createStudentDemoSession } from "@/api/studentDemo";
import * as indexApi from "@/api/teacher";
import useUserStore from "@/store/modules/user";
@ -531,6 +535,26 @@ async function restoreAllTasksDefaults() {
}
}
async function openStudentDemo() {
if (!selectedTeachingClass.value) return;
const child = window.open("/student-demo", "_blank");
if (!child) { proxy?.$modal?.msgError?.("浏览器拦截了演示窗口,请允许弹窗后重试"); return; }
try {
const res = await createStudentDemoSession(selectedTeachingClassId.value);
const ticket = res?.data?.ticket;
const listener = (event) => {
if (event.origin !== window.location.origin || event.source !== child || event.data?.type !== "STUDENT_DEMO_READY") return;
child.postMessage({ type: "STUDENT_DEMO_TICKET", ticket }, window.location.origin);
window.removeEventListener("message", listener);
};
window.addEventListener("message", listener);
window.setTimeout(() => window.removeEventListener("message", listener), 60 * 1000);
} catch (error) {
child.close();
proxy?.$modal?.msgError?.("无法创建学生端讲解会话");
}
}
async function confirmRestoreDefault(action) {
try {
await proxy?.$modal?.confirm(

@ -85,7 +85,7 @@
<p class="upload-tip">{{ page.processUploadTip }}</p>
<div class="upload-action">
<input ref="uploadInputRef" class="hidden-file-input" type="file" accept="image/*,.xlsx,.xls,.pdf" @change="handleProcessFileChange" />
<button type="button" class="upload-btn" :disabled="uploading" @click="triggerUpload">
<button type="button" class="upload-btn" :disabled="uploading || isDemo" @click="triggerUpload">
<el-icon><Upload /></el-icon>
{{ uploading ? "上传中..." : "上传产品开发全流程甘特图" }}
</button>
@ -173,11 +173,13 @@ import { getStudentTrainingPage } from "./studentTrainingPages";
import TrainingAiEvaluation from "@/views/components/TrainingAiEvaluation.vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { isStudentDemo } from "@/utils/studentDemo";
const route = useRoute();
const { proxy } = getCurrentInstance();
const pageKey = computed(() => route.meta?.pageKey || route.params?.pageKey);
const isDemo = computed(() => isStudentDemo());
const isProductDevelopmentProcess = computed(() => pageKey.value === "product-development-process");
const remoteTask = ref(null);
const page = computed(() => mergeTrainingPage(getStudentTrainingPage(pageKey.value), remoteTask.value));
@ -423,6 +425,7 @@ async function resetTraining() {
}
function triggerUpload() {
if (isDemo.value) { proxy?.$modal?.msgWarning?.("演示模式不上传文件"); return; }
uploadInputRef.value?.click();
}

@ -0,0 +1,24 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const auth = read("src/utils/auth.js");
const demoStorage = read("src/utils/studentDemo.js");
const api = read("src/api/studentDemo.js");
const teacherPage = read("src/views/teacherEnd/trainingTask/index.vue");
const demoPage = read("src/views/studentDemo/index.vue");
assert(auth.includes("getStudentDemoSession") && demoStorage.includes("sessionStorage"), "demo token must use tab-local session storage");
assert(api.includes("/api/teacher/student-demo-sessions"), "teacher ticket API missing");
assert(api.includes("/api/student-demo-sessions/exchange"), "ticket exchange API missing");
assert(teacherPage.includes("学生端讲解"), "teacher entry missing");
assert(teacherPage.includes("window.open("), "teacher entry must open a new window");
assert(teacherPage.includes("STUDENT_DEMO_READY"), "teacher must wait for child readiness");
assert(demoPage.includes("STUDENT_DEMO_TICKET"), "demo page must receive ticket via postMessage");
assert(demoPage.includes("window.location.origin"), "postMessage must validate origin");
assert(!teacherPage.includes("ticket="), "ticket must not be sent in URL");
console.log("student demo mode front-end contract passed");
Loading…
Cancel
Save