399 lines
13 KiB
Vue
399 lines
13 KiB
Vue
<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>
|