feat(Navbar): add change password option and dialog

Introduced a new dropdown item in the Navbar for changing the password, along with a corresponding ChangePasswordDialog component. The dialog is triggered when the user selects the change password option, enhancing user account management. Additionally, updated the command handling to manage the visibility of the password change dialog and added a success handler to prompt users to log out after a successful password change.
fix/exam-question-access-material-excel-delete-draft
qinzhenpen 2 weeks ago
parent 6edf32a7b5
commit 181e657db4

@ -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: "确定",

@ -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>

@ -9,14 +9,14 @@
</header>
<nav v-if="!inProgress" class="prototype-nav" aria-label="考试编辑步骤">
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0"><span class="step-num">01</span>考试设置</button>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0"><span class="step-num">01</span>添加试题</button>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 1 }" @click="activeStep = 1"><span class="step-num">02</span>添加试题</button>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 1 }" @click="activeStep = 1"><span class="step-num">02</span>试卷概览</button>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2"><span class="step-num">03</span>试卷预览</button>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2"><span class="step-num">03</span>考试设置</button>
</nav>
<section v-if="activeStep === 0 || activeStep === 1" class="editor-panel prototype-base-panel">
<section v-if="activeStep === 2" class="editor-panel prototype-base-panel">
<div class="section-card">
<header class="card-head"><div><h2>基础设置</h2><p>填写考试名称、时间与班级,分值矩阵自动汇总</p></div></header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="base-form">
@ -53,7 +53,7 @@
</div>
</section>
<section v-if="!inProgress && activeStep === 0" class="editor-panel settings-stage">
<section v-if="!inProgress && activeStep === 2" class="editor-panel settings-stage">
<div class="section-card">
<header class="card-head"><div><h2>考试设置</h2><p>设置试题展示、作答行为和多选题计分方式</p></div></header>
<div class="settings-grid">
@ -103,7 +103,7 @@
</div>
</section>
<section v-if="!inProgress && activeStep === 1" class="editor-panel question-stage">
<section v-if="!inProgress && activeStep === 0" class="editor-panel question-stage">
<div class="section-card">
<header class="section-head"><div><h2>选择试题</h2><p>勾选题目并设置该题分值,选择结果实时同步到试卷概览</p></div>
<div class="import-tools">
@ -148,7 +148,7 @@
</div>
</section>
<section v-if="!inProgress && activeStep === 2" class="editor-panel preview-stage">
<section v-if="!inProgress && activeStep === 1" class="editor-panel preview-stage">
<div class="section-card preview-summary-card">
<header class="section-head"><div><h2>试卷概览</h2><p>{{ form.questions.length }} 道题,共 {{ totalScore }} 分</p></div><el-button :icon="Download" @click="exportPaper" :disabled="isNew">导出 Word 试卷</el-button></header>
<div class="score-cards preview-score-cards">
@ -214,7 +214,7 @@ import { saveAs } from "file-saver";
import * as examApi from "@/api/exam";
const route = useRoute(); const router = useRouter(); const id = computed(() => route.params.id); const isNew = computed(() => id.value === "new");
const loading = ref(false); const activeStep = ref(1); const activeType = ref("SINGLE"); const status = ref("DRAFT"); const classes = ref([]); const bank = ref([]); const formRef = ref(); const duration = ref(60);
const loading = ref(false); const activeStep = ref(0); const activeType = ref("SINGLE"); const status = ref("DRAFT"); const classes = ref([]); const bank = ref([]); const formRef = ref(); const duration = ref(60);
const typeOptions = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
const statusMeta = { DRAFT: { label: "未发布", tone: "info" }, NOT_STARTED: { label: "未开始", tone: "" }, IN_PROGRESS: { label: "进行中", tone: "success" }, ENDED: { label: "已结束", tone: "warning" } };
const form = reactive({ name: "", startTime: "", endTime: "", classIds: [], questionOrderMode: "FIXED", optionOrderMode: "FIXED", allowCopyQuestion: false, allowPasteAnswer: false, allowRollback: true, multipleScoreMode: "ALL_OR_NOTHING", questions: [] });
@ -235,8 +235,8 @@ function selectedByType(type) { const ids = new Set(bank.value.filter((item) =>
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 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"); }
async function nextStep() { if (activeStep.value === 0 && !form.questions.length) return ElMessage.warning("至少选择一道试题"); if (activeStep.value === 2) { 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; } } 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 = 2; return; } if (!form.questions.length) { activeStep.value = 0; 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"); }
async function importPaperQuestions({ file }) { const formData = new FormData(); formData.append("file", file); const { data } = await examApi.importQuestions(formData); bank.value = [...data, ...bank.value]; for (const q of data) if (!isSelected(q.id)) form.questions.push({ questionId: q.id, score: 1 }); ElMessage.success(`导入并选中 ${data.length} 道题`); }
async function downloadTemplate() { saveAs(await examApi.downloadQuestionTemplate(), "考试题库导入模板.docx"); }
async function exportPaper() { saveAs(await examApi.exportExamPaper(id.value), `${form.name}试卷.docx`); }

Loading…
Cancel
Save