chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,450 @@
<template>
<view class="captcha-verify">
<view class="captcha-verify-header">
<!-- 返回按钮 -->
<view class="back-box" @tap="handleBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</view>
<view class="captcha-verify-content">
<view class="title">
{{
phoneOrEmail?.includes("@")
? t("Mobile.Auth.inputEmailCode")
: t("Mobile.Auth.inputSmsCode")
}}
</view>
<view class="phone-info">
{{ t("Mobile.Auth.codeSentToPrefix") }}
{{
phoneOrEmail?.includes("@")
? `${t("Mobile.Auth.codeSentToEmailTarget")} `
: `${t("Mobile.Auth.codeSentToPhoneTarget")} `
}}
{{ `${!phoneOrEmail?.includes("@") ? "86" : ""} ${phoneOrEmail}` }}
</view>
<view class="captcha-input-container">
<input
ref="captchaInputRef"
class="captcha-input"
:class="{ active: isFocused }"
type="number"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
:value="captchaCode"
:focus="isFocused"
:adjust-position="false"
:hold-keyboard="true"
cursor-spacing="0"
:placeholder="t('Mobile.Auth.inputVerifyCode')"
cursor-color="#5147FF"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@paste="handlePaste"
@longpress="handleLongPress"
/>
</view>
<view class="resend-container">
<text class="countdown" v-if="countdown > 0">{{
t("Mobile.Auth.resendAfterSeconds", { seconds: countdown })
}}</text>
<text
class="resend-btn"
:class="{ disabled: countdown > 0 }"
@tap="handleResend"
>{{ t("Mobile.Auth.resend") }}</text
>
</view>
</view>
</view>
</template>
<script lang="uts" setup>
import { apiSendCode } from "@/servers/account";
import { SUCCESS_CODE, AGENT_NOT_EXIST } from "@/constants/codes.constants";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 定义组件props
interface Props {
phoneOrEmail?: string;
tenantConfigInfo?: TenantConfigInfo;
}
const props = withDefaults(defineProps<Props>(), {
phoneOrEmail: "",
tenantConfigInfo: null,
});
const emit = defineEmits(["verify-login", "back"]);
const captchaCode = ref<string>("");
const countdown = ref<number>(59);
let countdownTimer: number | null = null;
const captchaInputRef = ref<any>(null);
const isFocused = ref<boolean>(false);
// 判断配置信息是否邮箱登录
const isEmailAuth = computed(() => {
if (!props.tenantConfigInfo) {
return false;
}
return props.tenantConfigInfo?.authType === 3;
});
onMounted(() => {
open();
});
const open = () => {
captchaCode.value = "";
startCountdown();
nextTick(() => {
focusInput();
});
};
const handleBack = () => {
clearCountdown();
emit("back");
};
const handleInput = async (event: any) => {
let value = event.detail.value || "";
// 强制过滤非数字字符
value = value.replace(/[^0-9]/g, "");
// 限制最大长度为6位
if (value.length > 6) {
value = value.slice(0, 6);
}
captchaCode.value = value;
// 强制更新输入框显示值
// #ifdef MP-WEIXIN
if (captchaInputRef.value) {
captchaInputRef.value.value = value;
}
// #endif
// 检查是否输入完成6位数字
if (value.length === 6 && /^\d{6}$/.test(value)) {
emit("verify-login", value);
}
};
const handleFocus = () => {
isFocused.value = true;
};
const handleBlur = () => {
isFocused.value = false;
};
const handlePaste = async (event: any) => {
// #ifdef H5
// H5平台使用 clipboardData
event.preventDefault();
let pasteData = "";
if (event.clipboardData && event.clipboardData.getData) {
pasteData = event.clipboardData.getData("text");
await processPasteData(pasteData);
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
// 微信小程序和App平台在用户点击粘贴后触发此事件
// 延迟一下确保粘贴操作完成后再读取
setTimeout(async () => {
await getClipboardAndPaste();
}, 100);
// #endif
};
const getClipboardAndPaste = async () => {
try {
const res = await uni.getClipboardData();
await processPasteData(res.data);
} catch (error) {
uni.showToast({
title: t("Mobile.Auth.clipboardGetFailed"),
icon: "none",
duration: 2000,
});
}
};
const processPasteData = async (pasteData: string) => {
// 验证粘贴内容是否为6位数字
if (pasteData && /^\d{6}$/.test(pasteData)) {
// 直接设置验证码
captchaCode.value = pasteData;
// 强制更新输入框显示值
// #ifdef MP-WEIXIN
if (captchaInputRef.value) {
captchaInputRef.value.value = pasteData;
}
// #endif
// 触发验证
emit("verify-login", pasteData);
// 提示用户
uni.showToast({
title: t("Mobile.Auth.codeAutoFilled"),
icon: "success",
duration: 1500,
});
} else {
// 粘贴内容不是6位数字提示用户
uni.showToast({
title: t("Mobile.Auth.pasteSixDigits"),
icon: "none",
duration: 2000,
});
}
};
const handleLongPress = async (event: any) => {
// #ifdef MP-WEIXIN || APP-PLUS
// 长按时直接读取剪贴板并询问是否填充
try {
const res = await uni.getClipboardData();
const clipData = res.data;
// 检查剪贴板是否包含6位数字验证码
if (clipData && /^\d{6}$/.test(clipData)) {
// 直接填充,不询问
await processPasteData(clipData);
} else if (clipData) {
// 剪贴板有内容但不是6位数字
uni.showToast({
title: t("Mobile.Auth.clipboardNotSixDigits"),
icon: "none",
duration: 2000,
});
} else {
uni.showToast({
title: t("Mobile.Auth.clipboardEmpty"),
icon: "none",
duration: 1500,
});
}
} catch (error) {
uni.showToast({
title: t("Mobile.Auth.clipboardReadFailed"),
icon: "none",
duration: 2000,
});
}
// #endif
};
const startCountdown = () => {
countdown.value = 59;
countdownTimer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearCountdown();
}
}, 1000);
};
const clearCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
};
// 兼容微信小程序和H5的聚焦方法
const focusInput = () => {
// #ifdef MP-WEIXIN
// 微信小程序使用 isFocused 响应式变量控制聚焦
isFocused.value = true;
// #endif
// #ifdef H5
// H5平台使用 ref 引用
if (captchaInputRef.value && captchaInputRef.value.focus) {
captchaInputRef.value.focus();
}
// #endif
// #ifdef APP-PLUS
// App平台使用 ref 引用
if (captchaInputRef.value && captchaInputRef.value.focus) {
captchaInputRef.value.focus();
}
// #endif
};
const handleResend = async () => {
if (countdown.value > 0) return;
const params = {
type: "LOGIN_OR_REGISTER",
// "phone": props.phoneOrEmail
};
if (isEmailAuth.value) {
params.email = props.phoneOrEmail;
} else {
params.phone = props.phoneOrEmail;
}
try {
uni.showLoading({
title: t("Mobile.Auth.sending"),
mask: false,
});
// 发送验证码
const { code, data, message } = await apiSendCode(params);
if (isEmailAuth.value) {
// 邮箱验证码
if (code === SUCCESS_CODE) {
startCountdown();
uni.showToast({
title: t("Mobile.Auth.resendSuccess"),
icon: "success",
});
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
duration: 5000,
});
}
} else {
// 手机验证码
if (code === SUCCESS_CODE) {
startCountdown();
uni.showToast({
title: t("Mobile.Auth.resendSuccess"),
icon: "success",
});
} else {
// 手机验证失败提示消息
uni.showToast({
title: message,
icon: "none",
});
}
}
} finally {
uni.hideLoading();
}
};
onUnmounted(() => {
clearCountdown();
});
</script>
<style lang="scss" scoped>
.captcha-verify {
overflow: auto;
display: flex;
flex-direction: column;
.captcha-verify-header {
.back-box {
display: inline-block;
width: 58rpx;
height: 58rpx;
line-height: 58rpx;
text-align: center;
background-color: rgba(12, 20, 102, 0.04);
border-radius: 50%;
.iconfont {
font-size: 40rpx;
line-height: 58rpx;
color: #333;
}
}
}
.captcha-verify-content {
flex: 1;
overflow: auto;
.title {
font-size: 40rpx;
line-height: 56rpx;
font-weight: 600;
color: rgba(21, 23, 31, 1);
margin-bottom: 24rpx;
line-height: 64rpx;
text-align: center;
}
.phone-info {
font-size: 28rpx;
font-weight: 400;
color: rgba(21, 23, 31, 0.7);
margin-bottom: 80rpx;
line-height: 44rpx;
text-align: center;
}
.captcha-input-container {
display: flex;
justify-content: center;
margin-bottom: 80rpx;
.captcha-input {
width: 100%;
// max-width: 600rpx;
height: 88rpx;
border: 2rpx solid transparent;
border-radius: 16rpx;
text-align: left;
font-size: 32rpx;
font-weight: 400;
color: rgba(21, 23, 31, 1);
background-color: rgba(12, 20, 102, 0.04);
padding: 0 24rpx;
// letter-spacing: 8rpx;
&.active {
border-color: rgba(81, 71, 255, 1);
}
&:focus {
border-color: rgba(81, 71, 255, 1);
background-color: #fff;
outline: none;
}
}
}
.resend-container {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 16rpx;
font-size: 28rpx;
font-weight: 400;
line-height: 44rpx;
color: rgba(21, 23, 31, 0.7);
.countdown {
}
.resend-btn {
cursor: pointer;
color: #5147ff;
&.disabled {
color: #ccc;
cursor: not-allowed;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,643 @@
<template>
<view class="container">
<!-- 分段器组件 -->
<segmented-control
:options="loginOptions"
v-model="currentLoginType"
@change="handleLoginTypeChange"
class="login-segmented"
/>
<view class="sub-title-wrapper">
<text class="sub-title">{{
t("Mobile.Auth.welcomeUse", {
siteName: tenantConfigInfo?.siteName || "",
})
}}</text>
</view>
<view class="phone-input-wrapper" v-if="!isEmailAuth">
<view class="country-code">
<text>{{ t("Mobile.Auth.defaultAreaCode") }}</text>
<uni-icon class="iconfont icon-a-Chevrondown" />
</view>
<input
class="phone-input"
type="number"
:placeholder="t('Mobile.Auth.inputPhone')"
v-model="phoneNumber"
cursor-color="#5147FF"
/>
<view class="clear-btn" v-if="phoneNumber" @click="clearPhoneNumber">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<view v-else class="phone-input-wrapper">
<input
class="phone-input"
type="email"
:placeholder="t('Mobile.Auth.inputEmail')"
v-model="emailNumber"
cursor-color="#5147FF"
/>
<view class="clear-btn" v-if="emailNumber" @click="clearEmailNumber">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<view class="password-input-wrapper" v-if="!isCaptchaVerify">
<input
class="password-input"
:password="!showPassword"
:placeholder="t('Mobile.Auth.inputPassword')"
v-model="password"
cursor-color="#5147FF"
/>
<!-- <view class="eye-btn" @click="togglePasswordVisibility">
<text class="eye-icon">{{ showPassword ? '👁' : '🙈' }}</text>
</view> -->
<view class="clear-btn" v-if="password" @click="clearPassword">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<button
class="login-btn"
@click="onFinish"
:loading="loading"
:disabled="loading"
>
{{
isCaptchaVerify
? t("Mobile.Auth.nextStep")
: t("Mobile.Auth.login")
}}
</button>
<agreement-checkbox v-model="agreeTerms" />
<!-- #ifdef H5 || WEB-->
<button :id="buttonId" type="button" class="captcha-button" />
<aliyun-captcha-h5
:config="tenantConfigInfo"
:element-id="buttonId"
@doAction="handlerSuccess"
/>
<!-- #endif -->
</view>
</template>
<script setup lang="uts">
import { ref } from "vue";
import { apiLogin } from "@/servers/account";
import type { ILoginResult, LoginFieldType } from "@/types/interfaces/login";
import { SUCCESS_CODE, AGENT_NOT_EXIST } from "@/constants/codes.constants";
import { ACCESS_TOKEN, EXPIRE_DATE } from "@/constants/home.constants";
import { apiSendCode } from "@/servers/account";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import SegmentedControl from "@/components/segmented-control/segmented-control.uvue";
import AgreementCheckbox from "@/components/agreement-checkbox/agreement-checkbox.uvue";
import { redirectTo } from "@/utils/common.uts";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 定义组件props
interface Props {
tenantConfigInfo?: TenantConfigInfo;
redirectUrl?: string;
isPopup?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
tenantConfigInfo: null,
redirectUrl: "",
isPopup: false,
});
const emit = defineEmits(["show-verify", "login-success"]);
const phoneNumber = ref("");
const emailNumber = ref("");
const password = ref("");
const agreeTerms = ref(false);
const showPassword = ref(false);
const captchaVerifyParam = ref(""); // 验证码
const isCaptchaVerify = ref(false); // 是否验证码
const loading = ref(false);
// 阿里云验证码按钮id
const buttonId = ref<string>("aliyun-captcha-id");
// 分段器相关数据
const currentLoginType = ref("password");
const loginOptions = computed(() => [
{ label: t("Mobile.Auth.passwordLogin"), value: "password" },
{ label: t("Mobile.Auth.codeLoginRegister"), value: "phone" },
]);
// 处理登录方式切换
const handleLoginTypeChange = (value: string | number, index: number) => {
currentLoginType.value = value as string;
// 根据选择的登录方式更新状态
switch (value) {
case "password":
isCaptchaVerify.value = false;
break;
case "phone":
isCaptchaVerify.value = true;
break;
}
};
// 判断配置信息是否邮箱登录
const isEmailAuth = computed(() => {
if (!props.tenantConfigInfo) {
return false;
}
return props.tenantConfigInfo?.authType === 3;
});
const onFinish = () => {
if (!agreeTerms.value) {
uni.showModal({
title: t("Mobile.Common.tip"),
content: t("Mobile.Auth.pleaseAgreeProtocol"),
confirmText: t("Mobile.Common.agree"),
cancelText: t("Mobile.Common.disagree"),
success: function (res) {
if (res.confirm) {
agreeTerms.value = true;
doLogin();
}
},
});
} else {
doLogin();
}
};
const doLogin = () => {
// 阿里云验证码
const tenantConfigInfo = props?.tenantConfigInfo;
const { captchaSceneId, captchaPrefix, openCaptcha } =
tenantConfigInfo || {};
// 只有同时满足三个条件才启用验证码场景ID存在、身份标存在、开启验证码
const needAliyunCaptcha = !!(
tenantConfigInfo &&
captchaSceneId !== "" &&
captchaPrefix !== "" &&
openCaptcha
);
// 如果需要阿里云验证码,则点击按钮触发验证码
if (needAliyunCaptcha) {
// #ifdef H5 || WEB
// H5和WEB端验证码
document?.getElementById(buttonId.value)?.click();
// #endif
// #ifdef MP-WEIXIN
// 微信小程序直接执行登录/验证码逻辑
handleClickAliyunCaptcha();
// #endif
} else {
//不需要阿里云验证码,直接执行登录/验证码逻辑
handlerSuccess();
}
};
const handleClickAliyunCaptcha = () => {
uni.navigateTo({
url: "/subpackages/pages/aliyun-captcha/aliyun-captcha",
events: {
getCaptchaVerifyParam: (captchaVerifyParam) => {
// 定义一个getCaptchaVerifyParam事件获取二次验证参数captchaVerifyParam
console.log("验证码返回结果:", captchaVerifyParam);
// 发起二次校验
handlerSuccess(captchaVerifyParam);
},
},
});
};
const handlerSuccess = (captchaVerifyParam: string = "") => {
if (isCaptchaVerify.value) {
// 验证码登录
handleNext(captchaVerifyParam);
} else {
// 密码登录
handleLogin(captchaVerifyParam);
}
};
const handleLogin = (captchaVerifyParam: string = "") => {
if (isEmailAuth.value) {
handleLoginByEmail(captchaVerifyParam);
} else {
handleLoginByPhone(captchaVerifyParam);
}
};
// 校验登录参数
const validateLoginParams = () => {
if (isEmailAuth.value) {
// 校验邮箱是否为空
if (!emailNumber.value) {
uni.showToast({
title: t("Mobile.Auth.inputEmail"),
icon: "none",
});
return false;
}
// 校验邮箱格式
const emailRegex =
/^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9]{2,6}$/;
if (!emailRegex.test(emailNumber.value)) {
uni.showToast({
title: t("Mobile.Auth.invalidEmailFormat"),
icon: "none",
});
return false;
}
} else {
// 校验手机号是否为空
if (!phoneNumber.value) {
uni.showToast({
title: t("Mobile.Auth.inputPhone"),
icon: "none",
});
return false;
}
// 校验手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(phoneNumber.value)) {
uni.showToast({
title: t("Mobile.Auth.invalidPhoneFormat"),
icon: "none",
});
return false;
}
}
return true;
};
// 下一步
const handleNext = async (captchaVerifyParam: string = "") => {
// 校验登录参数
if (!validateLoginParams()) {
return;
}
const params = {
type: "LOGIN_OR_REGISTER",
captchaVerifyParam,
// "phone": phoneNumber.value
};
if (isEmailAuth.value) {
params.email = emailNumber.value;
} else {
params.phone = phoneNumber.value;
}
const phoneOrEmail = isEmailAuth.value
? emailNumber.value
: phoneNumber.value;
// 发送验证码
try {
loading.value = true;
const { code, data, message } = await apiSendCode(params);
if (isEmailAuth.value) {
// 邮箱验证码
if (code === SUCCESS_CODE) {
// 系统未配置邮件服务请直接输出本次验证码779895
uni.showToast({
title: t("Mobile.Auth.codeSentSuccess"),
icon: "none",
});
emit("show-verify", phoneOrEmail, captchaVerifyParam);
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
duration: 5000,
});
emit("show-verify", phoneOrEmail, captchaVerifyParam);
}
} else {
// 手机验证码
if (code === SUCCESS_CODE) {
uni.showToast({
title: t("Mobile.Auth.codeSentSuccess"),
icon: "none",
});
emit("show-verify", phoneOrEmail, captchaVerifyParam);
} else {
// 手机验证失败需要将 message 消息提示出来
uni.showToast({
title: message,
icon: "none",
});
}
}
} finally {
loading.value = false;
}
};
// 手机号登录
const handleLoginByPhone = async (captchaVerifyParam: string = "") => {
// 校验登录参数
if (!validateLoginParams()) {
return;
}
if (!password.value) {
uni.showToast({
title: t("Mobile.Auth.inputPassword"),
icon: "none",
});
return;
}
// 执行登录逻辑
const params: LoginFieldType = {
phoneOrEmail: phoneNumber.value,
areaCode: "86",
password: password.value,
captchaVerifyParam,
};
try {
loading.value = true;
const result = await apiLogin(params);
handleLoginSuccess(result);
} finally {
loading.value = false;
}
};
// 邮箱登录
const handleLoginByEmail = async (captchaVerifyParam: string = "") => {
// 校验登录参数
if (!validateLoginParams()) {
return;
}
if (!password.value) {
uni.showToast({
title: t("Mobile.Auth.inputPassword"),
icon: "none",
});
return;
}
// 执行登录逻辑
const params: LoginFieldType = {
phoneOrEmail: emailNumber.value,
areaCode: "",
password: password.value,
captchaVerifyParam,
};
const { code, data, message } = await apiLogin(params);
if (code === SUCCESS_CODE) {
// 邮箱登录
uni.showToast({
title: t("Mobile.Auth.loginSuccess"),
icon: "success",
});
setTimeout(() => {
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
uni.setStorageSync(ACCESS_TOKEN, data.token);
}
// #endif
// #ifdef MP-WEIXIN
uni.setStorageSync(ACCESS_TOKEN, data.token);
// #endif
if (props.isPopup) {
emit("login-success");
} else {
redirectTo(props.redirectUrl);
}
}, 1000);
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
});
}
};
// 登录成功后
const handleLoginSuccess = (result: ILoginResult) => {
const { code, data, message } = result;
if (code === SUCCESS_CODE) {
// 手机号登录
uni.showToast({
title: t("Mobile.Auth.loginSuccess"),
icon: "success",
});
setTimeout(() => {
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
uni.setStorageSync(ACCESS_TOKEN, data.token);
}
// #endif
// #ifdef MP-WEIXIN
uni.setStorageSync(ACCESS_TOKEN, data.token);
// #endif
if (props.isPopup) {
emit("login-success");
} else {
redirectTo(props.redirectUrl);
}
}, 1000);
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
});
}
};
const clearPhoneNumber = () => {
phoneNumber.value = "";
};
const clearEmailNumber = () => {
emailNumber.value = "";
};
const clearPassword = () => {
password.value = "";
};
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value;
};
</script>
<style lang="scss" scoped>
.container {
// .login-segmented {
// margin-bottom: 80rpx;
// }
.sub-title-wrapper {
margin-top: 80rpx;
margin-bottom: 48rpx;
.sub-title {
color: #000;
text-align: center;
font-size: 40rpx;
font-style: normal;
font-weight: 600;
line-height: 56rpx;
}
}
.clear-btn {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
.clear-icon {
color: #b2b4b8;
font-size: 32rpx;
}
}
.phone-input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
background: rgba(12, 20, 102, 0.04);
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 36rpx;
position: relative;
border: 2rpx solid transparent;
transition: border-color 0.3s ease;
&:focus-within {
border-color: #5147ff;
}
.country-code {
color: rgba(21, 23, 31, 1);
margin-right: 24rpx;
border-right: 2rpx solid rgba(12, 20, 102, 0.12);
padding-right: 24rpx;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10rpx;
text {
margin-left: 10rpx;
font-size: 32rpx;
font-weight: 400;
line-height: 48rpx;
}
.iconfont {
font-size: 28rpx;
}
}
.phone-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 32rpx;
color: #15171f;
font-weight: 400;
line-height: 48rpx;
padding-right: 50rpx;
caret-color: #5147ff;
cursor-color: #5147ff;
}
}
.password-input-wrapper {
background: rgba(12, 20, 102, 0.04);
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 36rpx;
position: relative;
border: 2rpx solid transparent;
transition: border-color 0.3s ease;
&:focus-within {
border-color: #5147ff;
}
.password-input {
width: 100%;
background: transparent;
border: none;
outline: none;
font-size: 32rpx;
color: #15171f;
font-weight: 400;
line-height: 48rpx;
padding-right: 60rpx;
caret-color: #5147ff;
cursor-color: #5147ff;
}
.eye-btn {
position: absolute;
right: 80rpx;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
.eye-icon {
font-size: 32rpx;
color: #b2b4b8;
}
}
}
.login-btn {
margin-top: 20rpx;
width: 100%;
border-radius: 16rpx;
padding: 25rpx 24rpx;
font-size: 32rpx;
font-weight: 400;
margin-bottom: 32rpx;
line-height: 48rpx;
text-align: center;
background: #5147ff;
color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.captcha-button {
position: absolute;
left: 10px;
top: 10px;
visibility: hidden;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<view class="login-lang-wrapper" v-if="langList.length > 1">
<view class="login-lang-switcher" @tap="handleOpen">
<text class="iconfont icon-Globe"></text>
<text class="lang-text">{{ currentLanguageName }}</text>
<text class="iconfont icon-a-Chevrondown"></text>
</view>
</view>
<radio-list-drawer
:visible="visible"
:title="t('Mobile.Profile.selectLanguage')"
:list="languageOptions"
:current-value="localCurrentLang"
@onClose="handleClose"
@onChange="handleLanguageChange"
/>
</template>
<script setup lang="uts">
import { useI18n, normalizeLang } from "@/utils/i18n";
import { RadioListItem } from "@/types/interfaces/radio";
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
import { ref, computed } from "vue";
const { t, currentLang, langList, setLanguage } = useI18n();
const visible = ref(false);
const localCurrentLang = ref("");
const languageOptions = computed((): RadioListItem[] => {
return langList.value.map((item): RadioListItem => {
return {
label: item.name,
value: normalizeLang(item.lang || ""),
disabled: item.status === 0,
} as RadioListItem;
});
});
const currentLanguageName = computed((): string => {
// 优先寻找接口返回的默认项 (isDefault === 1)
const defaultItem = langList.value.find((item) => item.isDefault === 1);
if (defaultItem != null) {
return defaultItem.name;
}
// 兜底逻辑:从当前语言中匹配
const normCurrent = normalizeLang(currentLang.value);
const currentItem = langList.value.find(
(item) => normalizeLang(item.lang || "") === normCurrent,
);
return currentItem?.name || currentItem?.lang || "Language";
});
const handleOpen = () => {
// 优先同步接口返回的默认语言到弹窗选中态
const defaultItem = langList.value.find((item) => item.isDefault === 1);
if (defaultItem != null) {
localCurrentLang.value = normalizeLang(defaultItem.lang || "");
} else {
localCurrentLang.value = normalizeLang(currentLang.value);
}
visible.value = true;
};
const handleClose = () => {
visible.value = false;
};
const handleLanguageChange = async (targetLang: string) => {
if (!targetLang || targetLang === currentLang.value) return;
uni.showLoading({
title: t("Mobile.Common.switching"),
});
try {
const ok = await setLanguage(targetLang);
if (!ok) {
uni.showToast({
title: t("Mobile.Common.switchFailed"),
icon: "none",
});
return;
}
uni.showToast({
title: t("Mobile.Common.switchSuccess"),
icon: "success",
});
// 切换语言后刷新页面,确保全局语言包生效
setTimeout(() => {
// #ifdef H5
window.location.reload();
// #endif
// #ifdef MP-WEIXIN
uni.relaunch({
url: "/subpackages/pages/login/login",
});
// #endif
}, 500);
} finally {
uni.hideLoading();
visible.value = false;
}
};
</script>
<style lang="scss" scoped>
.login-lang-wrapper {
position: absolute;
top: 40rpx;
right: 32rpx;
z-index: 999;
.login-lang-switcher {
width: fit-content;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 100rpx;
border: 1rpx solid rgba(255, 255, 255, 0.3);
cursor: pointer;
/* 确保点击态响应 */
&:active {
opacity: 0.8;
background-color: rgba(255, 255, 255, 0.3);
}
.iconfont {
font-size: 32rpx;
color: #fff;
}
.lang-text {
font-size: 26rpx;
color: #fff;
font-weight: 500;
}
.icon-a-Chevrondown {
font-size: 24rpx;
opacity: 0.8;
}
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<view class="container page-container">
<!-- #ifdef H5 -->
<login-lang-switcher />
<!-- #endif -->
<safety-zone />
<!-- #ifdef MP-WEIXIN -->
<custom-nav-bar
v-if="showNavBar"
:show-back="showBack"
:has-safety-zone="false"
:transparent-background="true"
/>
<!-- #endif -->
<view class="content-weixin">
<view class="header-content">
<image
v-if="!!logo"
class="icon"
:src="logo"
mode="aspectFill"
:alt="t('Mobile.Common.appLogoAlt')"
/>
<view class="title"> {{ title }} </view>
</view>
<view class="form-content">
<slot name="default"></slot>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { useI18n } from "@/utils/i18n";
import LoginLangSwitcher from "../login-lang-switcher/login-lang-switcher.uvue";
const { t } = useI18n();
// 定义组件props
interface Props {
title?: string;
logo?: string;
showNavBar?: boolean;
showBack?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: "",
logo: "",
showNavBar: true,
showBack: true,
});
</script>
<style lang="scss" scoped>
.container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgb(150, 136, 230) 0%, #f8b1c5 100%);
.content-weixin {
flex: 1;
min-height: 0; /* 允许收缩 */
overflow: auto;
padding: 16rpx;
/* 隐藏滚动条 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.header-content {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 256rpx;
.icon {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
}
.title {
margin-left: 20rpx;
font-weight: 600;
font-style: Semibold;
font-size: 32rpx;
leading-trim: NONE;
line-height: 48rpx;
letter-spacing: 0rpx;
vertical-align: middle;
color: rgba(255, 255, 255, 1);
}
}
.form-content {
flex-shrink: 0; /* 防止内容被压缩 */
padding: 48rpx;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 32rpx;
}
}
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<view class="captcha-verify">
<view class="captcha-verify-header">
<!-- 返回按钮 -->
<view class="back-box" @tap="handleBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</view>
<view class="captcha-verify-content">
<view class="title">{{ t("Mobile.Auth.setPasswordTitle") }}</view>
<view class="phone-info">{{ t("Mobile.Auth.setPasswordDesc") }}</view>
</view>
<view class="password-input-wrapper">
<input class="password-input" password :placeholder="t('Mobile.Auth.inputPassword')" v-model="password" cursor-color="#5147FF" />
<view class="clear-btn" v-if="password" @click="clearPassword">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<view class="password-input-wrapper">
<input class="password-input" password :placeholder="t('Mobile.Auth.inputPasswordAgain')" v-model="confirmPassword" cursor-color="#5147FF" />
<view class="clear-btn" v-if="confirmPassword" @click="clearConfirmPassword">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<button class="reset-btn" :class="{ loading: loadingReset }" @click="handleReset" :disabled="loadingReset" :loading="loadingReset">
<text>{{ loadingReset ? t("Mobile.Auth.setting") : t("Mobile.Common.ok") }}</text>
</button>
</view>
</template>
<script lang="uts" setup>
import { apiSetPassword } from '@/servers/account'
import { SUCCESS_CODE } from '@/constants/codes.constants'
import type { ResetPasswordParams } from '@/types/interfaces/login'
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const props = defineProps({
phoneNumber: {
type: String,
default: ''
},
verificationCode: {
type: String,
default: ''
}
})
const emit = defineEmits(['reset-success', 'back'])
// 密码
const password = ref('')
// 确认密码
const confirmPassword = ref('')
// 登录loading
const loadingReset = ref(false)
// 清除密码
const clearPassword = () => {
password.value = ''
}
// 清除确认密码
const clearConfirmPassword = () => {
confirmPassword.value = ''
}
// 返回登录
const handleBack = () => {
emit('back')
}
// 密码验证
const validatePassword = () => {
if (!password.value) {
uni.showToast({
title: t("Mobile.Auth.inputPassword"),
icon: 'none'
})
return false
}
if (password.value.length < 6) {
uni.showToast({
title: t("Mobile.Auth.passwordMinLength"),
icon: 'none'
})
return false
}
if (!confirmPassword.value) {
uni.showToast({
title: t("Mobile.Auth.confirmPassword"),
icon: 'none'
})
return false
}
if (password.value !== confirmPassword.value) {
uni.showToast({
title: t("Mobile.Auth.passwordMismatch"),
icon: 'none'
})
return false
}
return true
}
// 重置密码
const handleReset = async () => {
if (!validatePassword()) {
return
}
try {
loadingReset.value = true
const { code, message } = await apiSetPassword({password: password.value})
if (code === SUCCESS_CODE) {
uni.showToast({
title: t("Mobile.Auth.passwordSetSuccess"),
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, 1000)
} else {
uni.showToast({
title: message || t("Mobile.Auth.passwordSetFailed"),
icon: 'none'
})
}
} catch (error) {
uni.showToast({
title: t("Mobile.Common.networkRetry"),
icon: 'none'
})
} finally {
loadingReset.value = false
}
}
</script>
<style lang="scss" scoped>
.captcha-verify {
overflow: auto;
display: flex;
flex-direction: column;
.captcha-verify-header {
.back-box{
display: inline-block;
width: 58rpx;
height: 58rpx;
line-height: 58rpx;
text-align: center;
background-color: rgba(12, 20, 102, 0.04);
border-radius: 50%;
.iconfont {
font-size: 48rpx;
color: #333;
}
}
}
.captcha-verify-content {
flex: 1;
overflow: auto;
.title {
font-size: 40rpx;
line-height: 56rpx;
font-weight: 600;
color: rgba(21, 23, 31, 1);
margin-bottom: 24rpx;
line-height: 64rpx;
text-align: center;
}
.phone-info {
font-size: 28rpx;
font-weight: 400;
color: rgba(21, 23, 31, 0.7);
margin-bottom: 80rpx;
line-height: 44rpx;
text-align: center;
}
}
.password-input-wrapper {
background: rgba(12, 20, 102, 0.04);
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 36rpx;
position: relative;
border: 2rpx solid transparent;
transition: border-color 0.3s ease;
display: flex;
flex-direction: row;
align-items: center;
// justify-content: center;
&:focus-within {
border-color: #5147FF;
}
.password-input {
width: 100%;
background: transparent;
border: none;
outline: none;
font-size: 32rpx;
color: #15171f;
font-weight: 400;
line-height: 48rpx;
padding-right: 60rpx;
caret-color: #5147FF;
cursor-color: #5147FF;
}
.clear-btn {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
.clear-icon {
color: #b2b4b8;
font-size: 32rpx;
}
}
}
.reset-btn {
width: 100%;
border-radius: 16rpx;
margin-top: 20rpx;
padding: 32rpx 23rpx;
font-size: 32rpx;
font-weight: 400;
margin-bottom: 32rpx;
line-height: 48rpx;
text-align: center;
background: #5147ff;
color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: none;
}
.reset-btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
</style>