完善邮件验证与密码重置安全流程

This commit is contained in:
Cheng Zhou
2026-06-30 09:44:24 +08:00
parent 6c2bcc59b2
commit b25055775e
27 changed files with 4437 additions and 526 deletions
+18
View File
@@ -0,0 +1,18 @@
import type { AxiosResponse } from "axios";
import { apiDelete, apiGet, apiPost, apiPut } from "./axios";
import type { EmailSettings, EmailSettingsListResponse, EmailSettingsUpdate } from "../types/api";
export const fetchEmailSettings = (): Promise<AxiosResponse<EmailSettingsListResponse>> =>
apiGet<EmailSettingsListResponse>("/api/v1/admin/email-settings/");
export const createEmailSettingsDomain = (register_domain: string): Promise<AxiosResponse<EmailSettings>> =>
apiPost<EmailSettings>("/api/v1/admin/email-settings/", { register_domain });
export const updateEmailSettings = (registerDomain: string, payload: EmailSettingsUpdate): Promise<AxiosResponse<EmailSettings>> =>
apiPut<EmailSettings>(`/api/v1/admin/email-settings/${registerDomain}`, payload);
export const deleteEmailSettingsDomain = (registerDomain: string): Promise<AxiosResponse<{ message: string }>> =>
apiDelete(`/api/v1/admin/email-settings/${registerDomain}`);
export const sendEmailSettingsTest = (registerDomain: string, recipient_email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost(`/api/v1/admin/email-settings/${registerDomain}/test`, { recipient_email });
+38
View File
@@ -7,6 +7,12 @@ import type {
LoginResponse,
LoginKeyResponse,
RegisterRequest,
EmailAvailabilityResponse,
EmailDomainsResponse,
EmailCodeVerifyResponse,
PasswordResetCodeVerifyResponse,
PasswordResetRequest,
PasswordResetTokenRequest,
} from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
@@ -20,9 +26,41 @@ export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const fetchEmailDomains = (): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains");
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload);
export const checkEmailAvailability = (email: string): Promise<AxiosResponse<EmailAvailabilityResponse>> =>
apiGet<EmailAvailabilityResponse>("/api/v1/auth/register/email-availability", { params: { email } });
export const sendRegisterEmailCode = (email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register/email-code/send", { email });
export const verifyRegisterEmailCode = (email: string, code: string): Promise<AxiosResponse<EmailCodeVerifyResponse>> =>
apiPost<EmailCodeVerifyResponse>("/api/v1/auth/register/email-code/verify", { email, code });
export const sendPasswordResetEmailCode = (email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset/email-code/send", { email });
export const verifyPasswordResetEmailCode = (
email: string,
code: string
): Promise<AxiosResponse<PasswordResetCodeVerifyResponse>> =>
apiPost<PasswordResetCodeVerifyResponse>("/api/v1/auth/password-reset/email-code/verify", { email, code });
export const sendPasswordResetLink = (email: string): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset-link/send", { email });
export const resetPassword = (payload: PasswordResetRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset", payload);
export const resetPasswordWithToken = (
payload: PasswordResetTokenRequest
): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/password-reset-link", payload);
export const updateProfile = (payload: {
full_name?: string;
clinical_department?: string;
+1
View File
@@ -56,6 +56,7 @@ instance.interceptors.response.use(
const isAuthEndpoint =
reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/password-reset") ||
reqUrl.includes("/api/v1/auth/extend");
if (isAuthEndpoint) {
// 认证相关的错误由具体页面自行处理,避免重复提示
+9 -1
View File
@@ -32,6 +32,13 @@
<el-icon><DataAnalysis /></el-icon>
<span>{{ TEXT.menu.systemMonitoring }}</span>
</el-menu-item>
<el-sub-menu v-if="isAdmin" index="system-settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/admin/email-settings">邮件服务</el-menu-item>
</el-sub-menu>
<el-sub-menu v-if="canAccessAdminPermissions" index="permissions">
<template #title>
<el-icon><Key /></el-icon>
@@ -337,7 +344,7 @@ import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
@@ -596,6 +603,7 @@ const breadcrumbs = computed(() => {
topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" });
if (isAdmin.value) topItems.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs" });
if (isAdmin.value) topItems.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring" });
if (isAdmin.value) topItems.push({ label: "邮件服务", path: "/admin/email-settings" });
if (canAccessAdminPermissions.value) topItems.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system" });
// 权限管理子页,供「权限管理」父级与末级横跳
+16 -6
View File
@@ -783,7 +783,7 @@ export const TEXT = {
createdAt: "创建时间",
newTitle: "新建用户",
editTitle: "编辑用户",
resetPassword: "重置密码",
resetPassword: "应急重置",
accountStatus: "账号状态",
passwordOptional: "新密码(可选)",
passwordInitial: "初始密码",
@@ -806,12 +806,18 @@ export const TEXT = {
disableTitle: "禁用账号",
enableSuccess: "已启用",
disableSuccess: "已禁用",
resetTitle: "重置密码",
resetWarningTitle: "该操作将影响用户账号,请确认是否继续",
resetWarningDesc: "系统将生成临时密码,需再次输入用户名才能提交。",
resetTitle: "应急重置密码",
resetWarningTitle: "优先使用邮箱验证码自助找回",
resetWarningDesc: "仅在用户无法接收验证码、邮箱不可用或账号交接等应急场景下,由管理员手动重置密码。",
resetTarget: "重置对象:",
confirmUsername: "确认用户名",
confirmUsernameHint: "请输入用户名以确认",
selfServiceResetTitle: "发送自助重置链接",
selfServiceResetDesc: "系统会向该用户注册邮箱发送一次性链接,由用户点击后自行设置新密码。",
sendResetCode: "发送链接",
sendResetCodeSuccess: "重置链接已发送",
sendResetCodeFailed: "重置链接发送失败",
emergencyResetDivider: "邮箱不可用时使用应急重置",
passwordMode: "密码方式",
passwordAuto: "系统生成临时密码",
passwordManual: "手动输入新密码",
@@ -819,13 +825,17 @@ export const TEXT = {
newPassword: "新密码",
regenerate: "重新生成",
manualPasswordHint: "请输入新密码",
passwordHint: "不会明文展示,请通过安全渠道告知用户",
emergencyPassword: "应急新密码",
emergencyPasswordHint: "至少 8 位且包含字母与数字",
passwordHint: "仅用于应急场景,请通过安全渠道告知用户并要求其登录后立即修改。",
tempPasswordError: "临时密码生成异常",
manualPasswordRequired: "请输入新密码",
manualPasswordRule: "密码需至少 8 位且包含字母和数字",
confirmMismatch: "确认用户名不匹配,无法提交",
resetConfirmPrompt: "该操作将影响用户账号,请确认是否继续",
emergencyResetConfirmPrompt: "将由管理员直接修改该账号密码。请确认用户无法通过邮箱验证码自助找回,且已准备通过安全渠道告知新密码。",
resetConfirmTitle: "确认重置密码",
resetConfirm: "确认重置",
resetConfirm: "确认应急重置",
resetSuccess: "密码已重置",
resetFailed: "重置失败",
},
+7
View File
@@ -18,6 +18,7 @@ import AdminProjects from "../views/admin/Projects.vue";
import AdminSites from "../views/admin/Sites.vue";
import PermissionManagement from "../views/admin/PermissionManagement.vue";
import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue";
import EmailSettings from "../views/admin/EmailSettings.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue";
@@ -371,6 +372,12 @@ const routes: RouteRecordRaw[] = [
component: SystemMonitoringPage,
meta: { title: TEXT.menu.systemMonitoring, requiresAdmin: true },
},
{
path: "email-settings",
name: "AdminEmailSettings",
component: EmailSettings,
meta: { title: "邮件服务", requiresAdmin: true },
},
{
path: "permission-monitoring",
redirect: "/admin/system-monitoring",
+65
View File
@@ -63,6 +63,71 @@ export interface RegisterRequest {
clinical_department: string;
}
export interface EmailAvailabilityResponse {
available: boolean;
}
export interface EmailDomainsResponse {
items: string[];
}
export type SmtpSecurity = "NONE" | "SSL" | "STARTTLS";
export interface EmailSettings {
register_domain: string;
smtp_host: string;
smtp_port: number;
smtp_security: SmtpSecurity;
smtp_username: string;
smtp_password_configured: boolean;
sender_email: string;
sender_name?: string | null;
allowed_register_domain: string;
verification_code_ttl_minutes: number;
send_cooldown_seconds: number;
max_verify_attempts: number;
updated_at?: string | null;
}
export interface EmailSettingsUpdate {
smtp_host: string;
smtp_port: number;
smtp_security: SmtpSecurity;
smtp_username: string;
smtp_password?: string;
sender_email: string;
sender_name?: string | null;
allowed_register_domain: string;
verification_code_ttl_minutes: number;
send_cooldown_seconds: number;
max_verify_attempts: number;
}
export interface EmailSettingsListResponse {
items: EmailSettings[];
total: number;
}
export interface EmailCodeVerifyResponse {
verified: boolean;
}
export interface PasswordResetCodeVerifyResponse {
verified: boolean;
reset_token: string;
}
export interface PasswordResetRequest {
email: string;
code: string;
password: string;
}
export interface PasswordResetTokenRequest {
token: string;
password: string;
}
export interface AdminUserListResponse {
items: UserInfo[];
total: number;
File diff suppressed because it is too large Load Diff
+173 -16
View File
@@ -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;
+339 -20
View File
@@ -121,19 +121,56 @@
<div v-show="currentStep === 1" class="step-content">
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.emailLabel }}</label>
<div class="line-input-wrapper">
<input
v-model="form.email"
type="email"
class="line-input"
:placeholder="TEXT.modules.auth.emailPlaceholder"
autocomplete="email"
@input="clearError('email')"
@blur="validateEmailAvailability"
/>
<div class="line-action-row">
<div class="email-unified-box">
<input
v-model="form.emailLocal"
type="text"
class="email-local-field"
placeholder="请输入账号"
autocomplete="email"
@input="handleEmailInput"
@blur="validateEmailAvailability"
/>
<span class="email-at-sign">@</span>
<div class="email-domain-wrapper">
<select
v-model="form.emailDomain"
class="email-domain-field"
:disabled="emailDomains.length === 0"
@change="handleEmailInput"
>
<option v-for="domain in emailDomains" :key="domain" :value="domain">{{ domain }}</option>
</select>
<svg 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>
<button type="button" class="inline-action-btn" :disabled="sendingCode || codeCooldown > 0" @click="sendEmailCode">
{{ codeButtonText }}
</button>
</div>
<span v-if="errors.email" class="error-text">{{ errors.email }}</span>
</div>
<div class="form-group">
<label class="form-label">邮箱验证码</label>
<div class="line-action-row">
<div class="line-input-wrapper">
<input
v-model="form.emailCode"
type="text"
inputmode="numeric"
class="line-input"
placeholder="请输入 6 位验证码"
autocomplete="one-time-code"
@input="clearError('emailCode')"
/>
</div>
<button type="button" class="inline-action-btn" :disabled="verifyingCode || emailCodeVerified" @click="verifyEmailCode">
{{ emailCodeVerified ? "已验证" : "验证" }}
</button>
</div>
<span v-if="errors.emailCode" class="error-text">{{ errors.emailCode }}</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.fullNameLabel }}</label>
@@ -333,10 +370,16 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed } from "vue";
import { reactive, ref, computed, onMounted, onUnmounted, watch } from "vue";
import axios from "axios";
import { ElMessage } from "element-plus";
import { checkEmailAvailability, register as apiRegister } from "../api/auth";
import {
checkEmailAvailability,
fetchEmailDomains,
register as apiRegister,
sendRegisterEmailCode,
verifyRegisterEmailCode,
} from "../api/auth";
import type { ApiError, RegisterRequest } from "../types/api";
import { TEXT, requiredMessage } from "../locales";
import { privacyPolicySections, serviceTermsSections } from "../content/authProtocol";
@@ -349,6 +392,9 @@ const currentStep = ref(1);
// 表单数据
const form = reactive({
email: "",
emailLocal: "",
emailDomain: "",
emailCode: "",
password: "",
confirmPassword: "",
full_name: "",
@@ -362,11 +408,18 @@ const errors = reactive<Record<string, string>>({});
// UI状态
const submitting = ref(false);
const checkingEmail = ref(false);
const sendingCode = ref(false);
const verifyingCode = ref(false);
const emailCodeVerified = ref(false);
const verifiedEmail = ref("");
const codeCooldown = ref(0);
let cooldownTimer: number | undefined;
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const protocolDialogVisible = ref(false);
const activeProtocolType = ref<ProtocolType>("terms");
const registrationSubmitted = ref(false);
const emailDomains = ref<string[]>([]);
// 密码强度提示
const passwordHints = computed(() => ({
@@ -383,20 +436,96 @@ const activeProtocolSections = computed(() => (
activeProtocolType.value === "terms" ? serviceTermsSections : privacyPolicySections
));
const normalizeDomains = (items: string[]) => Array.from(new Set(
items.map(item => item.trim().toLowerCase().replace(/^@/, "")).filter(Boolean)
));
const syncEmailFromParts = () => {
const local = form.emailLocal.trim().toLowerCase();
form.email = local && form.emailDomain ? `${local}@${form.emailDomain}` : "";
};
const loadEmailDomains = async () => {
try {
const { data } = await fetchEmailDomains();
emailDomains.value = normalizeDomains(data.items);
if (!form.emailDomain && emailDomains.value.length > 0) {
form.emailDomain = emailDomains.value[0];
syncEmailFromParts();
}
} catch {
emailDomains.value = [];
}
};
// 清除字段错误
const clearError = (field: string) => {
delete errors[field];
};
const isEmailFormatValid = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isEmailLocalValid = () => {
const local = form.emailLocal.trim();
return /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local)
&& !local.startsWith(".")
&& !local.endsWith(".")
&& !local.includes("..");
};
const getEmailValidationMessage = (): string => {
if (!form.emailLocal.trim()) return requiredMessage(TEXT.modules.auth.emailLabel);
if (!form.emailDomain) return "暂无可用邮箱后缀,请联系管理员配置邮件服务";
if (!isEmailLocalValid()) return "邮箱格式不正确,请检查账号部分";
return "";
};
const isEmailFormatValid = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && isEmailLocalValid();
const normalizedEmail = () => form.email.trim().toLowerCase();
const codeButtonText = computed(() => {
if (sendingCode.value) return "发送中...";
if (codeCooldown.value > 0) return `${codeCooldown.value}s`;
return "发送验证码";
});
const resetEmailVerification = () => {
emailCodeVerified.value = false;
verifiedEmail.value = "";
};
const handleEmailInput = () => {
const normalized = form.emailLocal.trim().toLowerCase();
if (normalized.includes("@")) {
const [local, domain] = normalized.split("@");
form.emailLocal = local || "";
if (domain) {
form.emailDomain = emailDomains.value.includes(domain) ? domain : emailDomains.value[0] || "";
}
}
syncEmailFromParts();
clearError("email");
resetEmailVerification();
};
const startCooldown = (seconds: number) => {
codeCooldown.value = seconds;
if (cooldownTimer) window.clearInterval(cooldownTimer);
cooldownTimer = window.setInterval(() => {
codeCooldown.value -= 1;
if (codeCooldown.value <= 0 && cooldownTimer) {
window.clearInterval(cooldownTimer);
cooldownTimer = undefined;
}
}, 1000);
};
// 验证步骤1
const validateStep1 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.email) {
newErrors.email = requiredMessage(TEXT.modules.auth.emailLabel);
} else if (!isEmailFormatValid(form.email)) {
newErrors.email = TEXT.common.validation.invalidEmail;
const emailMessage = getEmailValidationMessage();
if (emailMessage) newErrors.email = emailMessage;
else if (!isEmailFormatValid(form.email)) newErrors.email = TEXT.common.validation.invalidEmail;
if (!form.emailCode) {
newErrors.emailCode = "请输入邮箱验证码";
}
if (!form.full_name) {
newErrors.full_name = requiredMessage(TEXT.modules.auth.fullNameLabel);
@@ -447,7 +576,13 @@ const closeProtocolDialog = () => {
};
const validateEmailAvailability = async (): Promise<boolean> => {
if (!form.email || !isEmailFormatValid(form.email)) {
const emailMessage = getEmailValidationMessage();
if (emailMessage) {
errors.email = emailMessage;
return false;
}
if (!isEmailFormatValid(form.email)) {
errors.email = TEXT.common.validation.invalidEmail;
return false;
}
checkingEmail.value = true;
@@ -470,6 +605,55 @@ const validateEmailAvailability = async (): Promise<boolean> => {
}
};
const sendEmailCode = async () => {
const emailMessage = getEmailValidationMessage();
if (emailMessage) {
errors.email = emailMessage;
return;
}
if (!(await validateEmailAvailability())) return;
sendingCode.value = true;
try {
await sendRegisterEmailCode(normalizedEmail());
form.emailCode = "";
resetEmailVerification();
startCooldown(60);
ElMessage.success("验证码已发送,请查收邮箱");
} catch (error) {
const message = getRegisterErrorMessage(error);
errors.email = message;
ElMessage.error(message);
} finally {
sendingCode.value = false;
}
};
const verifyEmailCode = async (): Promise<boolean> => {
if (emailCodeVerified.value && verifiedEmail.value === normalizedEmail()) {
return true;
}
if (!form.emailCode) {
errors.emailCode = "请输入邮箱验证码";
return false;
}
if (!(await validateEmailAvailability())) return false;
verifyingCode.value = true;
try {
const { data } = await verifyRegisterEmailCode(normalizedEmail(), form.emailCode);
emailCodeVerified.value = data.verified;
verifiedEmail.value = normalizedEmail();
delete errors.emailCode;
ElMessage.success("邮箱验证通过");
return true;
} catch (error) {
const message = getRegisterErrorMessage(error);
errors.emailCode = message;
return false;
} finally {
verifyingCode.value = false;
}
};
const getRegisterErrorMessage = (error: unknown): string => {
if (!axios.isAxiosError<ApiError | { detail?: unknown }>(error)) {
return TEXT.common.messages.requestFailed;
@@ -477,6 +661,14 @@ const getRegisterErrorMessage = (error: unknown): string => {
const data = error.response?.data;
const detail = (data as { detail?: unknown } | undefined)?.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const emailError = detail.find(item => {
const loc = Array.isArray(item?.loc) ? item.loc : [];
const message = String(item?.msg || "");
return loc.includes("email") || message.toLowerCase().includes("email");
});
if (emailError) return TEXT.common.validation.invalidEmail;
}
if (detail && typeof detail === "object" && "message" in detail) {
const message = (detail as { message?: unknown }).message;
if (typeof message === "string") return message;
@@ -490,7 +682,7 @@ const getRegisterErrorMessage = (error: unknown): string => {
// 下一步
const nextStep = async () => {
Object.keys(errors).forEach(key => delete errors[key]);
if (currentStep.value === 1 && validateStep1() && await validateEmailAvailability()) {
if (currentStep.value === 1 && validateStep1() && await validateEmailAvailability() && await verifyEmailCode()) {
currentStep.value = 2;
} else if (currentStep.value === 2 && validateStep2()) {
currentStep.value = 3;
@@ -523,9 +715,11 @@ const handleSubmit = async () => {
registrationSubmitted.value = true;
ElMessage.success(TEXT.modules.auth.registerSuccess);
Object.assign(form, {
email: "", password: "", confirmPassword: "",
email: "", emailLocal: "", emailCode: "", password: "", confirmPassword: "",
full_name: "", clinical_department: "", agreeTerms: false,
});
form.emailDomain = emailDomains.value[0] || "";
resetEmailVerification();
currentStep.value = 3;
} catch (error) {
const message = getRegisterErrorMessage(error);
@@ -543,6 +737,14 @@ const handleSubmit = async () => {
submitting.value = false;
}
};
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
onMounted(loadEmailDomains);
onUnmounted(() => {
if (cooldownTimer) window.clearInterval(cooldownTimer);
});
</script>
<style scoped>
@@ -914,6 +1116,14 @@ const handleSubmit = async () => {
.line-input-wrapper {
position: relative;
flex: 1;
min-width: 0;
}
.line-action-row {
display: flex;
align-items: flex-end;
gap: 12px;
}
.line-input {
@@ -938,6 +1148,111 @@ const handleSubmit = async () => {
border-bottom-color: #2563eb;
}
/* ═══ 统一邮箱输入框(账号 + @ + 后缀) ═══ */
.email-unified-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
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 {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: #0f172a;
font-family: inherit;
padding: 8px 0;
line-height: 1.5;
}
.email-local-field::placeholder {
color: #cbd5e1;
}
.email-at-sign {
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #64748b;
padding: 0 4px;
user-select: none;
line-height: 1;
}
.email-domain-wrapper {
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
}
.email-domain-field {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
outline: none;
font-size: 14px;
font-weight: 600;
color: #1e3a5f;
font-family: inherit;
padding: 8px 22px 8px 4px;
cursor: pointer;
line-height: 1.5;
}
.email-domain-field:disabled {
color: #94a3b8;
cursor: not-allowed;
}
.email-domain-chevron {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: #94a3b8;
pointer-events: none;
flex-shrink: 0;
}
.inline-action-btn {
min-width: 92px;
height: 36px;
padding: 0 14px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #f8fafc;
color: #2563eb;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.inline-action-btn:hover:not(:disabled) {
border-color: #2563eb;
background: #eff6ff;
}
.inline-action-btn:disabled {
color: #94a3b8;
cursor: not-allowed;
}
.password-wrapper .line-input {
padding-right: 32px;
}
@@ -1325,6 +1640,10 @@ const handleSubmit = async () => {
.form-row {
grid-template-columns: 1fr;
}
.line-action-row {
align-items: stretch;
flex-direction: column;
}
.step-label {
display: none;
}
File diff suppressed because it is too large Load Diff
+96 -109
View File
@@ -33,59 +33,32 @@
</div>
</div>
<div class="self-service-panel">
<div class="self-service-copy">
<span class="self-service-title">{{ TEXT.modules.adminUsers.selfServiceResetTitle }}</span>
<span class="self-service-desc">{{ TEXT.modules.adminUsers.selfServiceResetDesc }}</span>
</div>
<el-button class="reset-code-send-btn" type="primary" :loading="sendingCode" @click="sendResetCode">
{{ TEXT.modules.adminUsers.sendResetCode }}
</el-button>
</div>
<div class="emergency-divider">
<span>{{ TEXT.modules.adminUsers.emergencyResetDivider }}</span>
</div>
<el-form ref="formRef" :model="form" label-width="100px" :rules="rules" class="reset-form">
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
<div class="mode-cards">
<div class="mode-card" :class="{ active: form.mode === 'auto' }" @click="form.mode = 'auto'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordAuto }}</span>
</div>
<div class="mode-card" :class="{ active: form.mode === 'manual' }" @click="form.mode = 'manual'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordManual }}</span>
</div>
</div>
</el-form-item>
<el-form-item
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
:prop="form.mode === 'auto' ? 'tempPassword' : 'manualPassword'"
>
<el-form-item :label="TEXT.modules.adminUsers.emergencyPassword" prop="manualPassword">
<el-input
v-if="form.mode === 'auto'"
v-model="form.tempPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-temp-password"
readonly
>
<template #append>
<el-button @click="regenerate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ TEXT.modules.adminUsers.regenerate }}
</el-button>
</template>
</el-input>
<el-input
v-else
v-model="form.manualPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-manual-password"
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
:placeholder="TEXT.modules.adminUsers.emergencyPasswordHint"
/>
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
</el-form-item>
@@ -109,6 +82,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { sendPasswordResetLink } from "../../api/auth";
import { updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales";
@@ -130,10 +104,9 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>();
const submitting = ref(false);
const sendingCode = ref(false);
const form = reactive({
confirmUsername: "",
mode: "auto",
tempPassword: "",
manualPassword: "",
});
@@ -146,30 +119,17 @@ const getInitials = (name: string) => {
const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
tempPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "auto" && !form.tempPassword) {
callback(new Error(TEXT.modules.adminUsers.tempPasswordError));
return;
}
if (form.mode === "manual" && !form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
callback();
},
trigger: "blur",
},
],
manualPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "manual" && !form.manualPassword) {
if (!form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,}$/.test(form.manualPassword)) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRule));
return;
}
callback();
},
trigger: "blur",
@@ -177,19 +137,8 @@ const rules = reactive<FormRules>({
],
});
const generatePassword = () => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*";
let pwd = "";
for (let i = 0; i < 12; i++) {
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
}
form.tempPassword = pwd;
};
const regenerate = () => generatePassword();
const clearPasswordValidation = () => {
formRef.value?.clearValidate(["tempPassword", "manualPassword"]);
formRef.value?.clearValidate(["manualPassword"]);
};
watch(
@@ -197,20 +146,26 @@ watch(
(val) => {
if (val) {
form.confirmUsername = "";
form.mode = "auto";
generatePassword();
form.manualPassword = "";
clearPasswordValidation();
}
}
);
watch(
() => form.mode,
() => { clearPasswordValidation(); }
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username && !!form.manualPassword);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
const sendResetCode = async () => {
if (!props.user?.email) return;
sendingCode.value = true;
try {
await sendPasswordResetLink(props.user.email);
ElMessage.success(TEXT.modules.adminUsers.sendResetCodeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.modules.adminUsers.sendResetCodeFailed);
} finally {
sendingCode.value = false;
}
};
const onSubmit = async () => {
if (!props.user) return;
@@ -220,7 +175,7 @@ const onSubmit = async () => {
return;
}
const ok = await ElMessageBox.confirm(
TEXT.modules.adminUsers.resetConfirmPrompt,
TEXT.modules.adminUsers.emergencyResetConfirmPrompt,
TEXT.modules.adminUsers.resetConfirmTitle,
{
type: "warning",
@@ -231,8 +186,7 @@ const onSubmit = async () => {
if (!ok) return;
submitting.value = true;
try {
const password = form.mode === "manual" ? form.manualPassword : form.tempPassword;
await updateUser(props.user.id, { password });
await updateUser(props.user.id, { password: form.manualPassword });
ElMessage.success(TEXT.modules.adminUsers.resetSuccess);
emit("reset");
visibleProxy.value = false;
@@ -319,44 +273,77 @@ const onSubmit = async () => {
color: var(--ctms-text-secondary);
}
.reset-form :deep(.el-form-item) {
.self-service-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
margin-bottom: 16px;
}
.mode-cards {
display: flex;
gap: 10px;
width: 100%;
}
.mode-card {
flex: 1;
.self-service-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px;
border-radius: 10px;
border: 2px solid var(--ctms-border-color);
cursor: pointer;
transition: var(--ctms-transition);
color: var(--ctms-text-secondary);
gap: 3px;
min-width: 0;
}
.mode-card:hover {
border-color: var(--ctms-border-color-hover);
background: var(--ctms-neutral-100);
.self-service-title {
font-size: 13px;
font-weight: 700;
color: #1e40af;
}
.mode-card.active {
.self-service-desc {
font-size: 12px;
line-height: 1.5;
color: #3b5f9a;
}
.reset-code-send-btn {
flex: 0 0 auto;
min-width: 118px;
height: 38px;
color: #fff;
background: var(--ctms-primary);
border-color: var(--ctms-primary);
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.mode-card span {
.reset-code-send-btn:hover,
.reset-code-send-btn:focus {
color: #fff;
background: var(--ctms-primary-hover);
border-color: var(--ctms-primary-hover);
}
.reset-code-send-btn :deep(span) {
color: inherit;
}
.emergency-divider {
display: flex;
align-items: center;
gap: 12px;
color: var(--ctms-text-tertiary);
font-size: 12px;
font-weight: 600;
margin: 2px 0 16px;
}
.emergency-divider::before,
.emergency-divider::after {
content: "";
height: 1px;
flex: 1;
background: var(--ctms-border-color);
}
.reset-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.password-hint {