topfans/frontend/pages/login/quickLogin.vue
zerosaturation 83999995f5 refactor(frontend): consolidate user storage read via getStoredUser helper
uni.getStorageSync('user') 在 key 从未写过的 UniApp 平台下默认返回 ""(空串)
而非 null/undefined,旧式 `JSON.parse(uni.getStorageSync('user')) || {}` 在
parse 阶段直接抛 `SyntaxError: Unexpected end of JSON input`,导致 quickLogin
setup 崩溃,并触发 Vue 二次告警 `Invalid vnode type: undefined`。

新增 utils/getStoredUser.js 单点封装,统一处理:
  - 空值/损坏 JSON 兜底(返回 null,不抛)
  - 与 store/modules/user.js 初始化块同源守卫
  - 失败 warn 级别日志,不影响业务

替换全库 16 处不安全 `JSON.parse(userStr)` 调用:
  composables/useLaserMint.js                  composables/useShare.js
  utils/guideConfig.js                         utils/preloadApi/core.js
  pages/castlove/lenticular/lenticular-result.vue
  pages/support-activity/index.vue             pages/support-activity/components/{TopRanking,ActionBar}.vue
  pages/components/{Header,ShareModal,ShareReportButtons}.vue
  pages/tasks/{GuideModal,daily-tasks}.vue
  pages/asset-detail/asset-detail.vue          pages/exhibition/exhibition.vue
  pages/profile/profile.vue                    pages/login/quickLogin.vue(原崩点)

净 -18 行,17 文件改动 + 1 文件新增。后续如需支持多 key(如 'adminUser'),
把 getStoredUser 扩成泛型 getStoredJSON(key) 即可。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:16:49 +08:00

