Files
Docker/uniapp/youjiala/src/pages/login/login.vue
T
2026-10-23 15:15:53 +08:00

399 lines
13 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.
<script setup lang="ts">
// 最简登录页:复刻旧系统 WebVue 的登录流程,用于联调 CloudBuilder 后端接口
// 流程:GetRsaPublicKey -> RsaUserPassword = RSA(Guid + MD5(密码)) -> Login -> 存 JWT
// 加密已抽到 @/common/pwd-crypto(wxmp-rsa 纯 JS 实现,H5/小程序/App 全端可用)
import type { LoginData } from "@/data/auto/loginData";
import type { LoginProfile } from "@/data/auto/loginProfile";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { apiBaseUrl } from "@/common/apiEndpoint";
import { brandGlyph, veinGlyph } from "@/common/brand";
import { backendLocale, isNetworkError } from "@/common/http.interceptor";
import { DEFAULT_LOCALE, setCurrentUiLocale } from "@/common/locale-const";
import { encryptLoginPassword } from "@/common/pwd-crypto";
import { useThemeGradient, useThemeTokens } from "@/composables";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
import { reloadLocales } from "@/locale";
import { decodeQueryPath, isAuthFree, pageUrl, resolvePageName } from "@/router/routes";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/stores";
const userMobile = ref("");
const password = ref("");
const showPwd = ref(false);
const loading = ref(false);
/** 「访问线路」弹层显隐:登录连不上服务器时自动弹出(组件与「我的」共用) */
const endpointPopupVisible = ref(false);
const loginProfileStore = useLoginProfileStore();
/* 主题接线:页面底渐变与强调色全部跟随当前主题(与「我的」/媒体/共享页同一口径),
不再写死品牌绿——切主题后登录/注册/代理登录三页即时联动 */
const { themeBackground } = useThemeGradient();
const { primary, accentGradient, accentSoft, accentShadow } = useThemeTokens();
const themeVars = computed(() => ({
"--fg-primary": primary.value,
"--fg-gradient": accentGradient.value,
"--fg-soft": accentSoft.value,
"--fg-shadow": accentShadow.value,
}));
/**
* 登录成功后的落地地址:被守卫送去登录页时由 ?redirect= 带过来(见 nav-guard 的 goLoginWithRedirect)。
* 只接受 PAGE_PATHS 里认得出的地址,避免 ?redirect= 把人送到站外或不存在的路径
*/
const redirectUrl = ref(pageUrl("dashboard"));
onLoad((options) => {
const target = decodeQueryPath(options?.redirect);
const name = resolvePageName(target);
// 还要求"不是免登录页":把手敲的 ?redirect=/ 或 ?redirect=/error/page401 放过来,
// 登录后要么原地回到登录页,要么落到一个写着"你没登录"的页面
if (name && !isAuthFree(name)) {
redirectUrl.value = target;
}
});
/**
* 已登录访问 /login 不再自动放行进 dashboard——本页是"切换账号"的唯一入口,
* 自动跳走会让用户够不到登录表单。旧令牌在 Login 成功后被 updateLoginProfile 覆盖;
* 由守卫送回本页的场景 goLoginWithRedirect 已先 clearLoginState,会话本就是干净的。
*/
// 登录页中英文切换:走 uView Pro 语言包(t / setLocale),语言包 name 为 zh-CN / en-US
const { t, setLocale, currentLocale } = useLocale();
const isZh = computed(() => (currentLocale.value?.name ?? "zh-CN") !== "en-US");
// 非组件上下文(请求拦截器)拿不到 useLocale,把当前界面语言登记到全局
setCurrentUiLocale(currentLocale.value?.name ?? DEFAULT_LOCALE);
/** 非发布构建才显示「当前环境 + 实际调用的后端地址」:发布版 import.meta.env.PROD 为 true,整行编译期即被剔除 */
const isProd = import.meta.env.PROD;
const envMode = import.meta.env.MODE;
/** 当前后端地址:跟随「我的 → 访问线路」切换(内网直连时显示内网地址) */
const currentApiUrl = apiBaseUrl();
function toggleLang() {
const next = isZh.value ? "en-US" : "zh-CN";
setLocale(next);
setCurrentUiLocale(next);
// 开发构建顺手重拉数据库翻译:改完 DB 词条点一下切换就能看到新值,无需整页刷新
// (生产构建 import.meta.env.DEV 为 false,该分支编译期即被剔除)
if (import.meta.env.DEV) {
reloadLocales();
}
}
/** 进入注册页(/register,免登录):navigateTo 保留历史以便返回登录 */
function goRegister() {
uni.navigateTo({ url: pageUrl("register") });
}
/** 生成 v4 风格 Guid(无需引入 uuid 包,与旧系统 uuidv4() 等效) */
function makeGuid(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
async function handleLogin() {
if (!userMobile.value || !password.value) {
uni.showToast({ title: "请输入手机号或者账号名和密码", icon: "none" });
return;
}
loading.value = true;
try {
const guid = makeGuid();
// 1. 取后端 RSA 公钥(GET /Auth/GetRsaPublicKey,免鉴权)
const key = await authService.GetRsaPublicKey();
// 2. 密码先 MD5 再与 Guid 拼接后 RSA 加密(算法与旧系统一致,库换为跨端的 wxmp-rsa)
const rsaUserPassword = encryptLoginPassword(password.value, guid, String(key));
if (!rsaUserPassword) {
uni.showToast({ title: "密码加密失败,请重试", icon: "none" });
return;
}
// 3. 登录(失败时拦截器已统一弹窗提示,这里只处理成功路径)
const data: LoginData = new LoginDataClass();
data.UserMobile = userMobile.value;
data.Guid = guid;
data.RsaUserPassword = rsaUserPassword;
data.Culture = backendLocale();
const profile: LoginProfile = await authService.Login(data);
// 4. 存登录档案:档案字段进 store,Token 交由请求层保管(此后自动带 Bearer 头 + 401 自动续期)
loginProfileStore.updateLoginProfile(profile);
uni.showToast({ title: `欢迎,${profile.UserLocalName || profile.Username || profile.UserMobile}`, icon: "none" });
// 回到被拦前的页面(默认 dashboard):reLaunch 会清空页面栈,避免返回键退回登录页
setTimeout(() => uni.reLaunch({ url: redirectUrl.value }), 800);
}
catch (error: any) {
// 连不上服务器(地址填错 / 服务器宕机):提示并自动弹出共用的「访问线路」让用户重设地址后原地重试;
// 其余错误(如账号密码错)的提示已由 http.interceptor 统一处理
// #ifdef APP-PLUS
if (isNetworkError(error)) {
uni.showModal({
title: t("LoginForm.netFailTitle"),
content: t("LoginForm.netFailContent"),
showCancel: false,
success: () => {
endpointPopupVisible.value = true;
},
});
}
// #endif
}
finally {
loading.value = false;
}
}
/** 访问线路保存后:服务器真的变了就提示重试登录(本页本就未登录,无需再走登出) */
function onEndpointSaved(payload: { changed: boolean }): void {
if (payload.changed) {
uni.showToast({ title: t("MinePage.lineSavedRelogin"), icon: "none" });
}
}
</script>
<template>
<view class="login-page" :style="[{ background: themeBackground }, themeVars]">
<!-- 背景右上淡叶脉暗纹(纯装饰,低透明) -->
<image class="login-vein" :src="veinGlyph" mode="aspectFit" />
<!-- 品牌位:叶脉树 Logo + 名称 + 标语 -->
<view class="login-brand">
<view class="login-brand__logo">
<image class="login-brand__glyph" :src="brandGlyph" mode="aspectFit" />
</view>
<view class="login-brand__name">
{{ t("LoginForm.brandName") }}
</view>
<view class="login-brand__slogan">
{{ t("LoginForm.slogan") }}
</view>
</view>
<!-- 表单:图标内嵌白底圆角胶囊 -->
<view class="login-form">
<view class="login-field">
<u-icon name="account" size="34" color="#9aa0a6" />
<input v-model="userMobile" class="login-field__input" :placeholder="t('LoginForm.userLabel')"
placeholder-class="login-field__ph" @confirm="handleLogin">
</view>
<view class="login-field">
<u-icon name="lock" size="34" color="#9aa0a6" />
<input v-model="password" class="login-field__input" :password="!showPwd"
:placeholder="t('LoginForm.passwordLabel')" placeholder-class="login-field__ph" @confirm="handleLogin">
<u-icon :name="showPwd ? 'eye-fill' : 'eye-off'" size="34" color="#9aa0a6" @click="showPwd = !showPwd" />
</view>
</view>
<!-- 按钮:登录黛翠绿渐变实心 -->
<view class="login-btn login-btn--submit" @click="handleLogin">
<u-loading v-if="loading" mode="circle" color="#ffffff" :size="34" />
<text v-else>{{ t("LoginForm.loginBut") }}</text>
</view>
<!-- 底部:语言切换 + 注册入口(非发布版另显示当前环境与后端地址) -->
<view class="login-footer">
<view class="login-lang" @click="toggleLang">
<text class="login-lang__label">
{{ t("LoginForm.cultureLabel") }}
</text>
<text class="login-lang__switch">
{{ isZh ? t("LoginForm.langZh") : t("LoginForm.langEn") }}
</text>
</view>
<view class="login-register" @click="goRegister">
<text class="login-register__tip">
{{ t("LoginForm.registerTip") }}
</text>
<text class="login-register__link">
{{ t("LoginForm.registerBut") }}
</text>
</view>
<view v-if="!isProd" class="login-env">
{{ envMode }} · {{ currentApiUrl }}
</view>
</view>
<!-- 访问线路弹层(与「我的」共用组件):登录连不上服务器时自动弹出,改地址后原地重试 -->
<!-- #ifdef APP-PLUS -->
<endpoint-setting-popup v-model="endpointPopupVisible" @saved="onEndpointSaved" />
<!-- #endif -->
</view>
</template>
<style lang="scss" scoped>
/* 强调色(底渐变/按钮/Logo/链接)全部经 --fg-* 变量跟随主题(见 script 的 themeVars);
此处仅留中性文字色与柔和投影,不再写死品牌绿 */
$ink: #1f2329;
$soft-shadow: 0 6rpx 20rpx rgba(31, 35, 41, 0.06);
.login-page {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
min-height: 100vh;
padding: 150rpx 60rpx 60rpx;
overflow: hidden;
}
/* 右上淡叶脉暗纹:半隐若现,不拦截点击 */
.login-vein {
position: absolute;
top: -40rpx;
right: -60rpx;
width: 460rpx;
height: 460rpx;
opacity: 0.08;
pointer-events: none;
}
/* ===== 品牌位 ===== */
.login-brand {
display: flex;
z-index: 1;
flex-direction: column;
align-items: center;
margin-bottom: 80rpx;
}
.login-brand__logo {
display: flex;
align-items: center;
justify-content: center;
width: 132rpx;
height: 132rpx;
margin-bottom: 28rpx;
border-radius: 36rpx;
background: var(--fg-gradient, linear-gradient(135deg, #47a678, #1f5f47));
box-shadow: 0 16rpx 36rpx var(--fg-shadow, rgba(31, 95, 71, 0.32));
}
.login-brand__glyph {
width: 76rpx;
height: 76rpx;
}
.login-brand__name {
font-size: 52rpx;
font-weight: 700;
letter-spacing: 4rpx;
color: $ink;
}
.login-brand__slogan {
margin-top: 14rpx;
font-size: 26rpx;
letter-spacing: 6rpx;
color: var(--fg-primary, #2f7d5c);
}
/* ===== 表单 ===== */
.login-form {
display: flex;
z-index: 1;
flex-direction: column;
gap: 32rpx;
width: 100%;
}
.login-field {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 32rpx;
border-radius: 20rpx;
background-color: #ffffff;
box-shadow: $soft-shadow;
}
.login-field__input {
flex: 1;
height: 96rpx;
font-size: 30rpx;
color: $ink;
}
.login-field__ph {
color: #b3b8bd;
}
/* ===== 按钮 ===== */
.login-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
margin-top: 40rpx;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 600;
letter-spacing: 8rpx;
}
.login-btn--submit {
color: #ffffff;
background: var(--fg-gradient, linear-gradient(135deg, #47a678, #1f5f47));
box-shadow: 0 16rpx 32rpx var(--fg-shadow, rgba(31, 95, 71, 0.3));
}
/* ===== 底部 ===== */
.login-footer {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-top: auto;
padding-top: 60rpx;
}
.login-lang {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 26rpx;
}
.login-lang__label {
color: #9aa0a6;
}
.login-lang__switch {
font-weight: 600;
color: var(--fg-primary, #2f7d5c);
}
.login-register {
display: flex;
align-items: center;
gap: 6rpx;
margin-top: 24rpx;
font-size: 26rpx;
}
.login-register__tip {
color: #9aa0a6;
}
.login-register__link {
font-weight: 600;
color: var(--fg-primary, #2f7d5c);
}
/* 非发布版的环境 / 后端地址角标:低调灰字,仅开发或 UAT 构建出现 */
.login-env {
margin-top: 22rpx;
font-size: 22rpx;
letter-spacing: 0.5rpx;
color: #b0a894;
}
</style>