refactor: APP 扫码登录和文档优化
@@ -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="有来技术公众号">
|
||||||
|
|
||||||
<img src="./docs/images/qrcode/wechat-mp.jpg" height="180" alt="有来技术小程序">
|
<img src="./docs/images/qrcode/wechat-mp.jpg" height="180" alt="有来技术小程序">
|
||||||
|
|
||||||
|
|||||||
7
components.d.ts
vendored
@@ -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
@@ -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("部署完成");
|
||||||
|
Before Width: | Height: | Size: 4.1 MiB |
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
BIN
public/images/qrcode/wechat-official.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 306 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -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;
|
||||||
|
/** 头像 URL,scan 成功后返回 */
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
219
src/pages/qr-code-confirm/index.vue
Normal 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>
|
||||||
|
Before Width: | Height: | Size: 41 KiB |
@@ -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,
|
||||||
|
|||||||
1
src/types/uni-pages.d.ts
vendored
@@ -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" |
|
||||||
|
|||||||