初始化

This commit is contained in:
2026-03-02 08:31:49 +08:00
commit d8ae21ced4
582 changed files with 66532 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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()
}

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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;
}
}

View 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>

View 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>

View 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>

View 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: '请先退出当前账号,然后使用其他账号重新登录即可。',
},
],
},
]

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>