feat: 添加用户注册和密码登录功能,更新页面路由和样式

This commit is contained in:
fanpeng 2025-06-05 11:52:12 +08:00
parent a38e1bd4e6
commit 77d24ecb54
20 changed files with 2161 additions and 44 deletions

View File

@ -29,7 +29,7 @@
if (this.envVersion === 'trial') {
return 'XHJ'
}
return 'XHJ'
return 'DEV'
}
},
computed: {

12
api/system.js Normal file
View File

@ -0,0 +1,12 @@
import request from '../utils/request'
// system 系统模块
// 获取国家列表
export function getCountryListRequest(data) {
return request({
url: '/system/listCountry',
method: 'POST',
data
})
}

View File

@ -65,6 +65,24 @@ export function loginRequest(data) {
})
}
// 密码登录
export function passwordLoginRequest(data) {
return request({
url: '/user/register',
method: 'POST',
data
})
}
// 注册
export function registerRequest(data) {
return request({
url: '/user/login',
method: 'POST',
data
})
}
// 注册
export function phoneLoginRequest(data) {
return request({

View File

@ -0,0 +1,15 @@
import CryptoJS from 'crypto-js'
/**
* @word 要加密的内容
* @keyWord String 服务器随机返回的关键字
* */
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
let key = CryptoJS.enc.Utf8.parse(keyWord)
let src = CryptoJS.enc.Utf8.parse(word)
let encrypted = CryptoJS.AES.encrypt(src, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
})
return encrypted.toString()
}

View File

@ -0,0 +1,43 @@
import env from '@/config/env'
export const myRequest = async (option = {}) => {
const envConfig = getApp().globalData.getEnvConfig()
const baseConfig = env[envConfig]
console.log('=== 请求入参 ===')
console.log('URL:', baseConfig.baseUrl + option.url)
console.log('Data:', JSON.stringify(option.data || {}, null, 2))
console.log('Method:', option.method || 'GET')
console.log('================')
return new Promise((resolve, reject) => {
uni.request({
url: baseConfig.baseUrl + option.url,
data: option.data || {},
method: option.method || 'GET',
timeout: 30000,
header: {
'content-type': 'application/json',
...option.header
},
success: result => {
console.log('=== 请求出参 ===')
console.log('StatusCode:', result.statusCode)
console.log('Response:', JSON.stringify(result.data, null, 2))
console.log('================')
if (result.statusCode === 200) {
resolve(result)
} else {
reject(new Error(`HTTP ${result.statusCode}: ${result.errMsg || 'Request failed'}`))
}
},
fail: error => {
console.log('=== 请求失败 ===')
console.log('Error:', error)
console.log('================')
reject(new Error(error.errMsg || 'Network request failed'))
}
})
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
constant/reg.js Normal file
View File

@ -0,0 +1,3 @@
export const PHONE_REG = /^\d{7,12}$/
export const EMAIL_REG = /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/
export const PASSWORD_REG = /^(?!\d+$)(?![a-z]+$)(?![^0-9a-z]+$).{8,20}$/i

7
package-lock.json generated
View File

@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
"crypto-js": "^4.2.0",
"pinia": "^2.2.0",
"pinia-plugin-unistorage": "^0.1.2",
"uview-plus": "^3.3.12"
@ -3106,6 +3107,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-functions-list": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz",

View File

@ -1,5 +1,6 @@
{
"dependencies": {
"crypto-js": "^4.2.0",
"pinia": "^2.2.0",
"pinia-plugin-unistorage": "^0.1.2",
"uview-plus": "^3.3.12"

View File

@ -112,6 +112,36 @@
"style": {
"navigationBarTitleText": "验证邮箱"
}
},
{
"path": "login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "register",
"style": {
"navigationBarTitleText": "注册"
}
},
{
"path": "forgotPassword",
"style": {
"navigationBarTitleText": "忘记密码"
}
},
{
"path": "countryList",
"style": {
"navigationBarTitleText": "你所在的国家/地区"
}
},
{
"path": "safeVerify",
"style": {
"navigationBarTitleText": "安全验证"
}
}
]
},
@ -593,22 +623,13 @@
],
"preloadRule": {
"pages/main/home": {
"packages": [
"pages/others",
"pages/addDevice",
"pages/p2p"
]
"packages": ["pages/others", "pages/addDevice", "pages/p2p"]
},
"pages/main/lockDetail": {
"packages": [
"pages/feature",
"pages/setting"
]
"packages": ["pages/feature", "pages/setting"]
},
"pages/main/mine": {
"packages": [
"pages/user"
]
"packages": ["pages/user"]
}
},
"globalStyle": {

View File

@ -408,6 +408,12 @@
homeLogin() {
const that = this
return new Promise(resolve => {
// #ifdef APP-PLUS
that.routeJump({
name: 'login',
type: 'reLaunch'
})
// #endif
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',

View File

@ -0,0 +1,97 @@
<template>
<view class="flex flex-col" style="height: 100vh">
<view class="flex-shrink-0 p-4">
<up-input
v-model="searchKeyword"
placeholder="搜索"
:customStyle="{
padding: '0 16rpx',
height: '80rpx',
backgroundColor: '#f4f4f4'
}"
border="surround"
clearable
@input="onSearch"
></up-input>
</view>
<view class="flex-1" style="overflow: hidden">
<up-index-list :index-list="indexList" :sticky="true">
<up-index-item
v-for="(item, index) in countryList"
:key="index"
v-show="getFilteredGroup(item).length > 0"
>
<up-index-anchor :text="indexList[index]"></up-index-anchor>
<view
v-for="country in getFilteredGroup(item)"
:key="country.id"
class="px-4 py-2.5 border-b border-gray-2"
@click="handleCountryClick(country)"
>
{{ country.name }}
</view>
</up-index-item>
<view style="padding-bottom: calc(300rpx + env(safe-area-inset-bottom))"></view>
</up-index-list>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
import { getCountryListRequest } from '@/api/system'
const instance = getCurrentInstance().proxy
const eventChannel = instance.getOpenerEventChannel()
const indexList = ref(['★', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))])
const countryList = ref(new Array(27).fill().map(() => []))
const searchKeyword = ref('')
const getFilteredGroup = group => {
if (!searchKeyword.value) return group
const keyword = searchKeyword.value.toLowerCase()
return group.filter(country => country.name.toLowerCase().includes(keyword))
}
const onSearch = () => {}
const getCountryList = async () => {
uni.showLoading({ title: '加载中...' })
try {
const { code, data, message } = await getCountryListRequest()
if (code === 0) {
const newGroups = data
.filter(item => !indexList.value.includes(item.group))
.map(item => item.group)
.filter((group, index, arr) => arr.indexOf(group) === index)
indexList.value = [...indexList.value, ...newGroups]
countryList.value = new Array(indexList.value.length).fill().map(() => [])
if (data.length > 0) countryList.value[0].push(data[0])
data.forEach(item => {
const groupIndex = indexList.value.indexOf(item.group)
if (groupIndex !== -1) countryList.value[groupIndex].push(item)
})
} else {
uni.showToast({ title: message, icon: 'none' })
}
} catch (e) {
console.error(e)
uni.showToast({ title: '获取国家列表失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
onMounted(() => getCountryList())
const handleCountryClick = country => {
eventChannel.emit('country', country)
uni.navigateBack()
}
</script>

View File

@ -0,0 +1,5 @@
<template>
<view>忘记密码</view>
</template>
<script setup></script>

170
pages/user/login.vue Normal file
View File

@ -0,0 +1,170 @@
<template>
<view>
<view class="mx-4 mt-10 text-base">
<view class="font-bold text-2xl">欢迎使用星星锁</view>
<view class="mt-4 flex items-center w-full" @click="toJump('countryList')">
<view class="w-200">国家/地区</view>
<view class="text-#63b8af">{{ country.name }} +{{ country.code }}</view>
</view>
<view class="mt-4">
<up-input
fontSize="32rpx"
:placeholderStyle="{
color: '#333333',
fontSize: '32rpx'
}"
placeholder="请输入手机号或邮箱"
:customStyle="{
padding: '0',
height: '80rpx'
}"
border="bottom"
clearable
v-model="username"
:maxlength="50"
@change="handleUsernameInput"
></up-input>
</view>
<view class="mt-4">
<up-input
fontSize="32rpx"
:placeholderStyle="{
color: '#333333',
fontSize: '32rpx'
}"
:customStyle="{
padding: '0',
height: '80rpx'
}"
placeholder="请输入内容"
type="password"
border="bottom"
clearable
v-model="password"
:maxlength="20"
@change="handlePasswordInput"
></up-input>
</view>
<view class="mt-4 flex items-center text-sm" @click="agreeAgreement">
<image
class="w-35 h-35"
v-if="select"
src="https://oss-lock.xhjcn.ltd/mp/icon_select.png"
></image>
<image
v-else
class="w-35 h-35"
src="https://oss-lock.xhjcn.ltd/mp/icon_not_select.png"
></image>
<view class="ml-2">
我已阅读并同意
<span class="text-#63b8af" @click="toWebview('userAgreement')">用户协议 </span>
<span class="text-#63b8af" @click="toWebview('privacy')">隐私协议</span>
</view>
</view>
<view
class="mt-4 w-full rounded-full h-88 text-center leading-[88rpx] text-white"
:class="[canLogin ? 'bg-#63b8af' : 'bg-#9d9da1']"
@click="login"
>
登录
</view>
<view class="mt-4 mx-2 flex items-center justify-between">
<view class="text-#63b8af" @click="toJump('forgotPassword')">忘记密码</view>
<view class="text-#63b8af" @click="toJump('register')">注册</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useBasicStore } from '@/stores/basic'
import { useUserStore } from '@/stores/user'
import { PHONE_REG, EMAIL_REG, PASSWORD_REG } from '@/constant/reg'
const $basic = useBasicStore()
const $user = useUserStore()
const country = ref({
countryId: 0,
name: '中国',
code: '86',
flag: 'https://lock.xhjcn.ltd/storage/country-flags/86.png',
abbreviation: 'CN',
group: 'Z'
})
const username = ref('')
const password = ref('')
const select = ref(false)
const isValidUsername = computed(() => {
if (!username.value) return false
return PHONE_REG.test(username.value) || EMAIL_REG.test(username.value)
})
const isValidPassword = computed(() => {
if (!password.value) return false
return PASSWORD_REG.test(password.value)
})
const canLogin = computed(() => {
return isValidUsername.value && isValidPassword.value
})
const handleUsernameInput = text => {
username.value = text
}
const handlePasswordInput = text => {
password.value = text
}
const agreeAgreement = () => {
select.value = !select.value
}
const toJump = path => {
$basic.routeJump({
name: path,
events: {
country(data) {
country.value = data
}
}
})
}
const toWebview = type => {
$basic.routeJump({
name: 'webview',
params: {
type
}
})
}
const login = async () => {
if (!canLogin.value) {
return
}
if (!select.value) {
uni.showToast({
title: '请先同意用户协议及隐私协议',
icon: 'none'
})
return
}
const res = await $user.passwordLogin({
username: username.value,
password: password.value,
countryCode: country.value.code
})
if (res) {
$basic.routeJump({
name: 'home',
type: 'reLaunch'
})
}
}
</script>

317
pages/user/register.vue Normal file
View File

@ -0,0 +1,317 @@
<template>
<view>
<up-tabs
:list="list"
@click="click"
class="mx-4"
lineWidth="300rpx"
lineColor="#63b8af"
:activeStyle="{
color: '#63b8af',
fontWeight: 'bold',
transform: 'scale(1.05)'
}"
itemStyle="width: 343rpx;height: 108rpx;font-size: 32rpx"
></up-tabs>
<view class="mx-6 mt-10">
<view class="mt-4 flex items-center w-full" @click="toJump('countryList')">
<view class="w-200">国家/地区</view>
<view class="text-#63b8af">
{{ country.name }}
<span v-if="type === 1">+{{ country.code }}</span>
</view>
</view>
<view class="mt-4">
<up-input
:placeholder="type === 1 ? '请输入手机号' : '请输入邮箱'"
fontSize="32rpx"
:placeholderStyle="{
color: '#333333',
fontSize: '32rpx'
}"
:customStyle="{
padding: '0',
height: '80rpx'
}"
border="bottom"
clearable
v-model="username"
:maxlength="50"
@change="handleUsernameInput"
></up-input>
</view>
<view class="mt-4">
<up-input
fontSize="32rpx"
:placeholderStyle="{
color: '#333333',
fontSize: '32rpx'
}"
:customStyle="{
padding: '0',
height: '80rpx'
}"
placeholder="请输入内容"
type="password"
border="bottom"
clearable
v-model="password"
:maxlength="20"
@change="handlePasswordInput"
></up-input>
<view class="text-#999999 text-sm mt-1">
密码必须是8-20至少包括数字/字母/符号中的2种
</view>
</view>
<view class="mt-4">
<up-input
fontSize="32rpx"
:placeholderStyle="{
color: '#333333',
fontSize: '32rpx'
}"
:customStyle="{
padding: '0',
height: '80rpx'
}"
placeholder="确认密码"
type="password"
border="bottom"
clearable
v-model="confirmPassword"
:maxlength="20"
@change="handleConfirmPasswordInput"
></up-input>
</view>
<view class="mt-4 flex items-center">
<up-input
fontSize="32rpx"
:placeholderStyle="{
color: '#333333',
fontSize: '32rpx'
}"
:customStyle="{
padding: '0',
height: '80rpx'
}"
placeholder="请输入验证码"
type="password"
border="bottom"
clearable
v-model="code"
:maxlength="20"
@change="handleCodeInput"
>
<template #suffix>
<up-button @tap="getCode" :disabled="!canGetCode" color="#63b8af">
{{ tips }}
</up-button>
<up-code
:seconds="seconds"
ref="uCodeRef"
changeText="X秒后重新获取"
endText="获取验证码"
@change="codeChange"
></up-code
></template>
</up-input>
</view>
<view class="my-6 flex items-center text-sm" @click="agreeAgreement">
<image
class="w-35 h-35"
v-if="select"
src="https://oss-lock.xhjcn.ltd/mp/icon_select.png"
></image>
<image
v-else
class="w-35 h-35"
src="https://oss-lock.xhjcn.ltd/mp/icon_not_select.png"
></image>
<view class="ml-2">
我已阅读并同意
<span class="text-#63b8af" @click="toWebview('userAgreement')">用户协议 </span>
<span class="text-#63b8af" @click="toWebview('privacy')">隐私协议</span>
</view>
</view>
<view
class="w-full rounded-full h-88 text-center leading-[88rpx] text-white"
:class="[canRegister ? 'bg-#63b8af' : 'bg-#9d9da1']"
@click="register"
>
注册
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useBasicStore } from '@/stores/basic'
import { PHONE_REG, EMAIL_REG, PASSWORD_REG } from '@/constant/reg'
import { useUserStore } from '@/stores/user'
const $basic = useBasicStore()
const $user = useUserStore()
const list = ref([
{ name: '手机', receiverType: 1 },
{ name: '邮箱', receiverType: 2 }
])
const type = ref(1)
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const code = ref('')
const tips = ref('')
const seconds = ref(120)
const uCodeRef = ref(null)
const select = ref(false)
onMounted(async () => {
const res = await $basic.getDeviceInfo()
if (res.language !== 'zh-CN') {
list.value.reverse()
type.value = 2
}
})
const codeChange = text => {
tips.value = text
}
const getCode = () => {
if (!uCodeRef.value.canGetCode) {
return
}
const params = {
account: username.value,
codeType: 1,
channel: type.value,
countryCode: country.value.code
}
$basic.routeJump({
name: 'safeVerify',
events: {
successEvent() {
uCodeRef.value.start()
}
},
params
})
}
const country = ref({
countryId: 0,
name: '中国',
code: '86',
flag: 'https://lock.xhjcn.ltd/storage/country-flags/86.png',
abbreviation: 'CN',
group: 'Z'
})
const isValidUsername = computed(() => {
if (!username.value) return false
if (type.value === 1) {
return PHONE_REG.test(username.value)
}
return EMAIL_REG.test(username.value)
})
const isValidPassword = computed(() => {
if (!password.value) return false
return PASSWORD_REG.test(password.value)
})
const isValidConfirmPassword = computed(() => {
if (!confirmPassword.value) return false
return confirmPassword.value === password.value
})
const isValidCode = computed(() => {
if (!code.value) return false
return code.value.length === 6
})
const canGetCode = computed(() => {
return isValidUsername.value
})
const canRegister = computed(() => {
return (
isValidUsername.value &&
isValidPassword.value &&
isValidConfirmPassword.value &&
isValidCode.value
)
})
const agreeAgreement = () => {
select.value = !select.value
}
const register = async () => {
if (!canRegister.value) {
return
}
if (!select.value) {
uni.showToast({
title: '请先同意用户协议及隐私协议',
icon: 'none'
})
return
}
const res = await $user.register({
account: username.value,
password: password.value,
receiverType: type.value,
countryCode: country.value.code,
verificationCode: code.value
})
if (res) {
$basic.routeJump({
name: 'home',
type: 'reLaunch'
})
}
}
const click = item => {
type.value = item.receiverType
}
const handleUsernameInput = text => {
username.value = text
}
const handlePasswordInput = text => {
password.value = text
}
const handleConfirmPasswordInput = text => {
confirmPassword.value = text
}
const handleCodeInput = text => {
code.value = text
}
const toJump = path => {
$basic.routeJump({
name: path,
events: {
country(data) {
country.value = data
}
}
})
}
const toWebview = type => {
$basic.routeJump({
name: 'webview',
params: {
type
}
})
}
</script>

