merge: 合并dev-QQq分支(改密码/学生端演示/分页样式/考试编辑器调整)到master

fix/exam-question-access-material-excel-delete-draft
陈沅 2 weeks ago
commit b26bf7a429

@ -496,13 +496,13 @@ aside {
// color: #eef5ff !important;
// }
/* === 学生端分页:墨黑极简(覆盖上方裸深色 !important 规则) === */
/* === 学生端分页:科技蓝(与学生端亮色主题一致,覆盖上方裸深色 !important 规则) === */
#app .studentLayout .pagination-container .el-pagination,
#app .studentLayout .el-pagination {
--el-pagination-bg-color: transparent !important;
--el-pagination-button-bg-color: #ffffff !important;
--el-pagination-hover-color: #1f2937 !important;
--el-pagination-button-disabled-bg-color: #ffffff !important;
--el-pagination-hover-color: #2563eb !important;
--el-pagination-button-disabled-bg-color: #f9fafb !important;
--el-pagination-button-disabled-color: #9ca3af !important;
color: #4b5563 !important;
}
@ -517,21 +517,30 @@ aside {
border-radius: 8px !important;
background-color: #ffffff !important;
color: #374151 !important;
font-weight: 600;
font-weight: 500;
}
/* 悬浮:软蓝浅底 + 蓝字蓝边 */
#app .studentLayout .pagination-container .el-pagination .el-pager li:hover,
#app .studentLayout .el-pager li:hover,
#app .studentLayout .el-pagination button:hover {
border-color: #1f2937 !important;
background-color: #f9fafb !important;
color: #1f2937 !important;
#app .studentLayout .el-pagination button:hover:not(:disabled) {
border-color: #2563eb !important;
background-color: #eff6ff !important;
color: #2563eb !important;
}
/* 当前页:实心教育蓝(与学生端主按钮同色系) */
#app .studentLayout .pagination-container .el-pagination .el-pager li.is-active,
#app .studentLayout .el-pager li.is-active {
border-color: #1f2937 !important;
background: #1f2937 !important;
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
}
#app .studentLayout .pagination-container .el-pagination .el-pager li.is-active:hover,
#app .studentLayout .el-pager li.is-active:hover {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
color: #ffffff !important;
}
@ -539,8 +548,8 @@ aside {
#app .studentLayout .pagination-container .el-pagination .btn-next:disabled,
#app .studentLayout .el-pagination .btn-prev:disabled,
#app .studentLayout .el-pagination .btn-next:disabled {
background-color: #ffffff !important;
color: #9ca3af !important;
background-color: #f9fafb !important;
color: #b0c0d0 !important;
border-color: #e5e7eb !important;
}
@ -550,14 +559,23 @@ aside {
font-weight: 400;
}
#app .studentLayout .el-pagination .el-select .el-input__wrapper {
/* 每页条数下拉 + 跳页输入框:白底灰边,hover/focus 教育蓝 */
#app .studentLayout .el-pagination .el-input__wrapper {
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
box-shadow: none !important;
}
#app .studentLayout .el-pagination .el-select .el-input__inner {
#app .studentLayout .el-pagination .el-input__wrapper:hover {
border-color: #3b82f6 !important;
}
#app .studentLayout .el-pagination .el-input__wrapper.is-focus {
border-color: #2563eb !important;
}
#app .studentLayout .el-pagination .el-input__inner {
color: #374151 !important;
}

