更新:增加“我的”相关功能

This commit is contained in:
2025-11-06 13:33:41 +08:00
parent d908b049fa
commit 33f861fa14
24 changed files with 4413 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
<template>
<view class="container">
<view class="title bg-[red] text-left text-[#fff]">这是一个等待开发的首页</view>
<view class="title bg-[red] text-center text-[#fff]">这是一个等待开发的首页</view>
<view class="description bg-[red]">首页的内容是在线课程</view>
</view>
</template>

View File

@@ -99,9 +99,9 @@
</button>
</view>
<view class="loginHelp" v-if="submitClickNum > 0">
<text>登录遇到问题</text><text class="link" @click="onPageJump('/pages/user/workOrder?name=login')">去反馈</text>
</view>
<!-- <view class="loginHelp" v-if="submitClickNum > 0">
<text>登录遇到问题</text><text class="link" @click="onPageJump('/pages/user/feedback?name=login')">去反馈</text>
</view> -->
<!-- 切换登录方式 -->
<view class="qie-huan">
@@ -240,7 +240,6 @@ const isCodeEmpty = () => {
}
// 邮箱格式验证
const isEmailVerified = (email: string) => {
console.log(email, validateEmail(email))
if (!validateEmail(email)) {
uni.showToast({
title: t('login.emailError'),

239
pages/user/about/index.vue Normal file
View File

@@ -0,0 +1,239 @@
<template>
<view class="about-page">
<!-- 自定义导航栏 -->
<nav-bar :title="$t('user.about')"></nav-bar>
<view class="about-content">
<!-- 应用信息 -->
<view class="app-info">
<image
src="/static/icon/home_icon_logo.jpg"
mode="aspectFit"
class="app-logo"
/>
<text v-if="appVersion" class="app-version">
{{ $t('user.version') }} {{ appVersion }}
</text>
<!-- 应用简介 -->
<view class="app-description">
<text>{{ $t('user.appDescription') }}</text>
</view>
</view>
<!-- 联系信息 -->
<!-- <view class="contact-info">
<view class="contact-item">
<text class="label">{{ $t('user.hotline') }}</text>
<view class="value-wrapper">
<text class="value">022-24142321</text>
<image
src="/static/icon/tel.png"
mode="aspectFit"
class="tel-icon"
@click="makePhoneCall"
/>
</view>
</view>
</view> -->
<wd-cell-group border class="contact-info">
<wd-cell :title="$t('user.hotline')" clickable @click="handlePhoneCall">
022-24142321
<img src="/static/icon/tel.png" width="23" height="23" />
</wd-cell>
<!-- <wd-cell :title="$t('user.wechat')" value="yilujiankangkefu" clickable @click="handlePhoneCall" /> -->
</wd-cell-group>
<!-- 隐私政策链接 -->
<view class="privacy-link">
<text class="link-text" @click="openPrivacyPolicy">
{{ $t('user.privacyPolicy') }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { makePhoneCall } from '@/utils/index'
const { t } = useI18n()
// 导航栏高度
const statusBarHeight = ref(0)
const navbarHeight = ref('44px')
// 应用版本信息
const appVersion = ref('')
/**
* 获取应用版本信息
*/
const getAppVersion = () => {
// #ifdef APP-PLUS
plus.runtime.getProperty(plus.runtime.appid, (info: any) => {
appVersion.value = `${info.name} ${info.version}`
})
// #endif
// #ifndef APP-PLUS
appVersion.value = '1.0.0'
// #endif
}
/**
* 拨打电话
*/
const handlePhoneCall = () => {
makePhoneCall('022-24142321', t('user.hotline'), t)
}
/**
* 打开隐私政策
*/
const openPrivacyPolicy = () => {
// #ifdef APP-PLUS
plus.runtime.openURL('https://www.amazinglimited.com/privacy.html')
// #endif
// #ifndef APP-PLUS
uni.showToast({
title: '请在APP中查看',
icon: 'none'
})
// #endif
}
onMounted(() => {
getAppVersion()
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.about-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.about-content {
padding: 20rpx;
}
.app-info {
background: #fff;
border-radius: 15rpx;
padding: 60rpx 40rpx;
margin-bottom: 20rpx;
text-align: center;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.app-logo {
width: 150rpx;
height: 150rpx;
margin: 0 auto 20rpx;
display: block;
}
.app-version {
display: block;
font-size: 32rpx;
color: #333;
}
}
.contact-info {
background: #fff;
border-radius: 15rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.contact-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-top: 1rpx solid #e5e5e5;
&:first-child {
border-top: none;
}
.label {
font-size: 30rpx;
color: #666;
}
.value-wrapper {
display: flex;
align-items: center;
.value {
font-size: 30rpx;
color: #333;
margin-right: 10rpx;
}
.tel-icon {
width: 46rpx;
height: 46rpx;
}
}
}
.app-description {
padding: 30rpx 30rpx 0;
font-size: 28rpx;
color: #666;
line-height: 40rpx;
text-indent: 2em;
display: block;
text-align: left;
}
.privacy-link {
text-align: right;
padding: 30rpx;
.link-text {
font-size: 28rpx;
color: $theme-color;
text-decoration: underline;
}
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<view class="feedback-page">
<!-- 自定义导航栏 -->
<nav-bar :title="$t('user.feedback')"></nav-bar>
<view class="feedback-content">
<view class="feedback-form">
<!-- 问题类型 -->
<view class="form-item">
<text class="label required">{{ $t('user.issueType') }}</text>
<wd-select-picker
v-model="form.type"
type="radio"
:columns="issueTypeOptions"
:placeholder="$t('common.pleaseSelect')"
:show-confirm="false"
/>
</view>
<!-- 账号 -->
<view class="form-item">
<text class="label required">{{ $t('user.account') }}</text>
<wd-input
v-model="form.account"
:placeholder="$t('user.account')"
/>
</view>
<!-- 订单编号条件显示 -->
<view v-if="form.type === '3'" class="form-item">
<text class="label required">{{ $t('user.orderSn') }}</text>
<wd-input
v-model="form.relation"
type="number"
:placeholder="$t('user.pleaseInputOrderSn')"
@input="handleRelationInput"
/>
</view>
<!-- 问题描述 -->
<view class="form-item">
<text class="label required">{{ $t('user.description') }}</text>
<wd-textarea
v-model="form.content"
auto-height
:placeholder="$t('user.pleaseInputDescription')"
:maxlength="200"
show-word-limit
/>
</view>
<!-- 联系电话 -->
<view class="form-item">
<text class="label required">{{ $t('user.contactPhone') }}</text>
<wd-input
v-model="form.contactInformation"
type="number"
:placeholder="$t('user.pleaseInputPhone')"
/>
</view>
<!-- 截图上传 -->
<view class="form-item">
<text class="label">{{ $t('user.screenshots') }}</text>
<wd-upload
:fileList="imageList"
:limit="4"
action="https://global.nuttyreading.com/oss/fileoss"
@change="handleChangeImage"
/>
<text class="tip-text">{{ $t('user.maxImagesCount') }}</text>
</view>
<!-- 提交按钮 -->
<view class="submit-button">
<wd-button
type="success"
block
@click="handleSubmit"
>
{{ $t('common.submit') }}
</wd-button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { submitFeedback } from '@/api/modules/user'
import type { IFeedbackForm } from '@/types/user'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const userStore = useUserStore()
// 问题类型选项
const issueTypeOptions = computed(() => [
{ label: t('user.issueTypeAccount'), value: '1' },
{ label: t('user.issueTypePayment'), value: '2' },
{ label: t('user.issueTypeOrder'), value: '3' },
{ label: t('user.issueTypeContent'), value: '4' },
{ label: t('user.issueTypeSuggestion'), value: '5' },
{ label: t('user.issueTypeBug'), value: '6' },
{ label: t('user.issueTypeOther'), value: '7' }
])
// 表单数据
const form = ref<IFeedbackForm>({
type: '',
account: userStore.userInfo.email || userStore.userInfo.phone || '',
relation: '',
content: '',
contactInformation: '',
image: ''
})
// 图片列表
const imageList = ref<any[]>([])
// 错误状态
const relationError = ref(false)
/**
* 处理订单编号输入
*/
const handleRelationInput = () => {
relationError.value = false
}
/**
* 上传前检查
*/
const beforeUpload = async (file: any) => {
// TODO: 检查相机和存储权限
// 检查文件大小
if (file.size > 5 * 1024 * 1024) {
uni.showToast({
title: '图片大小不能超过5MB',
icon: 'none'
})
return false
}
return true
}
/**
* 上传文件变化
*/
const imageUrl = ref<any[]>([])
const handleChangeImage = ({ fileList }: any) => {
console.log('上传文件变化:', fileList)
imageUrl.value = []
fileList.forEach((file:any) => {
imageUrl.value.push(JSON.parse(file.response).url)
})
console.log('图片列表:', imageUrl.value)
// this.fileList = fileList
}
/**
* 表单验证
*/
const validateForm = (): boolean => {
// 验证问题类型
if (!form.value.type) {
uni.showToast({
title: t('common.pleaseSelect') + t('user.issueType'),
icon: 'none'
})
return false
}
// 验证账号
if (!form.value.account) {
uni.showToast({
title: t('user.pleaseInput') + t('user.account'),
icon: 'none'
})
return false
}
// 验证订单编号(当类型为订单相关时)
if (form.value.type === '3') {
if (!form.value.relation) {
relationError.value = true
uni.showToast({
title: t('user.pleaseInputOrderSn'),
icon: 'none'
})
return false
}
}
// 验证问题描述
if (!form.value.content) {
uni.showToast({
title: t('user.pleaseInputDescription'),
icon: 'none'
})
return false
}
// 验证联系电话
if (!form.value.contactInformation) {
uni.showToast({
title: t('user.pleaseInputPhone'),
icon: 'none'
})
return false
}
return true
}
/**
* 提交反馈
*/
const handleSubmit = async () => {
// 表单验证
if (!validateForm()) {
return
}
try {
uni.showLoading()
// 处理图片
if (imageUrl.value.length > 0) {
form.value.image = imageUrl.value.join(',')
}
// 提交反馈
await submitFeedback(form.value)
uni.hideLoading()
uni.showModal({
title: t('global.tips'),
content: t('user.feedbackSuccess'),
showCancel: false,
confirmText: t('common.confirm'),
success: () => {
// 清空表单
imageList.value = []
imageUrl.value = []
form.value = {
type: '',
account: userStore.userInfo.email || userStore.userInfo.phone || '',
relation: '',
content: '',
contactInformation: '',
image: ''
}
// 返回上一页
uni.navigateBack({
delta: 1
})
}
})
} catch (error) {
console.error('提交反馈失败:', error)
uni.hideLoading()
uni.showToast({
title: t('user.feedbackFailed'),
icon: 'none'
})
}
}
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.feedback-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.feedback-content {
padding: 20rpx;
}
.feedback-form {
background: #fff;
border-radius: 15rpx;
padding: 30rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.form-item {
margin-bottom: 30rpx;
.label {
display: block;
font-size: 30rpx;
color: #333;
font-weight: bold;
margin-bottom: 15rpx;
&.required::before {
content: '*';
color: red;
margin-right: 5rpx;
}
}
.error-text {
display: block;
font-size: 24rpx;
color: red;
margin-top: 10rpx;
}
.tip-text {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
}
.submit-button {
margin-top: 50rpx;
}
</style>

334
pages/user/index.vue Normal file
View File

@@ -0,0 +1,334 @@
<template>
<view class="user-page">
<!-- 设置图标 -->
<view class="settings-icon" @click="goSettings">
<wd-icon name="setting1" size="24px" color="#666" />
<text>{{ $t('user.settings') }}</text>
</view>
<!-- 用户信息区域 -->
<view class="user-info-section">
<view class="user-info">
<image
:src="userInfo.avatar || defaultAvatar"
class="avatar"
@click="goProfile"
/>
<view class="user-details">
<text class="nickname">{{ userInfo.nickname || $t('user.notSet') }}</text>
<text v-if="userInfo.email" class="email">{{ userInfo.email }}</text>
<text v-if="vipInfo.endTime && platform === 'ios'" class="vip-time">
VIP {{ vipInfo.endTime.split(' ')[0] }} {{ $t('user.vipExpireTime') }}
</text>
</view>
</view>
</view>
<!-- VIP订阅卡片 -->
<view class="vip-card-section">
<view class="vip-card">
<view v-if="vipInfo.id" class="vip-info">
<text class="label">{{ $t('user.vip') }}</text>
<text class="value">{{ vipInfo.endTime ? vipInfo.endTime.split(' ')[0] : '' }}</text>
</view>
<wd-button
v-else
type="success"
size="small"
@click="goSubscribe"
>
{{ $t('user.subscribe') }}
</wd-button>
</view>
</view>
<!-- 功能菜单列表 -->
<view class="menu-section">
<view class="menu-list">
<view
v-for="item in menuItems"
:key="item.id"
class="menu-item"
@click="handleMenuClick(item)"
>
<text class="menu-text">{{ item.name }}</text>
<wd-icon name="arrow-right" size="16px" color="#aaa" />
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { getUserInfo, getVipInfo } from '@/api/modules/user'
import type { IVipInfo } from '@/types/user'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const userStore = useUserStore()
// 默认头像
const defaultAvatar = '/static/home_icon.png'
// 用户信息
const userInfo = computed(() => userStore.userInfo)
// VIP信息
const vipInfo = ref<IVipInfo>({
id: 0,
endTime: '',
vipType: 0
})
// 平台信息
const platform = ref('')
// 菜单项
const menuItems = computed(() => [
{
id: 1,
name: t('user.myOrders'),
url: '/pages/user/order/index',
type: 'pageJump'
},
{
id: 2,
name: t('user.myBooklist'),
url: '/pages/book/index',
type: 'switchTab'
},
{
id: 3,
name: t('user.profile'),
url: '/pages/user/profile/index',
type: 'pageJump'
},
{
id: 4,
name: t('user.about'),
url: '/pages/user/about/index',
type: 'pageJump'
},
{
id: 5,
name: t('user.feedback'),
url: '/pages/user/feedback/index',
type: 'pageJump'
}
])
/**
* 获取平台信息
*/
const getPlatform = () => {
const systemInfo = uni.getSystemInfoSync()
platform.value = systemInfo.platform === 'android' ? 'android' : 'ios'
}
/**
* 获取数据
*/
const getData = async () => {
try {
// 获取用户信息
const userRes = await getUserInfo()
if (userRes.user) {
userStore.setUserInfo(userRes.user)
}
// 获取VIP信息
const vipRes = await getVipInfo()
if (vipRes.vipInfo) {
vipInfo.value = vipRes.vipInfo
}
} catch (error) {
console.error('获取数据失败:', error)
}
}
/**
* 跳转到设置页面
*/
const goSettings = () => {
uni.navigateTo({
url: '/pages/user/settings/index'
})
}
/**
* 跳转到个人资料页面
*/
const goProfile = () => {
uni.navigateTo({
url: '/pages/user/profile/index'
})
}
/**
* 跳转到订阅页面
*/
const goSubscribe = () => {
uni.navigateTo({
url: '/pages/user/wallet/index'
})
}
/**
* 处理菜单点击
*/
const handleMenuClick = (item: any) => {
switch (item.type) {
case 'pageJump':
uni.navigateTo({
url: item.url
})
break
case 'switchTab':
uni.switchTab({
url: item.url
})
break
}
}
onMounted(() => {
getPlatform()
getData()
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.user-page {
min-height: 100vh;
background-color: #f7faf9;
padding-top: 130rpx;
}
.settings-icon {
position: absolute;
right: 40rpx;
top: 60rpx;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.2;
text {
display: block;
font-size: 28rpx;
color: #333;
}
}
.user-info-section {
padding: 0 30rpx;
margin-bottom: 50rpx;
}
.user-info {
display: flex;
align-items: center;
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 100rpx;
margin-right: 30rpx;
}
.user-details {
flex: 1;
.nickname {
display: block;
font-size: 38rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.email {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
}
.vip-time {
display: block;
font-size: 28rpx;
color: #333;
}
}
}
.vip-card-section {
padding: 0 20rpx;
margin-bottom: 20rpx;
}
.vip-card {
background: #fff;
border-radius: 15rpx;
padding: 40rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.vip-info {
text-align: center;
.label {
display: block;
font-size: 32rpx;
color: #333;
margin-bottom: 10rpx;
}
.value {
display: block;
font-size: 30rpx;
color: $theme-color;
}
}
}
.menu-section {
padding: 20rpx 20rpx 0;
}
.menu-list {
background: #fff;
border-radius: 15rpx;
overflow: hidden;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #e0e0e0;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f5f5f5;
}
.menu-text {
font-size: 30rpx;
color: #333;
line-height: 40rpx;
}
}
</style>

309
pages/user/order/index.vue Normal file
View File

@@ -0,0 +1,309 @@
<template>
<view class="order-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ height: navbarHeight }">
<view class="navbar-content" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-left" @click="goBack">
<wd-icon name="arrow-left" size="18px" color="#333" />
</view>
<text class="navbar-title">{{ $t('user.myOrders') }}</text>
</view>
</view>
<!-- 订单列表 -->
<scroll-view
v-if="orderList.length > 0"
scroll-y
class="order-scroll"
:style="{ paddingTop: navbarHeight }"
@scrolltolower="loadMore"
>
<view class="order-list">
<view
v-for="order in orderList"
:key="order.id"
class="order-item"
>
<view class="order-header">
<text class="book-name">{{ order.bookEntity.name }}</text>
<view class="price-info">
<image
v-if="order.paymentMethod === '4'"
src="/static/icon/coin.png"
class="payment-icon"
/>
<image
v-else
src="/static/icon/currency.png"
class="payment-icon"
/>
<text class="price">{{ order.orderMoney }}</text>
<text v-if="order.paymentMethod === '5'" class="currency">NZD</text>
</view>
</view>
<text class="order-sn">
{{ $t('user.orderSn') }}
{{ order.orderSn }}
</text>
<text class="create-time">{{ order.createTime }}</text>
</view>
</view>
<!-- 加载提示 -->
<view class="load-tips">
<wd-divider v-if="hasMore && showLoadTip">
{{ $t('common.loadMore') }}
</wd-divider>
<wd-divider v-if="!hasMore && showLoadTip">
{{ $t('common.noMore') }}
</wd-divider>
</view>
</scroll-view>
<!-- 空状态 -->
<view v-else-if="!loading" class="empty-state">
<text class="empty-text">{{ $t('common.noData') }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getOrderList } from '@/api/modules/user'
import type { IOrder } from '@/types/user'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 导航栏高度
const statusBarHeight = ref(0)
const navbarHeight = ref('44px')
// 订单列表
const orderList = ref<IOrder[]>([])
// 分页信息
const page = ref({
current: 1,
limit: 10
})
// 加载状态
const loading = ref(false)
const hasMore = ref(true)
const showLoadTip = ref(false)
const total = ref(0)
/**
* 获取导航栏高度
*/
const getNavbarHeight = () => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.indexOf('iPhone') !== -1 && parseInt(systemInfo.model.slice(-2)) >= 11) {
navBarHeight = 48
}
const totalHeight = statusBarHeight.value + navBarHeight
navbarHeight.value = totalHeight + 'px'
}
/**
* 获取订单列表
*/
const getData = async () => {
if (loading.value || !hasMore.value) {
return
}
try {
loading.value = true
uni.showLoading({
title: t('common.loading')
})
const res = await getOrderList(page.value.current, page.value.limit)
uni.hideLoading()
if (res.orders && res.orders.records) {
total.value = res.orders.total
const records = res.orders.records
if (records.length > 0) {
orderList.value = [...orderList.value, ...records]
page.value.current += 1
showLoadTip.value = true
// 判断是否还有更多数据
if (orderList.value.length >= total.value || records.length < page.value.limit) {
hasMore.value = false
}
} else {
hasMore.value = false
}
}
} catch (error) {
console.error('获取订单列表失败:', error)
uni.hideLoading()
} finally {
loading.value = false
}
}
/**
* 加载更多
*/
const loadMore = () => {
if (!loading.value && hasMore.value) {
getData()
}
}
/**
* 返回上一页
*/
const goBack = () => {
uni.navigateBack({
delta: 1
})
}
onMounted(() => {
getNavbarHeight()
getData()
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.order-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.order-scroll {
height: 100vh;
}
.order-list {
padding: 0 20rpx 10rpx;
}
.order-item {
background: #fff;
border-radius: 15rpx;
padding: 30rpx;
margin-top: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.order-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.book-name {
flex: 1;
font-size: 34rpx;
color: #333;
font-weight: bold;
max-width: 85%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-info {
display: flex;
align-items: center;
.payment-icon {
width: 38rpx;
height: 38rpx;
margin-right: 5rpx;
}
.price {
font-size: 36rpx;
color: $theme-color;
font-weight: bold;
}
.currency {
font-size: 32rpx;
color: $theme-color;
font-weight: bold;
margin-left: 5rpx;
}
}
}
.order-sn {
display: block;
font-size: 30rpx;
color: #666;
line-height: 38rpx;
margin-bottom: 10rpx;
}
.create-time {
display: block;
font-size: 28rpx;
color: #aaa;
}
}
.load-tips {
padding: 40rpx 0 20rpx;
text-align: center;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding-top: 100rpx;
.empty-text {
font-size: 30rpx;
color: #999;
}
}
</style>

View File

@@ -0,0 +1,645 @@
<template>
<view class="profile-page">
<!-- 自定义导航栏 -->
<nav-bar :title="$t('user.profile')"></nav-bar>
<view class="profile-content">
<!-- 头像区域 -->
<view class="avatar-section">
<image
:src="userInfo.avatar || defaultAvatar"
class="avatar"
@click="editAvatar"
/>
<text class="avatar-text" @click="editAvatar">
{{ userInfo.avatar ? $t('user.changeAvatar') : $t('user.setAvatar') }}
</text>
</view>
<!-- 信息列表 -->
<wd-cell-group border custom-class="info-list">
<wd-cell
v-for="field in fields"
title-width="100rpx"
:key="field.key"
:title="field.label"
clickable
@click="editField(field)"
>
<view class="value-wrapper">
<text v-if="userInfo[field.key]" class="value">
{{ formatValue(field, userInfo[field.key]) }}
</text>
<text v-else class="placeholder">{{ $t('user.notSet') }}</text>
<wd-icon
v-if="userInfo[field.key]"
name="edit-1"
size="16px"
color="#54a966"
/>
</view>
</wd-cell>
</wd-cell-group>
</view>
<!-- 编辑弹窗 -->
<wd-popup v-model="showEditModal" position="bottom">
<view class="edit-modal">
<text class="modal-title">{{ editTitle }}</text>
<!-- 昵称/姓名/年龄编辑 -->
<view v-if="['nickname', 'name', 'age'].includes(currentField?.key || '')">
<wd-input
v-model="editValue"
:placeholder="getPlaceholder(currentField?.key)"
:type="currentField?.key === 'age' ? 'number' : 'text'"
/>
</view>
<!-- 邮箱编辑 -->
<view v-if="currentField?.key === 'email'">
<wd-input
v-model="editForm.email"
:placeholder="$t('user.email')"
type="email"
/>
<view class="code-input">
<wd-input
v-model="editForm.code"
:placeholder="$t('login.codePlaceholder')"
type="number"
class="flex-1"
/>
<wd-button
size="small"
:disabled="codeDisabled"
class="w-[100px]"
@click="sendCode"
>
{{ codeText }}
</wd-button>
</view>
</view>
<!-- 密码编辑 -->
<view v-if="currentField?.key === 'password'">
<wd-input
v-model="editForm.password"
:placeholder="$t('user.pleaseInputPassword')"
type="password"
@input="checkPasswordStrength"
/>
<view class="password-note">
<view v-if="passwordNote">{{ passwordNote }}</view>
<view v-html="passwordStrength"></view>
</view>
<wd-input
v-model="editForm.confirmPassword"
:placeholder="$t('user.pleaseInputPasswordAgain')"
type="password"
style="margin-top: 20rpx"
/>
</view>
<!-- 性别编辑 -->
<view v-if="currentField?.key === 'sex'">
<wd-radio-group v-model="editValue" shape="button" class="text-center">
<wd-radio v-for="item in sexOptions" :key="item.value" :value="item.value">{{ item.label }}</wd-radio>
</wd-radio-group>
</view>
<!-- 头像上传 -->
<view v-if="currentField?.key === 'avatar'">
<wd-upload
:file-list="avatarList"
:limit="1"
action="https://global.nuttyreading.com/oss/fileoss"
@change="handleChangeAvatar"
/>
</view>
<!-- 确认按钮 -->
<wd-button type="success" block :round="false" @click="handleSubmit" style="margin-top: 50rpx">
{{ $t('common.confirm') }}
</wd-button>
<view class="cancel-btn" @click="closeModal">
{{ $t('common.cancel') }}
</view>
</view>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import {
getUserInfo,
updateUserInfo,
updateEmail,
updatePassword,
uploadImage,
sendEmailCode
} from '@/api/modules/user'
import { checkPasswordStrength as validatePassword } from '@/utils/validator'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const userStore = useUserStore()
// 默认头像
const defaultAvatar = '/static/home_icon.png'
// 字段列表
const fields = computed(() => [
{ key: 'nickname', label: t('user.nickname') },
{ key: 'email', label: t('user.email') },
{ key: 'password', label: t('login.password') },
{ key: 'age', label: t('user.age') },
{ key: 'sex', label: t('user.sex') },
{ key: 'name', label: t('user.name') }
])
// 性别选项
const sexOptions = [
{ label: t('user.male'), value: 1 },
{ label: t('user.female'), value: 2 }
]
// 编辑相关
const showEditModal = ref(false)
const currentField = ref<any>(null)
const editTitle = ref('')
const editValue = ref<any>('')
const editForm = ref({
email: '',
code: '',
password: '',
confirmPassword: ''
})
// 验证码相关
const codeDisabled = ref(false)
const codeText = ref(t('login.getCode'))
let codeTimer: any = null
// 密码强度
const passwordNote = ref('')
const passwordStrength = ref('')
const passwordOk = ref(false)
// 头像上传
const avatarList = ref<any[]>([])
const avatarUrl = ref('')
/**
* 获取用户数据
*/
const userInfo = ref<any>({}) // 用户信息
const getData = async () => {
uni.showLoading()
try {
const res = await getUserInfo()
uni.hideLoading()
if (res.result) {
userStore.setUserInfo(res.result)
userInfo.value = res.result
}
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
/**
* 格式化显示值
*/
const formatValue = (field: any, value: any) => {
if (field.key === 'sex') {
return value === 1 ? t('user.male') : t('user.female')
}
if (field.key === 'password') {
return '******'
}
return value
}
/**
* 获取输入框占位符
*/
const getPlaceholder = (key?: string) => {
const placeholders: Record<string, string> = {
nickname: t('common.pleaseInput') + t('user.nickname'),
name: t('common.pleaseInput') + t('user.name'),
age: t('common.pleaseInput') + t('user.age')
}
return placeholders[key || ''] || ''
}
/**
* 编辑字段
*/
const editField = (field: any) => {
currentField.value = field
editTitle.value = (userInfo.value[field.key] ? t('common.edit') : t('user.clickToSet')) + field.label
// 重置表单
editValue.value = ''
editForm.value = {
email: '',
code: '',
password: '',
confirmPassword: ''
}
passwordNote.value = ''
passwordStrength.value = ''
// 设置初始值
if (field.key === 'sex' && userInfo.value.sex) {
editValue.value = userInfo.value.sex
}
showEditModal.value = true
}
/**
* 编辑头像
*/
const editAvatar = () => {
currentField.value = { key: 'avatar', label: t('user.avatar') }
editTitle.value = userInfo.value.avatar ? t('user.changeAvatar') : t('user.setAvatar')
avatarList.value = userInfo.value.avatar ? [{ url: userInfo.value.avatar }] : []
showEditModal.value = true
}
/**
* 上传前检查
*/
const beforeUpload = async () => {
// TODO: 检查相机和存储权限
return true
}
/**
* 处理头像上传变化
*/
const handleChangeAvatar = async(filelist: any) => {
if (filelist.fileList.length > 0) {
avatarUrl.value = JSON.parse(filelist.fileList[0].response).url
} else {
avatarUrl.value = ''
}
}
/**
* 删除头像
*/
const handleDeleteAvatar = () => {
avatarList.value = []
}
/**
* 发送验证码
*/
const sendCode = async () => {
if (!editForm.value.email) {
uni.showToast({
title: t('user.pleaseInput') + t('user.email'),
icon: 'none'
})
return
}
try {
await sendEmailCode(editForm.value.email)
uni.showToast({
title: t('user.sendCodeSuccess'),
icon: 'none'
})
startCountdown()
} catch (error) {
console.error('发送验证码失败:', error)
}
}
/**
* 验证码倒计时
*/
const startCountdown = () => {
if (codeTimer) {
clearInterval(codeTimer)
}
codeDisabled.value = true
let countdown = 60
codeText.value = `${countdown}S`
codeTimer = setInterval(() => {
countdown--
codeText.value = `${countdown}S`
if (countdown <= 0) {
clearInterval(codeTimer)
codeText.value = t('login.getCode')
codeDisabled.value = false
}
}, 1000)
}
/**
* 检查密码强度
*/
const checkPasswordStrength = () => {
const password = editForm.value.password
if (!password) {
passwordNote.value = ''
passwordStrength.value = ''
passwordOk.value = false
return
}
const strength = validatePassword(password)
if (strength === 'strong') {
passwordStrength.value = `<span style="color:#18bc37">${t('forget.passwordStrengthStrong')}</span>`
passwordNote.value = ''
passwordOk.value = true
} else if (strength === 'medium') {
passwordStrength.value = `<span style="color:#2979ff">${t('forget.passwordStrengthMedium')}</span>`
passwordNote.value = t('forget.passwordStrengthWeak')
passwordOk.value = true
} else if (strength === 'weak') {
passwordStrength.value = `<span style="color:#f3a73f">弱</span>`
passwordNote.value = t('forget.passwordStrengthWeak')
passwordOk.value = false
} else {
passwordStrength.value = ''
passwordNote.value = t('forget.passwordStrengthWeak')
passwordOk.value = false
}
}
/**
* 提交修改
*/
const handleSubmit = async () => {
const key = currentField.value?.key
try {
uni.showLoading()
// 构建更新数据对象
let updateData: any = Object.assign({}, userInfo.value)
switch (key) {
case 'email':
// 更新邮箱
if (!editForm.value.email || !editForm.value.code) {
uni.hideLoading()
uni.showToast({
title: t('user.pleaseInputCode'),
icon: 'none'
})
return
}
await updateEmail(userInfo.value.id, editForm.value.email, editForm.value.code)
break
case 'password':
// 更新密码
if (!passwordOk.value) {
uni.hideLoading()
uni.showToast({
title: passwordNote.value,
icon: 'none'
})
return
}
if (editForm.value.password !== editForm.value.confirmPassword) {
uni.hideLoading()
uni.showToast({
title: t('user.passwordNotMatch'),
icon: 'none'
})
return
}
await updatePassword(userInfo.value.id, editForm.value.password)
break
case 'avatar':
// 更新头像
console.log('avatarUrl.value:', avatarUrl.value)
if (!avatarUrl.value) {
uni.hideLoading()
uni.showToast({
title: t('common.pleaseSelect') + t('user.avatar'),
icon: 'none'
})
return
}
// 如果是新上传的图片,需要先上传
updateData.avatar = avatarUrl.value
await updateUserInfo(updateData)
break
case 'sex':
// 更新性别
const sexValue = editValue.value === 2 ? 0 : editValue.value
updateData.sex = sexValue
await updateUserInfo(updateData)
break
default:
// 更新其他字段
if (!editValue.value) {
uni.showToast({
title: getPlaceholder(key),
icon: 'none'
})
return
}
updateData[key] = editValue.value
await updateUserInfo(updateData)
break
}
uni.hideLoading()
uni.showToast({
title: t('user.updateSuccess'),
icon: 'success'
})
closeModal()
// 刷新数据
setTimeout(() => {
getData()
}, 500)
} catch (error) {
console.error('更新失败:', error)
uni.hideLoading()
}
}
/**
* 关闭弹窗
*/
const closeModal = () => {
showEditModal.value = false
currentField.value = null
if (codeTimer) {
clearInterval(codeTimer)
codeDisabled.value = false
codeText.value = t('login.getCode')
}
}
onMounted(() => {
getData()
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.profile-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.profile-content {
padding: 20rpx;
}
.avatar-section {
background: #fff;
border-radius: 15rpx;
padding: 40rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.avatar {
width: 180rpx;
height: 180rpx;
border-radius: 180rpx;
background-color: #f5f5f5;
margin-bottom: 20rpx;
}
.avatar-text {
font-size: 30rpx;
color: $theme-color;
font-weight: 600;
}
}
.info-list {
background: #fff;
border-radius: 15rpx;
overflow: hidden;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.value-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
.value {
color: #666;
margin-right: 10rpx;
}
.placeholder {
color: #999;
margin-right: 10rpx;
}
}
.edit-modal {
padding: 60rpx 50rpx 80rpx;
.modal-title {
font-size: 32rpx;
color: #555;
text-align: center;
font-weight: bold;
margin-bottom: 50rpx;
display: block;
}
.code-input {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 20rpx;
}
.password-note {
margin-top: 10rpx;
font-size: 28rpx;
color: #999;
line-height: 40rpx;
}
.radio-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0;
border-bottom: 1rpx solid #ededed;
&:last-child {
border-bottom: none;
}
text {
font-size: 30rpx;
color: #333;
}
}
.cancel-btn {
text-align: center;
font-size: 28rpx;
color: #888;
margin-top: 25rpx;
}
}
</style>

View File

@@ -0,0 +1,340 @@
<template>
<view class="settings-page">
<!-- 自定义导航栏 -->
<nav-bar :title="$t('user.settings')"></nav-bar>
<view class="settings-content">
<!-- 设置列表 -->
<view class="settings-list">
<view
v-for="item in settingItems"
:key="item.id"
class="setting-item"
@click="handleSettingClick(item)"
>
<text class="label">{{ item.label }}</text>
<view class="value-wrapper">
<text class="value">{{ item.value }}</text>
<wd-icon name="arrow-right" size="16px" color="#aaa" />
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<wd-button type="info" block :round="false" custom-class="logout-btn" @click="handleLogoff">{{ $t('user.logoff') }}</wd-button>
<wd-button type="error" block :round="false" @click="handleLogout">
{{ $t('user.logout') }}
</wd-button>
</view>
</view>
<!-- 微信二维码弹窗 -->
<wd-popup v-model="showQrCode" position="bottom" :closeable="true">
<view class="qrcode-modal">
<text class="modal-title">{{ $t('user.wechatTip') }}</text>
<image
src="/static/qiyeWx.jpg"
mode="aspectFit"
class="qrcode-image"
@click="previewQrCode"
/>
</view>
</wd-popup>
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { useMessage } from '@/uni_modules/wot-design-uni'
import { makePhoneCall, copyToClipboard } from '@/utils/index'
const { t } = useI18n()
const userStore = useUserStore()
const message = useMessage()
// 导航栏高度
const statusBarHeight = ref(0)
const navbarHeight = ref('44px')
// 设置项列表
const settingItems = computed(() => [
{
id: 1,
label: t('user.hotline'),
value: '022-24142321',
type: 'tel'
},
{
id: 2,
label: t('user.customerEmail'),
value: 'appyilujiankang@sina.com',
type: 'email'
},
{
id: 3,
label: t('user.wechat'),
value: '',
type: 'wechat'
},
{
id: 4,
label: t('user.checkVersion'),
value: '',
type: 'version'
}
])
// 弹窗状态
const showQrCode = ref(false)
/**
* 获取导航栏高度
*/
const getNavbarHeight = () => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.indexOf('iPhone') !== -1 && parseInt(systemInfo.model.slice(-2)) >= 11) {
navBarHeight = 48
}
const totalHeight = statusBarHeight.value + navBarHeight
navbarHeight.value = totalHeight + 'px'
}
/**
* 处理设置项点击
*/
const handleSettingClick = (item: any) => {
switch (item.type) {
case 'tel':
handlePhoneCall(item.value, item.label)
break
case 'email':
handleCopyEmail(item.value, item.label)
break
case 'wechat':
showQrCode.value = true
break
case 'version':
checkVersion()
break
}
}
/**
* 拨打电话
*/
const handlePhoneCall = (phoneNumber: string, title: string) => {
makePhoneCall(phoneNumber, title, t)
}
/**
* 复制到剪贴板
*/
const handleCopyEmail = (content: string, title: string) => {
copyToClipboard(content, title, t)
}
/**
* 预览二维码
*/
const previewQrCode = () => {
showQrCode.value = false
uni.previewImage({
urls: ['/static/qiyeWx.jpg']
})
}
/**
* 检查版本更新
*/
const checkVersion = () => {
// #ifdef APP-PLUS
// TODO: 集成 uni-upgrade-center-app 插件
uni.showToast({
title: '当前已是最新版本',
icon: 'none'
})
// #endif
// #ifndef APP-PLUS
uni.showToast({
title: '仅支持APP端检查更新',
icon: 'none'
})
// #endif
}
/**
* 注销账户
*/
const handleLogoff = () => {
message.confirm({
title: t('global.tips'),
msg: t('user.logoffConfirm'),
}).then(() => {
message.confirm({
title: t('global.tips'),
msg: t('user.logoffConfirmAgain'),
}).then(() => {
performLogout()
}).catch(() => {
// 取消注销账户
})
}).catch(() => {
// 取消注销账户
})
}
/**
* 退出登录
*/
const handleLogout = () => {
message.confirm({
title: t('global.tips'),
msg: t('user.logoutConfirm'),
}).then(() => {
performLogout()
}).catch(() => {
// 取消退出登录
})
}
/**
* 执行退出登录
*/
const performLogout = () => {
// 清除用户信息
userStore.logout()
uni.reLaunch({
url: '/pages/login/login'
})
}
onMounted(() => {
getNavbarHeight()
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.settings-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.settings-content {
padding: 20rpx;
}
.settings-list {
background: #fff;
border-radius: 15rpx;
overflow: hidden;
margin-bottom: 40rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #e0e0e0;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f5f5f5;
}
.label {
font-size: 30rpx;
color: #333;
width: 40%;
}
.value-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
.value {
font-size: 28rpx;
color: #b0b0b0;
margin-right: 10rpx;
}
}
}
.action-buttons {
padding: 40rpx 30rpx 60rpx;
:deep(.logout-btn) {
margin-bottom: 40rpx;
background-color: #e5e5e5;
color: #8a1a1a;
}
}
.qrcode-modal {
padding: 40rpx;
text-align: center;
.modal-title {
display: block;
font-size: 28rpx;
color: #999;
margin-bottom: 30rpx;
}
.qrcode-image {
width: 400rpx;
height: 400rpx;
margin: 0 auto;
}
}
</style>

523
pages/user/wallet/index.vue Normal file
View File

@@ -0,0 +1,523 @@
<template>
<view class="wallet-page">
<!-- 自定义导航栏 -->
<nav-bar :title="$t('user.subscribe')"></nav-bar>
<view class="wallet-content">
<!-- VIP套餐列表 -->
<view class="package-section">
<text class="section-title">{{ $t('user.subscribe') }}</text>
<view class="package-list">
<view
v-for="(pkg, index) in packages"
:key="pkg.id"
:class="['package-item', { active: selectedIndex === index }]"
@click="selectPackage(index)"
>
<view class="package-price">
<image src="/static/icon/currency.png" class="currency-icon" />
<text class="price">{{ pkg.dictType }}</text>
<text class="unit">NZD</text>
</view>
<text class="package-duration">{{ getDuration(pkg.remark) }}</text>
</view>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-section">
<text class="section-title">{{ $t('user.paymentMethod') }}</text>
<radio-group class="payment-group">
<view class="payment-item">
<radio :checked="true" color="#54a966" />
<text>{{ paymentName }}</text>
</view>
</radio-group>
</view>
<!-- 协议勾选 -->
<view class="agreement-section">
<view class="agreement-checkbox" @click="toggleAgreement">
<view :class="['checkbox', { checked: agreed }]">
<wd-icon v-if="agreed" name="check" size="16px" color="#fff" />
</view>
<text class="agreement-text">
{{ $t('user.agreeText') }}
<text class="link" @click.stop="showAgreementModal">{{ $t('user.agreement') }}</text>
</text>
</view>
</view>
<!-- 订阅按钮 -->
<view class="subscribe-button">
<wd-button
type="success"
block
@click="handleSubscribe"
>
{{ $t('user.subscribe') }}
</wd-button>
</view>
</view>
<!-- 协议弹窗 -->
<wd-popup v-model="agreementShow" position="bottom" :closeable="true">
<view class="agreement-modal">
<view class="modal-title">{{ agreementData.title }}</view>
<scroll-view scroll-y class="modal-content">
<view v-html="agreementData.content"></view>
</scroll-view>
</view>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { getVipPackages, createOrder, verifyGooglePay, verifyIAP } from '@/api/modules/user'
import { commonApi } from '@/api/modules/common'
import type { IVipPackage } from '@/types/user'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const userStore = useUserStore()
// 导航栏高度
const statusBarHeight = ref(0)
const navbarHeight = ref('44px')
// VIP套餐列表
const packages = ref<IVipPackage[]>([])
const selectedIndex = ref<number | null>(null)
const selectedPackage = computed(() =>
selectedIndex.value !== null ? packages.value[selectedIndex.value] : null
)
// 平台和支付方式
const platform = ref('')
const paymentMethod = ref(5) // 3-iOS IAP, 5-Google Pay
const paymentName = computed(() =>
platform.value === 'android' ? 'Google Pay' : 'Apple Pay'
)
// 协议相关
const agreed = ref(false)
const agreementShow = ref(false)
const agreementData = ref({
title: '',
content: ''
})
// 订单号
const orderSn = ref('')
/**
* 获取导航栏高度
*/
const getNavbarHeight = () => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.indexOf('iPhone') !== -1 && parseInt(systemInfo.model.slice(-2)) >= 11) {
navBarHeight = 48
}
const totalHeight = statusBarHeight.value + navBarHeight
navbarHeight.value = totalHeight + 'px'
}
/**
* 获取平台信息
*/
const getPlatform = () => {
const systemInfo = uni.getSystemInfoSync()
platform.value = systemInfo.platform === 'android' ? 'android' : 'ios'
paymentMethod.value = platform.value === 'android' ? 5 : 3
}
/**
* 获取VIP套餐列表
*/
const getPackages = async () => {
try {
uni.showLoading({
title: t('common.loading')
})
const res = await getVipPackages()
uni.hideLoading()
if (res.sysDictDatas && res.sysDictDatas.length > 0) {
packages.value = res.sysDictDatas
// 检查是否有缓存的选择
const cachedIndex = uni.getStorageSync('selectVipCard')
if (cachedIndex !== null && cachedIndex < packages.value.length) {
selectedIndex.value = cachedIndex
} else {
selectedIndex.value = 0
}
}
} catch (error) {
console.error('获取套餐列表失败:', error)
uni.hideLoading()
}
}
/**
* 选择套餐
*/
const selectPackage = (index: number) => {
selectedIndex.value = index
uni.setStorageSync('selectVipCard', index)
}
/**
* 获取时长文本
*/
const getDuration = (remark: string) => {
const days = parseInt(remark)
if (days === 30) {
return t('user.monthCard')
} else if (days === 90) {
return t('user.seasonCard')
} else if (days === 365) {
return t('user.yearCard')
}
return `${days} ${t('user.days')}`
}
/**
* 切换协议勾选
*/
const toggleAgreement = () => {
agreed.value = !agreed.value
uni.setStorageSync('Agreements_agreed', agreed.value)
}
/**
* 显示协议弹窗
*/
const showAgreementModal = async () => {
try {
const res = await commonApi.getAgreement(113)
if (res) {
let content = res.content || ''
content = content.replace(
/<h5>/g,
'<view style="font-weight: bold;font-size: 32rpx;margin-top: 20rpx;margin-bottom: 20rpx;">'
)
content = content.replace(/<\/h5>/g, '</view>')
agreementData.value = {
title: res.title,
content: content
}
agreementShow.value = true
}
} catch (error) {
console.error('获取协议失败:', error)
}
}
/**
* 订阅处理
*/
const handleSubscribe = async () => {
// 验证是否勾选协议
if (!agreed.value) {
uni.showToast({
title: t('user.agreeFirst'),
icon: 'none'
})
return
}
// 验证是否选择套餐
if (selectedIndex.value === null || !selectedPackage.value) {
uni.showToast({
title: t('user.selectPackage'),
icon: 'none'
})
return
}
try {
uni.showLoading({
title: t('common.loading')
})
// 创建订单
const orderData = {
paymentMethod: paymentMethod.value,
orderMoney: selectedPackage.value.money,
vipBuyConfigId: selectedPackage.value.priceTypeId,
orderType: 'abroadVip',
abroadVipId: selectedPackage.value.id
}
const orderRes = await createOrder(orderData)
if (orderRes.orderSn) {
orderSn.value = orderRes.orderSn
// 根据平台调起支付
if (platform.value === 'android') {
await initiateGooglePay()
} else {
await initiateIAP()
}
}
} catch (error) {
console.error('创建订单失败:', error)
uni.hideLoading()
}
}
/**
* Google Pay 支付Android
*/
const initiateGooglePay = async () => {
// TODO: 集成 Google Pay SDK
// 这里需要使用 sn-googlepay5 插件
uni.hideLoading()
uni.showToast({
title: 'Google Pay 功能开发中',
icon: 'none'
})
}
/**
* IAP 支付iOS
*/
const initiateIAP = async () => {
// TODO: 集成 IAP SDK
uni.hideLoading()
uni.showToast({
title: 'IAP 功能开发中',
icon: 'none'
})
}
/**
* 返回上一页
*/
const goBack = () => {
uni.navigateBack({
delta: 1
})
}
onMounted(() => {
getNavbarHeight()
getPlatform()
getPackages()
})
onUnmounted(() => {
// 清除缓存
uni.removeStorageSync('selectVipCard')
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.wallet-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.wallet-content {
padding: 20rpx;
}
.section-title {
display: block;
font-size: 34rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.package-section {
background: #fff;
border-radius: 15rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.package-list {
display: flex;
flex-wrap: wrap;
margin: -10rpx;
}
.package-item {
width: calc(33.333% - 20rpx);
margin: 10rpx;
padding: 30rpx 0;
border: 2rpx solid #ebebeb;
border-radius: 10rpx;
text-align: center;
transition: all 0.3s;
&.active {
border-color: $theme-color;
background: rgba(84, 169, 102, 0.1);
}
.package-price {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
.currency-icon {
width: 25rpx;
height: 25rpx;
margin-right: 8rpx;
}
.price {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.unit {
font-size: 26rpx;
color: #333;
margin-left: 5rpx;
}
}
.package-duration {
display: block;
font-size: 26rpx;
color: #666;
}
}
.payment-section {
background: #fff;
border-radius: 15rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.payment-group {
margin-top: 20rpx;
}
.payment-item {
display: flex;
align-items: center;
padding: 10rpx 0;
text {
font-size: 30rpx;
color: #333;
margin-left: 10rpx;
}
}
.agreement-section {
padding: 20rpx 30rpx;
}
.agreement-checkbox {
display: flex;
align-items: flex-start;
.checkbox {
width: 36rpx;
height: 36rpx;
border: 2rpx solid #ddd;
border-radius: 4rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15rpx;
flex-shrink: 0;
margin-top: 2rpx;
&.checked {
background-color: $theme-color;
border-color: $theme-color;
}
}
.agreement-text {
flex: 1;
font-size: 28rpx;
color: #333;
line-height: 40rpx;
.link {
color: $theme-color;
}
}
}
.subscribe-button {
padding: 40rpx 30rpx 60rpx;
}
.agreement-modal {
padding: 40rpx 30rpx;
max-height: 70vh;
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
text-align: center;
margin-bottom: 30rpx;
}
.modal-content {
max-height: 60vh;
font-size: 28rpx;
color: #555;
line-height: 45rpx;
}
}
</style>

242
pages/user/wallet/list.vue Normal file
View File

@@ -0,0 +1,242 @@
<template>
<view class="transaction-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ height: navbarHeight }">
<view class="navbar-content" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-left" @click="goBack">
<wd-icon name="arrow-left" size="18px" color="#333" />
</view>
<text class="navbar-title">{{ $t('user.myAccount') }}</text>
</view>
</view>
<!-- 交易记录列表 -->
<view
v-if="transactionList.length > 0"
class="transaction-list"
:style="{ paddingTop: navbarHeight }"
>
<view
v-for="item in transactionList"
:key="item.id"
class="transaction-item"
>
<view class="item-header">
<text class="transaction-type">{{ item.orderType }}</text>
<text
:class="['amount', { positive: item.orderType === '充值' }]"
>
{{ item.orderType === '充值' ? '+' : '' }}{{ item.changeAmount }}
</text>
</view>
<text class="remark">{{ formatRemark(item.remark) }}</text>
<text class="create-time">{{ item.createTime }}</text>
</view>
</view>
<!-- 空状态 -->
<view
v-else-if="!loading"
class="empty-state"
:style="{ paddingTop: navbarHeight }"
>
<text class="empty-text">{{ $t('common.noData') }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { getTransactionList } from '@/api/modules/user'
import type { ITransaction } from '@/types/user'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const userStore = useUserStore()
// 导航栏高度
const statusBarHeight = ref(0)
const navbarHeight = ref('44px')
// 交易记录列表
const transactionList = ref<ITransaction[]>([])
// 加载状态
const loading = ref(false)
/**
* 获取导航栏高度
*/
const getNavbarHeight = () => {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.indexOf('iPhone') !== -1 && parseInt(systemInfo.model.slice(-2)) >= 11) {
navBarHeight = 48
}
const totalHeight = statusBarHeight.value + navBarHeight
navbarHeight.value = totalHeight + 'px'
}
/**
* 获取交易记录
*/
const getData = async () => {
if (!userStore.userInfo.id) {
return
}
try {
loading.value = true
uni.showLoading({
title: t('common.loading')
})
const res = await getTransactionList(userStore.userInfo.id)
uni.hideLoading()
if (res.transactionDetailsList && res.transactionDetailsList.length > 0) {
transactionList.value = res.transactionDetailsList
}
} catch (error) {
console.error('获取交易记录失败:', error)
uni.hideLoading()
} finally {
loading.value = false
}
}
/**
* 格式化备注信息(换行显示订单编号)
*/
const formatRemark = (remark: string) => {
if (remark.includes('Stripe充值:')) {
return remark.replace('Stripe充值:', 'Stripe充值:\n')
} else if (remark.includes('订单编号为 - ')) {
return remark.replace('订单编号为 - ', '订单编号为 - \n')
}
return remark
}
/**
* 返回上一页
*/
const goBack = () => {
uni.navigateBack({
delta: 1
})
}
onMounted(() => {
getNavbarHeight()
getData()
})
</script>
<style lang="scss" scoped>
$theme-color: #54a966;
.transaction-page {
min-height: 100vh;
background-color: #f7faf9;
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
position: relative;
.navbar-left {
position: absolute;
left: 10px;
display: flex;
align-items: center;
padding: 10rpx;
}
.navbar-title {
font-size: 16px;
font-weight: 700;
color: #333;
}
}
}
.transaction-list {
padding: 20rpx;
}
.transaction-item {
background: #fff;
border-radius: 15rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.transaction-type {
font-size: 34rpx;
color: #333;
font-weight: bold;
}
.amount {
font-size: 34rpx;
color: #333;
font-weight: bold;
&.positive {
color: $theme-color;
}
}
}
.remark {
display: block;
font-size: 30rpx;
color: #666;
line-height: 38rpx;
margin-bottom: 10rpx;
white-space: pre-line;
}
.create-time {
display: block;
font-size: 28rpx;
color: #aaa;
}
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding-top: 100rpx;
.empty-text {
font-size: 30rpx;
color: #999;
}
}
</style>