initial commit

This commit is contained in:
Mr.April
2026-01-16 22:04:48 +08:00
parent 28cc3ec005
commit c5244e2b75
95 changed files with 27633 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import type { CSSProperties } from 'vue'
export default defineComponent({
name: 'Icon',
props: {
name: {
type: String,
required: true,
},
size: {
type: [String, Number],
default: '18px',
},
color: {
type: String,
default: '#000000',
},
attr: {
type: Object,
default: () => {},
},
},
setup(props:any) {
// 添加isExternal函数定义
const isExternal = (path: string): boolean => {
return /^(https?:|mailto:|tel:)/.test(path)
}
const iconStyle = computed((): CSSProperties => {
const { size, color } = props
// 修复:添加类型检查,确保size是字符串才能调用replace方法
let fontSize = typeof size === 'string' ? `${size.replace('px', '')}px` : `${size}px`
return {
fontSize: fontSize,
color: color,
}
})
if (props.name.startsWith('el-icon-')) {
// 提取去掉前缀后的组件名
const iconName = props.name.replace('el-icon-', '')
return () =>
h(resolveComponent('el-icon'), { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr }, () =>
h(resolveComponent(iconName))
)
} else if (props.name.startsWith('local-') || isExternal(props.name)) {
const name = props.name.replace('local-', '')
return () => h(resolveComponent('nuxt-icon'), { class: [props.name, 'icon'], name: name, style: iconStyle.value, ...props.attr })
} else {
// 处理fa前缀和其他普通图标
return () => h('i', { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr })
}
},
})
</script>
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="loginPopup">
<el-dialog
v-model="dialogVisible"
align-center
width="450"
:before-close="beforeClose"
custom-class="login !rounded-[var(--rounded-big)]"
:show-close="false"
append-to-body
>
<login v-if="type === 'login' && dialogVisible" @typeChange="typeChange" />
<register v-if="type === 'register' && dialogVisible" @typeChange="typeChange" />
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from "vue";
import login from "./login.vue";
import register from "./register.vue";
const type = ref("login");
const memberStore = useMemberStore();
//弹框状态
const dialogVisible = computed(() => {
return memberStore.loginPopup;
});
//弹框关闭
const beforeClose = (next: any) => {
memberStore.logClose();
type.value = "login";
next();
};
const handleClose = () => {
memberStore.logClose();
type.value = "login";
};
//判断当前是登录还是注册
const typeChange = (val: any) => {
type.value = val;
};
</script>
<style>
.login .el-dialog__header {
padding: 0 !important;
}
.login .el-dialog__body {
padding: 0 !important;
}
.login .el-dialog__headerbtn {
z-index: 99;
}
</style>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-input__wrapper {
box-shadow: unset !important;
border-radius: 0;
&.is-focus {
border: none;
}
}
&.is-error {
.el-input__wrapper {
border: none;
}
}
}
:deep(.el-form-item__error) {
padding-top: 5px;
}
.text-color {
color: var(--el-color-primary);
}
</style>
+649
View File
@@ -0,0 +1,649 @@
<template>
<div class="login-container" :class="{'dark-mode': isDarkMode}">
<!-- 登录方式切换图标 -->
<div class="login-mode-toggle">
<Icon
v-if="isAccountLoginActive"
@click="toggleLoginMode"
name="local-qr"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
/>
<Icon
v-else
@click="toggleLoginMode"
name="local-pc"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
/>
</div>
<!-- 账号密码登录表单 -->
<div v-if="isAccountLoginActive" class="login-form-container">
<div class="login-type-tabs">
<div
v-for="(item, index) in availableLoginTypes"
:key="item.type"
class="login-type-tab"
:class="{
'login-type-tab--active': currentLoginType === item.type,
'login-type-tab--margin': index + 1 !== availableLoginTypes.length,
}"
@click="currentLoginType = item.type"
>
{{ item.title }}
</div>
</div>
<el-form
:model="formData"
ref="formRef"
:rules="formRules"
:validate-on-rule-change="false"
class="login-form"
>
<!-- 用户名密码登录 -->
<div v-show="currentLoginType === 'username'" class="login-form-section">
<el-form-item prop="username" class="form-item">
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('username_placeholder')"
clearable
:inline-message="true"
:readonly="isUsernameInputReadonly"
@click="isUsernameInputReadonly = false"
@blur="isUsernameInputReadonly = true"
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-User"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="password" class="form-item">
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('password_placeholder')"
type="password"
clearable
show-password
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Lock"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 手机号登录 -->
<div v-show="currentLoginType === 'mobile'" class="login-form-section">
<el-form-item prop="mobile" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('mobile_placeholder')"
clearable
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Phone"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="mobile_code" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile_code"
:placeholder="t('code_placeholder')"
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Key"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<sms-code
:mobile="formData.mobile"
type="login"
v-model="formData.mobile_key"
@click="handleSendSmsCode"
ref="smsCodeRef"
></sms-code>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 注册链接 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToRegister" class="register-link">
{{ t("no_account") }},{{ t("to_register") }}
</el-button>
</div>
<!-- 登录按钮 -->
<div class="login-button-container">
<el-button
type="primary"
class="login-button"
size="large"
@click="handleLoginSubmit"
:loading="isLoading"
>
{{ isLoading ? t("logining") : t("login") }}
</el-button>
</div>
<!-- 协议同意 -->
<div v-if="configStore.login.agreement_show" class="agreement-container">
<span
class="iconfont agreement-checkbox"
:class="isAgreeProtocol ? 'icon-xuanze1' : 'icon-checkbox_nol'"
@click="isAgreeProtocol = !isAgreeProtocol"
></span>
{{ t("agree_tips") }}
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
<span class="agreement-link">{{ t("user_agreement") }}</span>
</NuxtLink>
{{ t("and") }}
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
<span class="agreement-link">{{ t("privacy_agreement") }}</span>
</NuxtLink>
</div>
</el-form>
</div>
<!-- 微信扫码登录 -->
<div v-else class="wechat-login-container">
<div class="wechat-login-title">{{ t("wechat_scan_login") }}</div>
<div class="qrcode-container">
<div class="qrcode-wrapper">
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
<div v-else class="qrcode-placeholder"></div>
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("click_refresh") }}</span>
</div>
</div>
<div class="wechat-tip">
<span class="iconfont icon-weixin1 wechat-icon"></span>
<span class="wechat-text">{{ t("wechat_scan_tip") }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onUnmounted } from "vue";
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
import useConfigStore from "@/stores/config";
import QRCode from "qrcode";
import type { FormInstance } from "element-plus";
// Store 实例
const memberStore = useMemberStore();
const configStore = useConfigStore();
// 暗黑模式状态
const isDarkMode = computed(() => {
return configStore.isDarkMode;
});
// 协议链接配置
const protocolUrls = computed(() => {
const basePath = location.pathname.includes("web") ? "/web/auth/agreement" : "/auth/agreement";
return {
serviceUrl: `${basePath}?key=service`,
privacyUrl: `${basePath}?key=privacy`,
};
})as any;
// 登录模式状态
const isAccountLoginActive = ref(true);
const currentLoginType = ref("");
let qrCodeTimer: any = null;
// 微信扫码登录状态
const wechatQrCode = ref({
url: "",
key: "",
isExpired: false,
expiredMessage: t("qrcode_generate_failed"),
});
// 表单数据
const isLoading = ref(false);
const isAgreeProtocol = ref(false);
const isUsernameInputReadonly = ref(true);
const formData = reactive({
username: "",
password: "",
mobile: "",
mobile_code: "",
mobile_key: "",
});
// 表单引用
const formRef = ref<FormInstance>();
const smsCodeRef = ref<AnyObject | null>(null);
// 计算属性
const availableLoginTypes = computed(() => {
const types = [];
if (configStore.login.is_username) {
types.push({ type: "username", title: t("username_login") });
}
if (configStore.login.is_mobile) {
types.push({ type: "mobile", title: t("mobile_login") });
}
currentLoginType.value = types[0] ? types[0].type : "";
return types;
});
const formRules = computed(() => ({
username: {
required: currentLoginType.value === "username",
message: t("username_placeholder"),
trigger: ["blur", "change"],
},
password: {
required: currentLoginType.value === "username",
message: t("password_placeholder"),
trigger: ["blur", "change"],
},
mobile: [
{
required: currentLoginType.value === "mobile",
message: t("mobile_placeholder"),
trigger: ["blur", "change"],
},
{
validator(rule: any, value: string, callback: any) {
if (currentLoginType.value !== "mobile") return true;
return validate.mobile(value);
},
message: t("mobile_error"),
trigger: ["blur"],
},
],
mobile_code: {
required: currentLoginType.value === "mobile",
message: t("code_placeholder"),
trigger: ["change"],
},
}));
// 生命周期
onUnmounted(() => {
clearTimeout(qrCodeTimer);
});
// 初始化配置
configStore.getLoginConfig();
// 登录模式切换
const toggleLoginMode = () => {
isAccountLoginActive.value = !isAccountLoginActive.value;
if (!isAccountLoginActive.value) {
handleGenerateWechatQrCode();
} else {
clearTimeout(qrCodeTimer);
}
};
// 微信扫码登录相关方法
const handleGenerateWechatQrCode = async () => {
try {
const response = await generateWechatQrCode() as any;
const data = response.data;
wechatQrCode.value.key = data.key;
if (data.url) {
const qrCodeUrl = await QRCode.toDataURL(data.url, {
errorCorrectionLevel: "L",
margin: 0,
width: 100,
});
wechatQrCode.value.url = qrCodeUrl;
wechatQrCode.value.isExpired = false;
setTimeout(() => {
checkScan(wechatQrCode.value.key);
}, 1000);
}
} catch (error) {
wechatQrCode.value.isExpired = true;
wechatQrCode.value.expiredMessage = t("qrcode_generate_failed");
}
};
/**
* 检查微信扫码登录状态
* @param key - 扫码登录密钥
*/
const checkScan = (key: string) => {
checkWechatScanStatus({ key })
.then((res: any) => {
const data = res.data;
switch (data.status) {
case "wait":
qrCodeTimer = setTimeout(() => {
checkScan(wechatQrCode?.value.key);
}, 1000);
break;
case "success":
handleWechatLoginSuccess(data.login_data);
break;
case "fail":
wechatQrCode.value.expiredMessage = data.fail_reason;
wechatQrCode.value.isExpired = true;
break;
}
})
.catch((error) => {
wechatQrCode.value.isExpired = true;
wechatQrCode.value.expiredMessage = error.msg || t("scan_check_failed");
});
};
const handleWechatLoginSuccess = (loginData: any) => {
if (!loginData.token) {
useCookie("openId").value = loginData.openid;
navigateTo("/auth/bind");
memberStore.logClose();
} else {
memberStore.setToken(loginData.token);
memberStore.logClose();
}
};
// 表单处理方法
const handleLoginSubmit = async () => {
const isValid = await formRef.value?.validate();
if (!isValid) return;
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
ElMessage.error(t("is_agree_tips"));
return;
}
if (isLoading.value) return;
isLoading.value = true;
try {
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
const response = await loginMethod(formData) as any;
await memberStore.setToken(response?.data?.token || "");
memberStore.logClose();
} catch (error) {
isLoading.value = false;
}
};
const handleSendSmsCode = async () => {
const isValid = await formRef.value?.validateField("mobile");
if (isValid) {
smsCodeRef.value?.send();
}
};
// 组件通信
const emit = defineEmits(["typeChange"]);
const switchToRegister = () => {
emit("typeChange", "register");
};
</script>
<style lang="scss" scoped>
.login-container {
position: relative;
width: 100%;
&.dark-mode {
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
}
.login-mode-toggle {
position: absolute;
top: 0;
right: 0;
}
.login-toggle-icon {
font-size: 50px;
cursor: pointer;
}
.login-form-container {
background: var(--el-bg-color);
width: 100%;
padding: 60px 30px;
border-radius: var(--rounded-big);
}
.login-type-tabs {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 30px;
}
.login-type-tab {
font-size: 18px;
cursor: pointer;
color: var(--el-text-color-secondary);
line-height: 24px;
font-family: "OppoSans-R";
&--active {
color: var(--el-text-color-primary);
font-weight: 600;
}
&--margin {
margin-right: 70px;
}
}
.login-form {
width: 100%;
}
.form-item {
margin-bottom: 20px;
}
.input-container {
flex: 1;
height: 50px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
display: flex;
align-items: center;
background: var(--el-bg-color);
}
.login-input {
width: 100%;
border: none;
:deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
.el-input__inner {
color: var(--el-text-color-primary);
&::placeholder {
color: var(--el-text-color-placeholder);
}
}
}
}
.input-prefix-icon {
margin-right: 14px;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.register-link {
font-size: 12px;
}
.login-button-container {
margin-top: 20px;
}
.login-button {
width: 100%;
height: 50px;
border-radius: 8px;
font-family: "OppoSans-M";
}
.agreement-container {
font-size: 12px;
line-height: 24px;
display: flex;
align-items: center;
width: 100%;
margin-top: 20px;
color: var(--el-text-color-secondary);
}
.agreement-checkbox {
color: var(--el-color-primary);
margin-right: 5px;
cursor: pointer;
}
.agreement-link {
color: var(--el-color-primary);
margin: 0 4px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
.wechat-login-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 30px;
background: var(--el-bg-color);
}
.wechat-login-title {
font-size: 18px;
color: var(--el-text-color-primary);
font-weight: 600;
line-height: 24px;
font-family: "OppoSans-R";
}
.qrcode-container {
margin-top: 30px;
}
.qrcode-wrapper {
padding: 20px;
border: 1px solid var(--el-border-color);
border-radius: var(--rounded-small);
position: relative;
background: var(--el-bg-color);
}
.qrcode-image {
width: 200px;
height: 200px;
}
.qrcode-placeholder {
width: 202px;
height: 202px;
background: var(--el-bg-color-page);
}
.qrcode-overlay {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
inset: 0;
background: var(--el-bg-color-overlay);
}
.qrcode-error-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.qrcode-refresh-link {
font-size: 12px;
cursor: pointer;
color: var(--el-color-primary);
margin-top: 8px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
.wechat-tip {
margin-top: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-icon {
color: #00c22c;
font-size: 16px;
}
.wechat-text {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-left: 4px;
}
</style>
+520
View File
@@ -0,0 +1,520 @@
<template>
<div class="register-container" :class="{'dark-mode': isDarkMode}">
<div class="register-form-container">
<div class="register-type-tabs">
<div
v-for="(item, index) in registerTypes"
:key="item.type"
class="register-type-tab"
:class="{
'register-type-tab--active': currentType === item.type,
'register-type-tab--margin': index + 1 !== registerTypes.length
}"
@click="currentType = item.type"
>
{{ item.title }}
</div>
</div>
<el-form
:model="formData"
ref="formRef"
:rules="formRules"
:validate-on-rule-change="false"
class="register-form"
>
<!-- 用户名注册 -->
<div v-show="currentType === 'username'" class="register-form-section">
<el-form-item prop="username" class="form-item">
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('username_placeholder')"
clearable
:inline-message="true"
:readonly="realNameInput"
@click="realNameInput = false"
@blur="realNameInput = true"
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-User"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="password" class="form-item">
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('password_placeholder')"
type="password"
clearable
show-password
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Lock"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="confirmPassword" class="form-item">
<div class="input-container">
<el-input
v-model="formData.confirmPassword"
:placeholder="t('confirm_password_placeholder')"
type="password"
clearable
show-password
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Lock"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="captchaCode" class="form-item">
<div class="input-container">
<el-input
v-model="formData.captchaCode"
:placeholder="t('captcha_placeholder')"
class="register-input"
>
<template #prefix>
<Icon
name="local-verify"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<div class="captcha-image-container">
<el-image
:src="captcha.image.value"
class="captcha-image"
@click="captcha.refresh()"
></el-image>
</div>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 手机号注册 -->
<div v-show="currentType === 'mobile' || configStore.login.is_bind_mobile" class="register-form-section">
<el-form-item prop="mobile" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('mobile_placeholder')"
clearable
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Phone"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="mobileCode" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobileCode"
:placeholder="t('code_placeholder')"
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Key"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<sms-code
:mobile="formData.mobile"
type="login"
v-model="formData.mobileKey"
@click="sendSmsCode"
ref="smsCodeRef"
></sms-code>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 登录链接 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToLogin" class="login-link">
{{ t('have_account') }},{{ t('to_login') }}
</el-button>
</div>
<!-- 注册按钮 -->
<div class="register-button-container">
<el-button
type="primary"
class="register-button"
size="large"
@click="handleRegister"
:loading="isLoading"
>
{{ isLoading ? t('registering') : t('register') }}
</el-button>
</div>
<!-- 协议同意 -->
<div
v-if="configStore.login.agreement_show"
class="agreement-container"
>
<span
class="iconfont agreement-checkbox"
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
@click="toggleAgreement"
></span>
{{ t('register_agree_tips') }}
<NuxtLink :to="protocolUrls.service" target="_blank">
<span class="agreement-link">{{ t('user_agreement') }}</span>
</NuxtLink>
{{ t('and') }}
<NuxtLink :to="protocolUrls.privacy" target="_blank">
<span class="agreement-link">{{ t('privacy_agreement') }}</span>
</NuxtLink>
</div>
</el-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { registerUser, registerWithMobile } from '@/api/auth'
import useConfigStore from '@/stores/config'
import type { FormInstance } from 'element-plus'
// 状态管理
const memberStore = useMemberStore()
const configStore = useConfigStore()
// 暗黑模式状态
const isDarkMode = computed(() => {
return configStore.isDarkMode
})
// 初始化配置
configStore.getLoginConfig()
// 协议链接配置
const protocolUrls = computed(() => {
const basePath = location.pathname.includes('web') ? '/web' : ''
return {
service: `${basePath}/auth/agreement?key=service`,
privacy: `${basePath}/auth/agreement?key=privacy`
}
})
// 注册类型配置
const currentType = ref('')
const registerTypes = computed(() => {
const types = []
if (configStore.login.is_username) {
types.push({ type: 'username', title: t('username_register') })
}
if (configStore.login.is_mobile && !configStore.login.is_bind_mobile) {
types.push({ type: 'mobile', title: t('mobile_register') })
}
currentType.value = types[0]?.type || ''
return types
})
// 表单数据
const isLoading = ref(false)
const formData = reactive({
username: '',
password: '',
confirmPassword: '',
mobile: '',
mobileCode: '',
mobileKey: '',
captchaKey: '',
captchaCode: ''
})
// 表单验证规则
const formRef = ref<FormInstance>()
const formRules = computed(() => ({
username: {
type: 'string',
required: currentType.value === 'username',
message: t('username_placeholder'),
trigger: ['blur', 'change'],
},
password: {
type: 'string',
required: currentType.value === 'username',
message: t('password_placeholder'),
trigger: ['blur', 'change']
},
confirmPassword: [
{
type: 'string',
required: currentType.value === 'username',
message: t('confirm_password_placeholder'),
trigger: ['blur', 'change']
},
{
validator(rule: any, value: string, callback: any) {
return value === formData.password
},
message: t('confirm_password_error'),
trigger: ['change', 'blur'],
}
],
mobile: [
{
type: 'string',
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
message: t('mobile_placeholder'),
trigger: ['blur', 'change'],
},
{
validator(rule: any, value: string, callback: any) {
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
return validate.mobile(value)
},
message: t('mobile_error'),
trigger: ['change', 'blur'],
}
],
mobileCode: {
type: 'string',
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
message: t('code_placeholder'),
trigger: ['blur', 'change']
},
captchaCode: {
type: 'string',
required: currentType.value === 'username',
message: t('captcha_placeholder'),
trigger: ['blur', 'change'],
}
}))
// 协议同意状态
const isAgreeChecked = ref(false)
const toggleAgreement = () => {
isAgreeChecked.value = !isAgreeChecked.value
}
// 注册处理
const handleRegister = async () => {
await formRef.value?.validate(async (valid) => {
if (!valid) return
// 检查协议同意
if (configStore.login.agreement_show && !isAgreeChecked.value) {
ElMessage.error(t('is_agree_tips'))
return
}
if (isLoading.value) return
isLoading.value = true
try {
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
const res = await registerApi(formData)
memberStore.setToken(res.data.token)
memberStore.logClose()
} catch (error) {
isLoading.value = false
captcha.refresh()
}
})
}
// 验证码功能
const captcha = useCaptcha(formData)
captcha.refresh()
// 短信验证码
const smsCodeRef = ref<AnyObject | null>(null)
const sendSmsCode = async () => {
await formRef.value?.validateField('mobile', (valid) => {
if (valid) {
smsCodeRef.value?.send()
}
})
}
// 切换到登录
const emit = defineEmits(['typeChange'])
const switchToLogin = () => {
emit('typeChange', 'login')
}
// 输入框状态
const realNameInput = ref(true)
</script>
<style lang="scss" scoped>
.register-container {
width: 100%;
&.dark-mode {
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
}
.register-form-container {
background: var(--el-bg-color);
width: 100%;
padding: 60px 30px;
border-radius: var(--rounded-big);
}
.register-type-tabs {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 30px;
}
.register-type-tab {
font-size: 18px;
cursor: pointer;
color: var(--el-text-color-secondary);
line-height: 24px;
font-family: 'OppoSans-R';
&--active {
color: var(--el-text-color-primary);
font-weight: 600;
}
&--margin {
margin-right: 70px;
}
}
.register-form {
width: 100%;
}
.form-item {
margin-bottom: 20px;
}
.input-container {
flex: 1;
height: 50px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
display: flex;
align-items: center;
background: var(--el-bg-color);
}
.register-input {
width: 100%;
border: none;
:deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
.el-input__inner {
color: var(--el-text-color-primary);
&::placeholder {
color: var(--el-text-color-placeholder);
}
}
}
}
.input-prefix-icon {
margin-right: 14px;
}
.captcha-image-container {
padding: 0;
line-height: 1;
}
.captcha-image {
height: 30px;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.login-link {
font-size: 12px;
}
.register-button-container {
margin-top: 20px;
}
.register-button {
width: 100%;
height: 50px;
border-radius: 8px;
font-family: 'OppoSans-M';
}
.agreement-container {
font-size: 12px;
line-height: 24px;
display: flex;
align-items: center;
width: 100%;
margin-top: 20px;
color: var(--el-text-color-secondary);
}
.agreement-checkbox {
color: var(--el-color-primary);
margin-right: 5px;
cursor: pointer;
}
.agreement-link {
color: var(--el-color-primary);
margin: 0 4px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
</style>
+268
View File
@@ -0,0 +1,268 @@
<template>
<div class="sms-code-container">
<el-button
type="primary"
link
:disabled="!canSendCode"
@click="handleSendCode"
class="sms-code-button"
>
{{ buttonText }}
</el-button>
</div>
<el-dialog
v-model="showCaptchaDialog"
:title="t('captcha_title')"
width="350px"
:append-to-body="true"
:align-center="true"
class="captcha-dialog"
>
<el-form
:model="captchaForm"
ref="captchaFormRef"
:rules="captchaRules"
@submit.native.prevent
class="captcha-form"
>
<el-form-item prop="captcha_code" class="captcha-form-item">
<el-input
v-model="captchaForm.captcha_code"
:placeholder="t('captcha_placeholder')"
class="captcha-input"
>
<template #suffix>
<div class="captcha-image-wrapper">
<el-image
:src="captchaImage"
class="captcha-image"
@click="refreshCaptcha"
></el-image>
</div>
</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeCaptchaDialog" class="cancel-button">
{{ t('cancel') }}
</el-button>
<el-button
type="primary"
:loading="isLoading"
@click="confirmSendCode"
class="confirm-button"
>
{{ t('confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { sendSmsVerificationCode } from '@/api/system'
import type { FormInstance } from 'element-plus'
interface SmsCodeProps {
mobile: string
type: string
modelValue: string
}
interface CaptchaFormData {
mobile: string
captcha_code: string
captcha_key: string
type: string
}
const props = defineProps<SmsCodeProps>()
const emit = defineEmits(['update:modelValue', 'click'])
// 响应式数据
const isLoading = ref(false)
const showCaptchaDialog = ref(false)
const canSendCode = ref(true)
const buttonText = ref('')
const seconds = ref(90)
let countdownTimer: number | undefined = undefined
// 验证码相关
const captchaForm = reactive<CaptchaFormData>({
mobile: '',
captcha_code: '',
captcha_key: '',
type: props.type
})
const captchaImage = ref('')
const captchaFormRef = ref<FormInstance>()
// 验证规则
const captchaRules = reactive({
captcha_code: {
required: true,
message: '请输入验证码',
trigger: ['blur', 'change']
}
})
// 计算属性
const smsValue = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
// 初始化按钮文本
buttonText.value = t('get_sms_code')
// 发送短信验证码
const sendSmsCode = async () => {
captchaForm.mobile = props.mobile
if (!canSendCode.value) return
try {
isLoading.value = true
const response = await sendSmsVerificationCode(captchaForm) as any
if (response?.code !== -1) {
smsValue.value = response.data.key
startCountdown()
closeCaptchaDialog()
} else {
refreshCaptcha()
}
} catch (error) {
refreshCaptcha()
} finally {
isLoading.value = false
}
}
// 开始倒计时
const startCountdown = () => {
canSendCode.value = false
countdownTimer = setInterval(() => {
if (seconds.value > 0) {
seconds.value -= 1
buttonText.value = `${seconds.value}${t('sms_code_change_text')}`
} else {
resetCountdown()
}
}, 1000) as any
}
// 重置倒计时
const resetCountdown = () => {
clearInterval(countdownTimer)
seconds.value = 90
canSendCode.value = true
buttonText.value = t('get_sms_code')
}
// 刷新验证码
const refreshCaptcha = async () => {
try {
// 这里需要调用验证码API,暂时使用空字符串
captchaImage.value = ''
captchaForm.captcha_code = ''
} catch (error) {
console.error('刷新验证码失败:', error)
}
}
// 处理发送验证码点击
const handleSendCode = () => {
emit('click')
showCaptchaDialog.value = true
refreshCaptcha()
}
// 确认发送验证码
const confirmSendCode = async () => {
await captchaFormRef.value?.validate(async (valid) => {
if (valid) {
await sendSmsCode()
}
})
}
// 关闭验证码对话框
const closeCaptchaDialog = () => {
showCaptchaDialog.value = false
captchaForm.captcha_code = ''
}
// 暴露方法给父组件
defineExpose({
send: sendSmsCode
})
</script>
<style lang="scss" scoped>
.sms-code-container {
height: 30px;
display: flex;
align-items: center;
}
.sms-code-button {
font-size: 12px;
padding: 0;
height: auto;
}
.captcha-dialog {
:deep(.el-dialog__header) {
padding: 20px 20px 10px;
}
:deep(.el-dialog__body) {
padding: 10px 20px;
}
}
.captcha-form {
width: 100%;
}
.captcha-form-item {
margin-bottom: 0;
}
.captcha-input {
:deep(.el-input__wrapper) {
padding-right: 0;
}
}
.captcha-image-wrapper {
padding: 5px 0;
line-height: 1;
}
.captcha-image {
height: 30px;
cursor: pointer;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancel-button,
.confirm-button {
min-width: 80px;
}
</style>