635 lines
15 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>
<LoginBackground :show-logo="true" :showBack="false">
<view class="quicklogin-content">
<view class="quicklogin-card">
<!-- 标题 -->
<view class="card-title-wrapper">
<text class="card-title">欢迎回来</text>
</view>
<!-- 头像 -->
<view class="avatar-wrapper">
<view class="avatar-select-box">
<image
class="avatar-select-bg"
src="/static/login/portal/setnickname-avatar-box.png"
mode="aspectFit"
></image>
<view class="avatar-select-inner">
<image
v-if="userAvatar"
class="avatar-inner-img"
:src="userAvatar"
mode="aspectFill"
></image>
</view>
</view>
</view>
<!-- 手机号 -->
<view class="phone-row">
<text class="phone-text">{{ maskedPhone }}</text>
</view>
<!-- 分割线 -->
<view class="divider"></view>
<!-- 一键登录按钮 -->
<view class="primary-btn-wrapper">
<view class="primary-btn" @tap="handleQuickLogin">
<text class="primary-btn-text">一键登录</text>
</view>
</view>
<!-- 其他账号登录 -->
<view class="secondary-btn-wrapper">
<view class="secondary-btn" @tap="handleOtherLogin">
<text class="secondary-btn-text">其他账号登录</text>
</view>
</view>
<!-- 底部协议 -->
<view class="agreement-wrapper">
<view
class="agreement-checkbox"
:class="{ checked: agreedToTerms }"
@tap="toggleAgreement"
>
<view v-if="agreedToTerms" class="checkbox-inner"></view>
</view>
<view class="agreement-text">
<text class="agreement-line"
>登陆代表您已阅读并接受统一认证服务条款,以及</text
>
<text
class="agreement-line agreement-link"
@tap="showAgreementModal"
>隐私政策和用户服务协议</text
>
</view>
</view>
</view>
</view>
<!-- 提示弹窗 -->
<view v-if="showTipDialog" class="tip-dialog-mask" @tap="closeTipDialog">
<view class="tip-dialog" @tap.stop>
<view class="tip-dialog-header">
<text class="tip-dialog-title">提示</text>
<text class="tip-dialog-close" @tap="closeTipDialog">×</text>
</view>
<view class="tip-dialog-content">
<text class="tip-text">阅读并同意以下条款</text>
<text class="tip-agreement-link" @tap="openAgreementFromTip"
>《Topfans用户服务协议》</text
>
</view>
</view>
</view>
<!-- 协议全文弹窗 -->
<view
v-if="showAgreementDialog"
class="agreement-dialog-mask"
@tap="closeAgreementDialog"
>
<view class="agreement-dialog" @tap.stop>
<view class="agreement-dialog-header">
<text class="agreement-dialog-title">Topfans用户服务协议</text>
<text class="agreement-dialog-close" @tap="closeAgreementDialog"
>×</text
>
</view>
<scroll-view class="agreement-dialog-content" scroll-y>
<text class="agreement-dialog-text">{{
agreementContent || "协议内容加载中..."
}}</text>
</scroll-view>
<view class="agreement-dialog-footer">
<button class="agreement-confirm-btn" @tap="agreeAndClose">
我同意
</button>
</view>
</view>
</view>
</LoginBackground>
</template>
<script setup>
import { ref } from "vue";
import LoginBackground from "@/components/LoginBackground.vue";
import { AGREEMENT_CONTENT } from "@/utils/agreement";
import { getStoredUser } from "@/utils/getStoredUser.js";
// 用户信息(从本地缓存读取 user 信息)
// ★ 修复:uni.getStorageSync('user') 在 key 未写过时返回 ""或 undefined,
// JSON.parse("") 会抛 SyntaxError: Unexpected end of JSON input,导致 setup 崩溃。
// 统一委托 @/utils/getStoredUser.js(标准守卫写法),失败降级为 {}。
const userInfo = getStoredUser() || {};
// 本地脱敏兜底:服务端若返回原始手机号,前端做脱敏
const maskPhone = (phone) => {
if (!phone) return "";
const s = String(phone);
if (s.length < 7) return s;
if (s.includes("*")) return s; // 已是脱敏值
return `${s.slice(0, 3)}****${s.slice(-4)}`;
};
const maskedPhone = ref(
userInfo.mobile_masked|| userInfo.mobile || maskPhone(userInfo.mobile) || "174****2223",
);
const userAvatar = ref(
userInfo.avatar_url || "/static/login/portal/quicklogin-avatar.png",
);
const agreedToTerms = ref(false);
// 协议弹窗相关
const agreementContent = ref("");
const showAgreementDialog = ref(false);
const showTipDialog = ref(false);
const toggleAgreement = () => {
agreedToTerms.value = !agreedToTerms.value;
};
// 一键登录
const handleQuickLogin = () => {
if (!agreedToTerms.value) {
showTipDialog.value = true;
return;
}
uni.showLoading({ title: "登录中..." });
setTimeout(() => {
uni.hideLoading();
// 标记从一键登录进入,触发 TopfansWelcome 显示
// 设计进程被销毁OS 杀进程 / 用户主动杀掉 / 设备重启)后冷启动,
// 只要 token 还在就一定会走到本 quickLoginApp.vue onLaunch 已改为
// shouldShowQuickLogin = !!token重置为新会话 → 弹 welcome。
// needs_welcome 由 login.vue密码登录/ selectRole.vue注册选角/
// 本 quickLogin 三处共同负责写入。
uni.setStorageSync("needs_welcome", true);
// ★ P2 修复(2026-07-21):无论有没有 deep link,登录后都走 square。
// deep link 唤起时 pending_scan_url 已经由 App.vue onShow 落盘,
// 不要在这里 reLaunch 到 verify —— 否则会和 App.vue 后续的导航撞车
// ("do not operate continuously")。
// 验真页的真正跳转交给 square.vue 的 handleEnterTopfans:
// 用户点 welcome 按钮进入 square 时消费 pending_scan_url,
// 有就走验真页,没有就走正常 square 流程。
uni.reLaunch({ url: "/pages/square/square" });
}, 800);
};
// 其他账号登录
const handleOtherLogin = () => {
uni.reLaunch({ url: "/pages/login/portal" });
};
// 显示协议全文弹窗
const showAgreementModal = () => {
if (!agreementContent.value) {
agreementContent.value = AGREEMENT_CONTENT;
}
showAgreementDialog.value = true;
};
// 关闭协议弹窗
const closeAgreementDialog = () => {
showAgreementDialog.value = false;
};
// 同意并关闭弹窗
const agreeAndClose = () => {
agreedToTerms.value = true;
showAgreementDialog.value = false;
};
// 关闭提示弹窗
const closeTipDialog = () => {
showTipDialog.value = false;
};
// 从提示弹窗打开协议弹窗
const openAgreementFromTip = () => {
showTipDialog.value = false;
setTimeout(() => {
showAgreementModal();
}, 300);
};
</script>
<style scoped>
.quicklogin-content {
position: absolute;
z-index: 10;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
top: 160rpx;
}
/* 卡片 */
.quicklogin-card {
width: 710rpx;
padding: 30rpx 40rpx 50rpx;
box-sizing: border-box;
border-radius: 56rpx;
border: 2rpx solid rgba(255, 255, 255, 0.15);
background: rgba(241, 163, 163, 0.17);
backdrop-filter: blur(64rpx);
-webkit-backdrop-filter: blur(64rpx);
box-shadow: 0 0 41rpx 0 rgba(0, 0, 0, 0.31);
display: flex;
flex-direction: column;
align-items: center;
}
/* 标题 */
.card-title-wrapper {
width: 270rpx;
height: 84rpx;
display: flex;
align-items: center;
justify-content: center;
background:
linear-gradient(240deg, #cdaffa 9.77%, rgba(205, 175, 250, 0) 62.44%),
linear-gradient(108deg, #f7cbbf 13.91%, rgba(247, 203, 191, 0) 51.34%),
linear-gradient(297deg, #81a9f8 31.28%, rgba(129, 169, 248, 0) 90.74%),
linear-gradient(80deg, #69f4e9 39.86%, rgba(164, 252, 245, 0) 66.88%);
border-radius: 84rpx;
margin-top: 88rpx;
margin-bottom: 30rpx;
}
.card-title {
font-size: 40rpx;
color: #fff9e7;
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
font-family: "Abhaya Libre Medium", sans-serif;
}
/* 头像 */
.avatar-wrapper {
position: relative;
width: 270rpx;
height: 270rpx;
display: flex;
align-items: center;
justify-content: center;
margin: 48rpx 0;
}
.avatar-select-box {
position: relative;
width: 270rpx;
height: 270rpx;
}
.avatar-select-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
filter: drop-shadow(0 0 23rpx rgba(135, 16, 16, 0.67));
}
.avatar-select-inner {
position: absolute;
top: 18rpx;
left: 18rpx;
right: 18rpx;
bottom: 18rpx;
border-radius: 50%;
background: linear-gradient(
147deg,
rgba(223, 223, 146, 0.87) 17%,
rgba(135, 197, 216, 0.87) 39%,
rgba(194, 123, 209, 0.87) 99%
);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatar-inner-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
/* 手机号 */
.phone-row {
margin-bottom: 56rpx;
}
.phone-text {
font-size: 60rpx;
color: #fff9e7;
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
font-family: "Acme", sans-serif;
letter-spacing: 4rpx;
}
/* 分割线 */
.divider {
width: 584rpx;
height: 2rpx;
background: rgba(255, 228, 228, 0.46);
border-radius: 20rpx;
margin: 0 0 62rpx;
}
/* 一键登录按钮 */
.primary-btn-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 32rpx;
}
.primary-btn {
width: 394rpx;
height: 104rpx;
border-radius: 58rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.41) 0%,
rgba(252, 100, 102, 0.83) 64%,
rgba(244, 88, 104, 0.83) 100%
);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.96;
}
.primary-btn-text {
font-size: 42rpx;
color: #fff9e7;
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
font-family: "Abhaya Libre Medium", sans-serif;
}
/* 其他账号登录 */
.secondary-btn-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
.secondary-btn {
width: 394rpx;
height: 104rpx;
border-radius: 58rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%
);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.96;
}
.secondary-btn-text {
font-size: 42rpx;
color: #fff9e7;
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
font-family: "Abhaya Libre Medium", sans-serif;
}
/* 底部协议 */
.agreement-wrapper {
margin-top: 200rpx;
width: 100%;
display: flex;
align-items: center;
padding: 0 8rpx;
}
.agreement-checkbox {
width: 44rpx;
height: 44rpx;
border-radius: 58rpx;
background: rgba(59, 58, 56, 0.19);
box-shadow: 4rpx 4rpx 18rpx 0 rgba(242, 21, 21, 0.27);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.96;
}
.checkbox-inner {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
background: #ffffff;
}
.agreement-text {
margin-left: 12rpx;
display: flex;
flex-direction: column;
}
.agreement-line {
font-size: 24rpx;
color: #fff;
text-shadow: 0 2rpx 13rpx rgba(0, 0, 0, 1);
font-family: "Abhaya Libre Medium", sans-serif;
line-height: 1.4;
}
.agreement-link {
text-decoration: underline;
cursor: pointer;
}
/* 提示弹窗样式 */
.tip-dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.tip-dialog {
width: 100%;
max-width: 600rpx;
background: #e6e6e6;
border-radius: 20rpx;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tip-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
border-bottom: 1rpx solid #e5e5e5;
position: relative;
}
.tip-dialog-title {
font-size: 36rpx;
font-weight: bold;
color: #333333;
flex: 1;
text-align: center;
}
.tip-dialog-close {
font-size: 48rpx;
color: #999999;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
}
.tip-dialog-content {
padding: 40rpx;
text-align: center;
}
.tip-text {
font-size: 32rpx;
color: #333333;
line-height: 1.6;
display: block;
margin-bottom: 20rpx;
}
.tip-agreement-link {
font-size: 32rpx;
color: #007aff;
text-decoration: underline;
cursor: pointer;
display: block;
}
/* 协议全文弹窗 */
.agreement-dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.agreement-dialog {
width: 100%;
max-width: 700rpx;
max-height: 80vh;
background: #e6e6e6;
border-radius: 20rpx;
display: flex;
flex-direction: column;
overflow: hidden;
}
.agreement-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
border-bottom: 1rpx solid #e5e5e5;
position: relative;
}
.agreement-dialog-title {
font-size: 36rpx;
font-weight: bold;
color: #333333;
flex: 1;
text-align: center;
}
.agreement-dialog-close {
font-size: 48rpx;
color: #999999;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
}
.agreement-dialog-content {
flex: 1;
padding: 40rpx;
overflow-y: auto;
box-sizing: border-box;
width: 100%;
}
.agreement-dialog-text {
font-size: 28rpx;
color: #333333;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: break-word;
width: 100%;
box-sizing: border-box;
display: block;
}
.agreement-dialog-footer {
padding: 30rpx 40rpx;
border-top: 1rpx solid #e5e5e5;
}
.agreement-confirm-btn {
width: 100%;
height: 88rpx;
background: #000000;
color: #e6e6e6;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.agreement-confirm-btn::after {
border: none;
}
</style>