refactor: APP 扫码登录和文档优化

This commit is contained in:
Ray.Hao
2026-07-23 17:46:16 +08:00
parent 86aede7c7f
commit ce01e3cff8
23 changed files with 382 additions and 12 deletions

View File

@@ -171,7 +171,7 @@ youlai-app/
如果这个项目对你有帮助,欢迎关注公众号、体验小程序,也可以添加作者微信交流技术、问题反馈、二次开发和商务合作。 如果这个项目对你有帮助,欢迎关注公众号、体验小程序,也可以添加作者微信交流技术、问题反馈、二次开发和商务合作。
<p align="center"> <p align="center">
<img src="./docs/images/qrcode/wechat-official.png" height="180" alt="有来技术公众号"> <img src="./docs/images/qrcode/wechat-official.jpg" height="180" alt="有来技术公众号">
&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;
<img src="./docs/images/qrcode/wechat-mp.jpg" height="180" alt="有来技术小程序"> <img src="./docs/images/qrcode/wechat-mp.jpg" height="180" alt="有来技术小程序">
&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;

7
components.d.ts vendored
View File

@@ -19,14 +19,11 @@ declare module 'vue' {
QiunError: typeof import('./src/components/qiun-error/qiun-error.vue')['default'] QiunError: typeof import('./src/components/qiun-error/qiun-error.vue')['default']
QiunLoading: typeof import('./src/components/qiun-loading/qiun-loading.vue')['default'] QiunLoading: typeof import('./src/components/qiun-loading/qiun-loading.vue')['default']
WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['default'] WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['default']
WdBadge: typeof import('@wot-ui/ui/components/wd-badge/wd-badge.vue')['default']
WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['default'] WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['default']
WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['default']
WdCard: typeof import('@wot-ui/ui/components/wd-card/wd-card.vue')['default'] WdCard: typeof import('@wot-ui/ui/components/wd-card/wd-card.vue')['default']
WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['default'] WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['default']
WdCell: typeof import('@wot-ui/ui/components/wd-cell/wd-cell.vue')['default'] WdCell: typeof import('@wot-ui/ui/components/wd-cell/wd-cell.vue')['default']
WdCellGroup: typeof import('@wot-ui/ui/components/wd-cell-group/wd-cell-group.vue')['default'] WdCellGroup: typeof import('@wot-ui/ui/components/wd-cell-group/wd-cell-group.vue')['default']
WdCheckbox: typeof import('@wot-ui/ui/components/wd-checkbox/wd-checkbox.vue')['default']
WdCollapse: typeof import('@wot-ui/ui/components/wd-collapse/wd-collapse.vue')['default'] WdCollapse: typeof import('@wot-ui/ui/components/wd-collapse/wd-collapse.vue')['default']
WdCollapseItem: typeof import('@wot-ui/ui/components/wd-collapse-item/wd-collapse-item.vue')['default'] WdCollapseItem: typeof import('@wot-ui/ui/components/wd-collapse-item/wd-collapse-item.vue')['default']
WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['default'] WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['default']
@@ -38,9 +35,6 @@ declare module 'vue' {
WdFab: typeof import('@wot-ui/ui/components/wd-fab/wd-fab.vue')['default'] WdFab: typeof import('@wot-ui/ui/components/wd-fab/wd-fab.vue')['default']
WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['default'] WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['default']
WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['default'] WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['default']
WdGap: typeof import('@wot-ui/ui/components/wd-gap/wd-gap.vue')['default']
WdGrid: typeof import('@wot-ui/ui/components/wd-grid/wd-grid.vue')['default']
WdGridItem: typeof import('@wot-ui/ui/components/wd-grid-item/wd-grid-item.vue')['default']
WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default'] WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default']
WdImgCropper: typeof import('@wot-ui/ui/components/wd-img-cropper/wd-img-cropper.vue')['default'] WdImgCropper: typeof import('@wot-ui/ui/components/wd-img-cropper/wd-img-cropper.vue')['default']
WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default'] WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default']
@@ -53,7 +47,6 @@ declare module 'vue' {
WdRadioGroup: typeof import('@wot-ui/ui/components/wd-radio-group/wd-radio-group.vue')['default'] WdRadioGroup: typeof import('@wot-ui/ui/components/wd-radio-group/wd-radio-group.vue')['default']
WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default'] WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default']
WdSelectPicker: typeof import('@wot-ui/ui/components/wd-select-picker/wd-select-picker.vue')['default'] WdSelectPicker: typeof import('@wot-ui/ui/components/wd-select-picker/wd-select-picker.vue')['default']
WdSwiper: typeof import('@wot-ui/ui/components/wd-swiper/wd-swiper.vue')['default']
WdSwitch: typeof import('@wot-ui/ui/components/wd-switch/wd-switch.vue')['default'] WdSwitch: typeof import('@wot-ui/ui/components/wd-switch/wd-switch.vue')['default']
WdTabbar: typeof import('@wot-ui/ui/components/wd-tabbar/wd-tabbar.vue')['default'] WdTabbar: typeof import('@wot-ui/ui/components/wd-tabbar/wd-tabbar.vue')['default']
WdTabbarItem: typeof import('@wot-ui/ui/components/wd-tabbar-item/wd-tabbar-item.vue')['default'] WdTabbarItem: typeof import('@wot-ui/ui/components/wd-tabbar-item/wd-tabbar-item.vue')['default']

