完善邮件验证与密码重置安全流程
This commit is contained in:
+173
-16
@@ -95,11 +95,9 @@
|
||||
<span class="brand-system-text">CTMS</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab 选项卡(只读展现) -->
|
||||
<!-- 当前登录方式 -->
|
||||
<div class="login-tabs">
|
||||
<span class="tab-item active">账号登录</span>
|
||||
<span class="tab-item disabled">邮箱登录</span>
|
||||
<span class="tab-item disabled">手机登录</span>
|
||||
</div>
|
||||
|
||||
<!-- 退出通知 -->
|
||||
@@ -141,11 +139,38 @@
|
||||
<!-- 登录表单 -->
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form">
|
||||
<el-form-item label="账号" prop="email">
|
||||
<el-input
|
||||
id="email" v-model="form.email" type="email"
|
||||
placeholder="请输入用户名 / 邮箱" size="large"
|
||||
name="username" autocomplete="username" class="login-input">
|
||||
</el-input>
|
||||
<div class="email-unified-box">
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.emailLocal"
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
class="email-local-field"
|
||||
@paste="handleAccountPaste"
|
||||
/>
|
||||
<span class="email-at-sign">@</span>
|
||||
<div class="email-domain-wrapper">
|
||||
<select
|
||||
v-if="showDomainSelect"
|
||||
v-model="form.emailDomain"
|
||||
class="email-domain-field"
|
||||
aria-label="邮箱域名"
|
||||
>
|
||||
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model.trim="form.emailDomain"
|
||||
type="text"
|
||||
class="email-domain-field email-domain-input"
|
||||
placeholder="邮箱域名"
|
||||
aria-label="邮箱域名"
|
||||
/>
|
||||
<svg v-if="showDomainSelect" class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
@@ -221,7 +246,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch } from "vue";
|
||||
import { computed, reactive, ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
// 从 package.json 读取版本号,构建时由 Vite 注入
|
||||
@@ -230,6 +255,7 @@ const appVersion = (import.meta.env.VITE_APP_VERSION as string) || "1.0.0";
|
||||
const currentYear = new Date().getFullYear();
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchEmailDomains } from "../api/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
import {
|
||||
consumeLogoutReason,
|
||||
@@ -244,7 +270,9 @@ const router = useRouter();
|
||||
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ email: "", password: "", agreeProtocol: false });
|
||||
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
|
||||
const configuredEmailDomains = ref<string[]>([]);
|
||||
const preservedEmailDomain = ref("");
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
email: [
|
||||
@@ -260,7 +288,59 @@ const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: str
|
||||
const loginError = ref<{ title: string; message?: string } | null>(null);
|
||||
const protocolSections = authProtocolSections;
|
||||
|
||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||
const availableEmailDomains = computed(() => Array.from(new Set([
|
||||
...configuredEmailDomains.value,
|
||||
preservedEmailDomain.value,
|
||||
].filter(Boolean))));
|
||||
const showDomainSelect = computed(
|
||||
() => configuredEmailDomains.value.length > 0 || Boolean(preservedEmailDomain.value)
|
||||
);
|
||||
|
||||
const syncEmailFromParts = () => {
|
||||
const local = form.emailLocal.trim().toLowerCase();
|
||||
const domain = normalizeDomain(form.emailDomain);
|
||||
form.emailDomain = domain;
|
||||
form.email = local && domain ? `${local}@${domain}` : "";
|
||||
};
|
||||
|
||||
const applyEmailValue = (email: string) => {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const separator = normalized.lastIndexOf("@");
|
||||
if (separator > 0) {
|
||||
form.emailLocal = normalized.slice(0, separator);
|
||||
form.emailDomain = normalizeDomain(normalized.slice(separator + 1));
|
||||
preservedEmailDomain.value = configuredEmailDomains.value.includes(form.emailDomain)
|
||||
? ""
|
||||
: form.emailDomain;
|
||||
} else {
|
||||
form.emailLocal = normalized;
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
preservedEmailDomain.value = "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
};
|
||||
|
||||
const loadEmailDomains = async () => {
|
||||
try {
|
||||
const { data } = await fetchEmailDomains();
|
||||
configuredEmailDomains.value = Array.from(new Set(
|
||||
data.items.map(normalizeDomain).filter(Boolean)
|
||||
));
|
||||
} catch {
|
||||
configuredEmailDomains.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountPaste = (event: ClipboardEvent) => {
|
||||
const pasted = event.clipboardData?.getData("text").trim() || "";
|
||||
if (!pasted.includes("@")) return;
|
||||
event.preventDefault();
|
||||
applyEmailValue(pasted);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEmailDomains();
|
||||
const reason = consumeLogoutReason();
|
||||
if (reason === LOGOUT_REASON_TIMEOUT) {
|
||||
logoutNotice.value = { type: "warning", title: "已自动退出登录", message: "30 分钟未检测到任何操作。为保护项目数据,请重新登录后继续。" };
|
||||
@@ -269,11 +349,12 @@ onMounted(async () => {
|
||||
} else if (reason === LOGOUT_REASON_MANUAL) {
|
||||
logoutNotice.value = { type: "info", title: "已安全退出", message: "本机登录状态已清除,需要时可再次登录。" };
|
||||
}
|
||||
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
||||
applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
|
||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||
});
|
||||
|
||||
watch(() => form.agreeProtocol, (v) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(v)));
|
||||
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
|
||||
|
||||
const openProtocolDialog = () => { protocolDialogVisible.value = true; };
|
||||
const confirmProtocol = () => { form.agreeProtocol = true; protocolDialogVisible.value = false; };
|
||||
@@ -696,11 +777,6 @@ const onSubmit = async () => {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tab-item.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ═══════════════════════
|
||||
通知与错误提示
|
||||
═══════════════════════ */
|
||||
@@ -812,6 +888,87 @@ const onSubmit = async () => {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 账号与邮箱域名组合输入 */
|
||||
.email-unified-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-bottom: 1.5px solid #e2e8f0;
|
||||
transition: border-color 0.25s ease;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.email-unified-box:focus-within {
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.email-local-field,
|
||||
.email-domain-field {
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.email-local-field {
|
||||
flex: 1;
|
||||
color: #0f172a;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.email-local-field::placeholder,
|
||||
.email-domain-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.email-at-sign {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.email-domain-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
max-width: 55%;
|
||||
}
|
||||
|
||||
.email-domain-field {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
color: #1e3a5f;
|
||||
font-weight: 600;
|
||||
padding: 6px 22px 6px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.email-domain-input {
|
||||
width: 150px;
|
||||
padding-right: 4px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.email-domain-chevron {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* 协议区 */
|
||||
.login-options {
|
||||
margin: 4px 0 24px;
|
||||
|
||||
Reference in New Issue
Block a user