Files
ctms/frontend/src/views/Register.vue
T
Cheng Zhou ab1975d3c6 登录注册:优化协议弹窗与PM后台落点
授权项目负责人登录后,若当前项目具备项目成员、中心管理或审计导出读取权限,优先进入对应管理后台页面,避免仍停留在我的工作台。

抽取登录协议为共享内容模块,注册页新增服务条款和隐私政策弹窗,并在注册成功后展示待管理员审核提示。

补充路由、登录页和注册页静态回归测试,覆盖 PM 管理后台默认落点和协议弹窗行为。
2026-05-13 11:03:46 +08:00

1297 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="register-container">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="floating-orb orb-1"></div>
<div class="floating-orb orb-2"></div>
<div class="floating-orb orb-3"></div>
</div>
<!-- 主容器 -->
<div class="glass-card">
<!-- 玻璃层效果 -->
<div class="glass-backdrop"></div>
<div class="glass-overlay"></div>
<div class="glass-border"></div>
<!-- 顶部区域 - 欢迎与进度 -->
<div class="card-header">
<!-- Logo & 品牌 -->
<div class="brand-section">
<div class="logo-container">
<div class="logo-glass"></div>
<div class="logo-gradient"></div>
<div class="logo-border"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="logo-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
</div>
<h1 class="welcome-title">{{ TEXT.modules.auth.registerTitle }}</h1>
<p class="welcome-subtitle">{{ TEXT.modules.auth.registerDesc }}</p>
</div>
<!-- 进度步骤 -->
<div class="progress-steps">
<div class="step-group">
<div :class="['step-indicator', { active: currentStep >= 1 }]">1</div>
<span class="step-label">基本信息</span>
</div>
<div class="step-line"></div>
<div class="step-group">
<div :class="['step-indicator', { active: currentStep >= 2 }]">2</div>
<span class="step-label">安全设置</span>
</div>
<div class="step-line"></div>
<div class="step-group">
<div :class="['step-indicator', { active: currentStep >= 3 }]">3</div>
<span class="step-label">完成注册</span>
</div>
</div>
</div>
<!-- 底部区域 - 注册表单 -->
<div class="card-body">
<!-- 表单头部 -->
<div class="form-header">
<h2 class="form-title">{{ stepTitle }}</h2>
<p class="form-subtitle">步骤 {{ currentStep }}/3 · {{ stepDescription }}</p>
</div>
<section v-if="registrationSubmitted" class="approval-notice" aria-live="polite">
<div class="approval-icon">
<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="approval-title">注册申请已提交</h3>
<p class="approval-message">待管理员审核通过后方可登录</p>
<p class="approval-hint">管理员审核通过前请勿重复提交注册申请</p>
</section>
<!-- 注册表单 -->
<form v-else class="register-form" @submit.prevent="handleSubmit">
<!-- 步骤 1: 基本信息 -->
<div v-show="currentStep === 1" class="step-content">
<!-- 邮箱 -->
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.emailLabel }}</label>
<div class="input-wrapper">
<div class="input-glass"></div>
<div class="input-overlay"></div>
<div class="input-border"></div>
<input
v-model="form.email"
type="email"
class="glass-input"
:placeholder="TEXT.modules.auth.emailPlaceholder"
autocomplete="email"
/>
</div>
<span v-if="errors.email" class="error-text">{{ errors.email }}</span>
</div>
<!-- 姓名和部门 -->
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.fullNameLabel }}</label>
<div class="input-wrapper">
<div class="input-glass"></div>
<div class="input-overlay"></div>
<div class="input-border"></div>
<input
v-model="form.full_name"
type="text"
class="glass-input"
:placeholder="TEXT.modules.auth.fullNamePlaceholder"
/>
</div>
<span v-if="errors.full_name" class="error-text">{{ errors.full_name }}</span>
</div>
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.clinicalDepartmentLabel }}</label>
<div class="input-wrapper">
<div class="input-glass"></div>
<div class="input-overlay"></div>
<div class="input-border"></div>
<input
v-model="form.clinical_department"
type="text"
class="glass-input"
:placeholder="TEXT.modules.auth.clinicalDepartmentPlaceholder"
/>
</div>
<span v-if="errors.clinical_department" class="error-text">{{ errors.clinical_department }}</span>
</div>
</div>
</div>
<!-- 步骤 2: 安全设置 -->
<div v-show="currentStep === 2" class="step-content">
<!-- 密码 -->
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.passwordLabel }}</label>
<div class="input-wrapper">
<div class="input-glass"></div>
<div class="input-overlay"></div>
<div class="input-border"></div>
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="glass-input"
:placeholder="TEXT.modules.auth.passwordRuleMix"
autocomplete="new-password"
/>
<button type="button" class="toggle-password" @click="showPassword = !showPassword">
<svg v-if="!showPassword" width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<span v-if="errors.password" class="error-text">{{ errors.password }}</span>
</div>
<!-- 确认密码 -->
<div class="form-group">
<label class="form-label">{{ TEXT.modules.auth.confirmPasswordLabel }}</label>
<div class="input-wrapper">
<div class="input-glass"></div>
<div class="input-overlay"></div>
<div class="input-border"></div>
<input
v-model="form.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
class="glass-input"
:placeholder="TEXT.modules.auth.confirmPasswordPlaceholder"
autocomplete="new-password"
/>
<button type="button" class="toggle-password" @click="showConfirmPassword = !showConfirmPassword">
<svg v-if="!showConfirmPassword" width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<span v-if="errors.confirmPassword" class="error-text">{{ errors.confirmPassword }}</span>
</div>
<!-- 密码强度提示 -->
<div class="password-hints">
<div :class="['hint-item', { valid: passwordHints.minLength }]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="passwordHints.minLength" points="20 6 9 17 4 12"></polyline>
<circle v-else cx="12" cy="12" r="10"></circle>
</svg>
<span>至少 8 位字符</span>
</div>
<div :class="['hint-item', { valid: passwordHints.hasLetter }]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="passwordHints.hasLetter" points="20 6 9 17 4 12"></polyline>
<circle v-else cx="12" cy="12" r="10"></circle>
</svg>
<span>包含字母</span>
</div>
<div :class="['hint-item', { valid: passwordHints.hasNumber }]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="passwordHints.hasNumber" points="20 6 9 17 4 12"></polyline>
<circle v-else cx="12" cy="12" r="10"></circle>
</svg>
<span>包含数字</span>
</div>
</div>
</div>
<!-- 步骤 3: 完成确认 -->
<div v-show="currentStep === 3" class="step-content">
<!-- 条款复选框 -->
<div class="terms-section">
<label class="checkbox-wrapper">
<input v-model="form.agreeTerms" type="checkbox" class="checkbox-input" />
<span class="checkbox-custom"></span>
<span class="checkbox-label">
我同意
<button type="button" class="link link-button" @click.stop.prevent="openProtocolDialog('terms')">
服务条款
</button>
<button type="button" class="link link-button" @click.stop.prevent="openProtocolDialog('privacy')">
隐私政策
</button>
</span>
</label>
<span v-if="errors.agreeTerms" class="error-text">{{ errors.agreeTerms }}</span>
</div>
<!-- 汇总信息 -->
<div class="summary-section">
<h3 class="summary-title">注册信息确认</h3>
<div class="summary-grid">
<div class="summary-item">
<span class="summary-label">邮箱</span>
<span class="summary-value">{{ form.email || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">姓名</span>
<span class="summary-value">{{ form.full_name || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">部门</span>
<span class="summary-value">{{ form.clinical_department || '-' }}</span>
</div>
</div>
</div>
</div>
<!-- 按钮区域 -->
<div class="button-group">
<button
v-if="currentStep > 1"
type="button"
class="btn-secondary"
@click="prevStep"
>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"></path>
</svg>
<span>上一步</span>
</button>
<button
v-if="currentStep < 3"
type="button"
class="btn-primary"
@click="nextStep"
>
<span>继续</span>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</button>
<button
v-else
type="submit"
class="btn-primary"
:disabled="submitting"
>
<span v-if="submitting">提交中...</span>
<template v-else>
<span>{{ TEXT.modules.auth.registerButton }}</span>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</template>
</button>
</div>
</form>
<!-- 页脚 -->
<div class="card-footer">
<p class="footer-text">
{{ TEXT.modules.auth.backToLogin.replace('已有账号?', '') }}
<RouterLink to="/login" class="footer-link">{{ TEXT.common.actions.login }}</RouterLink>
</p>
<div class="footer-links">
<button type="button" class="footer-small-link footer-link-button">帮助中心</button>
<span class="footer-divider"></span>
<button type="button" class="footer-small-link footer-link-button">联系支持</button>
</div>
</div>
</div>
</div>
<el-dialog
v-model="protocolDialogVisible"
:title="activeProtocolTitle"
width="860px"
class="register-protocol-dialog"
append-to-body
align-center
>
<div class="protocol-content">
<section v-for="section in activeProtocolSections" :key="section.title" class="protocol-section">
<h3>{{ section.title }}</h3>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
</section>
</div>
<template #footer>
<el-button type="primary" size="large" class="protocol-confirm-btn" @click="closeProtocolDialog">
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from "vue";
import { ElMessage } from "element-plus";
import { register as apiRegister } from "../api/auth";
import type { RegisterRequest } from "../types/api";
import { TEXT, requiredMessage } from "../locales";
import { privacyPolicySections, serviceTermsSections } from "../content/authProtocol";
const defaultRole: RegisterRequest["role"] = "CRA";
type ProtocolType = "terms" | "privacy";
// 步骤控制
const currentStep = ref(1);
// 表单数据
const form = reactive({
email: "",
password: "",
confirmPassword: "",
full_name: "",
clinical_department: "",
agreeTerms: false,
});
// 表单错误
const errors = reactive<Record<string, string>>({});
// UI状态
const submitting = ref(false);
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const protocolDialogVisible = ref(false);
const activeProtocolType = ref<ProtocolType>("terms");
const registrationSubmitted = ref(false);
// 步骤标题
const stepTitle = computed(() => {
if (registrationSubmitted.value) return "注册申请已提交";
switch (currentStep.value) {
case 1: return "基本信息";
case 2: return "安全设置";
case 3: return "完成注册";
default: return "";
}
});
// 步骤描述
const stepDescription = computed(() => {
if (registrationSubmitted.value) return "待管理员审核通过后方可登录";
switch (currentStep.value) {
case 1: return "填写您的基本信息";
case 2: return "设置登录密码";
case 3: return "确认信息并提交";
default: return "";
}
});
// 密码强度提示
const passwordHints = computed(() => ({
minLength: form.password.length >= 8,
hasLetter: /[A-Za-z]/.test(form.password),
hasNumber: /[0-9]/.test(form.password),
}));
const activeProtocolTitle = computed(() => (
activeProtocolType.value === "terms" ? "服务条款" : "隐私政策"
));
const activeProtocolSections = computed(() => (
activeProtocolType.value === "terms" ? serviceTermsSections : privacyPolicySections
));
// 验证步骤1
const validateStep1 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.email) {
newErrors.email = requiredMessage(TEXT.modules.auth.emailLabel);
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
newErrors.email = TEXT.common.validation.invalidEmail;
}
if (!form.full_name) {
newErrors.full_name = requiredMessage(TEXT.modules.auth.fullNameLabel);
}
if (!form.clinical_department) {
newErrors.clinical_department = requiredMessage(TEXT.modules.auth.clinicalDepartmentLabel);
}
Object.assign(errors, newErrors);
return Object.keys(newErrors).length === 0;
};
// 验证步骤2
const validateStep2 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.password) {
newErrors.password = requiredMessage(TEXT.modules.auth.passwordLabel);
} else if (form.password.length < 8) {
newErrors.password = TEXT.modules.auth.passwordRuleMin;
} else if (!/[A-Za-z]/.test(form.password) || !/[0-9]/.test(form.password)) {
newErrors.password = TEXT.modules.auth.passwordRuleMix;
}
if (!form.confirmPassword) {
newErrors.confirmPassword = requiredMessage(TEXT.modules.auth.confirmPasswordLabel);
} else if (form.confirmPassword !== form.password) {
newErrors.confirmPassword = TEXT.modules.auth.passwordMismatch;
}
Object.assign(errors, newErrors);
return Object.keys(newErrors).length === 0;
};
// 验证步骤3
const validateStep3 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.agreeTerms) {
newErrors.agreeTerms = "请同意服务条款和隐私政策";
}
Object.assign(errors, newErrors);
return Object.keys(newErrors).length === 0;
};
// 清除特定字段的错误
const clearError = (field: string) => {
delete errors[field];
};
const openProtocolDialog = (type: ProtocolType) => {
activeProtocolType.value = type;
protocolDialogVisible.value = true;
};
const closeProtocolDialog = () => {
protocolDialogVisible.value = false;
};
// 下一步
const nextStep = () => {
// 清除之前的错误
Object.keys(errors).forEach(key => delete errors[key]);
if (currentStep.value === 1 && validateStep1()) {
currentStep.value = 2;
} else if (currentStep.value === 2 && validateStep2()) {
currentStep.value = 3;
}
};
// 上一步
const prevStep = () => {
if (currentStep.value > 1) {
currentStep.value--;
// 清除错误
Object.keys(errors).forEach(key => delete errors[key]);
}
};
// 提交表单
const handleSubmit = async () => {
// 先验证最后一步
if (!validateStep3()) return;
// 再验证所有步骤
if (!validateStep1() || !validateStep2()) {
ElMessage.warning("请检查所有填写的信息");
return;
}
submitting.value = true;
try {
await apiRegister({
email: form.email,
password: form.password,
full_name: form.full_name,
role: defaultRole,
clinical_department: form.clinical_department,
});
registrationSubmitted.value = true;
ElMessage.success(TEXT.modules.auth.registerSuccess);
// 清空表单
Object.assign(form, {
email: "",
password: "",
confirmPassword: "",
full_name: "",
clinical_department: "",
agreeTerms: false,
});
currentStep.value = 3;
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
/* 基础变量 */
:root {
--glass-bg: rgba(255, 255, 255, 0.15);
--glass-border: rgba(255, 255, 255, 0.3);
--glass-shadow: rgba(0, 0, 0, 0.1);
}
/* 容器 */
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.floating-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
animation: float 20s ease-in-out infinite;
}
.orb-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%);
top: -100px;
right: -100px;
animation-delay: 0s;
}
.orb-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.3) 0%, transparent 70%);
bottom: -50px;
left: -100px;
animation-delay: -7s;
}
.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.25) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -14s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(20px, 30px) scale(1.02); }
}
/* 玻璃卡片 */
.glass-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
max-width: 560px;
width: 100%;
border-radius: 24px;
box-shadow:
0 2.8px 2.2px rgba(0, 0, 0, 0.034),
0 6.7px 5.3px rgba(0, 0, 0, 0.048),
0 12.5px 10px rgba(0, 0, 0, 0.06),
0 22.3px 17.9px rgba(0, 0, 0, 0.072),
0 41.8px 33.4px rgba(0, 0, 0, 0.086),
0 100px 80px rgba(0, 0, 0, 0.12);
}
.glass-backdrop {
position: absolute;
inset: 0;
z-index: 0;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.glass-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(255, 255, 255, 0.1);
}
.glass-border {
position: absolute;
inset: 0;
z-index: 2;
border-radius: 24px;
box-shadow:
inset 2px 2px 1px 0 rgba(255, 255, 255, 0.4),
inset -1px -1px 1px 1px rgba(255, 255, 255, 0.2);
pointer-events: none;
}
/* 卡片头部 */
.card-header {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 32px 32px 24px;
background: rgba(0, 0, 0, 0.1);
}
/* 品牌区域 */
.brand-section {
margin-bottom: 20px;
}
.logo-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 16px;
margin-bottom: 12px;
overflow: hidden;
}
.logo-glass {
position: absolute;
inset: 0;
backdrop-filter: blur(8px);
}
.logo-gradient {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1));
}
.logo-border {
position: absolute;
inset: 0;
border-radius: 16px;
box-shadow:
inset 3px 3px 2px 0 rgba(255, 255, 255, 0.6),
inset -2px -2px 2px 2px rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.logo-icon {
position: relative;
z-index: 10;
color: white;
}
.welcome-title {
font-size: 2.5rem;
font-weight: 400;
color: white;
letter-spacing: -0.02em;
margin: 0 0 8px;
line-height: 1.2;
}
.welcome-subtitle {
font-size: 0.875rem;
font-weight: 300;
color: rgba(255, 255, 255, 0.7);
margin: 0;
line-height: 1.5;
}
/* 进度步骤 */
.progress-steps {
display: flex;
align-items: center;
gap: 12px;
}
.step-group {
display: flex;
align-items: center;
gap: 8px;
}
.step-indicator {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
transition: all 0.3s ease;
}
.step-indicator.active {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.2));
box-shadow:
inset 1px 1px 1px rgba(255, 255, 255, 0.5),
0 4px 12px rgba(99, 102, 241, 0.3);
}
.step-label {
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.step-indicator.active + .step-label {
color: rgba(255, 255, 255, 0.9);
}
.step-line {
width: 24px;
height: 1px;
background: rgba(255, 255, 255, 0.3);
}
/* 卡片主体 */
.card-body {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
padding: 24px 32px 32px;
}
/* 表单头部 */
.form-header {
margin-bottom: 24px;
}
.form-title {
font-size: 1.5rem;
font-weight: 500;
color: white;
margin: 0 0 8px;
}
.form-subtitle {
font-size: 0.875rem;
font-weight: 400;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
/* 表单 */
.register-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.approval-notice {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 18px 12px 10px;
text-align: center;
}
.approval-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
color: #ffffff;
background: linear-gradient(135deg, rgba(52, 211, 153, 0.45), rgba(99, 102, 241, 0.28));
border-radius: 16px;
box-shadow:
inset 1px 1px 1px rgba(255, 255, 255, 0.45),
0 12px 30px rgba(52, 211, 153, 0.16);
}
.approval-title {
margin: 4px 0 0;
color: #ffffff;
font-size: 1.375rem;
font-weight: 600;
}
.approval-message,
.approval-hint {
max-width: 360px;
margin: 0;
color: rgba(255, 255, 255, 0.72);
font-size: 0.9375rem;
line-height: 1.7;
}
.approval-hint {
color: rgba(255, 255, 255, 0.54);
font-size: 0.8125rem;
}
.step-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: white;
}
/* 输入框 */
.input-wrapper {
position: relative;
border-radius: 12px;
overflow: hidden;
}
.input-glass {
position: absolute;
inset: 0;
backdrop-filter: blur(4px);
}
.input-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.1);
}
.input-border {
position: absolute;
inset: 0;
border-radius: 12px;
box-shadow:
inset 1px 1px 1px 0 rgba(255, 255, 255, 0.3),
inset -1px -1px 1px 1px rgba(255, 255, 255, 0.1);
pointer-events: none;
transition: box-shadow 0.2s ease;
}
.input-wrapper:focus-within .input-border {
box-shadow:
inset 1px 1px 1px 0 rgba(255, 255, 255, 0.5),
inset -1px -1px 1px 1px rgba(255, 255, 255, 0.2),
0 0 0 2px rgba(99, 102, 241, 0.3);
}
.glass-input {
position: relative;
z-index: 5;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
outline: none;
font-size: 0.875rem;
color: white;
font-family: inherit;
}
.glass-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: rgba(255, 255, 255, 0.5);
transition: color 0.2s ease;
}
.toggle-password:hover {
color: rgba(255, 255, 255, 0.8);
}
/* 错误文本 */
.error-text {
font-size: 0.75rem;
color: #f87171;
}
/* 密码提示 */
.password-hints {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.hint-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
transition: color 0.2s ease;
}
.hint-item.valid {
color: #34d399;
}
.hint-item svg {
flex-shrink: 0;
}
/* 条款部分 */
.terms-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-wrapper {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
}
.checkbox-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-custom {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.checkbox-input:checked + .checkbox-custom {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-color: transparent;
}
.checkbox-input:checked + .checkbox-custom::after {
content: '';
width: 10px;
height: 6px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: rotate(-45deg) translateY(-1px);
}
.checkbox-label {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
line-height: 1.5;
}
.link {
color: white;
text-decoration: underline;
font-weight: 500;
transition: opacity 0.2s ease;
}
.link-button,
.footer-link-button {
padding: 0;
border: 0;
background: transparent;
font: inherit;
line-height: inherit;
cursor: pointer;
}
.link:hover {
opacity: 0.8;
}
.protocol-content {
max-height: 58vh;
overflow-y: auto;
padding: 2px 8px 2px 0;
}
.protocol-section {
margin-bottom: 24px;
}
.protocol-section:last-child {
margin-bottom: 0;
}
.protocol-section h3 {
margin: 0 0 12px;
color: #2f3946;
font-size: 18px;
font-weight: 700;
line-height: 1.4;
}
.protocol-section p {
margin: 0 0 10px;
color: #5f6b7a;
font-size: 15px;
font-weight: 500;
line-height: 1.8;
}
.protocol-section p:last-child {
margin-bottom: 0;
}
.protocol-confirm-btn {
min-width: 112px;
height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
background: linear-gradient(135deg, #3f5d75, #4a6d87);
border: none;
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
}
:global(.register-protocol-dialog .el-dialog__header) {
padding: 24px 28px 12px;
margin-right: 0;
}
:global(.register-protocol-dialog .el-dialog__title) {
color: #2f3946;
font-size: 20px;
font-weight: 800;
}
:global(.register-protocol-dialog .el-dialog__body) {
padding: 8px 28px 20px;
}
:global(.register-protocol-dialog .el-dialog__footer) {
padding: 0 28px 24px;
}
/* 汇总部分 */
.summary-section {
padding: 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.summary-title {
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin: 0 0 12px;
}
.summary-grid {
display: grid;
gap: 12px;
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.summary-value {
font-size: 0.875rem;
color: white;
font-weight: 500;
}
/* 按钮组 */
.button-group {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn-primary,
.btn-secondary {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 20px;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 2.2);
}
.btn-primary {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.15));
border: none;
color: white;
box-shadow:
inset 2px 2px 1px 0 rgba(255, 255, 255, 0.5),
inset -1px -1px 1px 1px rgba(255, 255, 255, 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow:
inset 2px 2px 1px 0 rgba(255, 255, 255, 0.6),
inset -1px -1px 1px 1px rgba(255, 255, 255, 0.4),
0 10px 30px rgba(99, 102, 241, 0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
/* 页脚 */
.card-footer {
text-align: center;
margin-top: 24px;
}
.footer-text {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 12px;
}
.footer-link {
color: white;
font-weight: 600;
text-decoration: none;
margin-left: 4px;
transition: opacity 0.2s ease;
}
.footer-link:hover {
opacity: 0.8;
}
.footer-links {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.footer-small-link {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
text-decoration: none;
transition: color 0.2s ease;
}
.footer-small-link:hover {
color: rgba(255, 255, 255, 0.7);
}
.footer-divider {
color: rgba(255, 255, 255, 0.3);
}
/* 响应式 */
@media (max-width: 640px) {
.register-container {
padding: 16px;
}
.glass-card {
border-radius: 20px;
}
.card-header {
padding: 24px 20px 20px;
}
.card-body {
padding: 20px;
}
.welcome-title {
font-size: 2rem;
}
.form-row {
grid-template-columns: 1fr;
}
.progress-steps {
gap: 8px;
}
.step-label {
display: none;
}
}
</style>