Procházet zdrojové kódy

V110个人二维码

liuq před 2 týdny
rodič
revize
0220766af4

+ 2 - 2
manifest.json

@@ -2,8 +2,8 @@
     "name" : "韫珠IM",
     "appid" : "__UNI__6801EE3",
     "description" : "",
-    "versionName" : "1.0.2",
-    "versionCode" : 102,
+    "versionName" : "1.1.0",
+    "versionCode" : 110,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {

+ 9 - 0
package-lock.json

@@ -8,6 +8,9 @@
       "name": "yunzhu-im-mobile",
       "version": "1.0.0",
       "license": "ISC",
+      "dependencies": {
+        "uqrcodejs": "^4.0.7"
+      },
       "devDependencies": {
         "sharp": "^0.34.5"
       }
@@ -588,6 +591,12 @@
       "dev": true,
       "license": "0BSD",
       "optional": true
+    },
+    "node_modules/uqrcodejs": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmmirror.com/uqrcodejs/-/uqrcodejs-4.0.7.tgz",
+      "integrity": "sha512-84+aZmD2godCVI+93lxE3YUAPNY8zAJvNA7xRS7R7U+q57KzMDepBSfNCwoRUhWOfR6eHFoAOcHRPwsP6ka1cA==",
+      "license": "Apache-2.0"
     }
   }
 }

+ 3 - 0
package.json

@@ -12,5 +12,8 @@
   "type": "commonjs",
   "devDependencies": {
     "sharp": "^0.34.5"
+  },
+  "dependencies": {
+    "uqrcodejs": "^4.0.7"
   }
 }

+ 6 - 0
pages.json

@@ -66,6 +66,12 @@
 				"navigationBarTitleText": "个人信息"
 			}
 		},