@ -98,6 +98,13 @@
</div>
<div class="dropdown-divider"></div>
<el-dropdown-menu class="dropdown-menu-custom">
<el-dropdown-item divided command="changePassword" class="dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<span>修改密码</span>
</el-dropdown-item>
<el-dropdown-item divided command="logout" class="dropdown-item logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
@ -112,6 +119,7 @@
</el-dropdown>
</div>
</div>
<ChangePasswordDialog v-model="changePasswordVisible" @success="onPasswordChanged" />
</div>
</template>
@ -126,6 +134,7 @@ import SizeSelect from "@/components/SizeSelect";
import HeaderSearch from "@/components/HeaderSearch";
import RuoYiGit from "@/components/RuoYi/Git";
import RuoYiDoc from "@/components/RuoYi/Doc";
import ChangePasswordDialog from "@/views/profile/ChangePasswordDialog.vue";
import useAppStore from "@/store/modules/app";
import useUserStore from "@/store/modules/user";
import useSettingsStore from "@/store/modules/settings";
@ -145,6 +154,7 @@ const roleText = computed(() => {
});
const studentTopTask = ref(null);
const changePasswordVisible = ref(false);
const mentList = ref([
{ name: "首页", path: "/index", icon: HomeFilled },
@ -177,6 +187,9 @@ function toggleSideBar() {
function handleCommand(command) {
switch (command) {
case "changePassword":
changePasswordVisible.value = true;
break;
case "logout":
logout();
break;
@ -185,6 +198,27 @@ function handleCommand(command) {
}
}
function onPasswordChanged() {
// 密码修改成功后,引导用户重新登录,避免旧 token 仍持有访问权限
setTimeout(() => {
ElMessageBox.confirm(
"密码已更新,建议立即重新登录。是否现在退出?",
"提示",
{
confirmButtonText: "立即退出",
cancelButtonText: "稍后再说",
type: "warning",
}
)
.then(() => {
userStore.logOut().then(() => {
location.href = "/index";
});
})
.catch(() => {});
}, 200);
}
function logout() {
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
confirmButtonText: "确定",

@ -270,7 +270,7 @@ const updateChart = () => {
.map((item) => {
if (item.value) {
console.log(item);
const [marketImportance, itemImportance] = parseCoordinates(item.basedOn, item.categoryType);
console.log(item);
return {

@ -94,26 +94,37 @@
<section class="intro-card compact">
<p class="step-caption">Step 02</p>
<h2>为供应渠道评分并比较结果</h2>
<p>每项评分为 0–10 分,渠道名称和评分均由学生填写;系统会按第一步已保存的权重自动计算加权总分。</p>
<p>每项评分为 0–10 分,渠道名称与评分由学生填写;系统按第一步已保存的试产/量产权重分别计算两个加权总分。</p>
</section>
<section v-for="section in scoreSections" :key="section.key" class="score-section">
<section class="score-section">
<div class="section-heading">
<div><span>{{ section.label }}</span><h3>{{ section.title }}</h3></div>
<small>加权总分 = Σ(维度评分 × 权重)</small>
<div><span>渠道评分</span><h3>供应渠道评分与加权对比</h3></div>
<small>试产加权总分 = Σ(评分 × 试产权重) · 量产加权总分 = Σ(评分 × 量产权重)</small>
</div>
<div class="table-scroll">
<table class="training-table score-table">
<table class="training-table score-table merged-score-table">
<thead>
<tr><th>渠道</th><th v-for="dimension in rows" :key="dimension.key">{{ dimension.name }}</th><th>加权总分</th></tr>
<tr class="group-header">
<th rowspan="2" class="col-channel">渠道</th>
<th :colspan="rows.length" class="group-dimensions">维度评分(0–10 分)</th>
<th class="group-total group-total-small">试产加权总分</th>
<th class="group-total group-total-large">量产加权总分</th>
</tr>
<tr>
<th v-for="dimension in rows" :key="dimension.key" class="col-dimension">{{ dimension.name }}</th>
<th class="col-total col-total-small">Σ × 试产权重</th>
<th class="col-total col-total-large">Σ × 量产权重</th>
</tr>
</thead>
<tbody>
<tr v-for="row in section.rows" :key="row.id">
<td><el-input v-model.trim="row.channel" maxlength="40" placeholder="填写渠道名称" :disabled="section.key === 'large'" @input="section.key === 'small' && syncChannelNames()" /></td>
<td v-for="dimension in rows" :key="dimension.key">
<tr v-for="row in channelRows" :key="row.id">
<td class="col-channel"><el-input v-model.trim="row.channel" maxlength="40" placeholder="填写渠道名称" /></td>
<td v-for="dimension in rows" :key="dimension.key" class="col-dimension">
<el-input-number v-model="scoreFor(row, dimension.key).score" :min="0" :max="10" :precision="0" controls-position="right" />
</td>
<td class="total-score">{{ weightedScore(row, section.weights) }}</td>
<td class="total-score col-total col-total-small">{{ weightedScore(row, 'smallBatch') }}</td>
<td class="total-score col-total col-total-large">{{ weightedScore(row, 'largeBatch') }}</td>
</tr>
</tbody>
</table>
@ -268,15 +279,18 @@ function normalizeScores(source = {}) {
}, {});
}
function createScoreRows(savedRows = []) {
const source = Array.isArray(savedRows) ? savedRows : [];
function createChannelRows(smallSaved = [], largeSaved = []) {
const small = Array.isArray(smallSaved) ? smallSaved : [];
const large = Array.isArray(largeSaved) ? largeSaved : [];
return Array.from({ length: 5 }, (_, index) => {
const saved = source[index] || {};
const s = small[index] || {};
const l = large[index] || {};
return {
id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`,
channel: String(saved.channel || ""),
scores: normalizeScores(saved.scores),
weightedTotal: saved.weightedTotal ?? null,
channel: String(s.channel || l.channel || ""),
scores: normalizeScores(s.scores ?? l.scores),
smallWeightedTotal: s.weightedTotal ?? null,
largeWeightedTotal: l.weightedTotal ?? null,
};
});
}
@ -302,8 +316,7 @@ function createRiskRows(savedRows = []) {
}));
}
const smallBatchRows = ref(createScoreRows());
const largeBatchRows = ref(createScoreRows());
const channelRows = ref(createChannelRows());
const scenarioAnalyses = ref(createScenarioAnalyses());
const procurementRecommendation = ref("");
const riskRows = ref(createRiskRows());
@ -312,16 +325,12 @@ const smallWeightTotal = computed(() => rows.value.reduce((sum, row) => sum + Nu
const largeWeightTotal = computed(() => rows.value.reduce((sum, row) => sum + Number(row.largeBatchWeight || 0), 0));
const smallWeights = computed(() => rows.value.reduce((result, row) => ({ ...result, [row.key]: Number(row.smallBatchWeight || 0) }), {}));
const largeWeights = computed(() => rows.value.reduce((result, row) => ({ ...result, [row.key]: Number(row.largeBatchWeight || 0) }), {}));
const scoreSections = computed(() => [
{ key: "small", label: "场景 A", title: "小批量试产(100台)渠道评分", rows: smallBatchRows.value, weights: smallWeights.value },
{ key: "large", label: "场景 B", title: "大规模量产(10000台)渠道评分", rows: largeBatchRows.value, weights: largeWeights.value },
]);
function scoreFor(row, key) {
if (!Object.prototype.hasOwnProperty.call(row.scores, key)) row.scores[key] = 0;
return {
get score() { return row.scores[key]; },
set score(value) { row.scores[key] = value; row.weightedTotal = null; },
set score(value) { row.scores[key] = value; row.smallWeightedTotal = null; row.largeWeightedTotal = null; },
};
}
@ -330,18 +339,18 @@ function toNumber(value) {
return Number.isFinite(numeric) ? numeric : 0;
}
function weightedScore(row, weights) {
function weightedScore(row, scenario) {
const weights = scenario === "smallBatch" ? smallWeights.value : largeWeights.value;
const score = rows.value.reduce((sum, item) => sum + toNumber(row.scores[item.key]) * toNumber(weights[item.key]) / 100, 0);
return score.toFixed(2);
}
function channelRanking(scenario) {
const source = scenario === "smallBatch" ? smallBatchRows.value : largeBatchRows.value;
const weights = scenario === "smallBatch" ? smallWeights.value : largeWeights.value;
return source
const storedKey = scenario === "smallBatch" ? "smallWeightedTotal" : "largeWeightedTotal";
return channelRows.value
.map((row) => {
const stored = Number(row.weightedTotal);
return { channel: String(row.channel || "").trim(), score: Number.isFinite(stored) ? stored : Number(weightedScore(row, weights)) };
const stored = Number(row[storedKey]);
return { channel: String(row.channel || "").trim(), score: Number.isFinite(stored) ? stored : Number(weightedScore(row, scenario)) };
})
.filter((item) => item.channel)
.sort((left, right) => right.score - left.score || left.channel.localeCompare(right.channel, "zh-CN"));
@ -356,12 +365,6 @@ function formatScore(value) {
return Number.isFinite(score) ? score.toFixed(2) : "--";
}
function syncChannelNames() {
largeBatchRows.value.forEach((row, index) => {
row.channel = String(smallBatchRows.value[index]?.channel || "").trim();
});
}
function buildStepOnePayload() {
return {
items: rows.value.map((row) => ({
@ -373,16 +376,17 @@ function buildStepOnePayload() {
}
function buildStepTwoPayload() {
const serializeChannels = (source) => source.map((row) => ({
const serializeChannels = (storedKey) => channelRows.value.map((row) => ({
channel: String(row.channel || "").trim(),
scores: rows.value.map((dimension) => ({
dimension: dimension.name,
score: toNumber(row.scores[dimension.key]),
})),
weightedTotal: row[storedKey],
}));
return {
smallBatchChannels: serializeChannels(smallBatchRows.value),
largeBatchChannels: serializeChannels(largeBatchRows.value),
smallBatchChannels: serializeChannels("smallWeightedTotal"),
largeBatchChannels: serializeChannels("largeWeightedTotal"),
};
}
@ -421,9 +425,7 @@ function applyStepOne(answer) {
function applyStepTwo(answer) {
const result = parseJson(answer?.step2Answer);
if (!Array.isArray(result?.smallBatchChannels) || !Array.isArray(result?.largeBatchChannels)) return false;
smallBatchRows.value = createScoreRows(result.smallBatchChannels);
largeBatchRows.value = createScoreRows(result.largeBatchChannels);
syncChannelNames();
channelRows.value = createChannelRows(result.smallBatchChannels, result.largeBatchChannels);
return true;
}
@ -451,14 +453,11 @@ async function saveStepOne() {
async function saveStepTwo() {
try {
syncChannelNames();
const result = (await validateSupplierEvaluationStepTwo(buildStepTwoPayload()))?.data || {};
if (!result?.valid) throw new Error(result?.message || "渠道评分校验未通过");
smallBatchRows.value = createScoreRows(result.smallBatchChannels);
largeBatchRows.value = createScoreRows(result.largeBatchChannels);
channelRows.value = createChannelRows(result.smallBatchChannels, result.largeBatchChannels);
await saveStudentTrainingAnswer(TASK_KEY, { currentStep: 2, saveAction: "SAVE", progressStatus: "IN_PROGRESS", step2Answer: JSON.stringify({ weights: result.weights, smallBatchChannels: result.smallBatchChannels, largeBatchChannels: result.largeBatchChannels }) });
stepTwoSaved.value = true;
syncChannelNames();
ElMessage.success("渠道评分已保存,可进入第 3 步形成采购建议");
} catch (error) {
ElMessage.error(error?.message || "保存失败,请检查渠道和评分");
@ -878,6 +877,35 @@ onMounted(async () => {
.score-table th:first-child, .score-table td:first-child { width: 175px; }
.score-table :deep(.el-input-number) { width: 100%; }
/* 合并评分表:双列组布局 */
.merged-score-table { min-width: 1080px; table-layout: fixed; }
.merged-score-table .col-channel { width: 150px; }
.merged-score-table .col-dimension { width: auto; }
.merged-score-table .col-total { width: 110px; }
/* 顶层分组表头:维度组中性底,总分组着色 */
.merged-score-table .group-header th {
text-align: center;
font-size: var(--student-fs-body-sm);
}
.merged-score-table .group-dimensions {
background: #fafbfc;
color: var(--student-text);
}
.merged-score-table .group-total { color: #ffffff; font-weight: 800; }
.merged-score-table .group-total-small { background: var(--student-accent); }
.merged-score-table .group-total-large { background: var(--student-accent-deep); }
/* 第二行子表头 */
.merged-score-table .col-dimension,
.merged-score-table .col-total { background: #fafbfc; }
.merged-score-table .col-total-small { color: var(--student-accent); }
.merged-score-table .col-total-large { color: var(--student-accent-deep); }
/* 列组竖向分隔线 */
.merged-score-table .col-channel,
.merged-score-table .col-total-small { border-right: 2px solid var(--student-panel-border); }
.total-score {
color: var(--student-accent) !important;
font-size: var(--student-fs-data);

@ -0,0 +1,266 @@
<!--
修改密码弹窗
- 接收父组件的 open()/close() 控制
- 调用 updatePassword 接口
- 密码用 jsencrypt 的 RSA 加密(与登录一致)
-->
<template>
<el-dialog
:model-value="modelValue"
title="修改密码"
width="480px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="!submitting"
:before-close="handleClose"
class="change-password-dialog"
@update:model-value="onModelUpdate"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="change-password-form"
@submit.prevent
>
<el-form-item label="账号">
<el-input :model-value="accountDisplay" disabled />
</el-form-item>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="form.oldPassword"
type="password"
show-password
clearable
placeholder="请输入当前密码"
autocomplete="current-password"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="form.newPassword"
type="password"
show-password
clearable
placeholder="请输入新密码(6-20 位)"
autocomplete="new-password"
/>
<div class="pwd-strength" :class="`pwd-strength-${strength.level}`">
<span class="pwd-strength-bar"></span>
<span class="pwd-strength-bar"></span>
<span class="pwd-strength-bar"></span>
<span class="pwd-strength-text">强度:{{ strength.label }}</span>
</div>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
show-password
clearable
placeholder="请再次输入新密码"
autocomplete="new-password"
@keyup.enter="submit"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose" :disabled="submitting">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确认修改</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { updatePassword } from "@/api/teacher";
import { encrypt } from "@/utils/jsencrypt";
import useUserStore from "@/store/modules/user";
const props = defineProps({
modelValue: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "success"]);
const userStore = useUserStore();
const accountDisplay = computed(() => {
const info = userStore.userInfo || {};
return info.userName || info.name || info.username || "-";
});
const currentUserId = computed(() => userStore.userInfo?.userId || userStore.userInfo?.id);
const initialForm = () => ({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
const form = reactive(initialForm());
const formRef = ref(null);
const submitting = ref(false);
const validateNewPassword = (rule, value, cb) => {
if (value === "") {
cb(new Error("请输入新密码"));
} else if (value.length < 6 || value.length > 20) {
cb(new Error("新密码长度应为 6-20 位"));
} else if (/\s/.test(value)) {
cb(new Error("新密码不能包含空格"));
} else if (form.oldPassword && value === form.oldPassword) {
cb(new Error("新密码不能与原密码相同"));
} else {
cb();
}
};
const validateConfirm = (rule, value, cb) => {
if (value === "") {
cb(new Error("请再次输入新密码"));
} else if (value !== form.newPassword) {
cb(new Error("两次输入的新密码不一致"));
} else {
cb();
}
};
const rules = {
oldPassword: [
{ required: true, message: "请输入原密码", trigger: "blur" },
{ min: 1, max: 50, message: "原密码长度不正确", trigger: "blur" },
],
newPassword: [
{ required: true, validator: validateNewPassword, trigger: "blur" },
{ required: true, validator: validateNewPassword, trigger: "change" },
],
confirmPassword: [
{ required: true, validator: validateConfirm, trigger: "blur" },
{ required: true, validator: validateConfirm, trigger: "change" },
],
};
// 简单密码强度评估:仅 UI 提示,不参与校验
const strength = computed(() => {
const pwd = form.newPassword || "";
if (!pwd) return { level: 0, label: "-" };
let score = 0;
if (pwd.length >= 8) score += 1;
if (/[A-Z]/.test(pwd)) score += 1;
if (/[a-z]/.test(pwd)) score += 1;
if (/[0-9]/.test(pwd)) score += 1;
if (/[^A-Za-z0-9]/.test(pwd)) score += 1;
// 把分值收敛到 0/1/2 三档
if (score <= 2) return { level: 1, label: "弱" };
if (score <= 3) return { level: 2, label: "中" };
return { level: 3, label: "强" };
});
watch(
() => props.modelValue,
(val) => {
if (val) {
// 每次打开都重置表单,避免上一次的输入残留
Object.assign(form, initialForm());
submitting.value = false;
formRef.value?.clearValidate();
}
}
);
function onModelUpdate(val) {
emit("update:modelValue", val);
}
function handleClose() {
if (submitting.value) return;
emit("update:modelValue", false);
}
async function submit() {
if (!currentUserId.value) {
ElMessage.error("未获取到当前用户信息,请重新登录后再试");
return;
}
try {
await formRef.value?.validate();
} catch {
return;
}
submitting.value = true;
try {
const params = {
userId: currentUserId.value,
oldPasswordEncode: encrypt(form.oldPassword),
newPasswordEncode: encrypt(form.newPassword),
};
await updatePassword(params);
ElMessage.success("密码修改成功,请使用新密码重新登录");
emit("success");
emit("update:modelValue", false);
} catch (err) {
// 错误已经在 axios 拦截器里统一提示过,这里只打印方便排查
console.error("[updatePassword] failed:", err);
} finally {
submitting.value = false;
}
}
</script>
<style lang="scss" scoped>
.change-password-form {
padding: 4px 4px 0;
:deep(.el-form-item__label) {
font-weight: 500;
color: #303133;
padding-bottom: 4px;
}
:deep(.el-input.is-disabled .el-input__wrapper) {
background: #f5f7fa;
box-shadow: none;
}
}
.pwd-strength {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 12px;
color: #909399;
.pwd-strength-bar {
flex: 1;
height: 4px;
border-radius: 2px;
background: #ebeef5;
transition: background-color 0.2s ease;
}
.pwd-strength-text {
flex: none;
min-width: 56px;
text-align: right;
}
&.pwd-strength-1 .pwd-strength-bar:nth-child(1) {
background: #f56c6c;
}
&.pwd-strength-2 .pwd-strength-bar:nth-child(1),
&.pwd-strength-2 .pwd-strength-bar:nth-child(2) {
background: #e6a23c;
}
&.pwd-strength-3 .pwd-strength-bar:nth-child(1),
&.pwd-strength-3 .pwd-strength-bar:nth-child(2),
&.pwd-strength-3 .pwd-strength-bar:nth-child(3) {
background: #67c23a;
}
}
</style>

@ -7,6 +7,23 @@ import { exchangeStudentDemoSession } from "@/api/studentDemo";
import { saveStudentDemoSession } from "@/utils/studentDemo";
import useUserStore from "@/store/modules/user";
// 学生端演示登录态的角色:固定为学生 (roleId === 4),避免后端返回
// 教师身份导致跳转到教师首页。后端若返回 demoTeachingClassId,会被
// 透传保留,登录页/Navbar/AppMain 可据此识别为"演示学生"。
const STUDENT_DEMO_ROLE_ID = "4";
function unwrapDemoPayload(payload) {
if (!payload || typeof payload !== "object") {
return { userInfo: { roleId: STUDENT_DEMO_ROLE_ID }, token: "" };
}
// 兼容两种返回结构:
// 1) { token, roleId, userId, ... } —— 扁平结构
// 2) { token, userInfo: { roleId, ... } } —— userInfo 嵌套
const inner = payload.userInfo && typeof payload.userInfo === "object" ? payload.userInfo : payload;
const token = inner.token || payload.token || "";
return { userInfo: { ...inner, token, roleId: STUDENT_DEMO_ROLE_ID }, token };
}
const userStore = useUserStore();
const message = ref("正在连接教师演示会话…");
let readyTimer;
@ -15,15 +32,36 @@ function notifyReady() {
if (window.opener) window.opener.postMessage({ type: "STUDENT_DEMO_READY" }, window.location.origin);
}
function applyDemoSession(demoPayload) {
const { userInfo, token } = unwrapDemoPayload(demoPayload);
// 保存到 sessionStorage(getUserInfo/getToken 优先从这里读取)
// 注意:演示 session 只放在新窗口的 sessionStorage,避免覆盖
// 教师主窗口的 cookie/sessionStorage 而把教师侧误切到学生身份。
saveStudentDemoSession(userInfo);
// 立即更新 Pinia,避免 reload 之前出现教师内容闪烁
userStore.token = token;
userStore.userInfo = userInfo;
}
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);
userStore.token = res.data.token;
userStore.userInfo = res.data;
if (!res || !res.data) {
message.value = "演示服务返回数据为空,请稍后重试";
return;
}
applyDemoSession(res.data);
window.clearInterval(readyTimer);
window.removeEventListener("message", receiveTicket);
window.location.hash = "#/index";
// 跳转学生端基础课首页 #/foundation/new-product-survey:
// roleId 已被改写为 4,filterDynamicRoutes 会加载 student 路由,
// 避开 /index 的角色分发逻辑(role==='3' 会落到 <teacher/>),
// 强制落到学生工作台,避免任何教师侧兜底。
const targetHash = "#/foundation/new-product-survey";
if (window.location.hash !== targetHash) {
// 先改 hash 再 reload,避免仅 hash 变化时浏览器不重新加载
window.location.hash = targetHash;
}
window.location.reload();
}).catch(() => { message.value = "演示链接已失效,请返回教师端重新打开。"; });
}

@ -26,24 +26,12 @@
</el-form>
<div class="score-table-wrap">
<table class="score-table">
<thead><tr><th>题型</th><th>题库题目数量</th><th>选题数量</th><th>每题分值</th><th>小计</th></tr></thead>
<thead><tr><th>题型</th><th>题库题目数量</th><th>选题数量</th><th>小计</th></tr></thead>
<tbody>
<tr v-for="item in typeOptions" :key="item.value">
<td>{{ item.label }}</td>
<td>{{ questionCount(item.value) }}</td>
<td>{{ selectedByType(item.value) }}</td>
<td>
<el-input-number
class="type-score-input"
:model-value="scorePerQuestion(item.value)"
:min="0.5"
:precision="1"
:step="0.5"
controls-position="right"
size="small"
@change="setTypeScore(item.value, $event)"
/>
</td>
<td>{{ scoreByType(item.value) }}</td>
</tr>
</tbody>
@ -130,10 +118,6 @@
<div class="q-card-head">
<span class="q-card-no">{{ idx + 1 }}</span>
<span class="q-card-type">{{ typeOptions.find(t => t.value === question.type)?.label || question.type }}</span>
<span v-if="isSelected(question.id)" class="q-score-chip" @click.stop>
<el-input-number :model-value="selectedScore(question.id)" :min="0.5" :precision="1" :step="0.5" controls-position="right" size="small" @change="setScore(question.id, $event)" />
<span>分</span>
</span>
</div>
<h3 class="q-card-stem">{{ question.stem }}</h3>
<div v-if="question.options?.length" class="q-options">
@ -240,12 +224,11 @@ function isSelected(questionId) { return form.questions.some((item) => item.ques
function selectedScore(questionId) { return form.questions.find((item) => item.questionId === questionId)?.score || 1; }
function toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: typeScores[question.type] || 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
function removeQuestionFromPreview(question) { toggleQuestion(question, false); }
function setScore(questionId, score) { const item = form.questions.find((row) => row.questionId === questionId); if (item) item.score = Number(score); }
function selectedByType(type) { const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); return form.questions.filter((item) => ids.has(item.questionId)).length; }
function questionCount(type) { return bank.value.filter((item) => item.type === type).length; }
function scoreByType(type) { return selectedQuestions(type).reduce((sum, item) => sum + Number(selectedScore(item.id)), 0); }
function scorePerQuestion(type) { return Number(typeScores[type] || 1); }
function setTypeScore(type, score) { const value = Number(score); if (!Number.isFinite(value) || value < 0.5) return; typeScores[type] = value; const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); for (const item of form.questions) if (ids.has(item.questionId)) item.score = value; }
function selectedQuestions(type) { const selected = new Set(form.questions.map((item) => item.questionId)); return bank.value.filter((item) => item.type === type && selected.has(item.id)); }
async function nextStep() { if (activeStep.value === 0) { try { await formRef.value.validate(); const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end <= start) return ElMessage.warning("结束时间必须晚于开始时间"); duration.value = Math.round((end - start) / 60000); } catch { return; } } if (activeStep.value === 1 && !form.questions.length) return ElMessage.warning("至少选择一道试题"); activeStep.value++; }
async function save(publish) { if (inProgress.value) { await examApi.updateExam(id.value, form); ElMessage.success("结束时间已更新"); return router.push("/exam/index"); } try { await formRef.value?.validate(); } catch { activeStep.value = 0; return; } if (!form.questions.length) { activeStep.value = 1; return ElMessage.warning("至少选择一道试题"); } const result = isNew.value ? await examApi.createExam(form) : await examApi.updateExam(id.value, form); const examId = result.data.id; if (publish) await examApi.publishExam(examId); ElMessage.success(publish ? "考试已保存并发布" : "考试已保存"); router.push("/exam/index"); }
@ -397,8 +380,6 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
tbody tr:hover td { background: #fafcff; }
}
.type-score-input { width: 112px; }
.fixed-step-actions {
display: flex;
align-items: center;
@ -690,32 +671,6 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
font-weight: 500;
}
.q-score-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
padding: 2px var(--sp-3);
border-radius: var(--r-control);
background: #ffffff;
border: 1px solid var(--edu-primary);
font-size: var(--fs-tag);
color: var(--edu-primary-deep);
span { color: var(--edu-primary-deep); font-weight: 500; }
:deep(.el-input-number) { width: 88px; }
:deep(.el-input-number .el-input__wrapper) {
padding: 2px 6px;
box-shadow: none !important;
background: transparent;
}
:deep(.el-input-number__decrease),
:deep(.el-input-number__increase) {
display: none;
}
}
.q-card-stem {
margin: 0 0 var(--sp-2);
color: var(--text-strong);

@ -486,7 +486,7 @@ const beforeImportStudents = (file) => {
return true
}
const getClassList = () => {
indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId }).then((res) => {
return indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId }).then((res) => {
classList.value = res.data
})
}
@ -542,11 +542,15 @@ const initPassword = (row, status) => {
}
onMounted(async () => {
await userStore.ensureSchoolProductConfig().catch(() => null)
getList()
getClassList()
if (!isTeacherRosterMode.value) {
getFacultyList()
}
await getClassList()
// 默认展示第一个班级数据:把 select 绑到第一个教学班并立即拉取学生列表
if (!paramsquery.value.schoolClassId && studentClassOptions.value.length) {
paramsquery.value.schoolClassId = studentClassOptions.value[0].schoolClassId
}
getList()
})
</script>
<style lang="scss" scoped>

@ -102,8 +102,169 @@ function isStepNavigation(element) {
</script>
<style lang="scss" scoped>
/*
* 教师端任务评阅右侧预览:
* 学生页面已通过 dynamic component 嵌入,保留学生端样式;
* 仅保留步骤导航与步骤内容,隐藏标题区、任务简介、AI 助手与所有顶部/底部操作按钮(仅查看)。
*/
.student-task-readonly-preview {
background: #ffffff;
font-family: var(--student-font-base, "PingFang SC", "Source Han Sans SC", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif);
}
/* ===== 1. 隐藏 AI 助手、AI 评测、独立的 TrainingTaskBrief 组件(保持原行为) ===== */
.student-task-readonly-preview :deep(.training-ai-sidebar),
.student-task-readonly-preview :deep(.training-ai-evaluation),
.student-task-readonly-preview :deep(.training-task-brief) { display: none !important; }
/* ===== 2. 隐藏学生页顶部 chrome(标题、任务简介、展开/收起按钮、顶部工具栏等) ===== */
.student-task-readonly-preview :deep(.task-header),
.student-task-readonly-preview :deep(.task-head-main),
.student-task-readonly-preview :deep(.task-brief),
.student-task-readonly-preview :deep(.task-brief-toggle),
.student-task-readonly-preview :deep(.task-brief-more),
.student-task-readonly-preview :deep(.workbench-head),
.student-task-readonly-preview :deep(.workbench-head--actions-only),
.student-task-readonly-preview :deep(.workbench-actions),
.student-task-readonly-preview :deep(.training-task-toolbar),
.student-task-readonly-preview :deep(.training-legacy-actions),
.student-task-readonly-preview :deep(.new-product-survey-toolbar),
.student-task-readonly-preview :deep(.comprehensive-training-toolbar),
.student-task-readonly-preview :deep(.release-channel-toolbar),
.student-task-readonly-preview :deep(.purchase-workbench) > .workbench-head,
.student-task-readonly-preview :deep(.process-workbench) > .workbench-head,
.student-task-readonly-preview :deep(.factors-workbench) > .workbench-head,
.student-task-readonly-preview :deep(.channel-toolbar),
.student-task-readonly-preview :deep(.supplier-toolbar),
.student-task-readonly-preview :deep(.promotion-toolbar),
.student-task-readonly-preview :deep(.hardware-workbench-head),
.student-task-readonly-preview :deep(.legacy-toolbar),
.student-task-readonly-preview :deep(.material-actions) { display: none !important; }
/* ===== 2.1 旧版实训页:隐藏 TrainingIntro 组件的标题/简介,仅保留其步骤导航 ===== */
.student-task-readonly-preview :deep(.training-intro__head),
.student-task-readonly-preview :deep(.training-intro__body) { display: none !important; }
.student-task-readonly-preview :deep(.training-intro) {
margin-bottom: 12px !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow: visible !important;
}
.student-task-readonly-preview :deep(.training-intro__steps.step-indicator) { margin: 4px 0 18px !important; }
/* ===== 2.2 旧版实训页:top_item 是 section 切换 tab,保留作为步骤导航 ===== */
.student-task-readonly-preview :deep(.top_item) {
margin: 0 0 14px !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
}
/* ===== 3. 让学生页面把 AI 侧栏所在的网格列收回,让预览通栏展示 ===== */
.student-task-readonly-preview :deep(.student-training-shell) { grid-template-columns: minmax(0, 1fr) !important; }
/* ===== 4. 紧贴 app-main 内边距,复用学生端整体留白 ===== */
.student-task-readonly-preview :deep(.student-training-shell) > main { padding: 14px 16px 24px !important; }
/* ===== 5. 步骤导航横排、并与学生端胶囊样式一致 ===== */
.student-task-readonly-preview :deep(.step-indicator),
.student-task-readonly-preview :deep(.comprehensive-step-nav),
.student-task-readonly-preview :deep(.comprehensive-step-strip),
.student-task-readonly-preview :deep(.step-tabs),
.student-task-readonly-preview :deep(.flow-steps),
.student-task-readonly-preview :deep(.flow-rail),
.student-task-readonly-preview :deep(.training-intro__steps) {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
gap: 0 !important;
width: 100% !important;
max-width: 100% !important;
margin: 4px 0 18px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow-x: auto !important;
overflow-y: hidden !important;
scrollbar-width: thin !important;
-webkit-overflow-scrolling: touch !important;
}
/* 步骤胶囊:固定宽度并排,不让任何一条占满整行 */
.student-task-readonly-preview :deep(.step-indicator) .step-tab,
.student-task-readonly-preview :deep(.comprehensive-step-strip) .step-tab,
.student-task-readonly-preview :deep(.step-tabs) .step-tab,
.student-task-readonly-preview :deep(.step-tabs) .step-pill,
.student-task-readonly-preview :deep(.step-indicator) .flow-pill,
.student-task-readonly-preview :deep(.flow-steps) .step-pill,
.student-task-readonly-preview :deep(.flow-rail) .flow-step,
.student-task-readonly-preview :deep(.training-intro__steps) .step-tab {
flex: 0 0 auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
flex-basis: auto !important;
width: auto !important;
min-width: 132px !important;
max-width: 220px !important;
margin: 0 !important;
}
.student-task-readonly-preview :deep(.comprehensive-step-strip) {
width: max-content !important;
min-width: 100% !important;
}
.student-task-readonly-preview :deep(.step-connector),
.student-task-readonly-preview :deep(.step-arrow),
.student-task-readonly-preview :deep(.flow-arrow) {
flex: 0 0 22px !important;
width: 22px !important;
min-width: 22px !important;
display: inline-block !important;
}
/* ===== 6. 步骤内容卡片:保留学生端白卡 + 灰边样式 ===== */
.student-task-readonly-preview :deep(.step-card),
.student-task-readonly-preview :deep(.process-step-card),
.student-task-readonly-preview :deep(.factor-step-card),
.student-task-readonly-preview :deep(.step-content > .step-card),
.student-task-readonly-preview :deep(.training-step-area) {
border: 1px solid #e5e7eb !important;
border-radius: 12px !important;
background: #ffffff !important;
box-shadow: none !important;
}
/* ===== 7. 关闭底部"提交/导出/清空"等动作按钮区(仅查看,不需要学生端的提交动作) ===== */
.student-task-readonly-preview :deep(.task-actions),
.student-task-readonly-preview :deep(.task-actions-row),
.student-task-readonly-preview :deep(.taskfooter),
.student-task-readonly-preview :deep(.step-footer),
.student-task-readonly-preview :deep(.step-actions),
.student-task-readonly-preview :deep(.footer-actions) {
pointer-events: none !important;
opacity: 0.55 !important;
filter: grayscale(0.4) !important;
}
/* ===== 8. 禁用所有表单交互,确保仅查看 ===== */
.student-task-readonly-preview :deep(input),
.student-task-readonly-preview :deep(textarea),
.student-task-readonly-preview :deep(select),
.student-task-readonly-preview :deep(.el-input__inner),
.student-task-readonly-preview :deep(.el-textarea__inner),
.student-task-readonly-preview :deep(.el-select) { pointer-events: none !important; }
/* ===== 9. 隐藏通用浮层与弹窗,避免误触 ===== */
.student-task-readonly-preview :deep(.gdialog),
.student-task-readonly-preview :deep(.popModal),
.student-task-readonly-preview :deep(.el-popper),
.student-task-readonly-preview :deep(.el-overlay) { display: none !important; }
</style>

Loading…
Cancel
Save