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 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) { export function getStudentTrainingAnswer(taskKey, params) {
if (isStudentDemo()) return Promise.resolve({ code: 200, data: demoResult(taskKey) });
return request({ return request({
url: `/api/student-training-answers/${taskKey}`, url: `/api/student-training-answers/${taskKey}`,
method: "get", method: "get",
@ -9,6 +15,11 @@ export function getStudentTrainingAnswer(taskKey, params) {
} }
export function saveStudentTrainingAnswer(taskKey, data) { 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({ return request({
url: `/api/student-training-answers/${taskKey}`, url: `/api/student-training-answers/${taskKey}`,
method: "post", method: "post",
@ -18,6 +29,7 @@ export function saveStudentTrainingAnswer(taskKey, data) {
} }
export function deleteStudentTrainingAnswer(taskKey, params) { export function deleteStudentTrainingAnswer(taskKey, params) {
if (isStudentDemo()) { sessionStorage.removeItem(getDemoAnswerKey(taskKey)); return Promise.resolve({ code: 200 }); }
return request({ return request({
url: `/api/student-training-answers/${taskKey}`, url: `/api/student-training-answers/${taskKey}`,
method: "delete", method: "delete",
@ -26,6 +38,7 @@ export function deleteStudentTrainingAnswer(taskKey, params) {
} }
export function uploadStudentTrainingFile(data) { export function uploadStudentTrainingFile(data) {
if (isStudentDemo()) return Promise.reject(new Error("演示模式不上传文件"));
return request({ return request({
url: "/common/upload", url: "/common/upload",
method: "post", method: "post",

@ -6,8 +6,9 @@
* @FilePath: \vue3\src\layout\components\AppMain.vue * @FilePath: \vue3\src\layout\components\AppMain.vue
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
--> -->
<template> <template>
<section :class="mainClass"> <section :class="mainClass">
<div v-if="isDemo" class="student-demo-banner">演示模式:{{ demoClassName }}<button @click="exitDemo">退</button></div>
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive :include="tagsViewStore.cachedViews"> <keep-alive :include="tagsViewStore.cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="route.path" /> <component v-if="!route.meta.link" :is="Component" :key="route.path" />
@ -23,12 +24,16 @@ import { dynamicRoutes } from "@/router";
import iframeToggle from "./IframeToggle/index"; import iframeToggle from "./IframeToggle/index";
import useTagsViewStore from "@/store/modules/tagsView"; import useTagsViewStore from "@/store/modules/tagsView";
import { getUserInfo } from "@/utils/auth"; import { getUserInfo } from "@/utils/auth";
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
import { clearStudentDemoSession, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
const userStore = useUserStore(); const userStore = useUserStore();
const tagsViewStore = useTagsViewStore(); const tagsViewStore = useTagsViewStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const role = ref(JSON.parse(getUserInfo()).roleId); 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(() => { const mainClass = computed(() => {
if (Number(role.value) === 4) return "app-main"; if (Number(role.value) === 4) return "app-main";
if (Number(role.value) === 3) return "teacher-main"; if (Number(role.value) === 3) return "teacher-main";
@ -81,6 +86,7 @@ function useTimeRecorder() {
} }
const { start, end, reset } = useTimeRecorder(); const { start, end, reset } = useTimeRecorder();
const getCurrentTeachingClassId = async () => { const getCurrentTeachingClassId = async () => {
if (isDemo.value) return getStudentDemoSession()?.userInfo?.demoTeachingClassId || "";
const cachedClassId = proxy.$cache.session.get("currentTeachingClassId"); const cachedClassId = proxy.$cache.session.get("currentTeachingClassId");
if (cachedClassId) return cachedClassId; if (cachedClassId) return cachedClassId;
const res = await indexApi.getCurrentTeachingClass({ userId: userStore.userInfo.userId }); const res = await indexApi.getCurrentTeachingClass({ userId: userStore.userInfo.userId });
@ -91,6 +97,7 @@ const getCurrentTeachingClassId = async () => {
return classId; return classId;
}; };
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
if (isDemo.value) { next(); return; }
const disabledTitles = proxy.$cache.session.getJSON("disabledTitles"); const disabledTitles = proxy.$cache.session.getJSON("disabledTitles");
const routess = Array.isArray(disabledTitles) ? 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: url("@/assets/images/背景.webp") no-repeat;
background-size: 100% 100%; 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 { .app-main2 {
min-height: calc(100vh - 50px); min-height: calc(100vh - 50px);
width: 100%; width: 100%;

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

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

@ -1,24 +1,28 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { clearStudentDemoSession, getStudentDemoSession, isStudentDemo } from "@/utils/studentDemo";
const TokenKey = "Admin-Token"; const TokenKey = "Admin-Token";
const user = "UserInfo"; const user = "UserInfo";
export function getToken() { export function getToken() {
return Cookies.get(TokenKey); return getStudentDemoSession()?.token || Cookies.get(TokenKey);
} }
export function setToken(token) { export function setToken(token) {
return Cookies.set(TokenKey, token); return Cookies.set(TokenKey, token);
} }
export function removeToken() { export function removeToken() {
return Cookies.remove(TokenKey); if (isStudentDemo()) return clearStudentDemoSession();
return Cookies.remove(TokenKey);
} }
export function setUserInfo(userinfo) { export function setUserInfo(userinfo) {
return Cookies.set(user, userinfo); return Cookies.set(user, userinfo);
} }
export function getUserInfo() { export function getUserInfo() {
return Cookies.get(user); const demo = getStudentDemoSession();
} return demo?.userInfo ? JSON.stringify(demo.userInfo) : Cookies.get(user);
export function removeUserInfo() { }
return Cookies.remove(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> <template>
<aside class="training-ai-evaluation" aria-label="AI"> <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"> <section class="evaluation-panel">
<div class="evaluation-panel__header"> <div class="evaluation-panel__header">
<div> <div>
@ -56,12 +58,14 @@
</ul> </ul>
</div> </div>
</section> </section>
</template>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation"; import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation";
import { normalizeEvaluationReports, normalizeOperation } from "./trainingAiEvaluationReport"; import { normalizeEvaluationReports, normalizeOperation } from "./trainingAiEvaluationReport";
import { isStudentDemo } from "@/utils/studentDemo";
const props = defineProps({ const props = defineProps({
taskKey: { type: String, default: "" }, taskKey: { type: String, default: "" },
@ -74,6 +78,7 @@ const helpLoading = ref(false);
const assessmentLoading = ref(false); const assessmentLoading = ref(false);
const helpError = ref(""); const helpError = ref("");
const assessmentError = ref(""); const assessmentError = ref("");
const isDemo = computed(() => isStudentDemo());
const normalizedReports = computed(() => normalizeEvaluationReports(evaluation.value)); const normalizedReports = computed(() => normalizeEvaluationReports(evaluation.value));
const help = computed(() => normalizedReports.value.help); 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>
<el-button v-if="selectedTeachingClass" type="primary" size="small" @click="openStudentDemo">
学生端讲解
</el-button>
</div> </div>
</div> </div>
</div> </div>
@ -221,6 +224,7 @@ import {
restoreClassTrainingTasksDefaults, restoreClassTrainingTasksDefaults,
uploadTrainingTaskMaterial, uploadTrainingTaskMaterial,
} from "@/api/trainingTask"; } from "@/api/trainingTask";
import { createStudentDemoSession } from "@/api/studentDemo";
import * as indexApi from "@/api/teacher"; import * as indexApi from "@/api/teacher";
import useUserStore from "@/store/modules/user"; 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) { async function confirmRestoreDefault(action) {
try { try {
await proxy?.$modal?.confirm( await proxy?.$modal?.confirm(

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