初始化
This commit is contained in:
204
src/pages-core/auth/code-login.vue
Normal file
204
src/pages-core/auth/code-login.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<view class="auth-container">
|
||||
<!-- 顶部 -->
|
||||
<Header />
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<view class="form-container">
|
||||
<view class="input-item">
|
||||
<wd-icon name="phone" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.mobile"
|
||||
placeholder="请输入手机号"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
no-border
|
||||
type="number"
|
||||
:maxlength="11"
|
||||
/>
|
||||
</view>
|
||||
<CodeInput
|
||||
v-model="formData.code"
|
||||
:mobile="formData.mobile"
|
||||
:scene="21"
|
||||
:before-send="validateBeforeSend"
|
||||
/>
|
||||
<view v-if="captchaEnabled">
|
||||
<Verify
|
||||
ref="verifyRef"
|
||||
:captcha-type="captchaType"
|
||||
explain="向右滑动完成验证"
|
||||
:img-size="{ width: '300px', height: '150px' }"
|
||||
mode="pop"
|
||||
@success="verifySuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<view class="mb-2 mt-2 flex justify-between">
|
||||
<text class="text-28rpx text-[#1890ff]" @click="goToLogin">
|
||||
账号登录
|
||||
</text>
|
||||
<text class="text-28rpx text-[#1890ff]" @click="goToForgetPassword">
|
||||
忘记密码?
|
||||
</text>
|
||||
</view>
|
||||
<!-- 用户协议 -->
|
||||
<view class="mb-24rpx flex items-center">
|
||||
<wd-checkbox v-model="agreePolicy" shape="square" />
|
||||
<text class="text-24rpx text-[#666]">我已阅读并同意</text>
|
||||
<text class="text-24rpx text-[#1890ff]" @click="goToUserAgreement">
|
||||
《用户协议》
|
||||
</text>
|
||||
<text class="text-24rpx text-[#666]">与</text>
|
||||
<text class="text-24rpx text-[#1890ff]" @click="goToPrivacyPolicy">
|
||||
《隐私政策》
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<wd-button block :loading="loading" :disabled="!agreePolicy" type="primary" @click="handleLogin">
|
||||
登录
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { FORGET_PASSWORD_PAGE, LOGIN_PAGE } from '@/router/config'
|
||||
import { useTokenStore } from '@/store/token'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ensureDecodeURIComponent, redirectAfterLogin } from '@/utils'
|
||||
import { isMobile } from '@/utils/validator'
|
||||
import CodeInput from './components/code-input.vue'
|
||||
import Header from './components/header.vue'
|
||||
import Verify from './components/verifition/verify.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SmsLoginPage',
|
||||
})
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
excludeLoginPath: true,
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false) // 加载状态
|
||||
const redirectUrl = ref<string>() // 重定向地址
|
||||
const captchaEnabled = import.meta.env.VITE_APP_CAPTCHA_ENABLE === 'true' // 验证码开关
|
||||
const verifyRef = ref()
|
||||
const captchaType = ref('blockPuzzle') // 滑块验证码 blockPuzzle|clickWord
|
||||
const agreePolicy = ref(false) // 用户协议勾选
|
||||
|
||||
const formData = reactive({
|
||||
mobile: '',
|
||||
code: '',
|
||||
captchaVerification: '', // 验证码校验值
|
||||
}) // 表单数据
|
||||
|
||||
/** 页面加载时处理重定向 */
|
||||
onLoad((options) => {
|
||||
if (options?.redirect) {
|
||||
redirectUrl.value = ensureDecodeURIComponent(options.redirect)
|
||||
}
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
})
|
||||
|
||||
/** 发送验证码前的校验 */
|
||||
function validateBeforeSend(): boolean {
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 获取验证码 */
|
||||
async function getCode() {
|
||||
// 情况一,未开启:则直接登录
|
||||
if (!captchaEnabled) {
|
||||
await verifySuccess({})
|
||||
} else {
|
||||
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录
|
||||
// 弹出验证码
|
||||
verifyRef.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录处理 */
|
||||
async function handleLogin() {
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
if (!agreePolicy.value) {
|
||||
toast.warning('请阅读并同意《用户协议》与《隐私政策》')
|
||||
return
|
||||
}
|
||||
if (!formData.mobile) {
|
||||
toast.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!isMobile(formData.mobile)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
if (!formData.code) {
|
||||
toast.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
await getCode()
|
||||
}
|
||||
|
||||
/** 验证码验证成功回调 */
|
||||
async function verifySuccess(params: any) {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用短信登录接口
|
||||
const tokenStore = useTokenStore()
|
||||
formData.captchaVerification = params.captchaVerification
|
||||
await tokenStore.login({
|
||||
type: 'sms',
|
||||
...formData,
|
||||
})
|
||||
// 处理跳转
|
||||
redirectAfterLogin(redirectUrl.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到账号密码登录 */
|
||||
function goToLogin() {
|
||||
uni.navigateTo({ url: LOGIN_PAGE })
|
||||
}
|
||||
|
||||
/** 跳转到忘记密码 */
|
||||
function goToForgetPassword() {
|
||||
uni.navigateTo({ url: FORGET_PASSWORD_PAGE })
|
||||
}
|
||||
|
||||
/** 跳转到用户协议 */
|
||||
function goToUserAgreement() {
|
||||
uni.navigateTo({ url: '/pages-core/user/settings/agreement/index' })
|
||||
}
|
||||
|
||||
/** 跳转到隐私政策 */
|
||||
function goToPrivacyPolicy() {
|
||||
uni.navigateTo({ url: '/pages-core/user/settings/privacy/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './styles/auth.scss';
|
||||
</style>
|
||||
90
src/pages-core/auth/components/code-input.vue
Normal file
90
src/pages-core/auth/components/code-input.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<view class="input-item">
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
:model-value="modelValue"
|
||||
placeholder="请输入验证码"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
no-border
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<view
|
||||
class="whitespace-nowrap border-l-1rpx border-l-[#e5e5e5] border-l-solid px-20rpx text-28rpx text-[#1890ff]"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
<text :class="{ 'text-gray-400': countdown > 0 }">
|
||||
{{ countdown > 0 ? `${countdown} 秒后重发` : "获取验证码" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { sendSmsCode } from '@/api/login'
|
||||
import { isMobile } from '@/utils/validator'
|
||||
|
||||
defineOptions({
|
||||
name: 'CodeInput',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string // 验证码值 (v-model)
|
||||
mobile: string // 手机号
|
||||
scene: number // 短信场景:21-登录 23-重置密码
|
||||
beforeSend?: () => boolean // 发送前的校验函数,返回 false 则不发送
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const countdown = ref(0) // 验证码倒计时,单位秒
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null // 倒计时定时器
|
||||
|
||||
/** 页面卸载时清除倒计时定时器 */
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
/** 发送验证码 */
|
||||
async function handleSendCode() {
|
||||
// 执行前置校验
|
||||
if (props.beforeSend && !props.beforeSend()) {
|
||||
return
|
||||
}
|
||||
if (countdown.value > 0) {
|
||||
return
|
||||
}
|
||||
if (!props.mobile) {
|
||||
toast.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!isMobile(props.mobile)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
await sendSmsCode({ mobile: props.mobile, scene: props.scene })
|
||||
toast.success('验证码已发送')
|
||||
|
||||
// 开始倒计时
|
||||
countdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer!)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
12
src/pages-core/auth/components/header.vue
Normal file
12
src/pages-core/auth/components/header.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<view class="header flex flex-col items-center pb-60rpx pt-120rpx">
|
||||
<image class="mb-24rpx h-160rpx w-160rpx" src="/static/logo.png" mode="aspectFit" />
|
||||
<view class="text-44rpx text-[#1890ff] font-bold">
|
||||
{{ title }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const title = import.meta.env.VITE_APP_TITLE // 应用标题
|
||||
</script>
|
||||
135
src/pages-core/auth/components/tenant-picker.vue
Normal file
135
src/pages-core/auth/components/tenant-picker.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<view v-if="tenantEnabled" class="input-item">
|
||||
<wd-icon name="home" size="20px" color="#1890ff" />
|
||||
<wd-picker
|
||||
:model-value="tenantId"
|
||||
:columns="tenantList"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
label=""
|
||||
placeholder="请选择租户"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TenantVO } from '@/api/login'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import {
|
||||
getTenantByWebsite,
|
||||
getTenantSimpleList,
|
||||
|
||||
} from '@/api/login'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const tenantEnabled = computed(
|
||||
() => import.meta.env.VITE_APP_TENANT_ENABLE === 'true',
|
||||
) // 租户开关:通过环境变量控制
|
||||
const tenantList = ref<TenantVO[]>([]) // 租户列表数据
|
||||
|
||||
const tenantId = computed(
|
||||
() =>
|
||||
userStore.tenantId
|
||||
|| Number(import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT_ID)
|
||||
|| undefined,
|
||||
) // 当前选中的租户
|
||||
|
||||
/** 获取租户列表,并根据域名/appId 自动选中租户 */
|
||||
async function fetchTenantList() {
|
||||
if (!tenantEnabled.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 1. 并行获取租户列表和域名对应的租户
|
||||
const websiteTenantPromise = fetchTenantByWebsite()
|
||||
const list = await getTenantSimpleList()
|
||||
tenantList.value = list || []
|
||||
|
||||
// 2. 确定选中的租户:域名/appId > store 中的租户 > 列表第一个
|
||||
let selectedTenantId: number | null = null
|
||||
// 2.1 优先使用域名/appId 对应的租户
|
||||
const websiteTenant = await websiteTenantPromise
|
||||
if (websiteTenant?.id) {
|
||||
selectedTenantId = websiteTenant.id
|
||||
}
|
||||
// 2.2 如果没有从域名获取到,使用 store 中的租户
|
||||
if (!selectedTenantId && userStore.tenantId) {
|
||||
selectedTenantId = userStore.tenantId
|
||||
}
|
||||
// 2.3 如果还是没有,使用列表第一个
|
||||
if (!selectedTenantId && tenantList.value.length > 0) {
|
||||
selectedTenantId = tenantList.value[0].id
|
||||
}
|
||||
|
||||
// 3. 设置选中的租户
|
||||
if (selectedTenantId && selectedTenantId !== userStore.tenantId) {
|
||||
userStore.setTenantId(selectedTenantId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取租户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据域名或 appId 获取租户 */
|
||||
async function fetchTenantByWebsite(): Promise<TenantVO | null> {
|
||||
try {
|
||||
let website: string | null = null
|
||||
|
||||
// #ifdef H5
|
||||
// H5 环境:使用域名
|
||||
if (window?.location?.hostname) {
|
||||
website = window.location.hostname
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP
|
||||
// 小程序环境:使用 appId
|
||||
const appId = uni.getAccountInfoSync?.()?.miniProgram?.appId
|
||||
if (appId) {
|
||||
website = appId
|
||||
}
|
||||
// #endif
|
||||
|
||||
if (website) {
|
||||
return await getTenantByWebsite(website)
|
||||
}
|
||||
} catch (error) {
|
||||
// 域名未配置租户时会报错,忽略即可
|
||||
console.debug('根据域名获取租户失败:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 租户选择确认 */
|
||||
function handleConfirm({ value }: { value: number }) {
|
||||
userStore.setTenantId(value)
|
||||
}
|
||||
|
||||
/** 校验租户是否已选择 */
|
||||
function validate(): boolean {
|
||||
if (!tenantEnabled.value) {
|
||||
return true
|
||||
}
|
||||
if (!tenantId.value) {
|
||||
toast.warning('请选择租户')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 页面加载时获取租户列表 */
|
||||
onMounted(() => {
|
||||
fetchTenantList()
|
||||
})
|
||||
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../styles/auth.scss';
|
||||
</style>
|
||||
15
src/pages-core/auth/components/verifition/utils/ase.ts
Normal file
15
src/pages-core/auth/components/verifition/utils/ase.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
/**
|
||||
* @word 要加密的内容
|
||||
* @keyWord String 服务器随机返回的关键字
|
||||
*/
|
||||
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
|
||||
const key = CryptoJS.enc.Utf8.parse(keyWord)
|
||||
const srcs = CryptoJS.enc.Utf8.parse(word)
|
||||
const encrypted = CryptoJS.AES.encrypt(srcs, key, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
})
|
||||
return encrypted.toString()
|
||||
}
|
||||
432
src/pages-core/auth/components/verifition/verify.vue
Normal file
432
src/pages-core/auth/components/verifition/verify.vue
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<view style="position: relative">
|
||||
<view class="verify-img-out">
|
||||
<view
|
||||
:style="{
|
||||
'width': setSize.imgWidth,
|
||||
'height': setSize.imgHeight,
|
||||
'background-size': `${setSize.imgWidth} ${setSize.imgHeight}`,
|
||||
'margin-bottom': `${pSpace}px`,
|
||||
}" class="verify-img-panel"
|
||||
>
|
||||
<view v-show="showRefresh" class="verify-refresh" style="z-index: 3" @click="refresh">
|
||||
<i class="iconfont icon-refresh" />
|
||||
</view>
|
||||
<img
|
||||
id="image" ref="canvas" :src="`data:image/png;base64,${pointBackImgBase}`"
|
||||
alt="" style="display: block; width: 100%; height: 100%"
|
||||
@click="bindingClick ? canvasClick($event) : undefined"
|
||||
>
|
||||
|
||||
<view
|
||||
v-for="(tempPoint, index) in tempPoints" :key="index" :style="{
|
||||
'background-color': '#1abd6c',
|
||||
'color': '#fff',
|
||||
'z-index': 9999,
|
||||
'width': '20px',
|
||||
'height': '20px',
|
||||
'text-align': 'center',
|
||||
'line-height': '20px',
|
||||
'border-radius': '50%',
|
||||
'position': 'absolute',
|
||||
'top': `${parseInt(tempPoint.y - 10)}px`,
|
||||
'left': `${parseInt(tempPoint.x - 10)}px`,
|
||||
}" class="point-area"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 'height': this.barSize.height, -->
|
||||
<view
|
||||
:style="{
|
||||
'width': setSize.imgWidth,
|
||||
'color': barAreaColor,
|
||||
'border-color': barAreaBorderColor,
|
||||
'line-height': barSize.height,
|
||||
}" class="verify-bar-area"
|
||||
>
|
||||
<span class="verify-msg">{{ text }}</span>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup type="text/babel" name="VerifyPoints">
|
||||
import { getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs } from 'vue'
|
||||
import { checkCaptcha, getCode } from '@/api/login'
|
||||
/**
|
||||
* VerifyPoints
|
||||
* @description 点选
|
||||
*/
|
||||
import { aesEncrypt } from './../utils/ase'
|
||||
|
||||
const props = defineProps({
|
||||
// 弹出式pop,固定fixed
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'fixed',
|
||||
},
|
||||
captchaType: {
|
||||
type: String,
|
||||
},
|
||||
// 间隔
|
||||
pSpace: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
imgSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '155px',
|
||||
}
|
||||
},
|
||||
},
|
||||
barSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '40px',
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['error', 'ready', 'success'])
|
||||
const { mode, captchaType } = toRefs(props)
|
||||
const { proxy } = getCurrentInstance()
|
||||
const secretKey = ref('') // 后端返回的ase加密秘钥
|
||||
const checkNum = ref(3) // 默认需要点击的字数
|
||||
const fontPos = reactive([]) // 选中的坐标信息
|
||||
const checkPosArr = reactive([]) // 用户点击的坐标
|
||||
const num = ref(1) // 点击的记数
|
||||
const pointBackImgBase = ref('') // 后端获取到的背景图片
|
||||
const poinTextList = reactive([]) // 后端返回的点击字体顺序
|
||||
const backToken = ref('') // 后端返回的token值
|
||||
const setSize = reactive({
|
||||
imgHeight: 0,
|
||||
imgWidth: 0,
|
||||
barHeight: 0,
|
||||
barWidth: 0,
|
||||
})
|
||||
const tempPoints = reactive([])
|
||||
const text = ref('')
|
||||
const barAreaColor = ref(undefined)
|
||||
const barAreaBorderColor = ref(undefined)
|
||||
const showRefresh = ref(true)
|
||||
const bindingClick = ref(true)
|
||||
const imgLeft = ref('')
|
||||
const imgTop = ref('')
|
||||
|
||||
function init() {
|
||||
// 加载页面
|
||||
fontPos.splice(0, fontPos.length)
|
||||
checkPosArr.splice(0, checkPosArr.length)
|
||||
num.value = 1
|
||||
getPictrue()
|
||||
nextTick(() => {
|
||||
setSize.imgHeight = props.imgSize.height
|
||||
setSize.imgWidth = props.imgSize.width
|
||||
setSize.barHeight = props.barSize.height
|
||||
setSize.barWidth = props.barSize.width
|
||||
emit('ready', proxy)
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
// 禁止拖拽
|
||||
init()
|
||||
proxy.$el.onselectstart = function () {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const canvas = ref(null)
|
||||
const instance = getCurrentInstance()
|
||||
function canvasClick(e) {
|
||||
const query = uni.createSelectorQuery().in(instance.proxy)
|
||||
query.select('#image').boundingClientRect((rect) => {
|
||||
imgLeft.value = Math.ceil(rect.left)
|
||||
imgTop.value = Math.ceil(rect.top)
|
||||
|
||||
checkPosArr.push(getMousePos(canvas, e))
|
||||
if (num.value === checkNum.value) {
|
||||
num.value = createPoint(getMousePos(canvas, e))
|
||||
// 按比例转换坐标值
|
||||
const arr = pointTransfrom(checkPosArr, setSize)
|
||||
checkPosArr.length = 0
|
||||
checkPosArr.push(...arr)
|
||||
// 等创建坐标执行完
|
||||
setTimeout(() => {
|
||||
// var flag = this.comparePos(this.fontPos, this.checkPosArr);
|
||||
// 发送后端请求
|
||||
const captchaVerification = secretKey.value
|
||||
? aesEncrypt(`${backToken.value}---${JSON.stringify(checkPosArr)}`, secretKey.value)
|
||||
: `${backToken.value}---${JSON.stringify(checkPosArr)}`
|
||||
const data = {
|
||||
captchaType: captchaType.value,
|
||||
pointJson: secretKey.value
|
||||
? aesEncrypt(JSON.stringify(checkPosArr), secretKey.value)
|
||||
: JSON.stringify(checkPosArr),
|
||||
token: backToken.value,
|
||||
}
|
||||
checkCaptcha(data).then((res) => {
|
||||
if (res.repCode === '0000') {
|
||||
barAreaColor.value = '#4cae4c'
|
||||
barAreaBorderColor.value = '#5cb85c'
|
||||
text.value = '验证成功'
|
||||
bindingClick.value = false
|
||||
if (mode.value === 'pop') {
|
||||
setTimeout(() => {
|
||||
proxy.$parent.clickShow = false
|
||||
refresh()
|
||||
}, 1500)
|
||||
}
|
||||
emit('success', { captchaVerification })
|
||||
} else {
|
||||
emit('error', proxy)
|
||||
barAreaColor.value = '#d9534f'
|
||||
barAreaBorderColor.value = '#d9534f'
|
||||
text.value = '验证失败'
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
}, 700)
|
||||
}
|
||||
})
|
||||
}, 400)
|
||||
}
|
||||
if (num.value < checkNum.value) {
|
||||
num.value = createPoint(getMousePos(canvas, e))
|
||||
}
|
||||
}).exec()
|
||||
}
|
||||
// 获取坐标,H5用offsetX,Y,小程序用clientX,Y,pageX,Y,x,y
|
||||
function getMousePos(obj, e) {
|
||||
const x = e?.offsetX ? e.offsetX : Math.ceil(e?.detail.clientX || e?.detail.pageX || e?.detail.x || 0) - imgLeft.value
|
||||
const y = e?.offsetY ? e.offsetY : Math.ceil(e?.detail.clientY || e?.detail.pageY || e?.detail.y || 0) - imgTop.value
|
||||
return { x, y }
|
||||
}
|
||||
// 创建坐标点
|
||||
function createPoint(pos) {
|
||||
tempPoints.push(Object.assign({}, pos))
|
||||
return num.value + 1
|
||||
}
|
||||
async function refresh() {
|
||||
tempPoints.splice(0, tempPoints.length)
|
||||
barAreaColor.value = '#000'
|
||||
barAreaBorderColor.value = '#ddd'
|
||||
bindingClick.value = true
|
||||
fontPos.splice(0, fontPos.length)
|
||||
checkPosArr.splice(0, checkPosArr.length)
|
||||
num.value = 1
|
||||
await getPictrue()
|
||||
showRefresh.value = true
|
||||
}
|
||||
|
||||
// 请求背景图片和验证图片
|
||||
async function getPictrue() {
|
||||
const data = {
|
||||
captchaType: captchaType.value,
|
||||
}
|
||||
const res = await getCode(data)
|
||||
if (res.repCode === '0000') {
|
||||
pointBackImgBase.value = res.repData.originalImageBase64
|
||||
backToken.value = res.repData.token
|
||||
secretKey.value = res.repData.secretKey
|
||||
poinTextList.value = res.repData.wordList
|
||||
text.value = `请依次点击` + `【${poinTextList.value.join(',')}】`
|
||||
} else {
|
||||
text.value = res.repMsg
|
||||
}
|
||||
}
|
||||
// 坐标转换函数
|
||||
function pointTransfrom(pointArr, imgSize) {
|
||||
const newPointArr = pointArr.map((p) => {
|
||||
const x = Math.round((310 * p.x) / Number.parseInt(imgSize.imgWidth))
|
||||
const y = Math.round((155 * p.y) / Number.parseInt(imgSize.imgHeight))
|
||||
return { x, y }
|
||||
})
|
||||
return newPointArr
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<view style="position: relative">
|
||||
<view v-if="type === '2'" :style="{ height: `${parseInt(setSize.imgHeight) + pSpace}px` }" class="verify-img-out">
|
||||
<view :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel">
|
||||
<img :src="`data:image/png;base64,${backImgBase}`" alt="" style="display: block; width: 100%; height: 100%">
|
||||
<view v-show="showRefresh" class="verify-refresh" @click="refresh">
|
||||
<view class="iconfont icon-refresh" />
|
||||
</view>
|
||||
<transition name="tips">
|
||||
<text v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">
|
||||
{{ tipWords }}
|
||||
</text>
|
||||
</transition>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 公共部分 -->
|
||||
<view
|
||||
:style="{ 'width': setSize.imgWidth, 'height': barSize.height, 'line-height': barSize.height }"
|
||||
class="verify-bar-area"
|
||||
>
|
||||
<text class="verify-msg">{{ text }}</text>
|
||||
<view
|
||||
:style="{
|
||||
'width': leftBarWidth !== undefined ? leftBarWidth : barSize.height,
|
||||
'height': barSize.height,
|
||||
'border-color': leftBarBorderColor,
|
||||
'transaction': transitionWidth,
|
||||
}" class="verify-left-bar"
|
||||
>
|
||||
<text class="verify-msg">{{ finishText }}</text>
|
||||
<view
|
||||
v-if="type === '2'" :style="{
|
||||
'width': barSize.height,
|
||||
'height': barSize.height,
|
||||
'background-color': moveBlockBackgroundColor,
|
||||
'left': moveBlockLeft,
|
||||
'transition': transitionLeft,
|
||||
}" class="verify-move-block" @touchstart="start" @touchend="end" @touchmove="move" @mouseup="end"
|
||||
>
|
||||
<view class="verify-icon iconfont" :class="[iconClass]" :style="{ color: iconColor }" />
|
||||
<view
|
||||
v-if="type === '2'" :style="{
|
||||
'width': `${Math.floor((parseInt(setSize.imgWidth) * 47) / 310)}px`,
|
||||
'height': setSize.imgHeight,
|
||||
'top': `-${parseInt(setSize.imgHeight) + pSpace}px`,
|
||||
'background-size': `${setSize.imgWidth} ${setSize.imgHeight}`,
|
||||
}" class="verify-sub-block"
|
||||
>
|
||||
<img
|
||||
:src="`data:image/png;base64,${blockBackImgBase}`" alt=""
|
||||
style="display: block; width: 100%; height: 100%; -webkit-user-drag: none"
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup name="VerifySlide">
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs, watch } from 'vue'
|
||||
import { checkCaptcha, getCode } from '@/api/login'
|
||||
/**
|
||||
* VerifySlide
|
||||
* @description 滑块
|
||||
*/
|
||||
import { aesEncrypt } from './../utils/ase'
|
||||
|
||||
const props = defineProps({
|
||||
captchaType: {
|
||||
type: String,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '1',
|
||||
},
|
||||
// 弹出式pop,固定fixed
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'fixed',
|
||||
},
|
||||
pSpace: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
explain: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
imgSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '155px',
|
||||
}
|
||||
},
|
||||
},
|
||||
blockSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
}
|
||||
},
|
||||
},
|
||||
barSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '30px',
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 父级传递来的函数,用于回调
|
||||
const emit = defineEmits(['success', 'error', 'ready'])
|
||||
|
||||
// const { t } = useI18n()
|
||||
const { mode, captchaType, type, blockSize, explain } = toRefs(props)
|
||||
const { proxy } = getCurrentInstance()
|
||||
const secretKey = ref('') // 后端返回的ase加密秘钥
|
||||
const passFlag = ref('') // 是否通过的标识
|
||||
const backImgBase = ref('') // 验证码背景图片
|
||||
const blockBackImgBase = ref('') // 验证滑块的背景图片
|
||||
const backToken = ref('') // 后端返回的唯一token值
|
||||
const startMoveTime = ref('') // 移动开始的时间
|
||||
const endMovetime = ref('') // 移动结束的时间
|
||||
const tipWords = ref('')
|
||||
const text = ref('')
|
||||
const finishText = ref('')
|
||||
const setSize = reactive({
|
||||
imgHeight: 0,
|
||||
imgWidth: 0,
|
||||
barHeight: 0,
|
||||
barWidth: 0,
|
||||
})
|
||||
const moveBlockLeft = ref(0)
|
||||
const leftBarWidth = ref(0)
|
||||
// 移动中样式
|
||||
const moveBlockBackgroundColor = ref(undefined)
|
||||
const leftBarBorderColor = ref('#ddd')
|
||||
const iconColor = ref(undefined)
|
||||
const iconClass = ref('icon-right')
|
||||
const status = ref(false) // 鼠标状态
|
||||
const isEnd = ref(false) // 是够验证完成
|
||||
const showRefresh = ref(true)
|
||||
const transitionLeft = ref('')
|
||||
const transitionWidth = ref('')
|
||||
const startLeft = ref(0)
|
||||
const instance = getCurrentInstance()
|
||||
const barArea = computed(() => {
|
||||
const query = uni.createSelectorQuery().in(instance.proxy)
|
||||
return query.select('.verify-bar-area')
|
||||
})
|
||||
const rectData = ref() // 布局数据
|
||||
function init() {
|
||||
if (explain.value === '') {
|
||||
text.value = '向右滑动完成验证'
|
||||
} else {
|
||||
text.value = explain.value
|
||||
}
|
||||
getPictrue()
|
||||
nextTick(() => {
|
||||
// let { imgHeight, imgWidth, barHeight, barWidth } = props.value.imgSize
|
||||
setSize.imgHeight = props.imgSize.height
|
||||
setSize.imgWidth = props.imgSize.width
|
||||
setSize.barHeight = props.barSize.height
|
||||
setSize.barWidth = props.barSize.width
|
||||
emit('ready', proxy)
|
||||
})
|
||||
}
|
||||
watch(type, () => {
|
||||
init()
|
||||
})
|
||||
onMounted(() => {
|
||||
// 禁止拖拽
|
||||
init()
|
||||
proxy.$el.onselectstart = function () {
|
||||
return false
|
||||
}
|
||||
})
|
||||
// 鼠标按下
|
||||
function start(e) {
|
||||
e = e || window.event
|
||||
let x = 0
|
||||
if (!e.touches) {
|
||||
// 兼容PC端
|
||||
x = e.clientX
|
||||
} else {
|
||||
// 兼容移动端
|
||||
x = e.touches[0].pageX
|
||||
}
|
||||
barArea.value.boundingClientRect((rect) => {
|
||||
rectData.value = rect
|
||||
startLeft.value = Math.floor(x - rect.left)
|
||||
}).exec()
|
||||
startMoveTime.value = +new Date() // 开始滑动的时间
|
||||
if (isEnd.value === false) {
|
||||
text.value = ''
|
||||
moveBlockBackgroundColor.value = '#337ab7'
|
||||
leftBarBorderColor.value = '#337AB7'
|
||||
iconColor.value = '#fff'
|
||||
e.stopPropagation()
|
||||
status.value = true
|
||||
}
|
||||
}
|
||||
// 鼠标移动
|
||||
function move(e) {
|
||||
e = e || window.event
|
||||
if (status.value && isEnd.value === false) {
|
||||
let x = 0
|
||||
if (!e.touches) {
|
||||
// 兼容PC端
|
||||
x = e.clientX
|
||||
} else {
|
||||
// 兼容移动端
|
||||
x = e.touches[0].pageX
|
||||
}
|
||||
if (rectData.value) {
|
||||
const bar_area_left = Math.ceil(rectData.value.left)
|
||||
const barArea_offsetWidth = Math.ceil(rectData.value.width)
|
||||
let move_block_left = x - bar_area_left // 小方块相对于父元素的left值
|
||||
if (move_block_left
|
||||
>= barArea_offsetWidth - Number.parseInt(Number.parseInt(blockSize.value.width) / 2) - 2
|
||||
) {
|
||||
move_block_left
|
||||
= barArea_offsetWidth - Number.parseInt(Number.parseInt(blockSize.value.width) / 2) - 2
|
||||
}
|
||||
if (move_block_left <= 0) {
|
||||
move_block_left = Number.parseInt(Number.parseInt(blockSize.value.width) / 2)
|
||||
}
|
||||
// 拖动后小方块的left值
|
||||
moveBlockLeft.value = `${move_block_left - Number.parseInt(Number.parseInt(blockSize.value.width) / 2)
|
||||
}px`
|
||||
leftBarWidth.value = `${move_block_left - Number.parseInt(Number.parseInt(blockSize.value.width) / 2)
|
||||
}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标松开
|
||||
function end() {
|
||||
endMovetime.value = +new Date()
|
||||
// 判断是否重合
|
||||
if (status.value && isEnd.value === false) {
|
||||
let moveLeftDistance = Number.parseInt((moveBlockLeft.value || '0').replace('px', ''))
|
||||
moveLeftDistance = (moveLeftDistance * 310) / Number.parseInt(setSize.imgWidth)
|
||||
const data = {
|
||||
captchaType: captchaType.value,
|
||||
pointJson: secretKey.value
|
||||
? aesEncrypt(JSON.stringify({ x: moveLeftDistance, y: 5.0 }), secretKey.value)
|
||||
: JSON.stringify({ x: moveLeftDistance, y: 5.0 }),
|
||||
token: backToken.value,
|
||||
}
|
||||
checkCaptcha(data).then((res) => {
|
||||
if (res.repCode === '0000') {
|
||||
moveBlockBackgroundColor.value = '#5cb85c'
|
||||
leftBarBorderColor.value = '#5cb85c'
|
||||
iconColor.value = '#fff'
|
||||
iconClass.value = 'icon-check'
|
||||
showRefresh.value = false
|
||||
isEnd.value = true
|
||||
if (mode.value === 'pop') {
|
||||
setTimeout(() => {
|
||||
proxy.$parent.clickShow = false
|
||||
refresh()
|
||||
}, 1500)
|
||||
}
|
||||
passFlag.value = true
|
||||
tipWords.value = `${((endMovetime.value - startMoveTime.value) / 1000).toFixed(2)}s 验证成功`
|
||||
const captchaVerification = secretKey.value
|
||||
? aesEncrypt(
|
||||
`${backToken.value}---${JSON.stringify({ x: moveLeftDistance, y: 5.0 })}`,
|
||||
secretKey.value,
|
||||
)
|
||||
: `${backToken.value}---${JSON.stringify({ x: moveLeftDistance, y: 5.0 })}`
|
||||
setTimeout(() => {
|
||||
tipWords.value = ''
|
||||
emit('success', { captchaVerification })
|
||||
}, 1000)
|
||||
} else {
|
||||
moveBlockBackgroundColor.value = '#d9534f'
|
||||
leftBarBorderColor.value = '#d9534f'
|
||||
iconColor.value = '#fff'
|
||||
iconClass.value = 'icon-close'
|
||||
passFlag.value = false
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
}, 1000)
|
||||
emit('error', proxy)
|
||||
tipWords.value = '验证失败'
|
||||
setTimeout(() => {
|
||||
tipWords.value = ''
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
status.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
showRefresh.value = true
|
||||
finishText.value = ''
|
||||
|
||||
transitionLeft.value = 'left .3s'
|
||||
moveBlockLeft.value = 0
|
||||
|
||||
leftBarWidth.value = 0
|
||||
transitionWidth.value = 'width .3s'
|
||||
|
||||
leftBarBorderColor.value = '#ddd'
|
||||
moveBlockBackgroundColor.value = '#fff'
|
||||
iconColor.value = '#000'
|
||||
iconClass.value = 'icon-right'
|
||||
isEnd.value = false
|
||||
|
||||
await getPictrue()
|
||||
setTimeout(() => {
|
||||
transitionWidth.value = ''
|
||||
transitionLeft.value = ''
|
||||
text.value = explain.value
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 请求背景图片和验证图片
|
||||
async function getPictrue() {
|
||||
const data = {
|
||||
captchaType: captchaType.value,
|
||||
}
|
||||
const res = await getCode(data)
|
||||
|
||||
if (res.repCode === '0000') {
|
||||
backImgBase.value = res.repData.originalImageBase64
|
||||
blockBackImgBase.value = res.repData.jigsawImageBase64
|
||||
backToken.value = res.repData.token
|
||||
secretKey.value = res.repData.secretKey
|
||||
} else {
|
||||
tipWords.value = res.repMsg
|
||||
}
|
||||
}
|
||||
</script>
|
||||
172
src/pages-core/auth/forget-password.vue
Normal file
172
src/pages-core/auth/forget-password.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<view class="auth-container">
|
||||
<!-- 顶部 -->
|
||||
<Header />
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<view class="form-container">
|
||||
<view class="input-item">
|
||||
<wd-icon name="phone" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.mobile"
|
||||
placeholder="请输入手机号"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
no-border
|
||||
type="number"
|
||||
:maxlength="11"
|
||||
/>
|
||||
</view>
|
||||
<CodeInput
|
||||
v-model="formData.code"
|
||||
:mobile="formData.mobile"
|
||||
:scene="23"
|
||||
:before-send="validateBeforeSend"
|
||||
/>
|
||||
<view class="input-item">
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
placeholder="请输入新密码"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
show-password
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view class="input-item">
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.confirmPassword"
|
||||
placeholder="请确认新密码"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
show-password
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 重置密码按钮 -->
|
||||
<wd-button
|
||||
block
|
||||
:loading="loading"
|
||||
type="primary"
|
||||
@click="handleResetPassword"
|
||||
>
|
||||
重置密码
|
||||
</wd-button>
|
||||
<wd-button class="mt-2" block type="info" @click="goToLogin">
|
||||
返回登录
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { smsResetPassword } from '@/api/login'
|
||||
import { LOGIN_PAGE } from '@/router/config'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { isMobile } from '@/utils/validator'
|
||||
import CodeInput from './components/code-input.vue'
|
||||
import Header from './components/header.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ForgetPasswordPage',
|
||||
})
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
excludeLoginPath: true,
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false) // 加载状态
|
||||
|
||||
const formData = reactive({
|
||||
mobile: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
}) // 表单数据
|
||||
|
||||
/** 页面加载时确保租户ID已设置 */
|
||||
onLoad(() => {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
})
|
||||
|
||||
/** 发送验证码前的校验 */
|
||||
function validateBeforeSend(): boolean {
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 重置密码处理 */
|
||||
async function handleResetPassword() {
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
if (!formData.mobile) {
|
||||
toast.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!isMobile(formData.mobile)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
if (!formData.code) {
|
||||
toast.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (!formData.password) {
|
||||
toast.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (!formData.confirmPassword) {
|
||||
toast.warning('请确认新密码')
|
||||
return
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
toast.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用重置密码接口
|
||||
await smsResetPassword({
|
||||
mobile: formData.mobile,
|
||||
code: formData.code,
|
||||
password: formData.password,
|
||||
})
|
||||
toast.success('密码重置成功')
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
goToLogin()
|
||||
}, 500)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到登录页面 */
|
||||
function goToLogin() {
|
||||
uni.navigateTo({ url: LOGIN_PAGE })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './styles/auth.scss';
|
||||
</style>
|
||||
195
src/pages-core/auth/login.vue
Normal file
195
src/pages-core/auth/login.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<view class="auth-container">
|
||||
<!-- 顶部 -->
|
||||
<Header />
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<view class="form-container">
|
||||
<view class="input-item">
|
||||
<wd-icon name="user" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view class="input-item">
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
placeholder="请输入密码"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
show-password
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view v-if="captchaEnabled">
|
||||
<Verify
|
||||
ref="verifyRef"
|
||||
:captcha-type="captchaType"
|
||||
explain="向右滑动完成验证"
|
||||
:img-size="{ width: '300px', height: '150px' }"
|
||||
mode="pop"
|
||||
@success="verifySuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 用户协议 -->
|
||||
<view class="mb-24rpx flex items-center">
|
||||
<wd-checkbox v-model="agreePolicy" shape="square" />
|
||||
<text class="text-24rpx text-[#666]">我已阅读并同意</text>
|
||||
<navigator
|
||||
class="text-24rpx text-[#1890ff]"
|
||||
url="/pages-core/user/settings/agreement/index"
|
||||
open-type="navigate"
|
||||
hover-class="none"
|
||||
>
|
||||
《用户协议》
|
||||
</navigator>
|
||||
<text class="text-24rpx text-[#666]">与</text>
|
||||
<navigator
|
||||
class="text-24rpx text-[#1890ff]"
|
||||
url="/pages-core/user/settings/privacy/index"
|
||||
open-type="navigate"
|
||||
hover-class="none"
|
||||
>
|
||||
《隐私政策》
|
||||
</navigator>
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<view class="mb-2 mt-2">
|
||||
<wd-button block :loading="loading" :disabled="!agreePolicy" type="primary" @click="handleLogin">
|
||||
登录
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useTokenStore } from '@/store/token'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ensureDecodeURIComponent, redirectAfterLogin } from '@/utils'
|
||||
import Header from './components/header.vue'
|
||||
import Verify from './components/verifition/verify.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'LoginPage',
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false) // 加载状态
|
||||
const redirectUrl = ref<string>() // 重定向地址
|
||||
const captchaEnabled = import.meta.env.VITE_APP_CAPTCHA_ENABLE === 'true' // 验证码开关
|
||||
const verifyRef = ref()
|
||||
const captchaType = ref('blockPuzzle') // 滑块验证码 blockPuzzle|clickWord
|
||||
const agreePolicy = ref(false) // 用户协议勾选
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captchaVerification: '', // 验证码校验值
|
||||
}) // 表单数据
|
||||
|
||||
const LOGIN_CREDENTIALS_KEY = 'auth_login_credentials'
|
||||
|
||||
/** 页面加载时处理重定向 */
|
||||
onLoad((options) => {
|
||||
if (options?.redirect) {
|
||||
redirectUrl.value = ensureDecodeURIComponent(options.redirect)
|
||||
}
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
// 回显“记住账号密码”
|
||||
try {
|
||||
const cached = uni.getStorageSync(LOGIN_CREDENTIALS_KEY)
|
||||
if (cached?.username) formData.username = cached.username
|
||||
if (cached?.password) formData.password = cached.password
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取验证码 */
|
||||
async function getCode() {
|
||||
// 情况一,未开启:则直接登录
|
||||
if (!captchaEnabled) {
|
||||
await verifySuccess({})
|
||||
} else {
|
||||
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录
|
||||
// 弹出验证码
|
||||
verifyRef.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录处理 */
|
||||
async function handleLogin() {
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
if (!agreePolicy.value) {
|
||||
toast.warning('请阅读并同意《用户协议》与《隐私政策》')
|
||||
return
|
||||
}
|
||||
if (!formData.username) {
|
||||
toast.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!formData.password) {
|
||||
toast.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
await getCode()
|
||||
}
|
||||
|
||||
async function verifySuccess(params: any) {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用登录接口
|
||||
const tokenStore = useTokenStore()
|
||||
formData.captchaVerification = params.captchaVerification
|
||||
await tokenStore.login({
|
||||
type: 'username',
|
||||
...formData,
|
||||
})
|
||||
// 默认记住账号密码
|
||||
try {
|
||||
uni.setStorageSync(LOGIN_CREDENTIALS_KEY, {
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 处理跳转
|
||||
redirectAfterLogin(redirectUrl.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './styles/auth.scss';
|
||||
</style>
|
||||
212
src/pages-core/auth/register.vue
Normal file
212
src/pages-core/auth/register.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<view class="auth-container">
|
||||
<!-- 顶部 -->
|
||||
<Header />
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<view class="form-container">
|
||||
<view class="input-item">
|
||||
<wd-icon name="user" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view class="input-item">
|
||||
<wd-icon name="person" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.nickname"
|
||||
placeholder="请输入昵称"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view class="input-item">
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
placeholder="请输入密码"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
show-password
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view class="input-item">
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" />
|
||||
<wd-input
|
||||
v-model="formData.confirmPassword"
|
||||
placeholder="请确认密码"
|
||||
clearable
|
||||
clear-trigger="focus"
|
||||
show-password
|
||||
no-border
|
||||
/>
|
||||
</view>
|
||||
<view v-if="captchaEnabled">
|
||||
<Verify
|
||||
ref="verifyRef"
|
||||
:captcha-type="captchaType"
|
||||
explain="向右滑动完成验证"
|
||||
:img-size="{ width: '300px', height: '150px' }"
|
||||
mode="pop"
|
||||
@success="verifySuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 用户协议 -->
|
||||
<view class="mb-24rpx flex items-center">
|
||||
<wd-checkbox v-model="agreePolicy" shape="square" />
|
||||
<text class="text-24rpx text-[#666]">我已阅读并同意</text>
|
||||
<text class="text-24rpx text-[#1890ff]" @click="goToUserAgreement">
|
||||
《用户协议》
|
||||
</text>
|
||||
<text class="text-24rpx text-[#666]">与</text>
|
||||
<text class="text-24rpx text-[#1890ff]" @click="goToPrivacyPolicy">
|
||||
《隐私政策》
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<wd-button
|
||||
block
|
||||
:loading="loading"
|
||||
type="primary"
|
||||
@click="handleRegister"
|
||||
>
|
||||
注册
|
||||
</wd-button>
|
||||
|
||||
<!-- 已有账号 -->
|
||||
<view class="mt-40rpx flex items-center justify-center">
|
||||
<text class="text-28rpx text-[#666]">已有账号?</text>
|
||||
<text class="text-28rpx text-[#1890ff]" @click="goToLogin">去登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { LOGIN_PAGE } from '@/router/config'
|
||||
import { useTokenStore } from '@/store/token'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { redirectAfterLogin } from '@/utils'
|
||||
import Header from './components/header.vue'
|
||||
import Verify from './components/verifition/verify.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'RegisterPage',
|
||||
})
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
excludeLoginPath: true,
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false) // 加载状态
|
||||
const agreePolicy = ref(false) // 用户协议勾选
|
||||
const captchaEnabled = import.meta.env.VITE_APP_CAPTCHA_ENABLE === 'true' // 验证码开关
|
||||
const verifyRef = ref()
|
||||
const captchaType = ref('blockPuzzle') // 滑块验证码 blockPuzzle|clickWord
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
captchaVerification: '', // 验证码校验值
|
||||
}) // 表单数据
|
||||
|
||||
/** 获取验证码 */
|
||||
async function getCode() {
|
||||
// 情况一,未开启:则直接注册
|
||||
if (!captchaEnabled) {
|
||||
await verifySuccess({})
|
||||
} else {
|
||||
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行注册
|
||||
// 弹出验证码
|
||||
verifyRef.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册处理 */
|
||||
async function handleRegister() {
|
||||
// 确保租户ID已设置(默认为129)
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.tenantId) {
|
||||
userStore.setTenantId(129)
|
||||
}
|
||||
if (!agreePolicy.value) {
|
||||
toast.warning('请阅读并同意《用户协议》与《隐私政策》')
|
||||
return
|
||||
}
|
||||
if (!formData.username) {
|
||||
toast.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!formData.nickname) {
|
||||
toast.warning('请输入昵称')
|
||||
return
|
||||
}
|
||||
if (!formData.password) {
|
||||
toast.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
if (!formData.confirmPassword) {
|
||||
toast.warning('请确认密码')
|
||||
return
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
toast.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
await getCode()
|
||||
}
|
||||
|
||||
/** 验证码验证成功回调 */
|
||||
async function verifySuccess(params: any) {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用注册接口
|
||||
const tokenStore = useTokenStore()
|
||||
formData.captchaVerification = params.captchaVerification
|
||||
await tokenStore.login({
|
||||
type: 'register',
|
||||
...formData,
|
||||
})
|
||||
toast.success('注册成功')
|
||||
// 处理跳转
|
||||
redirectAfterLogin()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到登录页面 */
|
||||
function goToLogin() {
|
||||
uni.navigateTo({ url: LOGIN_PAGE })
|
||||
}
|
||||
|
||||
/** 跳转到用户协议 */
|
||||
function goToUserAgreement() {
|
||||
uni.navigateTo({ url: '/pages-core/user/settings/agreement/index' })
|
||||
}
|
||||
|
||||
/** 跳转到隐私政策 */
|
||||
function goToPrivacyPolicy() {
|
||||
uni.navigateTo({ url: '/pages-core/user/settings/privacy/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './styles/auth.scss';
|
||||
</style>
|
||||
63
src/pages-core/auth/styles/auth.scss
Normal file
63
src/pages-core/auth/styles/auth.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
/** 认证页面公共样式 */
|
||||
|
||||
// 页面容器
|
||||
.auth-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #e8f4ff 0%, #fff 50%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 表单容器
|
||||
.form-container {
|
||||
flex: 1;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
background: #fff;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
// 输入项
|
||||
.input-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
background: #f5f7fa;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
:deep(.wd-input) {
|
||||
flex: 1;
|
||||
margin-left: 16rpx;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.wd-picker) {
|
||||
flex: 1;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
:deep(.wd-picker__field) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 移除 picker 的边框,保持与 input 一致
|
||||
:deep(.wd-picker__cell) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.wd-cell) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.wd-cell__wrapper) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
39
src/pages-core/error/404.vue
Normal file
39
src/pages-core/error/404.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
navigateBackPlus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="yd-page-container flex flex-col">
|
||||
<wd-navbar
|
||||
title="页面不存在"
|
||||
left-arrow placeholder safe-area-inset-top fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<view class="flex flex-1 flex-col items-center bg-white px-48rpx pt-1/3">
|
||||
<wd-icon name="warning" size="96rpx" color="#faad14" />
|
||||
<view class="mt-24rpx text-34rpx text-[#333] font-semibold">
|
||||
404
|
||||
</view>
|
||||
<view class="mt-16rpx text-center text-28rpx text-[#666] leading-44rpx">
|
||||
抱歉,您访问的页面不存在
|
||||
</view>
|
||||
|
||||
<view class="mt-32rpx w-full">
|
||||
<wd-button :block="true" :plain="true" @click="handleBack">
|
||||
返回
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
39
src/pages-core/error/pc-only.vue
Normal file
39
src/pages-core/error/pc-only.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
navigateBackPlus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="yd-page-container flex flex-col">
|
||||
<wd-navbar
|
||||
title="仅支持 PC"
|
||||
left-arrow placeholder safe-area-inset-top fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<view class="flex flex-1 flex-col items-center bg-white px-48rpx pt-1/3">
|
||||
<wd-icon name="laptop" size="96rpx" color="#1677ff" />
|
||||
<view class="mt-24rpx text-34rpx text-[#333] font-semibold">
|
||||
该功能仅支持 PC
|
||||
</view>
|
||||
<view class="mt-16rpx text-center text-28rpx text-[#666] leading-44rpx">
|
||||
由于移动端操作不便,请在电脑端打开管理后台完成相关操作。
|
||||
</view>
|
||||
|
||||
<view class="mt-32rpx w-full">
|
||||
<wd-button :block="true" :plain="true" @click="handleBack">
|
||||
返回
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
91
src/pages-core/user/contact/index.vue
Normal file
91
src/pages-core/user/contact/index.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="联系客服"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 客服卡片 -->
|
||||
<view class="mx-30rpx mt-20rpx rounded-16rpx bg-white px-60rpx py-80rpx">
|
||||
<view class="flex flex-col items-center">
|
||||
<!-- 二维码图片 -->
|
||||
<view class="mb-30rpx h-280rpx w-280rpx overflow-hidden rounded-16rpx">
|
||||
<wd-img
|
||||
:src="qrCodeUrl"
|
||||
width="280rpx"
|
||||
height="280rpx"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="mb-40rpx text-32rpx text-gray-800 font-bold">
|
||||
添加客服二维码
|
||||
</text>
|
||||
<text class="mb-16rpx text-28rpx text-gray-500">
|
||||
服务时间:早上 9:00 - 22:00
|
||||
</text>
|
||||
|
||||
<!-- 客服电话 -->
|
||||
<view class="flex items-center text-28rpx text-gray-500">
|
||||
<text>客服电话:{{ servicePhone }}</text>
|
||||
<text
|
||||
class="ml-10rpx text-blue-500 underline"
|
||||
@click="handleCallPhone"
|
||||
>
|
||||
拨打
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="mt-60rpx w-full">
|
||||
<wd-button type="primary" block @click="handleSaveQRCode">
|
||||
保存二维码图片
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
import { saveImageToAlbum, staticUrl } from '@/utils/download'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const qrCodeUrl = ref(staticUrl('/static/qrcode.png')) // 客服二维码图片地址
|
||||
const servicePhone = ref('18818818818') // 客服电话号码
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages/user/index')
|
||||
}
|
||||
|
||||
/** 拨打电话 */
|
||||
function handleCallPhone() {
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: servicePhone.value,
|
||||
fail: (_err) => {
|
||||
toast.show('拨打失败')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存二维码图片 */
|
||||
async function handleSaveQRCode() {
|
||||
await saveImageToAlbum(qrCodeUrl.value, 'weixin_qrcode.png')
|
||||
}
|
||||
</script>
|
||||
66
src/pages-core/user/faq/data.ts
Normal file
66
src/pages-core/user/faq/data.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* FAQ 常见问题数据
|
||||
*/
|
||||
export interface FaqItem {
|
||||
/** 问题标题 */
|
||||
title: string
|
||||
/** 问题答案 */
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface FaqCategory {
|
||||
/** 分类图标 */
|
||||
icon: string
|
||||
/** 分类标题 */
|
||||
title: string
|
||||
/** 问题列表 */
|
||||
childList: FaqItem[]
|
||||
}
|
||||
|
||||
/** FAQ 数据列表 */
|
||||
export const faqList: FaqCategory[] = [
|
||||
{
|
||||
icon: 'github-filled',
|
||||
title: '途安伴旅问题',
|
||||
childList: [
|
||||
{
|
||||
title: '途安伴旅开源吗?',
|
||||
content: '开源,基于 MIT 协议,可免费商用。',
|
||||
},
|
||||
{
|
||||
title: '途安伴旅可以商用吗?',
|
||||
content: '可以,途安伴旅采用 MIT 开源协议,允许商业使用。',
|
||||
},
|
||||
{
|
||||
title: '途安伴旅官网地址多少?',
|
||||
content: 'https://www.iocoder.cn',
|
||||
},
|
||||
{
|
||||
title: '途安伴旅文档地址多少?',
|
||||
content: 'https://doc.iocoder.cn',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'warning',
|
||||
title: '其他问题',
|
||||
childList: [
|
||||
{
|
||||
title: '如何退出登录?',
|
||||
content: '请点击 [我的] - [退出登录] 即可退出登录。',
|
||||
},
|
||||
{
|
||||
title: '如何修改用户头像?',
|
||||
content: '请点击 [我的] - [个人资料] - [选择头像] 即可更换用户头像。',
|
||||
},
|
||||
{
|
||||
title: '如何修改登录密码?',
|
||||
content: '请点击 [我的] - [账号安全] - [修改密码] 即可修改登录密码。',
|
||||
},
|
||||
{
|
||||
title: '如何切换用户?',
|
||||
content: '请先退出当前账号,然后使用其他账号重新登录即可。',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
99
src/pages-core/user/faq/index.vue
Normal file
99
src/pages-core/user/faq/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="常见问题"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<wd-search
|
||||
v-model="searchValue"
|
||||
placeholder="请输入你想咨询的问题"
|
||||
placeholder-left
|
||||
hide-cancel
|
||||
light
|
||||
/>
|
||||
|
||||
<!-- FAQ Tabs -->
|
||||
<wd-tabs v-model="activeTab" auto-line-width>
|
||||
<wd-tab
|
||||
v-for="(category, index) in faqList"
|
||||
:key="index"
|
||||
:title="category.title"
|
||||
:name="index"
|
||||
>
|
||||
<view class="min-h-[calc(100vh-300rpx)] bg-white">
|
||||
<wd-collapse v-model="activeNames" custom-class="faq-collapse">
|
||||
<wd-collapse-item
|
||||
v-for="(item, idx) in filteredList(category.childList)"
|
||||
:key="idx"
|
||||
:name="`${index}-${idx}`"
|
||||
>
|
||||
<template #title>
|
||||
<view class="flex items-center">
|
||||
<wd-icon name="edit-outline" size="18px" color="#1890ff" class="mr-16rpx" />
|
||||
<text>{{ item.title }}</text>
|
||||
</view>
|
||||
</template>
|
||||
<view class="text-28rpx text-gray-500 leading-relaxed">
|
||||
{{ item.content }}
|
||||
</view>
|
||||
</wd-collapse-item>
|
||||
</wd-collapse>
|
||||
</view>
|
||||
</wd-tab>
|
||||
</wd-tabs>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FaqItem } from './data'
|
||||
import { ref } from 'vue'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
import { faqList } from './data'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const activeTab = ref<number>(0) // 当前选中的 Tab
|
||||
const searchValue = ref('') // 搜索关键词
|
||||
const activeNames = ref<string[]>([]) // 展开的问题
|
||||
|
||||
/** 过滤问题列表 */
|
||||
function filteredList(list: FaqItem[]) {
|
||||
if (!searchValue.value) {
|
||||
return list
|
||||
}
|
||||
return list.filter(item =>
|
||||
item.title.includes(searchValue.value) || item.content.includes(searchValue.value),
|
||||
)
|
||||
}
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages/user/index')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.faq-collapse) {
|
||||
background: #fff;
|
||||
|
||||
.wd-collapse-item__header {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.wd-collapse-item__wrapper {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
202
src/pages-core/user/feedback/index.vue
Normal file
202
src/pages-core/user/feedback/index.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="意见反馈"
|
||||
left-arrow placeholder safe-area-inset-top fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<view class="p-24rpx">
|
||||
<wd-form ref="formRef" :model="formData" :rules="formRules">
|
||||
<wd-cell-group custom-class="cell-group" border>
|
||||
<wd-textarea
|
||||
v-model="formData.content"
|
||||
label="反馈内容"
|
||||
label-width="180rpx"
|
||||
prop="content"
|
||||
placeholder="请输入您的宝贵意见和建议"
|
||||
:maxlength="500"
|
||||
show-word-limit
|
||||
clearable
|
||||
:rows="5"
|
||||
/>
|
||||
<wd-cell title="反馈图片" title-width="180rpx" />
|
||||
<!-- TODO @芋艿:图片上传的接入 -->
|
||||
<view class="px-24rpx pb-24rpx">
|
||||
<wd-upload
|
||||
v-model:file-list="fileList"
|
||||
:upload-method="customUpload"
|
||||
accept="image"
|
||||
multiple
|
||||
:limit="9"
|
||||
/>
|
||||
</view>
|
||||
</wd-cell-group>
|
||||
</wd-form>
|
||||
</view>
|
||||
|
||||
<!-- 底部提交按钮 -->
|
||||
<view class="yd-detail-footer">
|
||||
<wd-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="formLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交反馈
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
|
||||
import type { UploadFile, UploadMethod } from 'wot-design-uni/components/wd-upload/types'
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { getEnvBaseUrl, navigateBackPlus } from '@/utils/index'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const formLoading = ref(false)
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const formData = ref({
|
||||
content: '',
|
||||
})
|
||||
const formRules = {
|
||||
content: [
|
||||
{ required: true, message: '请输入反馈内容' },
|
||||
{
|
||||
required: true,
|
||||
validator: (value: string) => value.length >= 10,
|
||||
message: '反馈内容至少10个字符',
|
||||
},
|
||||
],
|
||||
}
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages/user/index')
|
||||
}
|
||||
|
||||
/** 自定义上传方法 */
|
||||
const customUpload: UploadMethod = (file, formData, options) => {
|
||||
const uploadTask = uni.uploadFile({
|
||||
url: `${getEnvBaseUrl()}/infra/file/upload`,
|
||||
header: {
|
||||
...options.header,
|
||||
},
|
||||
name: options.name,
|
||||
fileType: options.fileType,
|
||||
formData,
|
||||
filePath: file.url,
|
||||
success(res) {
|
||||
if (res.statusCode === options.statusCode) {
|
||||
options.onSuccess(res, file, formData)
|
||||
} else {
|
||||
options.onError({ ...res, errMsg: res.errMsg || '' }, file, formData)
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
options.onError(err, file, formData)
|
||||
},
|
||||
})
|
||||
uploadTask.onProgressUpdate((res) => {
|
||||
options.onProgress(res, file)
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交表单 */
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
formLoading.value = true
|
||||
try {
|
||||
// 构建提交数据
|
||||
const submitData = {
|
||||
content: formData.value.content,
|
||||
// 提取已上传成功的图片 URL
|
||||
images: fileList.value
|
||||
.filter(file => file.status === 'success')
|
||||
.map((file) => {
|
||||
// 尝试从响应中解析 URL
|
||||
if (file.response) {
|
||||
try {
|
||||
const res = typeof file.response === 'string' ? JSON.parse(file.response) : file.response
|
||||
return res.data || file.url
|
||||
} catch {
|
||||
return file.url
|
||||
}
|
||||
}
|
||||
return file.url
|
||||
}),
|
||||
}
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
await mockSubmitFeedback(submitData)
|
||||
|
||||
toast.success('提交成功,感谢您的反馈!')
|
||||
setTimeout(() => {
|
||||
handleBack()
|
||||
}, 1500)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Mock API =====================
|
||||
// TODO: 后端 API 实现后,删除此 mock 函数,替换为真实 API 调用
|
||||
|
||||
interface FeedbackData {
|
||||
content: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 提交反馈接口
|
||||
*
|
||||
* @param data 反馈数据
|
||||
*/
|
||||
function mockSubmitFeedback(data: FeedbackData): Promise<{ code: number, message: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[Mock] 提交反馈数据:', data)
|
||||
|
||||
// 模拟网络延迟
|
||||
setTimeout(() => {
|
||||
// 模拟成功
|
||||
if (data.content && data.content.length >= 10) {
|
||||
resolve({
|
||||
code: 0,
|
||||
message: '提交成功',
|
||||
})
|
||||
} else {
|
||||
reject(new Error('反馈内容不能少于 10 个字符'))
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.cell-group) {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
|
||||
}
|
||||
|
||||
.safe-area-inset-bottom {
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
148
src/pages-core/user/profile/components/form.vue
Normal file
148
src/pages-core/user/profile/components/form.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="visible"
|
||||
position="bottom"
|
||||
custom-style="border-radius: 24rpx 24rpx 0 0;"
|
||||
safe-area-inset-bottom
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="p-32rpx">
|
||||
<view class="mb-32rpx text-center text-32rpx text-[#333] font-semibold">
|
||||
{{ title }}
|
||||
</view>
|
||||
<!-- 昵称输入 -->
|
||||
<template v-if="field === 'nickname'">
|
||||
<wd-input
|
||||
v-model="formValue"
|
||||
placeholder="请输入昵称"
|
||||
clearable
|
||||
:focus="visible"
|
||||
/>
|
||||
</template>
|
||||
<!-- 性别选择 -->
|
||||
<template v-else-if="field === 'sex'">
|
||||
<wd-radio-group v-model="formValue" cell>
|
||||
<wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" :key="dict.value" :value="dict.value">
|
||||
{{ dict.label }}
|
||||
</wd-radio>
|
||||
</wd-radio-group>
|
||||
</template>
|
||||
<!-- 手机输入 -->
|
||||
<template v-else-if="field === 'mobile'">
|
||||
<wd-input
|
||||
v-model="formValue"
|
||||
placeholder="请输入手机号"
|
||||
type="number"
|
||||
clearable
|
||||
:focus="visible"
|
||||
:maxlength="11"
|
||||
/>
|
||||
</template>
|
||||
<!-- 邮箱输入 -->
|
||||
<template v-else-if="field === 'email'">
|
||||
<wd-input
|
||||
v-model="formValue"
|
||||
placeholder="请输入邮箱"
|
||||
clearable
|
||||
:focus="visible"
|
||||
/>
|
||||
</template>
|
||||
<!-- 按钮 -->
|
||||
<view class="mt-30rpx">
|
||||
<wd-button block type="primary" :loading="submitting" @click="handleConfirm">
|
||||
确定
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { updateUserProfile } from '@/api/system/user/profile'
|
||||
import { getIntDictOptions } from '@/hooks/useDict'
|
||||
import { DICT_TYPE } from '@/utils/constants'
|
||||
import { isBlank, isEmail, isMobile } from '@/utils/validator'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
field: 'nickname' | 'sex' | 'mobile' | 'email'
|
||||
value: string | number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'success': []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
const formValue = ref<string | number>('') // 表单值
|
||||
const submitting = ref(false) // 提交中状态
|
||||
|
||||
const title = computed(() => {
|
||||
switch (props.field) {
|
||||
case 'nickname':
|
||||
return '修改昵称'
|
||||
case 'sex':
|
||||
return '修改性别'
|
||||
case 'mobile':
|
||||
return '修改手机'
|
||||
case 'email':
|
||||
return '修改邮箱'
|
||||
default:
|
||||
return '修改'
|
||||
}
|
||||
})
|
||||
|
||||
/** 监听弹窗打开,初始化值 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formValue.value = props.value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 处理关闭 */
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 处理确认 */
|
||||
async function handleConfirm() {
|
||||
// 参数校验
|
||||
if (props.field === 'sex' && !formValue.value) {
|
||||
toast.warning('请选择性别')
|
||||
return
|
||||
}
|
||||
if (props.field !== 'sex' && isBlank(formValue.value as string)) {
|
||||
toast.warning(`请输入${title.value.replace('修改', '')}`)
|
||||
return
|
||||
}
|
||||
if (props.field === 'mobile' && !isMobile(formValue.value as string)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
if (props.field === 'email' && !isEmail(formValue.value as string)) {
|
||||
toast.warning('请输入正确的邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用更新接口
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateUserProfile({ [props.field]: formValue.value })
|
||||
toast.success('修改成功')
|
||||
handleClose()
|
||||
emit('success')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
140
src/pages-core/user/profile/index.vue
Normal file
140
src/pages-core/user/profile/index.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="个人资料"
|
||||
left-arrow placeholder safe-area-inset-top fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<wd-cell-group custom-class="cell-group" border>
|
||||
<wd-cell title="头像" is-link center @click="handleEditAvatar">
|
||||
<view class="ml-auto h-50rpx w-50rpx overflow-hidden rounded-full">
|
||||
<image :src="userProfile?.avatar" mode="aspectFill" class="h-full w-full" />
|
||||
</view>
|
||||
</wd-cell>
|
||||
<wd-cell title="昵称" :value="userProfile?.nickname || '-'" is-link @click="handleEdit('nickname')" />
|
||||
<wd-cell title="性别" :value="getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, userProfile?.sex) || '-'" is-link @click="handleEdit('sex')" />
|
||||
<wd-cell title="手机" :value="userProfile?.mobile || '-'" is-link @click="handleEdit('mobile')" />
|
||||
<wd-cell title="邮箱" :value="userProfile?.email || '-'" is-link @click="handleEdit('email')" />
|
||||
</wd-cell-group>
|
||||
<wd-cell-group custom-class="cell-group mt-24rpx" border>
|
||||
<wd-cell title="部门" :value="userProfile?.dept?.name || '-'" />
|
||||
<wd-cell title="所属门店" :value="userProfile?.storeName || '-'" />
|
||||
<wd-cell title="岗位" :value="userProfile?.posts?.map(p => p.name).join('、') || '-'" />
|
||||
<wd-cell title="角色" :value="userProfile?.roles?.map(r => r.name).join('、') || '-'" />
|
||||
</wd-cell-group>
|
||||
|
||||
<!-- 头像裁剪 -->
|
||||
<wd-img-cropper
|
||||
v-model="showCropper"
|
||||
:img-src="cropperSrc"
|
||||
@confirm="handleCropperConfirm"
|
||||
/>
|
||||
<!-- 编辑弹窗 -->
|
||||
<Form
|
||||
v-model="formVisible"
|
||||
:field="formType"
|
||||
:value="formValue"
|
||||
@success="loadUserProfile"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { UserProfileVO } from '@/api/system/user/profile'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { getUserProfile, updateUserProfile } from '@/api/system/user/profile'
|
||||
import { getDictLabel } from '@/hooks/useDict'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
import { DICT_TYPE } from '@/utils/constants'
|
||||
import { uploadFileFromPath } from '@/utils/uploadFile'
|
||||
import Form from './components/form.vue'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(true)
|
||||
const userProfile = ref<UserProfileVO | null>(null)
|
||||
|
||||
// 头像裁剪相关
|
||||
const showCropper = ref(false)
|
||||
const cropperSrc = ref('')
|
||||
|
||||
// 编辑弹窗相关
|
||||
const formVisible = ref(false)
|
||||
const formType = ref<'nickname' | 'sex' | 'mobile' | 'email'>('nickname')
|
||||
const formValue = ref<string | number>('')
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages/user/index')
|
||||
}
|
||||
|
||||
/** 加载用户信息 */
|
||||
async function loadUserProfile() {
|
||||
loading.value = true
|
||||
try {
|
||||
userProfile.value = await getUserProfile()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑头像 */
|
||||
function handleEditAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
cropperSrc.value = res.tempFilePaths[0]
|
||||
showCropper.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 头像裁剪确认 */
|
||||
async function handleCropperConfirm(event: { tempFilePath: string }) {
|
||||
// 1.1 上传文件,获取 URL
|
||||
const avatarUrl = await uploadFileFromPath(event.tempFilePath, 'avatar')
|
||||
// 1.2 更新用户头像
|
||||
await updateUserProfile({ avatar: avatarUrl })
|
||||
|
||||
// 2.1 直接更新本地状态,避免重新加载
|
||||
if (userProfile.value) {
|
||||
userProfile.value.avatar = avatarUrl
|
||||
}
|
||||
// 2.2 同步更新 userStore 中的头像
|
||||
userStore.setUserAvatar(avatarUrl)
|
||||
toast.success('头像修改成功')
|
||||
}
|
||||
|
||||
/** 编辑字段 */
|
||||
function handleEdit(field: 'nickname' | 'sex' | 'mobile' | 'email') {
|
||||
formType.value = field
|
||||
formValue.value = userProfile.value?.[field] ?? (field === 'sex' ? 1 : '')
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
loadUserProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.cell-group) {
|
||||
margin: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
122
src/pages-core/user/security/components/password-form.vue
Normal file
122
src/pages-core/user/security/components/password-form.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<wd-popup
|
||||
v-model="visible"
|
||||
position="bottom"
|
||||
custom-style="border-radius: 24rpx 24rpx 0 0;"
|
||||
safe-area-inset-bottom
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="p-32rpx">
|
||||
<view class="mb-32rpx text-center text-32rpx text-[#333] font-semibold">
|
||||
修改密码
|
||||
</view>
|
||||
<wd-input
|
||||
v-model="formData.oldPassword"
|
||||
label="旧密码"
|
||||
placeholder="请输入旧密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
<wd-input
|
||||
v-model="formData.newPassword"
|
||||
label="新密码"
|
||||
placeholder="请输入新密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
<wd-input
|
||||
v-model="formData.confirmPassword"
|
||||
label="确认密码"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
<view class="mt-30rpx">
|
||||
<wd-button block type="primary" :loading="submitting" @click="handleConfirm">
|
||||
确定
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { updateUserPassword } from '@/api/system/user/profile'
|
||||
import { isBlank } from '@/utils/validator'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'success': []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 监听弹窗打开,重置表单 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formData.oldPassword = ''
|
||||
formData.newPassword = ''
|
||||
formData.confirmPassword = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 处理关闭 */
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 处理确认 */
|
||||
async function handleConfirm() {
|
||||
// 参数校验
|
||||
if (isBlank(formData.oldPassword)) {
|
||||
toast.warning('请输入旧密码')
|
||||
return
|
||||
}
|
||||
if (isBlank(formData.newPassword)) {
|
||||
toast.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (isBlank(formData.confirmPassword)) {
|
||||
toast.warning('请确认新密码')
|
||||
return
|
||||
}
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
toast.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用更新接口
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateUserPassword({
|
||||
oldPassword: formData.oldPassword,
|
||||
newPassword: formData.newPassword,
|
||||
})
|
||||
toast.success('密码修改成功')
|
||||
handleClose()
|
||||
emit('success')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
88
src/pages-core/user/security/index.vue
Normal file
88
src/pages-core/user/security/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="账号安全"
|
||||
left-arrow placeholder safe-area-inset-top fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 安全设置区域 -->
|
||||
<wd-cell-group custom-class="cell-group" border>
|
||||
<wd-cell title="修改密码" is-link @click="handleChangePassword">
|
||||
<template #icon>
|
||||
<wd-icon name="lock-on" size="20px" color="#1890ff" class="mr-16rpx" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
|
||||
<!-- 第三方绑定区域 -->
|
||||
<wd-cell-group custom-class="cell-group mt-24rpx" border>
|
||||
<wd-cell title="微信小程序" is-link @click="handleBindWechatMiniProgram">
|
||||
<template #icon>
|
||||
<wd-icon name="chat" size="20px" color="#07c160" class="mr-16rpx" />
|
||||
</template>
|
||||
<view class="text-[#999]">
|
||||
未绑定
|
||||
</view>
|
||||
</wd-cell>
|
||||
<wd-cell title="微信公众号" is-link @click="handleBindWechatOfficialAccount">
|
||||
<template #icon>
|
||||
<wd-icon name="chat" size="20px" color="#07c160" class="mr-16rpx" />
|
||||
</template>
|
||||
<view class="text-[#999]">
|
||||
未绑定
|
||||
</view>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<PasswordForm v-model="showPasswordPopup" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
import PasswordForm from './components/password-form.vue'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const showPasswordPopup = ref(false) // 密码弹窗相关
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages/user/index')
|
||||
}
|
||||
|
||||
/** 打开修改密码弹窗 */
|
||||
function handleChangePassword() {
|
||||
showPasswordPopup.value = true
|
||||
}
|
||||
|
||||
/** 绑定微信小程序 */
|
||||
function handleBindWechatMiniProgram() {
|
||||
toast.info('正在开发中')
|
||||
}
|
||||
|
||||
/** 绑定微信公众号 */
|
||||
function handleBindWechatOfficialAccount() {
|
||||
toast.info('正在开发中')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.cell-group) {
|
||||
margin: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
117
src/pages-core/user/settings/agreement/index.vue
Normal file
117
src/pages-core/user/settings/agreement/index.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="用户协议"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 协议内容 -->
|
||||
<view class="p-32rpx">
|
||||
<view class="text-28rpx text-gray-600 leading-relaxed">
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">一、总则</text>
|
||||
<text class="block">
|
||||
1.1 欢迎使用途安伴旅移动端应用(以下简称"本应用")。在使用本应用前,请您仔细阅读本协议。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
1.2 您在使用本应用时,即表示您已充分阅读、理解并接受本协议的全部内容,并与本应用达成协议。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
1.3 本应用有权根据需要不时地修订本协议,修订后的协议一经公布即生效。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">二、服务内容</text>
|
||||
<text class="block">
|
||||
2.1 本应用为用户提供企业管理、数据分析、业务处理等相关服务。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
2.2 本应用保留随时变更、中断或终止部分或全部服务的权利。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
2.3 用户理解,本应用仅提供相关的网络服务,除此之外与相关网络服务有关的设备及所需的费用均应由用户自行承担。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">三、用户账号</text>
|
||||
<text class="block">
|
||||
3.1 用户在使用本应用服务前需要注册账号。用户应当对其账号和密码的安全负责。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
3.2 用户不得将账号转让、出借或以任何方式提供给他人使用。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
3.3 如发现账号被盗用或存在安全漏洞,用户应立即通知本应用。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">四、用户行为规范</text>
|
||||
<text class="block">
|
||||
4.1 用户在使用本应用服务时,必须遵守国家法律法规,不得利用本应用从事违法违规活动。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
4.2 用户不得干扰本应用的正常运行,不得侵入本应用及国家计算机信息系统。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
4.3 用户不得传播违法、有害、骚扰、侵害他人隐私等信息。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">五、知识产权</text>
|
||||
<text class="block">
|
||||
5.1 本应用的所有内容,包括但不限于文字、图片、音频、视频、软件、程序、版面设计等均受知识产权法律法规保护。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
5.2 未经本应用书面许可,任何人不得擅自使用、复制、修改、传播本应用的任何内容。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">六、免责声明</text>
|
||||
<text class="block">
|
||||
6.1 本应用不对因网络状况、通讯线路等任何技术原因导致的服务中断或其他缺陷承担责任。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
6.2 用户因使用本应用而产生的任何损失,本应用不承担任何责任。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">七、其他</text>
|
||||
<text class="block">
|
||||
7.1 本协议的订立、执行和解释及争议的解决均应适用中华人民共和国法律。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
7.2 如本协议中的任何条款无论因何种原因完全或部分无效或不具有执行力,本协议的其余条款仍应有效并且有约束力。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus()
|
||||
}
|
||||
</script>
|
||||
149
src/pages-core/user/settings/index.vue
Normal file
149
src/pages-core/user/settings/index.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="应用设置"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- Logo 区域 -->
|
||||
<view class="flex flex-col items-center py-60rpx">
|
||||
<image
|
||||
class="mb-24rpx h-150rpx w-150rpx rounded-full"
|
||||
src="/static/logo.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="text-40rpx text-gray-800 font-medium">途安伴游</text>
|
||||
</view>
|
||||
|
||||
<!-- 设置列表 -->
|
||||
<view class="mx-24rpx">
|
||||
<wd-cell-group custom-class="cell-group" border>
|
||||
<wd-cell
|
||||
title="当前版本"
|
||||
:value="`v${version}`"
|
||||
is-link
|
||||
@click="handleShowVersion"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-icon name="warning" size="20px" color="#1890ff" class="mr-16rpx" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
<wd-cell
|
||||
title="本地缓存"
|
||||
:value="storageSize"
|
||||
is-link
|
||||
@click="handleClearCache"
|
||||
>
|
||||
<template #icon>
|
||||
<wd-icon name="delete" size="20px" color="#faad14" class="mr-16rpx" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 底部协议和版权 -->
|
||||
<view class="mt-80rpx flex flex-col items-center">
|
||||
<view class="mb-40rpx flex items-center text-26rpx">
|
||||
<text class="text-[#1890ff]" @click="handleGoAgreement">《用户协议》</text>
|
||||
<text class="text-gray-500">与</text>
|
||||
<text class="text-[#1890ff]" @click="handleGoPrivacy">《隐私协议》</text>
|
||||
</view>
|
||||
<text class="mb-10rpx text-24rpx text-gray-400">
|
||||
Copyright © 2026 iocoder.cn All Rights Reserved.
|
||||
</text>
|
||||
<text class="text-24rpx text-gray-400">
|
||||
途安伴游
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const version = ref('1.0.0') // 当前版本号
|
||||
const storageSize = ref('') // 本地缓存大小
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages/user/index')
|
||||
}
|
||||
|
||||
/** 获取应用版本号 */
|
||||
function getAppVersion() {
|
||||
// #ifdef APP-PLUS
|
||||
const appInfo = uni.getSystemInfoSync()
|
||||
version.value = appInfo.appVersion || '1.0.0'
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 获取本地缓存大小 */
|
||||
function getStorageSize() {
|
||||
const info = uni.getStorageInfoSync()
|
||||
storageSize.value = `${info.currentSize}KB`
|
||||
}
|
||||
|
||||
/** 显示版本信息 */
|
||||
function handleShowVersion() {
|
||||
toast.info(`当前版本:v${version.value}`)
|
||||
}
|
||||
|
||||
/** 清除缓存 */
|
||||
function handleClearCache() {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要清除本地缓存吗?',
|
||||
success: (res) => {
|
||||
if (!res.confirm) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.clearStorageSync()
|
||||
getStorageSize()
|
||||
toast.success('缓存清除成功')
|
||||
} catch {
|
||||
toast.error('缓存清除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 跳转到用户协议 */
|
||||
function handleGoAgreement() {
|
||||
uni.navigateTo({ url: '/pages-core/user/settings/agreement/index' })
|
||||
}
|
||||
|
||||
/** 跳转到隐私协议 */
|
||||
function handleGoPrivacy() {
|
||||
uni.navigateTo({ url: '/pages-core/user/settings/privacy/index' })
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
getStorageSize()
|
||||
getAppVersion()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.cell-group) {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3rpx 8rpx rgba(24, 144, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
139
src/pages-core/user/settings/privacy/index.vue
Normal file
139
src/pages-core/user/settings/privacy/index.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<view class="yd-page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<wd-navbar
|
||||
title="隐私协议"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<!-- 协议内容 -->
|
||||
<view class="p-32rpx">
|
||||
<view class="text-28rpx text-gray-600 leading-relaxed">
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">引言</text>
|
||||
<text class="block">
|
||||
途安伴旅移动端(以下简称"我们")非常重视用户的隐私和个人信息保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用我们的服务前,仔细阅读并理解本隐私政策。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">一、我们收集的信息</text>
|
||||
<text class="block">
|
||||
1.1 账号信息:当您注册账号时,我们会收集您的手机号码、邮箱地址等信息。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
1.2 个人资料:您可以选择填写昵称、头像、性别、生日等个人资料信息。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
1.3 设备信息:我们可能会收集您的设备型号、操作系统版本、设备标识符等信息。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
1.4 日志信息:我们会收集您使用服务时的操作日志,包括访问时间、功能使用记录等。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">二、信息的使用</text>
|
||||
<text class="block">
|
||||
2.1 为您提供服务:我们使用收集的信息来提供、维护和改进我们的服务。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
2.2 安全保障:我们使用信息来验证身份、预防欺诈和保护账号安全。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
2.3 服务优化:我们可能使用信息来分析服务使用情况,以优化用户体验。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
2.4 通知推送:我们可能向您发送服务通知、安全提醒等信息。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">三、信息的存储</text>
|
||||
<text class="block">
|
||||
3.1 我们会采取合理的安全措施来保护您的个人信息,防止未经授权的访问、使用或泄露。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
3.2 您的个人信息将存储在中华人民共和国境内的服务器上。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
3.3 我们仅在实现服务目的所必需的期限内保留您的个人信息。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">四、信息的共享</text>
|
||||
<text class="block">
|
||||
4.1 未经您的同意,我们不会向第三方共享您的个人信息,但以下情况除外:
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
- 为遵守法律法规或政府机关的要求;
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
- 为保护我们或用户的合法权益;
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
- 在涉及合并、收购或资产转让时,我们可能会转移您的信息。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">五、您的权利</text>
|
||||
<text class="block">
|
||||
5.1 访问权:您有权访问我们持有的关于您的个人信息。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
5.2 更正权:如果您发现我们持有的个人信息不准确,您有权要求更正。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
5.3 删除权:在特定情况下,您有权要求我们删除您的个人信息。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
5.4 撤回同意:您可以随时撤回之前给予的同意。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">六、未成年人保护</text>
|
||||
<text class="block">
|
||||
6.1 我们非常重视对未成年人个人信息的保护。如果您是未满18周岁的未成年人,请在监护人的陪同下阅读本政策。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
6.2 我们不会主动收集未成年人的个人信息。如果发现我们在未经监护人同意的情况下收集了未成年人的信息,我们将尽快删除相关信息。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-32rpx">
|
||||
<text class="mb-16rpx block text-gray-800 font-bold">七、政策更新</text>
|
||||
<text class="block">
|
||||
7.1 我们可能会不时更新本隐私政策。更新后的政策将在应用内公布。
|
||||
</text>
|
||||
<text class="mt-16rpx block">
|
||||
7.2 对于重大变更,我们会通过显著方式通知您。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
navigateBackPlus()
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user