+		{
+			"path": "pages/identity-qr/index",
+			"style": {
+				"navigationBarTitleText": "我的二维码"
+			}
+		},
 		{
 			"path": "pages/webview/index",
 			"style": {

+ 197 - 17
pages/change-password/index.vue

@@ -1,9 +1,30 @@
 <template>
 	<view class="change-page">
-		<text class="hint">修改成功后请使用新密码登录。</text>
+		<text class="hint">通过短信验证码修改密码,无需旧密码。修改成功后请使用新密码登录。</text>
 
 		<view class="form">
-			<input class="input" v-model="oldPassword" placeholder="当前密码" password />
+			<input class="input" v-model="mobile" placeholder="手机号" type="number" />
+
+			<view class="captcha-row">
+				<input
+					class="input captcha-input"
+					v-model="captchaCode"
+					placeholder="图形验证码"
+					maxlength="8"
+				/>
+				<view class="captcha-img-wrap" @click="refreshCaptcha">
+					<image v-if="captchaImageSrc" class="captcha-img" :src="captchaImageSrc" mode="aspectFit" />
+					<text v-else class="captcha-placeholder">{{ captchaLoading ? '加载中' : '点击刷新' }}</text>
+				</view>
+			</view>
+
+			<view class="sms-row">
+				<input class="input sms-input" v-model="smsCode" placeholder="短信验证码" type="number" maxlength="8" />
+				<view class="sms-btn" :class="{ disabled: sending || countdown > 0 }" @click="onSendSms">
+					<text>{{ countdown > 0 ? countdown + 's' : (sending ? '发送中' : '获取验证码') }}</text>
+				</view>
+			</view>
+
 			<input class="input" v-model="newPassword" placeholder="新密码(须含字母与数字)" password />
 			<input class="input" v-model="confirmPassword" placeholder="确认新密码" password />
 
@@ -15,22 +36,71 @@
 </template>
 
 <script>
-	import { getToken, setToken, changePasswordLoggedIn } from '../../utils/api'
+	import {
+		getToken,
+		setToken,
+		getCaptcha,
+		sendOpenSms,
+		resetPasswordOpen,
+		getCurrentUserInfo
+	} from '../../utils/api'
 
 	function passwordMeetsRule(pwd) {
 		const s = String(pwd || '')
 		return /[A-Za-z]/.test(s) && /\d/.test(s)
 	}
 
+	function normalizeCaptchaImageSrc(image) {
+		if (!image) return ''
+		const s = String(image)
+		if (s.startsWith('data:')) return s
+		return `data:image/png;base64,${s}`
+	}
+
+	function extractMobileFromMe(raw) {
+		if (!raw || typeof raw !== 'object') return ''
+		const o =
+			raw.user != null && typeof raw.user === 'object'
+				? raw.user
+				: raw.data != null && typeof raw.data === 'object'
+					? raw.data
+					: raw
+		const m = o.mobile ?? o.phone ?? o.phone_number
+		return m != null ? String(m).trim() : ''
+	}
+
 	export default {
 		data() {
 			return {
-				oldPassword: '',
+				mobile: '',
+				captchaId: '',
+				captchaCode: '',
+				captchaImageSrc: '',
+				captchaLoading: false,
+				smsCode: '',
 				newPassword: '',
 				confirmPassword: '',
+				sending: false,
+				countdown: 0,
+				timer: null,
 				loading: false
 			}
 		},
+		async onLoad() {
+			this.refreshCaptcha()
+			try {
+				const raw = uni.getStorageSync('current_user')
+				const fromStorage = extractMobileFromMe(raw)
+				if (fromStorage) this.mobile = fromStorage
+			} catch (e) {}
+			const token = getToken()
+			if (!token) return
+			try {
+				const me = await getCurrentUserInfo(token)
+				const m = extractMobileFromMe(me)
+				if (m) this.mobile = m
+			} catch (e) {}
+		},
 		onShow() {
 			if (!getToken()) {
 				uni.showToast({ title: '请先登录', icon: 'none' })
@@ -39,35 +109,90 @@
 				}, 80)
 			}
 		},
+		onUnload() {
+			if (this.timer) clearInterval(this.timer)
+			this.timer = null
+		},
 		methods: {
+			async refreshCaptcha() {
+				this.captchaLoading = true
+				this.captchaCode = ''
+				try {
+					const data = await getCaptcha()
+					this.captchaId = data.captcha_id != null ? String(data.captcha_id) : ''
+					this.captchaImageSrc = normalizeCaptchaImageSrc(data.image)
+				} catch (e) {
+					this.captchaImageSrc = ''
+					uni.showToast({ title: e.message || '图形码加载失败', icon: 'none' })
+				} finally {
+					this.captchaLoading = false
+				}
+			},
+			async onSendSms() {
+				if (this.sending || this.countdown > 0) return
+				const m = String(this.mobile || '').trim()
+				if (!m) {
+					uni.showToast({ title: '请输入手机号', icon: 'none' })
+					return
+				}
+				if (!this.captchaId) {
+					uni.showToast({ title: '请等待图形验证码加载', icon: 'none' })
+					return
+				}
+				const cc = String(this.captchaCode || '').trim()
+				if (!cc) {
+					uni.showToast({ title: '请输入图形验证码', icon: 'none' })
+					return
+				}
+				this.sending = true
+				try {
+					await sendOpenSms(m, this.captchaId, cc)
+					uni.showToast({ title: '短信已发送', icon: 'none' })
+					this.countdown = 60
+					this.timer = setInterval(() => {
+						this.countdown -= 1
+						if (this.countdown <= 0) {
+							clearInterval(this.timer)
+							this.timer = null
+							this.countdown = 0
+						}
+					}, 1000)
+				} catch (e) {
+					uni.showToast({ title: e.message || '发送失败', icon: 'none' })
+					this.refreshCaptcha()
+				} finally {
+					this.sending = false
+				}
+			},
 			async onSubmit() {
 				if (this.loading) return
-				const oldPwd = String(this.oldPassword || '')
-				const newPwd = String(this.newPassword || '')
-				const confirm = String(this.confirmPassword || '')
-				if (!oldPwd) {
-					uni.showToast({ title: '请输入当前密码', icon: 'none' })
+				const m = String(this.mobile || '').trim()
+				if (!m) {
+					uni.showToast({ title: '请输入手机号', icon: 'none' })
 					return
 				}
-				if (!newPwd) {
+				const code = String(this.smsCode || '').trim()
+				if (!code) {
+					uni.showToast({ title: '请输入短信验证码', icon: 'none' })
+					return
+				}
+				const pwd = String(this.newPassword || '')
+				const confirm = String(this.confirmPassword || '')
+				if (!pwd) {
 					uni.showToast({ title: '请输入新密码', icon: 'none' })
 					return
 				}
-				if (!passwordMeetsRule(newPwd)) {
+				if (!passwordMeetsRule(pwd)) {
 					uni.showToast({ title: '新密码须同时包含字母与数字', icon: 'none' })
 					return
 				}
-				if (newPwd !== confirm) {
+				if (pwd !== confirm) {
 					uni.showToast({ title: '两次新密码不一致', icon: 'none' })
 					return
 				}
-				if (oldPwd === newPwd) {
-					uni.showToast({ title: '新密码不能与当前密码相同', icon: 'none' })
-					return
-				}
 				this.loading = true
 				try {
-					await changePasswordLoggedIn(oldPwd, newPwd)
+					await resetPasswordOpen(m, code, pwd)
 					uni.showToast({ title: '修改成功,请重新登录', icon: 'success' })
 					setToken('')
 					try {
@@ -111,6 +236,61 @@
 		font-size: 28rpx;
 		margin-bottom: 20rpx;
 	}
+	.captcha-row {
+		display: flex;
+		align-items: center;
+		gap: 16rpx;
+		margin-bottom: 20rpx;
+	}
+	.captcha-input {
+		flex: 1;
+		margin-bottom: 0;
+	}
+	.captcha-img-wrap {
+		width: 220rpx;
+		height: 88rpx;
+		border-radius: 16rpx;
+		background: #f0f0f0;
+		overflow: hidden;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		flex-shrink: 0;
+	}
+	.captcha-img {
+		width: 100%;
+		height: 100%;
+	}
+	.captcha-placeholder {
+		font-size: 22rpx;
+		color: #999;
+		padding: 0 8rpx;
+		text-align: center;
+	}
+	.sms-row {
+		display: flex;
+		align-items: center;
+		gap: 16rpx;
+		margin-bottom: 20rpx;
+	}
+	.sms-input {
+		flex: 1;
+		margin-bottom: 0;
+	}
+	.sms-btn {
+		height: 88rpx;
+		padding: 0 24rpx;
+		border-radius: 16rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		background: #259653;
+		color: #fff;
+		font-size: 26rpx;
+	}
+	.sms-btn.disabled {
+		opacity: 0.6;
+	}
 	.submit {
 		margin-top: 32rpx;
 		height: 92rpx;

+ 217 - 0
pages/identity-qr/index.vue

@@ -0,0 +1,217 @@
+<template>
+	<view class="page">
+		<view v-if="loading && !qrToken" class="state">加载中…</view>
+		<view v-else-if="loadError" class="state error">{{ loadError }}</view>
+		<template v-else>
+			<view class="canvas-wrap">
+				<canvas
+					canvas-id="identityQr"
+					class="qr-canvas"
+					:style="canvasStyle"
+					:width="canvasPx"
+					:height="canvasPx"
+				/>
+			</view>
+			<text class="expire-line">{{ expireHint }}</text>
+			<text class="tip">请让核验端扫描此码。码内为加密身份令牌,请勿自行解析。</text>
+			<view class="btn-refresh" @click="refreshQr">刷新二维码</view>
+		</template>
+	</view>
+</template>
+
+<script>
+	import UQRCode from 'uqrcodejs'
+	import { getToken, getIdentityQrPayload } from '../../utils/api'
+
+	const CANVAS_PX = 300
+
+	export default {
+		data() {
+			return {
+				loading: false,
+				loadError: '',
+				qrToken: '',
+				expireAtMs: 0,
+				nowMs: Date.now(),
+				canvasPx: CANVAS_PX,
+				canvasReady: false,
+				countdownTimer: null,
+				refreshTimer: null
+			}
+		},
+		computed: {
+			canvasStyle() {
+				const n = this.canvasPx
+				return `width:${n}px;height:${n}px;`
+			},
+			expireHint() {
+				if (!this.expireAtMs) return ''
+				const left = Math.max(0, Math.floor((this.expireAtMs - this.nowMs) / 1000))
+				if (left <= 0) return '已过期,请刷新'
+				return `剩余有效时间约 ${left} 秒`
+			}
+		},
+		onReady() {
+			this.canvasReady = true
+			this.tryDrawQr()
+		},
+		onShow() {
+			this.fetchQr()
+		},
+		onUnload() {
+			this.clearTimers()
+		},
+		methods: {
+			clearTimers() {
+				if (this.countdownTimer) {
+					clearInterval(this.countdownTimer)
+					this.countdownTimer = null
+				}
+				if (this.refreshTimer) {
+					clearTimeout(this.refreshTimer)
+					this.refreshTimer = null
+				}
+			},
+			scheduleRefreshBeforeExpire() {
+				if (this.refreshTimer) {
+					clearTimeout(this.refreshTimer)
+					this.refreshTimer = null
+				}
+				if (!this.expireAtMs) return
+				const ms = this.expireAtMs - Date.now() - 5000
+				if (ms < 200) return
+				this.refreshTimer = setTimeout(() => {
+					this.refreshTimer = null
+					if (getToken()) this.fetchQr(true)
+				}, ms)
+			},
+			startCountdown() {
+				if (this.countdownTimer) clearInterval(this.countdownTimer)
+				this.nowMs = Date.now()
+				this.countdownTimer = setInterval(() => {
+					this.nowMs = Date.now()
+				}, 1000)
+			},
+			normalizePayload(raw) {
+				if (!raw || typeof raw !== 'object') return null
+				const o =
+					raw.data != null && typeof raw.data === 'object' && !raw.token
+						? raw.data
+						: raw
+				const token = o.token
+				const expiresAt = o.expires_at != null ? o.expires_at : o.expiresAt
+				if (!token || typeof token !== 'string') return null
+				return { token, expiresAt }
+			},
+			async fetchQr(isRefresh) {
+				const token = getToken()
+				if (!token) {
+					this.loadError = '请先登录'
+					return
+				}
+				if (!isRefresh) this.loadError = ''
+				this.loading = true
+				try {
+					const raw = await getIdentityQrPayload(token)
+					const parsed = this.normalizePayload(raw)
+					if (!parsed) {
+						throw new Error('接口返回无效')
+					}
+					this.qrToken = parsed.token
+					this.expireAtMs = parsed.expiresAt ? new Date(parsed.expiresAt).getTime() : 0
+					this.clearTimers()
+					this.startCountdown()
+					this.scheduleRefreshBeforeExpire()
+					this.$nextTick(() => this.tryDrawQr())
+				} catch (e) {
+					this.qrToken = ''
+					this.expireAtMs = 0
+					this.loadError = (e && e.message) || '获取失败'
+				} finally {
+					this.loading = false
+				}
+			},
+			refreshQr() {
+				this.fetchQr(true)
+			},
+			tryDrawQr() {
+				if (!this.canvasReady || !this.qrToken) return
+				this.drawQr()
+			},
+			drawQr() {
+				const text = this.qrToken
+				const size = this.canvasPx
+				try {
+					const qr = new UQRCode()
+					qr.data = text
+					qr.size = size
+					qr.errorCorrectLevel = UQRCode.errorCorrectLevel.L
+					qr.margin = 4
+					qr.make()
+					const ctx = uni.createCanvasContext('identityQr', this)
+					qr.canvasContext = ctx
+					qr.drawCanvas().catch(() => {
+						uni.showToast({ title: '二维码绘制失败', icon: 'none' })
+					})
+				} catch (e) {
+					uni.showToast({ title: '二维码生成失败', icon: 'none' })
+				}
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.page {
+		min-height: 100vh;
+		background: #f5f5f5;
+		padding: 48rpx 32rpx 80rpx;
+		box-sizing: border-box;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+	}
+	.state {
+		font-size: 28rpx;
+		color: #666;
+		margin-top: 120rpx;
+	}
+	.state.error {
+		color: #e54d42;
+		padding: 0 32rpx;
+		text-align: center;
+		line-height: 1.6;
+	}
+	.canvas-wrap {
+		background: #fff;
+		padding: 24rpx;
+		border-radius: 16rpx;
+		box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06);
+		margin-bottom: 32rpx;
+	}
+	.qr-canvas {
+		display: block;
+	}
+	.expire-line {
+		font-size: 28rpx;
+		color: #259653;
+		margin-bottom: 24rpx;
+	}
+	.tip {
+		display: block;
+		font-size: 24rpx;
+		color: #999;
+		line-height: 1.6;
+		text-align: center;
+		padding: 0 16rpx;
+		margin-bottom: 48rpx;
+	}
+	.btn-refresh {
+		background: #fff;
+		color: #259653;
+		font-size: 30rpx;
+		padding: 24rpx 56rpx;
+		border-radius: 12rpx;
+		border: 1rpx solid #259653;
+	}
+</style>

+ 7 - 1
pages/index/index.vue

@@ -1,6 +1,6 @@
 <template>
 	<view class="message-page">
-		<!-- 自定义顶栏:头像 + 组织名 + 搜索 -->
+		<!-- 自定义顶栏:头像 + 组织名 + 我的二维码 + 搜索 -->
 		<view class="custom-header">
 			<view class="header-left" @click="onAvatarClick">
 				<UserAvatar
@@ -16,6 +16,9 @@
 				</view>
 			</view>
 			<view class="header-right">
+				<view class="icon-btn" @click="onIdentityQr">
+					<image class="icon-img" src="/static/icons/qrcode.svg" mode="aspectFit" />
+				</view>
 				<view class="icon-btn" @click="onSearch">
 					<image class="icon-img" src="/static/icons/search.svg" mode="aspectFit" />
 				</view>
@@ -195,6 +198,9 @@
 			},
 			onSearch() {
 				uni.navigateTo({ url: '/pages/search-center/index' })
+			},
+			onIdentityQr() {
+				uni.navigateTo({ url: '/pages/identity-qr/index' })
 			}
 		}
 	}

+ 30 - 0
pages/profile/index.vue

@@ -38,6 +38,16 @@
 		</view>
 		<text class="hint">信息由服务端维护,暂不支持在应用内修改。</text>
 
+		<view class="card qr-entry-card">
+			<view class="row qr-row" @click="onOpenIdentityQr">
+				<text class="label">我的二维码</text>
+				<view class="row-right">
+					<text class="value sub">身份核验</text>
+					<text class="chevron">›</text>
+				</view>
+			</view>
+		</view>
+
 		<view class="logout-section">
 			<view
 				v-if="isAndroid"
@@ -178,6 +188,9 @@
 			onChangePassword() {
 				uni.navigateTo({ url: '/pages/change-password/index' })
 			},
+			onOpenIdentityQr() {
+				uni.navigateTo({ url: '/pages/identity-qr/index' })
+			},
 			onLogout() {
 				uni.showModal({
 					title: '提示',
@@ -238,6 +251,23 @@
 	.row.no-arrow {
 		border-bottom: none;
 	}
+	.qr-entry-card {
+		margin-top: 8rpx;
+	}
+	.qr-row {
+		border-bottom: none;
+	}
+	.value.sub {
+		color: #999;
+		max-width: 280rpx;
+	}
+	.chevron {
+		font-size: 36rpx;
+		color: #ccc;
+		line-height: 1;
+		margin-left: 8rpx;
+		font-weight: 300;
+	}
 	.label {
 		font-size: 30rpx;
 		color: #333;

+ 14 - 0
static/icons/qrcode.svg

@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+  <rect width="5" height="5" x="3" y="3" rx="1"/>
+  <rect width="5" height="5" x="16" y="3" rx="1"/>
+  <rect width="5" height="5" x="3" y="16" rx="1"/>
+  <path d="M21 16h-3a2 2 0 0 0-2 2v3"/>
+  <path d="M21 21v.01"/>
+  <path d="M12 7v3a2 2 0 0 1-2 2H7"/>
+  <path d="M3 12h.01"/>
+  <path d="M12 3h.01"/>
+  <path d="M12 16v.01"/>
+  <path d="M16 12h1"/>
+  <path d="M21 12v.01"/>
+  <path d="M12 21v-1"/>
+</svg>

+ 14 - 2
utils/api.js

@@ -318,6 +318,18 @@ export async function getCurrentUserInfo(token) {
 	throw lastErr || new Error('getCurrentUserInfo failed')
 }
 
+/**
+ * 个人身份二维码密文(展示端)
+ * GET /identity-qr/  返回 { token, expires_at },二维码内容即为 token,客户端勿解密
+ */
+export function getIdentityQrPayload(token) {
+	return request({
+		token,
+		url: '/identity-qr/',
+		method: 'GET'
+	})
+}
+
 /**
  * 将 /users/me 或登录返回的用户对象规范为本地 current_user 结构(头像、姓名、账号名/英文名、企业等)
  */
@@ -477,9 +489,9 @@ export function resetPasswordOpen(mobile, sms_code, new_password) {
 }
 
 /**
- * 已登录修改密码(需 Bearer)
+ * 已登录修改密码:旧密码方式(需 Bearer)
  * POST /simple/me/change-password
- * 若后端字段名不同,请与此处保持一致调整
+ * 移动端「修改密码」页已改为短信验证码 + /open/pwd/reset,不再调用本方法
  */
 export function changePasswordLoggedIn(old_password, new_password) {
 	return request({