31
deploy.mjs Normal file
View File

@@ -0,0 +1,31 @@
/* eslint-disable no-console */
/**
* 部署 H5 到服务器 | 用法pnpm run deploy
*
* 前置SSH 免密登录(只需一次)
* 1. 如果 %USERPROFILE%\.ssh\id_rsa 已存在跳过,否则 ssh-keygen -t rsa 回车到底
*
* 2. 拷贝公钥到服务器(会提示输一次服务器密码):
* Windows: type %USERPROFILE%\.ssh\id_rsa.pub | ssh root@server "cat >> ~/.ssh/authorized_keys"
* Mac: ssh-copy-id root@server
*
* 3. ssh root@server 输完不弹密码直接连上即成功
*/
import { execSync } from "node:child_process";
const HOST = "www.youlai.tech";
const USER = "root";
const PORT = 1010;
const TARGET = "/mnt/nginx/html/app";
// =========================================================
console.log("构建中...");
execSync("pnpm build:h5", { stdio: "inherit" });
console.log(`部署 → ${USER}@${HOST}:${TARGET}`);
execSync(`scp -o StrictHostKeyChecking=no -P ${PORT} -r "dist/build/h5/." "${USER}@${HOST}:${TARGET}/"`, {
stdio: "inherit",
shell: true,
});
console.log("部署完成");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

View File

@@ -27,6 +27,7 @@
"build:app-harmony": "uni build -p app-harmony", "build:app-harmony": "uni build -p app-harmony",
"build:custom": "uni build -p", "build:custom": "uni build -p",
"build:h5": "uni build --mode production", "build:h5": "uni build --mode production",
"deploy": "node deploy.mjs",
"build:h5:ssr": "uni build --ssr", "build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay", "build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu", "build:mp-baidu": "uni build -p mp-baidu",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -51,6 +51,16 @@ export interface WxMaBindMobileData {
smsCode: string; smsCode: string;
} }
/** APP 端扫码相关接口scan/confirm/cancel的响应 */
export interface QrCodeScanResult {
/** 当前票据状态APP 端扫码后流转为 SCANNED → CONFIRMED / CANCELED */
status: "SCANNED" | "CONFIRMED" | "CANCELED";
/** 脱敏昵称scan 成功后返回,用于确认页展示请求登录的用户 */
nickname?: string;
/** 头像 URLscan 成功后返回 */
avatar?: string;
}
const AuthAPI = { const AuthAPI = {
/** /**
* 获取图形验证码 * 获取图形验证码
@@ -169,6 +179,47 @@ const AuthAPI = {
data: { refreshToken }, data: { refreshToken },
}); });
}, },
// ============ 扫码登录 ============
/**
* 标记已扫码
*
* 把当前 APP 登录用户与票据绑定PC 端 status 随即进入 SCANNED 并展示头像昵称
*/
qrScan(ticket: string): Promise<QrCodeScanResult> {
return request<QrCodeScanResult>({
url: `${AUTH_BASE_URL}/qr-code/scan`,
method: "POST",
data: { ticket },
});
},
/**
* 确认登录
*
* 真正的授权动作,授权后 PC 端 status 进入 CONFIRMED可换取会话令牌
*/
qrConfirm(ticket: string): Promise<QrCodeScanResult> {
return request<QrCodeScanResult>({
url: `${AUTH_BASE_URL}/qr-code/confirm`,
method: "POST",
data: { ticket },
});
},
/**
* 取消登录
*
* 撤回本次扫码PC 端 status 进入 CANCELED
*/
qrCancel(ticket: string): Promise<QrCodeScanResult> {
return request<QrCodeScanResult>({
url: `${AUTH_BASE_URL}/qr-code/cancel`,
method: "POST",
data: { ticket },
});
},
}; };
export default AuthAPI; export default AuthAPI;