52
pages/user/safeVerify.vue Normal file
View File

@ -0,0 +1,52 @@
<template>
<view>
<view class="main">
<view v-if="params" class="content">
<verify
:imgSize="{ width: '330px', height: '155px' }"
:mode="'fixed'"
:params="params"
captchaType="blockPuzzle"
@success="success"
></verify>
</view>
</view>
</view>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app'
import { getCurrentInstance, ref } from 'vue'
import verify from '@/components/verify/verify.vue'
const instance = getCurrentInstance().proxy
const eventChannel = instance.getOpenerEventChannel()
const params = ref(null)
onLoad(async options => {
params.value = options
})
const success = result => {
eventChannel.emit('successEvent', result || {})
uni.navigateBack()
}
</script>
<style lang="scss">
page {
background: $uni-bg-color;
}
</style>
<style lang="scss" scoped>
.main {
margin-top: 32rpx;
text-align: center;
.content {
display: inline-block;
}
}
</style>

View File

@ -246,47 +246,47 @@
<style scoped lang="scss">
.view {
margin-top: 32rpx;
border-radius: 32rpx;
width: 710rpx;
margin-top: 32rpx;
margin-left: 20rpx;
background: #ffffff;
border-radius: 32rpx;
}
.view-button {
padding: 0 20rpx 0 40rpx;
display: flex;
justify-content: space-between;
align-items: center;
color: #292826;
justify-content: space-between;
padding: 0 20rpx 0 40rpx;
font-size: 32rpx;
font-weight: bold;
line-height: 80rpx;
color: #292826;
}
.info {
text-align: right;
width: 400rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 20rpx;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
white-space: nowrap;
}
.name-info {
width: 520rpx;
margin-right: 20rpx;
overflow: hidden;
line-height: 40rpx;
text-align: right;
width: 520rpx;
overflow: hidden;
word-break: break-all;
margin-right: 20rpx;
}
.red-dot {
margin-right: 20rpx;
background: #ec433c;
width: 20rpx;
height: 20rpx;
margin-right: 20rpx;
background: #ec433c;
border-radius: 50%;
}
@ -304,9 +304,9 @@
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
margin-top: 20rpx;
margin-right: 20rpx;
margin-bottom: 20rpx;
border-radius: 50%;
}
</style>