View File

@@ -210,6 +210,15 @@
"style": { "style": {
"navigationBarTitleText": "字典数据" "navigationBarTitleText": "字典数据"
} }
},
{
"path": "pages/qr-code-confirm/index",
"type": "page",
"name": "qr-code-confirm",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
} }
], ],
"subPackages": [], "subPackages": [],

View File

@@ -21,7 +21,7 @@
<view class="login__body" :style="{ paddingTop: `${statusBarHeight + navBarHeight + 4}px` }"> <view class="login__body" :style="{ paddingTop: `${statusBarHeight + navBarHeight + 4}px` }">
<!-- Logo --> <!-- Logo -->
<view class="login__brand"> <view class="login__brand">
<image class="login__logo" src="/static/logo.png" mode="aspectFit" /> <image class="login__logo" src="/logo.png" mode="aspectFit" />
<text class="login__brand-name">youlai-app</text> <text class="login__brand-name">youlai-app</text>
</view> </view>
@@ -442,10 +442,18 @@ async function doFormLogin() {
const handleLogin = async () => { const handleLogin = async () => {
// 先校验表单必填项(内联提示,不再弹窗) // 先校验表单必填项(内联提示,不再弹窗)
console.log("[handleLogin] loginFormRef.value:", !!loginFormRef.value);
console.log("[handleLogin] captchaCode:", JSON.stringify(formData.value.captchaCode));
console.log("[handleLogin] isAgreePolicy:", isAgreePolicy.value);
const validateResult = await loginFormRef.value?.validate(); const validateResult = await loginFormRef.value?.validate();
if (!validateResult?.valid) return; console.log("[handleLogin] validateResult:", JSON.stringify(validateResult));
if (!validateResult?.valid) {
console.log("[handleLogin] 表单校验未通过early return");
return;
}
// 再校验隐私协议 // 再校验隐私协议
if (!isAgreePolicy.value) { if (!isAgreePolicy.value) {
console.log("[handleLogin] 进入协议弹窗分支");
openPolicyDialog("FORM"); openPolicyDialog("FORM");
return; return;
} }

View File

@@ -2,7 +2,7 @@
<view class="page page--padding"> <view class="page page--padding">
<wd-card> <wd-card>
<view class="about__header"> <view class="about__header">
<image class="about__logo" src="/static/logo.png" mode="aspectFit" /> <image class="about__logo" src="/logo.png" mode="aspectFit" />
<view class="about__info"> <view class="about__info">
<text class="about__app-title">youlai-app</text> <text class="about__app-title">youlai-app</text>
<text class="about__app-version">版本 {{ version }}</text> <text class="about__app-version">版本 {{ version }}</text>

View File

@@ -81,7 +81,7 @@
/> />
<view class="community-card__mask" /> <view class="community-card__mask" />
<view class="community-card__logo"> <view class="community-card__logo">
<image src="/static/logo.png" mode="aspectFit" class="w-full h-full" /> <image src="/logo.png" mode="aspectFit" class="w-full h-full" />
</view> </view>
<view class="community-card__body"> <view class="community-card__body">
<view class="community-card__title-row"> <view class="community-card__title-row">
@@ -132,6 +132,16 @@
</view> </view>
<wd-icon name="right" size="16" color="var(--color-text-placeholder)" /> <wd-icon name="right" size="16" color="var(--color-text-placeholder)" />
</view> </view>
<view v-if="isLogin" class="menu-row" @click="handleScan">
<view class="menu-row__icon menu-row__icon--info">
<wd-icon name="search" size="18" color="var(--color-primary)" />
</view>
<view class="menu-row__main">
<text class="menu-row__title">扫一扫</text>
<text class="menu-row__desc">扫描二维码登录 PC</text>
</view>
<wd-icon name="right" size="16" color="var(--color-text-placeholder)" />
</view>
<view class="menu-row" @click="handleClearCache"> <view class="menu-row" @click="handleClearCache">
<view class="menu-row__icon menu-row__icon--danger"> <view class="menu-row__icon menu-row__icon--danger">
<wd-icon name="delete" size="18" color="var(--color-danger)" /> <wd-icon name="delete" size="18" color="var(--color-danger)" />
@@ -378,6 +388,25 @@ const handleClearCache = async () => {
} }
}; };
// 扫一扫:扫描 PC 端二维码后跳转确认页
const handleScan = async () => {
if (!isLogin.value) {
navigateToLogin();
return;
}
try {
const res = await uni.scanCode({ scanType: ["qrCode"] });
const ticket = (res.result || "").trim();
if (!ticket) {
uni.showToast({ title: "无效的二维码", icon: "none" });
return;
}
router.push({ path: "/pages/qr-code-confirm/index", query: { ticket: encodeURIComponent(ticket) } });
} catch {
// 用户取消扫码,静默处理
}
};
// 有来技术公众号 // 有来技术公众号
const openOfficialAccount = () => { const openOfficialAccount = () => {
uni.navigateTo({ uni.navigateTo({

View File

@@ -0,0 +1,219 @@
<template>
<view class="qr-confirm">
<view class="qr-confirm__navbar" :style="{ paddingTop: `${statusBarHeight}px` }">
<view class="qr-confirm__navbar-bar" :style="{ height: `${navBarHeight}px` }">
<view class="qr-confirm__navbar-btn" hover-class="qr-confirm__navbar-btn--active" @click="handleBack">
<text class="qr-confirm__navbar-icon"></text>
</view>
<text class="qr-confirm__navbar-title">扫码登录确认</text>
<view class="qr-confirm__navbar-placeholder" />
</view>
</view>
<view class="qr-confirm__body" :style="{ paddingTop: `${statusBarHeight + navBarHeight + 24}px` }">
<view class="qr-confirm__card">
<image class="qr-confirm__logo" src="/logo.png" mode="aspectFit" />
<text class="qr-confirm__tip">PC 端扫码登录</text>
<text class="qr-confirm__user">{{ scanResult?.nickname || "未知用户" }}</text>
<text class="qr-confirm__hint">是否确认在 PC 端登录</text>
<view class="qr-confirm__actions">
<wd-button type="error" plain block @click="handleCancel">取消登录</wd-button>
<wd-button type="primary" block :loading="isLoading" @click="handleConfirm">确认登录</wd-button>
</view>
</view>
</view>
<wd-toast />
</view>
</template>
<script lang="ts" setup>
import { onLoad } from "@dcloudio/uni-app";
import { useToast } from "@wot-ui/ui";
import { useUserStore } from "@/store/modules/user";
import type { QrCodeScanResult } from "@/api/auth";
definePage({
name: "qr-code-confirm",
style: { navigationStyle: "custom", navigationBarTitleText: "" },
});
const toast = useToast();
const userStore = useUserStore();
// 状态栏高度px自定义导航栏按它下压内容避免被遮挡
const statusBarHeight = ref(20);
// 导航栏高度px小程序按胶囊按钮尺寸计算其余平台固定 44
const navBarHeight = ref(44);
// 确认按钮 loading 态,防止连点重复确认
const isLoading = ref(false);
// 从扫码入口经 query 传入的票据
const ticket = ref("");
// scan 接口返回的脱敏用户信息,用于确认页展示
const scanResult = ref<QrCodeScanResult | null>(null);
// 进入页面先标记已扫码,让 PC 端立即显示「已扫码」并展示头像/昵称
const handleScan = async (t: string) => {
try {
scanResult.value = await userStore.qrScan(t);
} catch (error: any) {
toast.error(error?.message || "二维码无效或已过期");
setTimeout(() => uni.navigateBack(), 800);
}
};
// 用户点击「确认登录」:授权 PC 端用该票据换取会话
const handleConfirm = async () => {
if (isLoading.value) return;
isLoading.value = true;
try {
await userStore.qrConfirm(ticket.value);
toast.success("已确认登录");
setTimeout(() => uni.navigateBack(), 800);
} catch (error: any) {
toast.error(error?.message || "确认失败");
} finally {
isLoading.value = false;
}
};
// 用户点击「取消登录」撤回扫码PC 端状态变为 CANCELED
const handleCancel = async () => {
try {
await userStore.qrCancel(ticket.value);
toast.info("已取消登录");
} catch {
// 取消失败不影响返回
} finally {
setTimeout(() => uni.navigateBack(), 400);
}
};
const handleBack = () => uni.navigateBack();
onLoad((options: any) => {
const t = options?.ticket ? decodeURIComponent(options.ticket) : "";
if (!t) {
toast.error("二维码无效");
setTimeout(() => uni.navigateBack(), 800);
return;
}
ticket.value = t;
const sys = uni.getSystemInfoSync();
statusBarHeight.value = sys.statusBarHeight || 20;
navBarHeight.value = 44;
// #ifdef MP-WEIXIN
const menu = uni.getMenuButtonBoundingClientRect();
navBarHeight.value = menu.height + (menu.top - statusBarHeight.value) * 2;
// #endif
handleScan(t);
});
</script>
<style lang="scss" scoped>
.qr-confirm {
min-height: 100vh;
background: var(--color-bg);
&__navbar {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: var(--z-navbar);
padding: 0 32rpx;
}
&__navbar-bar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
&__navbar-btn,
&__navbar-placeholder {
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
transform: translateY(-50%);
}
&__navbar-btn {
left: 0;
background: var(--color-glass);
border: 2rpx solid var(--color-border-glass);
border-radius: 999rpx;
}
&__navbar-icon {
margin-top: -4rpx;
font-size: 44rpx;
color: var(--color-text);
}
&__navbar-title {
font-size: 32rpx;
font-weight: 600;
color: var(--color-text);
}
&__navbar-placeholder {
right: 0;
}
&__body {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 64rpx;
}
&__card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 64rpx 48rpx;
background: var(--color-bg);
border: 2rpx solid var(--color-border-light);
border-radius: 32rpx;
box-shadow: 0 20rpx 50rpx -10rpx rgba(0, 0, 0, 0.08);
}
&__logo {
width: 96rpx;
height: 96rpx;
margin-bottom: 24rpx;
}
&__tip {
font-size: 28rpx;
color: var(--color-text-secondary);
}
&__user {
margin: 16rpx 0;
font-size: 40rpx;
font-weight: 700;
color: var(--color-text);
}
&__hint {
margin-bottom: 48rpx;
font-size: 26rpx;
color: var(--color-text-placeholder);
}
&__actions {
display: flex;
gap: 24rpx;
width: 100%;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -79,6 +79,31 @@ export const useUserStore = defineStore("user", () => {
return result; return result;
}; };
// ==========================================================================
// 扫码登录方法
// ==========================================================================
/**
* 标记已扫码
*/
const qrScan = async (ticket: string) => {
return await AuthAPI.qrScan(ticket);
};
/**
* 确认 PC 端登录
*/
const qrConfirm = async (ticket: string) => {
return await AuthAPI.qrConfirm(ticket);
};
/**
* 取消 PC 端登录
*/
const qrCancel = async (ticket: string) => {
return await AuthAPI.qrCancel(ticket);
};
// ========================================================================== // ==========================================================================
// 用户信息方法 // 用户信息方法
// ========================================================================== // ==========================================================================
@@ -140,6 +165,9 @@ export const useUserStore = defineStore("user", () => {
loginByWxMa, loginByWxMa,
loginByWxMaPhone, loginByWxMaPhone,
bindMobileForWxMa, bindMobileForWxMa,
qrScan,
qrConfirm,
qrCancel,
logout, logout,
getInfo, getInfo,
checkSession, checkSession,

View File

@@ -7,6 +7,7 @@ type _LocationUrl =
"/pages/index/index" | "/pages/index/index" |
"/pages/login/index" | "/pages/login/index" |
"/pages/mine/index" | "/pages/mine/index" |
"/pages/qr-code-confirm/index" |
"/pages/webview/index" | "/pages/webview/index" |
"/pages/work/index" | "/pages/work/index" |
"/pages/mine/about/index" | "/pages/mine/about/index" |