View File

@ -406,6 +406,31 @@ const pages = [
name: 'videoEdit',
path: '/pages/p2p/videoEdit',
tabBar: false
},
{
name: 'login',
path: '/pages/user/login',
tabBar: false
},
{
name: 'register',
path: '/pages/user/register',
tabBar: false
},
{
name: 'forgotPassword',
path: '/pages/user/forgotPassword',
tabBar: false
},
{
name: 'countryList',
path: '/pages/user/countryList',
tabBar: false
},
{
name: 'safeVerify',
path: '/pages/user/safeVerify',
tabBar: false
}
]
@ -495,11 +520,13 @@ export const useBasicStore = defineStore('basic', {
// 获取胶囊信息
getButtonInfo() {
return new Promise(resolve => {
// #ifdef MP-WEIXIN
if (this.buttonInfo?.top) {
resolve(this.buttonInfo)
return
}
this.buttonInfo = uni.getMenuButtonBoundingClientRect()
// #endif
resolve(this.buttonInfo)
})
},

View File

@ -2,10 +2,17 @@
* @description 用户信息数据持久化
*/
import { defineStore } from 'pinia'
import { getUserInfoRequest, getWebUrlRequest, loginRequest, phoneLoginRequest } from '@/api/user'
import {
getUserInfoRequest,
getWebUrlRequest,
loginRequest,
phoneLoginRequest,
registerRequest
} from '@/api/user'
import { useLockStore } from '@/stores/lock'
import { setStorage, getStorage } from '@/utils/storage'
import { useNotificationStore } from '@/stores/notification'
import { useBasicStore } from '@/stores/basic'
export const useUserStore = defineStore('user', {
state() {
@ -34,6 +41,65 @@ export const useUserStore = defineStore('user', {
}
return code
},
async register(params) {
const { account, password, receiverType, countryCode, verificationCode } = params
const $basic = useBasicStore()
const deviceInfo = await $basic.getDeviceInfo()
const info = {
deviceBrand: deviceInfo.deviceBrand,
deviceId: deviceInfo.deviceId,
deviceModel: deviceInfo.deviceModel,
model: deviceInfo.model,
system: deviceInfo.system
}
const { code, data, message } = await registerRequest({
receiverType,
countryCode,
account,
password,
verificationCode,
platId: 2,
deviceInfo: info
})
if (code === 0) {
setStorage('token', data.accessToken)
return true
}
uni.showToast({
title: message,
icon: 'none'
})
return false
},
async passwordLogin(params) {
const { username, password, countryCode } = params
const $basic = useBasicStore()
const deviceInfo = await $basic.getDeviceInfo()
const info = {
deviceBrand: deviceInfo.deviceBrand,
deviceId: deviceInfo.deviceId,
deviceModel: deviceInfo.deviceModel,
model: deviceInfo.model,
system: deviceInfo.system
}
const { code, data, message } = await registerRequest({
username,
password,
countryCode,
loginType: 1,
platId: 2,
deviceInfo: info
})
if (code === 0) {
setStorage('token', data.accessToken)
return true
}
uni.showToast({
title: message,
icon: 'none'
})
return false
},
async phoneLogin(params) {
const { iv, encryptedData, code: js_code } = params
const openid = await getStorage('openid')