consumer模块完成度95%,优化安卓端界面和小程序测试3

This commit is contained in:
cyh666666
2026-03-13 17:12:00 +08:00
parent b2a6e5a142
commit b5c7947ad8
1741 changed files with 3427 additions and 2036 deletions

View File

@@ -0,0 +1,60 @@
# 图片资源复制说明
## 需要复制的图片文件
请从 `CRMEB/template/uni-app/pages/users/static/` 目录复制以下文件到 `static/user/` 目录:
### 必需文件
1. **phone_1.png** - 手机号输入框图标
- 尺寸24rpx × 34rpx
- 用途:手机号输入框左侧图标
2. **code_1.png** - 密码输入框图标
- 尺寸28rpx × 32rpx
- 用途:密码输入框左侧图标
3. **code_2.png** - 验证码输入框图标
- 尺寸28rpx × 32rpx
- 用途:验证码输入框左侧图标
### 可选文件
4. **logo2.png** - Logo 图片(可选)
- 用途:注册/找回密码页面 Logo
- 如果不存在,将使用 `/static/logo.png`
## 复制方法
### Windows PowerShell
```powershell
# 在项目根目录执行
Copy-Item "CRMEB\template\uni-app\pages\users\static\phone_1.png" -Destination "static\user\"
Copy-Item "CRMEB\template\uni-app\pages\users\static\code_1.png" -Destination "static\user\"
Copy-Item "CRMEB\template\uni-app\pages\users\static\code_2.png" -Destination "static\user\"
Copy-Item "CRMEB\template\uni-app\pages\users\static\logo2.png" -Destination "static\user\" -ErrorAction SilentlyContinue
```
### 手动复制
1. 打开 `CRMEB/template/uni-app/pages/users/static/` 目录
2. 复制上述文件到 `static/user/` 目录
3. 确保文件路径正确
## 文件结构
复制后的目录结构应该是:
```
static/
└── user/
├── phone_1.png
├── code_1.png
├── code_2.png
├── logo2.png (可选)
└── README.md
```
## 注意事项
- 如果图片文件不存在,页面会显示占位符或空白
- 建议使用原 CRMEB 项目的图片资源以保持设计一致性
- 图片路径在代码中使用:`/static/user/phone_1.png`

View File

@@ -0,0 +1,20 @@
# 用户相关图片资源
## 说明
这些图片资源来自 CRMEB 项目,用于登录注册页面。
## 需要复制的图片文件
请从 `CRMEB/template/uni-app/pages/users/static/` 目录复制以下文件到此目录:
1. `phone_1.png` - 手机号输入框图标24rpx × 34rpx
2. `code_1.png` - 密码输入框图标28rpx × 32rpx
3. `code_2.png` - 验证码输入框图标28rpx × 32rpx
4. `logo2.png` - Logo 图片(可选,用于注册/找回密码页面)
## 使用方式
在页面中使用:
```vue
<image src="/static/user/phone_1.png" />
```

View File

@@ -0,0 +1,463 @@
<template>
<view class="page-container">
<scroll-view class="address-edit-page" direction="vertical">
<view class="form-group">
<view class="form-item">
<text class="label">鏀惰揣浜?/text>
<input class="input" v-model="formData.name" placeholder="璇峰~鍐欐敹璐т汉濮撳悕" />
</view>
<view class="form-item">
<text class="label">鎵嬫満鍙风爜</text>
<input class="input" v-model="formData.phone" type="number" maxlength="11" placeholder="璇峰~鍐欐墜鏈哄彿鐮? />
</view>
<view class="form-item">
<text class="label">鎵?鍦ㄥ湴鍖?/text>
<input class="input" v-model="regionString" placeholder="鐪佸競鍖哄幙銆佷埂闀囩瓑" />
</view>
<view class="form-item">
<text class="label">璇︾粏鍦板潃</text>
<input class="input" v-model="formData.detail" placeholder="琛楅亾銆佹ゼ鐗屽彿绛? />
</view>
</view>
<view class="form-group">
<view class="form-item">
<text class="label">鏅鸿兘濉啓</text>
<textarea class="smart-textarea" v-model="smartInput" placeholder="绮樿创濮撳悕+鐢佃瘽+鍦板潃锛岃嚜鍔ㄨ瘑鍒~鍏? @input="parseSmartInput" maxlength="200"></textarea>
<text class="smart-tip">绀轰緥锛氬紶涓?13800138000 鍖椾含甯傛湞闃冲尯涓夐噷灞疭OHO A搴?/text>
</view>
<view class="form-item">
<text class="label">鏍囩</text>
<view class="tags-container">
<text
v-for="tag in tags"
:key="tag"
class="tag-item"
:class="{ active: formData.label === tag }"
@click="selectTag(tag)"
>{{ tag }}</text>
</view>
</view>
<view class="form-item switch-item">
<text class="label">璁句负榛樿鏀惰揣鍦板潃</text>
<switch :checked="formData.isDefault" color="#ff5000" @change="onSwitchChange" />
</view>
</view>
<view class="footer-btn">
<button class="save-btn" @click="saveAddress">淇濆瓨</button>
<button v-if="isEdit" class="delete-btn" @click="deleteAddress">鍒犻櫎鏀惰揣鍦板潃</button>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService, AddAddressParams, UpdateAddressParams } from '@/utils/supabaseService.uts'
type Address = {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
label?: string
}
const isEdit = ref(false)
const addressId = ref('')
const regionString = ref('')
const tags = ['瀹?, '鍏徃', '瀛︽牎']
const smartInput = ref('')
type AddressForm = {
name: string
phone: string
detail: string
isDefault: boolean
label: string
}
const formData = reactive({
name: '',
phone: '',
detail: '',
isDefault: false,
label: ''
} as AddressForm)
const loadAddress = async (id: string) => {
try {
// 浠嶴upabase鍔犺浇鍦板潃璇︽儏
const address = await supabaseService.getAddressById(id)
if (address != null) {
formData.name = address.recipient_name
formData.phone = address.phone
formData.detail = address.detail_address
formData.isDefault = address.is_default
formData.label = address.label ?? ''
regionString.value = `${address.province} ${address.city} ${address.district}`.trim()
} else {
// 濡傛灉Supabase娌℃湁鎵惧埌锛屽皾璇曚粠鏈湴瀛樺偍鍔犺浇
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
const addresses = JSON.parse(storedAddresses as string) as Address[]
const localAddress = addresses.find(item => item.id === id)
if (localAddress != null) {
formData.name = localAddress.name
formData.phone = localAddress.phone
formData.detail = localAddress.detail
formData.isDefault = localAddress.isDefault
formData.label = localAddress.label ?? ''
regionString.value = `${localAddress.province} ${localAddress.city} ${localAddress.district}`.trim()
}
}
}
} catch (error) {
console.error('鍔犺浇鍦板潃璇︽儏澶辫触:', error)
// 澶辫触鏃朵粠鏈湴瀛樺偍鍔犺浇
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
const addresses = JSON.parse(storedAddresses as string) as Address[]
const address = addresses.find(item => item.id === id)
if (address != null) {
formData.name = address.name
formData.phone = address.phone
formData.detail = address.detail
formData.isDefault = address.isDefault
formData.label = address.label ?? ''
regionString.value = `${address.province} ${address.city} ${address.district}`.trim()
}
} catch (e) {
console.error('瑙f瀽鏈湴鍦板潃鏁版嵁澶辫触', e)
}
}
}
}
onLoad((options) => {
if (options['id'] != null) {
isEdit.value = true
addressId.value = options['id'] as string
loadAddress(addressId.value)
}
})
const selectTag = (tag: string) => {
if (formData.label === tag) {
formData.label = ''
} else {
formData.label = tag
}
}
const onSwitchChange = (e: UniSwitchChangeEvent) => {
formData.isDefault = e.detail.value
}
const saveAddress = async () => {
if (formData.name == '') {
uni.showToast({ title: '璇峰~鍐欐敹璐т汉', icon: 'none' })
return
}
if (formData.phone == '') {
uni.showToast({ title: '璇峰~鍐欐墜鏈哄彿鐮?, icon: 'none' })
return
}
if (regionString.value == '') {
uni.showToast({ title: '璇峰~鍐欐墍鍦ㄥ湴鍖?, icon: 'none' })
return
}
if (formData.detail == '') {
uni.showToast({ title: '璇峰~鍐欒缁嗗湴鍧?', icon: 'none' })
return
}
// 绠?鍗曡В鏋愬湴鍖猴紙杩欓噷绠?鍖栧鐞嗭紝瀹為檯搴斾娇鐢ㄩ?夋嫨鍣級
const regions = regionString.value.split(' ')
const province = regions[0] ?? ''
const city = regions[1] ?? ''
const district = regions.slice(2).join(' ')
// 鏋勫缓鍦板潃瀵硅薄
const addressData = {
recipient_name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail_address: formData.detail,
postal_code: '', // 濡傛灉闇?瑕佸彲浠ユ坊鍔犻偖鏀跨紪鐮佸瓧娈?
is_default: formData.isDefault,
label: formData.label
} as AddAddressParams
let success = false
if (isEdit.value) {
// 鏇存柊鍦板潃
const updateData = {
recipient_name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail_address: formData.detail,
postal_code: '',
is_default: formData.isDefault,
label: formData.label
} as UpdateAddressParams
success = await supabaseService.updateAddress(addressId.value, updateData)
} else {
// 娣诲姞鏂板湴鍧?
success = await supabaseService.addAddress(addressData)
}
if (success) {
// 鍚屾椂鏇存柊鏈湴瀛樺偍浣滀负缂撳瓨
const storedAddresses = uni.getStorageSync('addresses')
let addresses: Address[] = []
if (storedAddresses != null) {
try {
addresses = JSON.parse(storedAddresses as string) as Address[]
} catch (e) {
addresses = []
}
}
// 濡傛灉璁句负榛樿锛屽彇娑堝叾浠栭粯璁?
if (formData.isDefault) {
addresses.forEach(item => {
item.isDefault = false
})
}
if (isEdit.value) {
const index = addresses.findIndex(item => item.id === addressId.value)
if (index !== -1) {
addresses[index] = {
...addresses[index],
name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail: formData.detail,
isDefault: formData.isDefault,
label: formData.label
}
}
} else {
const newAddress: Address = {
id: `addr_${Date.now()}`, // 涓存椂ID锛屽疄闄呯敱Supabase鐢熸垚
name: formData.name,
phone: formData.phone,
province: province,
city: city,
district: district,
detail: formData.detail,
isDefault: formData.isDefault,
label: formData.label
}
addresses.push(newAddress)
}
uni.setStorageSync('addresses', JSON.stringify(addresses))
uni.showToast({
title: '淇濆瓨鎴愬姛',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
console.error('淇濆瓨鍦板潃澶辫触')
uni.showToast({
title: '淇濆瓨澶辫触',
icon: 'none'
})
}
}
const parseSmartInput = () => {
const input = smartInput.value.trim()
if (input == '') return
// 鎻愬彇鎵嬫満鍙?
const phoneRegex = /(1[3-9]\d{9})/
const phoneMatch = input.match(phoneRegex)
if (phoneMatch != null) {
formData.phone = phoneMatch[0] ?? ''
}
// 鎻愬彇濮撳悕锛堝彇绗竴涓?-4浣嶄腑鏂囷級
const nameRegex = /([\u4e00-\u9fa5]{2,4})/
const nameMatch = input.match(nameRegex)
if (nameMatch != null) {
formData.name = nameMatch[0] ?? ''
}
// 鍘绘帀濮撳悕鍜岀數璇濆悗鍓╀綑浣滀负鍦板潃
let addrText = input
if (formData.name != '') addrText = addrText.replace(formData.name, '')
if (formData.phone != '') addrText = addrText.replace(formData.phone, '')
addrText = addrText.replace(/[锛?;锛沑s]+/g, ' ').trim()
// 瑙f瀽鐪佸競鍖?
const pattern1 = /^(.*?鐪??(.*?甯??(.*?[鍖哄幙])?(.*)$/
const m = addrText.match(pattern1)
if (m != null) {
const province = m[1] ?? ''
const city = m[2] ?? ''
const district = m[3] ?? ''
const detail = m[4] ?? ''
regionString.value = `${province.trim()} ${city.trim()} ${district.trim()}`.trim()
formData.detail = detail.trim()
} else {
formData.detail = addrText
}
}
const deleteAddress = () => {
uni.showModal({
title: '鎻愮ず',
content: '纭畾瑕佸垹闄よ鍦板潃鍚楋紵',
success: (res: UniShowModalResult) => {
if (res.confirm) {
// 璋冪敤Supabase鏈嶅姟鍒犻櫎鍦板潃
supabaseService.deleteAddress(addressId.value).then((success) => {
if (success) {
// 鍚屾椂浠庢湰鍦板瓨鍌ㄤ腑绉婚櫎
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
let addresses = JSON.parse(storedAddresses as string) as Address[]
addresses = addresses.filter(item => item.id !== addressId.value)
uni.setStorageSync('addresses', JSON.stringify(addresses))
} catch (e) {
console.error('瑙f瀽鏈湴鍦板潃鏁版嵁澶辫触', e)
}
}
uni.showToast({
title: '鍒犻櫎鎴愬姛',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
console.error('鍒犻櫎鍦板潃澶辫触')
uni.showToast({
title: '鍒犻櫎澶辫触',
icon: 'none'
})
}
})
}
}
})
}
</script>
<style>
.page-container {
flex: 1;
display: flex;
flex-direction: column;
}
.address-edit-page {
flex: 1; /* Replace min-height: 100vh */
}
.form-group {
background-color: white;
margin-bottom: 15px;
padding: 0 15px;
}
.form-item {
display: flex;
align-items: center;
border-bottom: 1px solid #f5f5f5;
padding: 15px 0;
}
.form-item:last-child {
border-bottom: none;
}
.label {
width: 80px;
font-size: 15px;
color: #333;
}
.input {
flex: 1;
font-size: 15px;
color: #333;
}
.switch-item {
justify-content: space-between;
}
.tags-container {
flex: 1;
display: flex;
flex-wrap: wrap;
}
.tag-item {
font-size: 12px;
color: #666;
border: 1px solid #ddd;
padding: 4px 12px;
border-radius: 15px;
margin-right: 10px;
}
.tag-item.active {
background-color: #ff5000;
color: white;
border-color: #ff5000;
}
.footer-btn {
margin-top: 30px;
padding: 0 15px;
}
.save-btn {
background-color: #ff5000;
color: white;
border-radius: 25px;
font-size: 16px;
height: 44px;
line-height: 44px;
border: none;
margin-bottom: 15px;
}
.delete-btn {
background-color: white;
color: #333;
border-radius: 25px;
font-size: 16px;
height: 44px;
line-height: 44px;
border: 1px solid #ddd;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<view class="address-list-page">
<view class="address-list">
<view v-if="addresses.length === 0" class="empty-state">
<text class="empty-icon">馃搷</text>
<text class="empty-text">鏆傛棤鏀惰揣鍦板潃</text>
</view>
<view v-else v-for="(item, index) in addresses" :key="item.id" class="address-item" @click="selectAddress(item)">
<view class="item-content">
<view class="item-header">
<text class="user-name">{{ item.name }}</text>
<text class="user-phone">{{ item.phone }}</text>
<text v-if="item.isDefault" class="default-tag">榛樿</text>
<text v-if="item.label" class="label-tag">{{ item.label }}</text>
</view>
<text class="address-text">{{ getFullAddress(item) }}</text>
</view>
<view class="item-actions">
<view class="action-item" @click.stop="editAddress(item.id)">
<text class="action-icon">馃摑</text>
</view>
<view class="action-item" @click.stop="deleteAddress(item.id)">
<text class="action-icon">锟斤笍</text>
</view>
</view>
</view>
</view>
<view class="footer-btn">
<button class="add-btn" @click="addAddress">鏂板缓鏀惰揣鍦板潃</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
type Address = {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
label?: string
}
const addresses = ref<Address[]>([])
const selectionMode = ref<boolean>(false)
let openerEventChannel: any | null = null
onShow(() => {
loadAddresses()
})
onMounted(() => {
try {
const ec = uni.getOpenerEventChannel()
openerEventChannel = ec
ec?.on('setSelectMode', (data: any) => {
if (data && typeof data.selectMode === 'boolean') {
selectionMode.value = data.selectMode
}
})
} catch (e) {
// ignore
}
})
const loadAddresses = () => {
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses) {
try {
addresses.value = JSON.parse(storedAddresses as string) as Address[]
} catch (e) {
console.error('Failed to parse addresses', e)
addresses.value = []
}
} else {
// 鍒濆Mock鏁版嵁
addresses.value = [
{
id: 'addr_001',
name: '寮犱笁',
phone: '13800138000',
province: '鍖椾含甯?,
city: '鍖椾含甯?,
district: '鏈濋槼鍖?,
detail: '涓夐噷灞疭OHO A搴?,
isDefault: true,
label: '鍏徃'
}
]
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
}
}
const getFullAddress = (item: Address): string => {
return `${item.province}${item.city}${item.district} ${item.detail}`
}
const addAddress = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address-edit'
})
}
// 鍒犻櫎鍦板潃
const deleteAddress = (id: string) => {
uni.showModal({
title: '鎻愮ず',
content: '纭畾瑕佸垹闄よ鍦板潃鍚楋紵',
success: (res) => {
if (res.confirm) {
const index = addresses.value.findIndex(addr => addr.id === id)
if (index !== -1) {
addresses.value.splice(index, 1)
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
uni.showToast({
title: '鍒犻櫎鎴愬姛',
icon: 'success'
})
}
}
}
})
}
const editAddress = (id: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/address-edit?id=${id}`
})
}
const selectAddress = (item: Address) => {
if (selectionMode.value && openerEventChannel) {
openerEventChannel.emit('addressSelected', {
id: item.id,
recipient_name: item.name,
phone: item.phone,
province: item.province,
city: item.city,
district: item.district,
detail: item.detail,
is_default: item.isDefault
})
uni.navigateBack()
} else {
editAddress(item.id)
}
}
</script>
<style>
.item-actions {
padding: 10px;
border-left: 1px solid #f0f0f0;
display: flex;
flex-direction: column; /* 绔栧悜鎺掑垪鍥炬爣 */
justify-content: center;
align-items: center;
gap: 15px;
}
.footer-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: white;
padding: 10px 15px;
padding-bottom: calc(10px + env(safe-area-inset-bottom));
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
display: flex;
justify-content: center; /* 灞呬腑鏄剧ず */
align-items: center;
}
.add-btn {
background-color: #ff5000;
color: white;
border-radius: 25px;
font-size: 16px;
height: 44px;
line-height: 44px;
border: none;
width: 100%; /* 榛樿鍗犳弧 */
max-width: 100%;
}
/* 鍝嶅簲寮忓竷灞€浼樺寲 */
@media screen and (min-width: 768px) {
.address-list {
max-width: 800px;
margin: 0 auto;
}
.address-list-page {
background-color: #f5f5f5;
}
.footer-btn {
max-width: 800px;
margin: 0 auto;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
border-radius: 12px 12px 0 0; /* 妗岄潰绔姞鐐瑰渾瑙掓洿缇庤 */
}
.add-btn {
width: 300px; /* 妗岄潰绔檺鍒跺搴?*/
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
<!-- pages/mall/consumer/cart.uvue -->
<template>
<view class="cart-page">
<!-- 鏅鸿兘椤堕儴瀵艰埅鏍?- 涓庢秷鎭〉淇濇寔涓€鑷?-->
<view class="smart-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-container">
<text class="nav-title">璐墿杞?/text>
<view class="nav-actions">
<view class="action-btn" @click="toggleManageMode">
<text class="action-icon">{{ isManageMode ? '鉁? : '鈿欙笍' }}</text>
<text class="action-text">{{ isManageMode ? '瀹屾垚' : '绠$悊' }}</text>
</view>
</view>
</view>
</view>
<!-- 瀵艰埅鏍忓崰浣嶇 - 闇€瑕佸寘鍚玸tatusBarHeight + 瀵艰埅鏍忛珮搴?4px -->
<view class="navbar-placeholder" :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 璐墿杞﹀唴瀹?-->
<scroll-view
:scroll-y="true"
class="cart-content"
:show-scrollbar="false"
:enhanced="true"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,794 @@
<!-- pages/mall/consumer/chat.uvue -->
<template>
<view class="chat-page">
<!-- 聊天头部 -->
<view class="chat-header" :style="{ paddingTop: navPaddingTop }">
<view class="header-back" @click="goBack">
<text class="back-icon">🔙</text>
</view>
<view class="header-info">
<text class="chat-title">{{ headerTitle }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
<text class="action-icon" @click="showMoreActions">⋯</text>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y="true"
class="chat-content"
:scroll-into-view="scrollToView"
:scroll-with-animation="false"
:show-scrollbar="false"
upper-threshold="100"
@scrolltoupper="onScrollToUpper"
>
<!-- 聊天消息列表 -->
<view class="chat-messages">
<!-- 系统消息 -->
<view class="message-item system">
<text class="system-text">客服 小美 已接入,请描述您的问题</text>
</view>
<!-- 时间分割线 -->
<view class="time-divider">
<text class="time-text">今天 14:30</text>
</view>
<!-- 消息项 -->
<view
v-for="message in messages"
:key="message.id"
:class="['message-item', message.type]"
:id="message.viewId"
>
<!-- 对方消息 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
:src="merchantAvatar"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<text class="sender-name">{{ headerTitle }}</text>
<view class="message-bubble">
<text class="message-text">{{ message.content }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
</view>
</view>
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
<text class="message-text">{{ message.content }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
</view>
<image
class="avatar me"
src="/static/default-avatar.png"
mode="aspectFill"
/>
</view>
</view>
</view>
</scroll-view>
<!-- 聊天输入框 -->
<view class="chat-input">
<view class="input-tools">
<text class="tool-icon" @click="showEmojiPicker">😊</text>
<text class="tool-icon" @click="showImagePicker">📷</text>
<text class="tool-icon" @click="showMoreTools"></text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputMessage"
placeholder="请输入消息..."
:focus="inputFocus"
@confirm="sendMessage"
confirm-type="send"
/>
<button
class="send-button"
:class="{ active: inputMessage.trim() }"
@click="sendMessage"
>
发送
</button>
</view>
</view>
<!-- 表情选择器 -->
<scroll-view v-if="showEmoji" class="emoji-picker" direction="vertical">
<view class="emoji-category">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { supabaseService, type ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import type { AkSupaRealtimeChannel } from '@/components/supadb/aksupa.uts'
import { getCurrentUser } from '@/utils/store.uts'
type UiChatMessage = {
id: string
viewId: string
type: string
content: string
time: string
}
// 响应式数据
const messages = ref<UiChatMessage[]>([])
const inputMessage = ref<string>('')
const inputFocus = ref<boolean>(false)
const showEmoji = ref<boolean>(false)
const scrollToView = ref<string>('')
const currentUserId = ref<string>('')
const merchantId = ref<string>('') // 商家ID
const headerTitle = ref<string>('在线客服')
const merchantAvatar = ref<string>('/static/default-shop.png') // 商家头像
const navPaddingTop = ref<string>('30px') // 默认值,包含状态栏高度+原有内边距
const isInitialLoading = ref<boolean>(true)
let realtimeChannel: AkSupaRealtimeChannel | null = null
// 模拟表情列表
const emojiList = ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '']
function scrollToBottom() : void {
if (messages.value.length === 0) return
// 获取最后一条消息的 ID
const lastMsg = messages.value[messages.value.length - 1]
const targetId = 'msg-' + lastMsg.id
// 关键点:在 UVue 安卓端,直接连续赋值可能被合并。
// 我们先清空 ID然后在下一帧赋值确保 scroll-view 监听到变化。
scrollToView.value = ''
// 增加多次尝试,确保在 DOM 彻底完成渲染(包含由于高度计算引起的多次排版)后定位。
setTimeout(() => {
scrollToView.value = targetId
console.log('[scrollToBottom] 发起第一次滚动定位', targetId)
// 二次校准:针对长消息或图片导致的高度变化
setTimeout(() => {
scrollToView.value = ''
setTimeout(() => {
scrollToView.value = targetId
console.log('[scrollToBottom] 二次校准完成:', targetId)
}, 50)
}, 100)
}, 300)
}
function getCurrentTime(): string {
const now = new Date()
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
return ${hours}:
}
function setupRealtimeSubscription(): void {
console.log('开始建立聊天实时订阅...')
console.log('当前用户ID:', currentUserId.value, '商家ID:', merchantId.value)
realtimeChannel = supa.channel('chat-messages-' + Date.now().toString())
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
}, (payload: any) => {
console.log('=== 收到实时订阅回调 ===')
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newMsgAny = payloadObj.get('new')
if (newMsgAny == null) {
console.log('newMsgAny 为空,跳过')
return
}
const newMsg = (newMsgAny instanceof UTSJSONObject) ? (newMsgAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newMsgAny)) as UTSJSONObject)
console.log('收到新消息', newMsg)
const senderId = newMsg.getString('sender_id') ?? ''
const receiverId = newMsg.getString('receiver_id') ?? ''
const msgId = newMsg.getString('id') ?? ''
const content = newMsg.getString('content') ?? ''
console.log('=== 消息详情 ===')
console.log('消息ID:', msgId)
console.log('发送者ID:', senderId)
console.log('接收者ID:', receiverId)
console.log('当前用户ID:', currentUserId.value)
console.log('商家ID:', merchantId.value)
console.log('消息内容:', content)
// 检查消息是否已经在列表中(避免重复)
for (let i = 0; i < messages.value.length; i++) {
if (messages.value[i].id == msgId) {
console.log('消息已存在,跳过')
return
}
}
// 判断消息类型
const isMyMessage = (senderId == currentUserId.value)
const isForMe = (receiverId == currentUserId.value)
const isRelatedToCurrentChat = (senderId == merchantId.value || receiverId == merchantId.value)
console.log('=== 条件判断 ===')
console.log('isMyMessage:', isMyMessage)
console.log('isForMe:', isForMe)
console.log('isRelatedToCurrentChat:', isRelatedToCurrentChat)
// 如果消息与当前聊天无关,跳过
if (!isRelatedToCurrentChat) {
console.log('消息与当前聊天无关,跳过')
return
}
// 如果是自己发送的消息,或者是发给自己的消息,都显示
if (isMyMessage || isForMe) {
const createdAt = newMsg.getString('created_at') ?? new Date().toISOString()
const date = new Date(createdAt)
const timeStr = ${date.getHours().toString().padStart(2, '0')}:
// 生成安全从 viewId
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
const incomingMsg: UiChatMessage = {
id: msgId,
viewId: safeViewId,
type: isMyMessage ? 'sent' : 'received',
content: content,
time: timeStr
}
console.log('=== 添加新消息到列表 ===')
console.log('消息类型:', incomingMsg.type)
console.log('消息内容:', incomingMsg.content)
messages.value.push(incomingMsg)
scrollToBottom()
} else {
console.log('条件不满足,不添加消息')
}
})
.subscribe((status: string, err: any | null) => {
console.log('订阅状态', status)
if (err != null) {
console.log('订阅错误:', err)
}
})
}
async function loadChatHistory(): Promise<void> {
let rawMsgs : ChatMessage[] = []
if (merchantId.value != '') {
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
console.warn("No merchant ID provided for chat")
return
}
// 确保时间顺序是升序(旧的在前,新的在后)
// Supabase 返回的消息如果是降序,我们需要 reverse 过来显示
const sortedRawMsgs = rawMsgs.sort((a, b) => {
const timeA = new Date(a.created_at ?? '').getTime()
const timeB = new Date(b.created_at ?? '').getTime()
return timeA - timeB
})
const uiMessages : UiChatMessage[] = []
for (let i = 0; i < sortedRawMsgs.length; i++) {
const m = sortedRawMsgs[i]
const date = new Date(m.created_at ?? new Date().toISOString())
const timeStr = ${date.getHours().toString().padStart(2, '0')}:
const sender = m.sender_id ?? ''
const msgType = (currentUserId.value != '' && sender == currentUserId.value) ? 'sent' : 'received'
const rawId = (m.id ?? '').toString()
const msgId = rawId != '' ? rawId : Date.now().toString() + i.toString()
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
const uiMsg : UiChatMessage = {
id: msgId,
viewId: safeViewId,
type: msgType,
content: m.content ?? '',
time: timeStr
}
uiMessages.push(uiMsg)
}
messages.value = uiMessages
if (isInitialLoading.value) {
// 增加一点初始化延迟,等待 scroll-view 渲染就绪
setTimeout(() => {
scrollToBottom()
isInitialLoading.value = false
}, 500)
}
}
function onScrollToUpper(e: any): void {
console.log('[onScrollToUpper] 触发加载历史记录')
}
async function loadMerchantInfo(): Promise<void> {
if (merchantId.value == '') return
try {
const response = await supa
.from('ml_shops')
.select('shop_logo, shop_name')
.eq('merchant_id', merchantId.value)
.limit(1)
.execute()
if (response.error != null) {
console.error('[loadMerchantInfo] 获取商家信息失败:', response.error)
return
}
const rawData = response.data
if (rawData == null) return
const rawList = rawData as any[]
if (rawList.length == 0) return
const shopData = rawList[0]
const shopObj = JSON.parse(JSON.stringify(shopData)) as UTSJSONObject
const logo = shopObj.getString('shop_logo')
if (logo != null && logo != '') {
merchantAvatar.value = logo
}
const name = shopObj.getString('shop_name')
if (name != null && name != '' && headerTitle.value == '在线客服') {
headerTitle.value = name
}
} catch (e) {
console.error('[loadMerchantInfo] 获取商家信息异常:', e)
}
}
// 生命周期
onLoad((options: any) => {
// 动态获取状态栏高度
const sysInfo = uni.getSystemInfoSync()
const statusBarH = sysInfo.statusBarHeight
// 状态栏高度 + 10px 原有顶部内边距
navPaddingTop.value = (statusBarH + 10) + 'px'
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const mid = optObj.getString('merchantId') ?? ''
if (mid !== '') {
merchantId.value = mid
}
const mname = optObj.getString('merchantName') ?? ''
if (mname !== '') {
headerTitle.value = mname
}
})
onMounted(() => {
supabaseService.ensureSession().then((uid) => {
if (uid != null) {
currentUserId.value = uid
} else {
getCurrentUser().then((user) => {
if (user != null) {
currentUserId.value = user.id ?? ''
}
})
}
loadMerchantInfo()
loadChatHistory()
setupRealtimeSubscription()
})
})
onUnmounted(() => {
if (realtimeChannel != null) {
supa.removeChannel(realtimeChannel!!)
}
})
const sendMessage = async () => {
const content = inputMessage.value.trim()
if (content == '') return
// 清空输入框
inputMessage.value = ''
// 发送到 Supabase
if (merchantId.value != '') {
console.log('[sendMessage] 开始发送消息到:', merchantId.value)
const success = await supabaseService.sendMessage(merchantId.value, content)
console.log('[sendMessage] 发送结果', success)
if (!success) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
// 不需要手动添加消息,等待实时订阅推送
}
}
// 模拟客服回复 (已禁用,改用 Realtime)
/*
const simulateCustomerReply = async () => {
// ...
}
*/
/* 移除不再使用的 simulateCustomerReply 和 addReceivedMessage */
// 插入表情
function insertEmoji(emoji: string): void {
inputMessage.value += emoji
inputFocus.value = true
}
// 显示表情选择器
function showEmojiPicker(): void {
showEmoji.value = !showEmoji.value
}
// 显示图片选择器
function showImagePicker(): void {
uni.chooseImage({
count: 1,
success: (res) => {
console.log('选择图片:', res.tempFilePaths)
// 这里可以处理图片上传
}
})
}
// 显示更多工具
function showMoreTools(): void {
uni.showActionSheet({
itemList: ['发送位置', '发送文件', '发送语音'],
success: (res) => {
console.log('选择工具:', res.tapIndex)
}
})
}
// 显示更多操作
function showMoreActions(): void {
uni.showActionSheet({
itemList: ['投诉客服', '结束对话', '清除记录'],
success: (res) => {
switch (res.tapIndex) {
case 0:
uni.navigateTo({ url: '/pages/mall/consumer/complaint' })
break
case 1:
uni.showModal({
title: '确认结束',
content: '确定要结束本次对话吗?',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
}
}
})
break
case 2:
uni.showModal({
title: '确认清除',
content: '确定要清除聊天记录吗?',
success: (res) => {
if (res.confirm) {
messages.value = []
}
}
})
break
}
}
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style>
.chat-page {
width: 100%;
height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 聊天头部 */
.chat-header {
background-color: white;
padding: 10px 15px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.header-back {
width: 40px;
}
.back-icon {
font-size: 24px;
color: #333;
}
.header-info {
flex: 1;
text-align: center;
}
.chat-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.chat-status {
font-size: 12px;
color: #34c759;
}
.header-actions .action-icon {
font-size: 20px;
color: #333;
width: 40px;
text-align: right;
}
/* 聊天内容区 */
.chat-content {
flex: 1;
height: 0;
padding: 10px;
padding-bottom: 20px;
box-sizing: border-box;
}
.chat-messages {
display: flex;
flex-direction: column;
padding-bottom: 80px;
}
/* 系统消息 */
.message-item.system {
text-align: center;
margin-bottom: 20px;
}
.system-text {
font-size: 12px;
color: #999;
background-color: #f0f0f0;
padding: 5px 15px;
border-radius: 15px;
}
/* 时间分割线 */
.time-divider {
text-align: center;
margin: 20px 0;
}
.time-text {
font-size: 12px;
color: #999;
background-color: #f0f0f0;
padding: 3px 10px;
border-radius: 10px;
}
/* 消息项 */
.message-wrapper {
display: flex;
flex-direction: row;
margin-bottom: 15px;
}
.message-wrapper.me {
justify-content: flex-end;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 20px;
margin-right: 10px;
flex-shrink: 0;
}
.avatar.me {
margin-right: 0;
margin-left: 10px;
/* order: 2; removed for uni-app-x */
}
.message-content-wrapper {
max-width: 70%;
display: flex;
flex-direction: column;
}
.message-bubble {
background-color: white;
padding: 10px 15px;
border-radius: 12px;
position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
align-self: flex-start; /* 关键:根据内容宽度自适应,不撑满 */
word-wrap: break-word;
word-break: break-all;
}
.message-bubble.me {
background-color: #95ec69;
align-self: flex-end; /* 关键:靠右对齐且宽度自适应 */
border-top-right-radius: 2px;
}
.message-bubble:not(.me) {
border-top-left-radius: 2px;
}
.sender-name {
font-size: 11px;
color: #999;
margin-bottom: 2px;
align-self: flex-start;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
margin-bottom: 5px;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
margin-bottom: 5px;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
}
.message-time {
font-size: 11px;
color: #999;
text-align: right;
}
/* 聊天输入框 */
.chat-input {
background-color: white;
border-top: 1px solid #eee;
padding: 10px 15px;
padding-bottom: 20px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
flex-shrink: 0;
}
.input-tools {
display: flex;
flex-direction: row;
margin-bottom: 10px;
}
.tool-icon {
font-size: 20px;
margin-right: 15px;
color: #666;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.message-input {
flex: 1;
background-color: #f5f5f5;
border-radius: 20px;
padding: 10px 15px;
font-size: 15px;
margin-right: 10px;
min-height: 40px;
max-height: 100px;
}
.send-button {
background-color: #ccc;
color: white;
border: none;
border-radius: 20px;
padding: 8px 20px;
font-size: 14px;
min-width: 60px;
transition: all 0.3s ease;
}
.send-button.active {
background-color: #ff5000;
}
/* 表情选择器 */
.emoji-picker {
background-color: white;
border-top: 1px solid #eee;
padding: 10px;
height: 200px;
position: fixed;
bottom: 80px;
left: 0;
right: 0;
z-index: 99;
}
.emoji-category {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.emoji-item {
font-size: 24px;
padding: 8px;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
}
/* 响应式适配 removed for strict uv-app-x compliance */
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,951 @@
<!-- 閽卞寘椤甸潰 -->
<template>
<view class="wallet-page">
<!-- 椤堕儴鏍?-->
<view class="wallet-header">
<text class="back-btn" @click="goBack">鈥?/text>
<text class="header-title">鎴戠殑閽卞寘</text>
<text class="more-btn" @click="showMoreActions">路路路</text>
</view>
<scroll-view class="wallet-content" scroll-y>
<!-- 浣欓姒傝 -->
<view class="balance-overview">
<text class="balance-label">璐︽埛浣欓</text>
<text class="balance-value">楼{{ balance.toFixed(2) }}</text>
<view class="balance-actions">
<button class="action-btn recharge" @click="recharge">鍏呭€?/button>
<button class="action-btn withdraw" @click="withdraw">鎻愮幇</button>
</view>
</view>
<!-- 璧勪骇缁熻 -->
<view class="assets-stats">
<view class="stat-item">
<text class="stat-label">绱鍏呭€?/text>
<text class="stat-value">楼{{ stats.totalRecharge.toFixed(2) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">绱娑堣垂</text>
<text class="stat-value">楼{{ stats.totalConsume.toFixed(2) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">绱鎻愮幇</text>
<text class="stat-value">楼{{ stats.totalWithdraw.toFixed(2) }}</text>
</view>
</view>
<!-- 蹇嵎鍔熻兘 -->
<view class="quick-actions">
<view class="action-grid">
<view class="action-item" @click="goToCoupons">
<text class="action-icon">馃帿</text>
<text class="action-text">浼樻儬鍒?/text>
</view>
<view class="action-item" @click="goToRedPackets">
<text class="action-icon">馃Ё</text>
<text class="action-text">绾㈠寘</text>
</view>
<view class="action-item" @click="goToPoints">
<text class="action-icon">猸?/text>
<text class="action-text">绉垎</text>
</view>
<view class="action-item" @click="goToBankCards">
<text class="action-icon">馃挸</text>
<text class="action-text">閾惰鍗?/text>
</view>
</view>
</view>
<!-- 浜ゆ槗璁板綍 -->
<view class="transactions-section">
<view class="section-header">
<text class="section-title">浜ゆ槗璁板綍</text>
<view class="filter-tabs">
<text :class="['filter-tab', { active: activeFilter === 'all' }]"
@click="changeFilter('all')">鍏ㄩ儴</text>
<text :class="['filter-tab', { active: activeFilter === 'income' }]"
@click="changeFilter('income')">鏀跺叆</text>
<text :class="['filter-tab', { active: activeFilter === 'expense' }]"
@click="changeFilter('expense')">鏀嚭</text>
</view>
</view>
<!-- 绌虹姸鎬?-->
<view v-if="transactions.length === 0 && !isLoading" class="empty-transactions">
<text class="empty-icon">馃挵</text>
<text class="empty-text">鏆傛棤浜ゆ槗璁板綍</text>
<text class="empty-subtext">蹇幓浣跨敤閽卞寘鍔熻兘鍚?/text>
</view>
<!-- 浜ゆ槗鍒楄〃 -->
<view class="transactions-list">
<view v-for="transaction in transactions"
:key="transaction.id"
class="transaction-item">
<view class="transaction-left">
<text class="transaction-icon">{{ getTransactionIcon(transaction.type) }}</text>
<view class="transaction-info">
<text class="transaction-title">{{ getTransactionTitle(transaction.type) }}</text>
<text class="transaction-time">{{ formatTime(transaction.created_at) }}</text>
<text v-if="transaction.remark" class="transaction-remark">{{ transaction.remark }}</text>
</view>
</view>
<view class="transaction-right">
<text :class="['transaction-amount',
{ income: transaction.amount > 0, expense: transaction.amount < 0 }]">
{{ transaction.amount > 0 ? '+' : '' }}楼{{ Math.abs(transaction.amount).toFixed(2) }}
</text>
<text class="transaction-balance">浣欓: 楼{{ transaction.current_balance.toFixed(2) }}</text>
</view>
</view>
</view>
<!-- 鍔犺浇鏇村 -->
<view v-if="isLoading" class="loading-more">
<text class="loading-text">鍔犺浇涓?..</text>
</view>
<view v-if="!hasMore && transactions.length > 0" class="no-more">
<text class="no-more-text">娌℃湁鏇村璁板綍浜?/text>
</view>
</view>
<!-- 瀹夊叏鎻愮ず -->
<view class="security-tips">
<text class="tip-title">瀹夊叏鎻愮ず</text>
<text class="tip-item">1. 璇峰Ε鍠勪繚绠℃偍鐨勬敮浠樺瘑鐮?/text>
<text class="tip-item">2. 涓嶈鍚戜粬浜洪€忛湶鎮ㄧ殑璐︽埛淇℃伅</text>
<text class="tip-item">3. 瀹氭湡淇敼瀵嗙爜浠ョ‘淇濊处鎴峰畨鍏?/text>
</view>
</scroll-view>
<!-- 鍏呭€煎脊绐?-->
<view v-if="showRechargePopup" class="recharge-popup">
<view class="popup-mask" @click="closeRechargePopup"></view>
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">鍏呭€?/text>
<text class="popup-close" @click="closeRechargePopup">脳</text>
</view>
<view class="popup-body">
<text class="amount-label">鍏呭€奸噾棰?/text>
<view class="amount-input">
<text class="currency-symbol">楼</text>
<input class="amount-field"
v-model="rechargeAmount"
type="number"
placeholder="璇疯緭鍏ュ厖鍊奸噾棰?
focus />
</view>
<view class="quick-amounts">
<text v-for="amount in quickAmounts"
:key="amount"
:class="['quick-amount', { active: rechargeAmount === amount.toString() }]"
@click="selectQuickAmount(amount)">
楼{{ amount }}
</text>
</view>
<text class="recharge-tip">鍗曠瑪鍏呭€兼渶浣?0鍏冿紝鏈€楂?000鍏?/text>
</view>
<view class="popup-footer">
<button class="cancel-btn" @click="closeRechargePopup">鍙栨秷</button>
<button class="confirm-btn"
:class="{ disabled: !canRecharge }"
@click="confirmRecharge">
纭鍏呭€?
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed, watch } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type WalletType = {
id: string
user_id: string
balance: number
total_recharge: number
total_consume: number
total_withdraw: number
updated_at: string
}
type TransactionType = {
id: string
user_id: string
change_amount: number
current_balance: number
change_type: string // 'recharge' | 'consume' | 'withdraw' | 'refund' | 'reward'
related_id: string | null
remark: string | null
created_at: string
}
type StatsType = {
totalRecharge: number
totalConsume: number
totalWithdraw: number
}
const balance = ref<number>(0)
const stats = ref<StatsType>({
totalRecharge: 0,
totalConsume: 0,
totalWithdraw: 0
})
const transactions = ref<Array<TransactionType>>([])
const activeFilter = ref<string>('all')
const isLoading = ref<boolean>(false)
const currentPage = ref<number>(1)
const pageSize = ref<number>(20)
const hasMore = ref<boolean>(true)
const showRechargePopup = ref<boolean>(false)
const rechargeAmount = ref<string>('')
const quickAmounts = [50, 100, 200, 500, 1000]
// 璁$畻灞炴€?
const canRecharge = computed(() => {
const amount = parseFloat(rechargeAmount.value)
return !isNaN(amount) && amount >= 10 && amount <= 5000
})
// 鐩戝惉杩囨护鍣ㄥ彉鍖?
watch(activeFilter, () => {
resetTransactions()
loadTransactions()
})
// 鐢熷懡鍛ㄦ湡
onMounted(() => {
loadWalletData()
})
// 閲嶇疆浜ゆ槗璁板綍
const resetTransactions = () => {
transactions.value = []
currentPage.value = 1
hasMore.value = true
}
// 鍔犺浇閽卞寘鏁版嵁
const loadWalletData = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.navigateTo({
url: '/pages/user/login'
})
return
}
await Promise.all([
loadBalance(),
loadTransactions()
])
}
// 鍔犺浇浣欓淇℃伅
const loadBalance = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
const { data, error } = await supa
.from('user_wallets')
.select('*')
.eq('user_id', userId)
.single()
if (error !== null) {
console.error('鍔犺浇閽卞寘澶辫触:', error)
return
}
if (data) {
balance.value = data.balance || 0
stats.value = {
totalRecharge: data.total_recharge || 0,
totalConsume: data.total_consume || 0,
totalWithdraw: data.total_withdraw || 0
}
}
} catch (err) {
console.error('鍔犺浇閽卞寘寮傚父:', err)
}
}
// 鍔犺浇浜ゆ槗璁板綍
const loadTransactions = async (loadMore: boolean = false) => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
isLoading.value = true
try {
const userId = getCurrentUserId()
if (!userId) return
const page = loadMore ? currentPage.value + 1 : 1
let query = supa
.from('balance_records')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
// 鏍规嵁杩囨护鍣ㄧ瓫閫?
if (activeFilter.value === 'income') {
query = query.gt('change_amount', 0)
} else if (activeFilter.value === 'expense') {
query = query.lt('change_amount', 0)
}
// 鍒嗛〉
query = query.range((page - 1) * pageSize.value, page * pageSize.value - 1)
const { data, error } = await query
if (error !== null) {
console.error('鍔犺浇浜ゆ槗璁板綍澶辫触:', error)
return
}
const newTransactions = data || []
if (loadMore) {
transactions.value.push(...newTransactions)
currentPage.value = page
} else {
transactions.value = newTransactions
currentPage.value = 1
}
hasMore.value = newTransactions.length === pageSize.value
} catch (err) {
console.error('鍔犺浇浜ゆ槗璁板綍寮傚父:', err)
} finally {
isLoading.value = false
}
}
// 鑾峰彇褰撳墠鐢ㄦ埛ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 鑾峰彇浜ゆ槗鍥炬爣
const getTransactionIcon = (type: string): string => {
const icons: Record<string, string> = {
recharge: '馃挸',
consume: '馃洅',
withdraw: '馃彟',
refund: '馃攧',
reward: '馃巵',
income: '馃挵',
expense: '馃摛'
}
return icons[type] || '馃挵'
}
// 鑾峰彇浜ゆ槗鏍囬
const getTransactionTitle = (type: string): string => {
const titles: Record<string, string> = {
recharge: '璐︽埛鍏呭€?,
consume: '鍟嗗搧娑堣垂',
withdraw: '浣欓鎻愮幇',
refund: '璁㈠崟閫€娆?,
reward: '娲诲姩濂栧姳',
income: '鏀跺叆',
expense: '鏀嚭'
}
return titles[type] || '浜ゆ槗'
}
// 鏍煎紡鍖栨椂闂?
const formatTime = (timeStr: string): string => {
const date = new Date(timeStr)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// 鏄剧ず鏇村鎿嶄綔
const showMoreActions = () => {
uni.showActionSheet({
itemList: ['浜ゆ槗璁板綍', '瀹夊叏璁剧疆', '甯姪涓績'],
success: (res) => {
switch (res.tapIndex) {
case 0:
// 浜ゆ槗璁板綍宸茬粡鍦ㄥ綋鍓嶉〉
break
case 1:
uni.navigateTo({
url: '/pages/mall/consumer/settings'
})
break
case 2:
uni.navigateTo({
url: '/pages/info/help'
})
break
}
}
})
}
// 鍏呭€?
const recharge = () => {
showRechargePopup.value = true
rechargeAmount.value = ''
}
// 鎻愮幇
const withdraw = () => {
uni.navigateTo({
url: '/pages/mall/consumer/withdraw'
})
}
// 璺宠浆鍒颁紭鎯犲埜
const goToCoupons = () => {
uni.navigateTo({
url: '/pages/mall/consumer/coupons'
})
}
// 璺宠浆鍒扮孩鍖?
const goToRedPackets = () => {
uni.navigateTo({
url: '/pages/mall/consumer/red-packets'
})
}
// 璺宠浆鍒扮Н鍒?
const goToPoints = () => {
uni.navigateTo({
url: '/pages/mall/consumer/points'
})
}
// 璺宠浆鍒伴摱琛屽崱
const goToBankCards = () => {
uni.navigateTo({
url: '/pages/mall/consumer/bank-cards'
})
}
// 鍒囨崲杩囨护鍣?
const changeFilter = (filter: string) => {
activeFilter.value = filter
}
// 鍔犺浇鏇村
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
loadTransactions(true)
}
}
// 閫夋嫨蹇嵎閲戦
const selectQuickAmount = (amount: number) => {
rechargeAmount.value = amount.toString()
}
// 纭鍏呭€?
const confirmRecharge = async () => {
if (!canRecharge.value) return
const amount = parseFloat(rechargeAmount.value)
if (isNaN(amount)) return
// 杩欓噷搴旇璺宠浆鍒版敮浠橀〉闈㈣繘琛屽厖鍊?
uni.navigateTo({
url: `/pages/mall/consumer/payment?type=recharge&amount=${amount}`
})
closeRechargePopup()
}
// 鍏抽棴鍏呭€煎脊绐?
const closeRechargePopup = () => {
showRechargePopup.value = false
rechargeAmount.value = ''
}
// 杩斿洖
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.wallet-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.wallet-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.more-btn {
color: #333333;
font-size: 20px;
padding: 5px;
}
.wallet-content {
flex: 1;
}
.balance-overview {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px 20px;
color: #ffffff;
}
.balance-label {
display: block;
font-size: 14px;
opacity: 0.9;
margin-bottom: 10px;
text-align: center;
}
.balance-value {
display: block;
font-size: 36px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
}
.balance-actions {
display: flex;
gap: 20px;
}
.action-btn {
flex: 1;
height: 40px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
border: none;
}
.action-btn.recharge {
background-color: #ffffff;
color: #667eea;
}
.action-btn.withdraw {
background-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.5);
}
.assets-stats {
background-color: #ffffff;
padding: 20px;
display: flex;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-item {
flex: 1;
text-align: center;
}
.stat-label {
display: block;
font-size: 12px;
color: #666666;
margin-bottom: 8px;
}
.stat-value {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
}
.quick-actions {
background-color: #ffffff;
margin-top: 10px;
padding: 20px;
}
.action-grid {
display: flex;
justify-content: space-between;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
}
.action-icon {
font-size: 28px;
margin-bottom: 8px;
}
.action-text {
font-size: 12px;
color: #666666;
}
.transactions-section {
background-color: #ffffff;
margin-top: 10px;
padding: 15px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.filter-tabs {
display: flex;
gap: 15px;
}
.filter-tab {
font-size: 14px;
color: #666666;
padding: 5px 0;
position: relative;
}
.filter-tab.active {
color: #007aff;
font-weight: bold;
}
.filter-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #007aff;
}
.empty-transactions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.empty-icon {
font-size: 60px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
}
.transactions-list {
display: flex;
flex-direction: column;
}
.transaction-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.transaction-item:last-child {
border-bottom: none;
}
.transaction-left {
display: flex;
align-items: flex-start;
}
.transaction-icon {
font-size: 24px;
margin-right: 15px;
}
.transaction-info {
display: flex;
flex-direction: column;
}
.transaction-title {
font-size: 14px;
color: #333333;
font-weight: bold;
margin-bottom: 5px;
}
.transaction-time {
font-size: 12px;
color: #999999;
margin-bottom: 3px;
}
.transaction-remark {
font-size: 12px;
color: #666666;
}
.transaction-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.transaction-amount {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.transaction-amount.income {
color: #4caf50;
}
.transaction-amount.expense {
color: #333333;
}
.transaction-balance {
font-size: 12px;
color: #999999;
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.security-tips {
background-color: #ffffff;
margin-top: 10px;
padding: 20px;
}
.tip-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.tip-item {
display: block;
font-size: 12px;
color: #666666;
line-height: 1.6;
margin-bottom: 8px;
}
.tip-item:last-child {
margin-bottom: 0;
}
.recharge-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.popup-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.popup-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
padding: 20px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e5e5e5;
}
.popup-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.popup-close {
font-size: 24px;
color: #999999;
padding: 5px;
}
.popup-body {
margin-bottom: 20px;
}
.amount-label {
display: block;
font-size: 14px;
color: #333333;
margin-bottom: 10px;
}
.amount-input {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.currency-symbol {
font-size: 20px;
color: #333333;
margin-right: 10px;
}
.amount-field {
flex: 1;
font-size: 24px;
font-weight: bold;
color: #333333;
}
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.quick-amount {
padding: 8px 15px;
border: 1px solid #e5e5e5;
border-radius: 15px;
font-size: 14px;
color: #333333;
}
.quick-amount.active {
background-color: #007aff;
color: #ffffff;
border-color: #007aff;
}
.recharge-tip {
display: block;
font-size: 12px;
color: #999999;
}
.popup-footer {
display: flex;
gap: 15px;
}
.cancel-btn,
.confirm-btn {
flex: 1;
height: 45px;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
border: none;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666666;
}
.confirm-btn {
background-color: #007aff;
color: #ffffff;
}
.confirm-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,985 @@
<!-- 閽卞寘椤甸潰 -->
<template>
<view class="wallet-page">
<!-- 椤堕儴鏍?-->
<!--<view class="wallet-header">
<text class="back-btn" @click="goBack">鈥?/text>
</view>-->
<scroll-view class="wallet-content" scroll-y>
<!-- 浣欓姒傝 -->
<view class="balance-overview">
<text class="balance-label">璐︽埛浣欓</text>
<text class="balance-value">楼{{ balance.toFixed(2) }}</text>
<view class="balance-actions">
<button class="action-btn recharge" @click="recharge">鍏呭€?/button>
<button class="action-btn withdraw" @click="withdraw">鎻愮幇</button>
</view>
</view>
<!-- 璧勪骇缁熻 -->
<view class="assets-stats">
<view class="stat-item">
<text class="stat-label">绱鍏呭€?/text>
<text class="stat-value">楼{{ stats.totalRecharge.toFixed(2) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">绱娑堣垂</text>
<text class="stat-value">楼{{ stats.totalConsume.toFixed(2) }}</text>
</view>
<view class="stat-item">
<text class="stat-label">绱鎻愮幇</text>
<text class="stat-value">楼{{ stats.totalWithdraw.toFixed(2) }}</text>
</view>
</view>
<!-- 蹇嵎鍔熻兘 -->
<view class="quick-actions">
<view class="action-grid">
<view class="action-item" @click="goToCoupons">
<text class="action-icon">馃帿</text>
<text class="action-text">浼樻儬鍒?/text>
</view>
<view class="action-item" @click="goToRedPackets">
<text class="action-icon">馃Ё</text>
<text class="action-text">绾㈠寘</text>
</view>
<view class="action-item" @click="goToPoints">
<text class="action-icon">猸?/text>
<text class="action-text">绉垎</text>
</view>
<view class="action-item" @click="goToBankCards">
<text class="action-icon">馃挸</text>
<text class="action-text">閾惰鍗?/text>
</view>
</view>
</view>
<!-- 浜ゆ槗璁板綍 -->
<view class="transactions-section">
<view class="section-header">
<text class="section-title">浜ゆ槗璁板綍</text>
<view class="filter-tabs">
<text :class="['filter-tab', { active: activeFilter === 'all' }]"
@click="changeFilter('all')">鍏ㄩ儴</text>
<text :class="['filter-tab', { active: activeFilter === 'income' }]"
@click="changeFilter('income')">鏀跺叆</text>
<text :class="['filter-tab', { active: activeFilter === 'expense' }]"
@click="changeFilter('expense')">鏀嚭</text>
</view>
</view>
<!-- 绌虹姸鎬?-->
<view v-if="transactions.length === 0 && !isLoading" class="empty-transactions">
<text class="empty-icon">馃挵</text>
<text class="empty-text">鏆傛棤浜ゆ槗璁板綍</text>
<text class="empty-subtext">蹇幓浣跨敤閽卞寘鍔熻兘鍚?/text>
</view>
<!-- 浜ゆ槗鍒楄〃 -->
<view class="transactions-list">
<view v-for="transaction in transactions"
:key="transaction.id"
class="transaction-item">
<view class="transaction-left">
<text class="transaction-icon">{{ getTransactionIcon(transaction.type) }}</text>
<view class="transaction-info">
<text class="transaction-title">{{ getTransactionTitle(transaction.type) }}</text>
<text class="transaction-time">{{ formatTime(transaction.created_at) }}</text>
<text v-if="transaction.remark" class="transaction-remark">{{ transaction.remark }}</text>
</view>
</view>
<view class="transaction-right">
<text :class="['transaction-amount',
{ income: transaction.amount > 0, expense: transaction.amount < 0 }]">
{{ transaction.amount > 0 ? '+' : '' }}楼{{ Math.abs(transaction.amount).toFixed(2) }}
</text>
<text class="transaction-balance">浣欓: 楼{{ transaction.current_balance.toFixed(2) }}</text>
</view>
</view>
</view>
<!-- 鍔犺浇鏇村 -->
<view v-if="isLoading" class="loading-more">
<text class="loading-text">鍔犺浇涓?..</text>
</view>
<view v-if="!hasMore && transactions.length > 0" class="no-more">
<text class="no-more-text">娌℃湁鏇村璁板綍浜?/text>
</view>
</view>
<!-- 瀹夊叏鎻愮ず -->
<view class="security-tips">
<text class="tip-title">瀹夊叏鎻愮ず</text>
<text class="tip-item">1. 璇峰Ε鍠勪繚绠℃偍鐨勬敮浠樺瘑鐮?/text>
<text class="tip-item">2. 涓嶈鍚戜粬浜洪€忛湶鎮ㄧ殑璐︽埛淇℃伅</text>
<text class="tip-item">3. 瀹氭湡淇敼瀵嗙爜浠ョ‘淇濊处鎴峰畨鍏?/text>
</view>
</scroll-view>
<!-- 鍏呭€煎脊绐?-->
<view v-if="showRechargePopup" class="recharge-popup">
<view class="popup-mask" @click="closeRechargePopup"></view>
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">鍏呭€?/text>
<text class="popup-close" @click="closeRechargePopup">脳</text>
</view>
<view class="popup-body">
<text class="amount-label">鍏呭€奸噾棰?/text>
<view class="amount-input">
<text class="currency-symbol">楼</text>
<input class="amount-field"
v-model="rechargeAmount"
type="number"
placeholder="璇疯緭鍏ュ厖鍊奸噾棰?
focus />
</view>
<view class="quick-amounts">
<text v-for="amount in quickAmounts"
:key="amount"
:class="['quick-amount', { active: rechargeAmount === amount.toString() }]"
@click="selectQuickAmount(amount)">
楼{{ amount }}
</text>
</view>
<text class="recharge-tip">鍗曠瑪鍏呭€兼渶浣?0鍏冿紝鏈€楂?000鍏?/text>
</view>
<view class="popup-footer">
<button class="cancel-btn" @click="closeRechargePopup">鍙栨秷</button>
<button class="confirm-btn"
:class="{ disabled: !canRecharge }"
@click="confirmRecharge">
纭鍏呭€?
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed, watch } from 'vue'
//import supa from '@/components/supadb/aksupainstance.uts'
type WalletType = {
id: string
user_id: string
balance: number
total_recharge: number
total_consume: number
total_withdraw: number
updated_at: string
}
type TransactionType = {
id: string
user_id: string
change_amount: number
current_balance: number
change_type: string // 'recharge' | 'consume' | 'withdraw' | 'refund' | 'reward'
related_id: string | null
remark: string | null
created_at: string
}
type StatsType = {
totalRecharge: number
totalConsume: number
totalWithdraw: number
}
const balance = ref<number>(0)
const stats = ref<StatsType>({
totalRecharge: 0,
totalConsume: 0,
totalWithdraw: 0
})
const transactions = ref<Array<TransactionType>>([])
const activeFilter = ref<string>('all')
const isLoading = ref<boolean>(false)
const currentPage = ref<number>(1)
const pageSize = ref<number>(20)
const hasMore = ref<boolean>(true)
const showRechargePopup = ref<boolean>(false)
const rechargeAmount = ref<string>('')
const quickAmounts = [50, 100, 200, 500, 1000]
// 璁$畻灞炴€?
const canRecharge = computed(() => {
const amount = parseFloat(rechargeAmount.value)
return !isNaN(amount) && amount >= 10 && amount <= 5000
})
// 鐩戝惉杩囨护鍣ㄥ彉鍖?
watch(activeFilter, () => {
resetTransactions()
loadTransactions()
})
// 鐢熷懡鍛ㄦ湡
onMounted(() => {
loadWalletData()
})
// 閲嶇疆浜ゆ槗璁板綍
const resetTransactions = () => {
transactions.value = []
currentPage.value = 1
hasMore.value = true
}
// 鍔犺浇閽卞寘鏁版嵁
const loadWalletData = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.navigateTo({
url: '/pages/user/login'
})
return
}
await Promise.all([
loadBalance(),
loadTransactions()
])
}
// 鍔犺浇浣欓淇℃伅
const loadBalance = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
const { data, error } = await supa
.from('user_wallets')
.select('*')
.eq('user_id', userId)
.single()
if (error !== null) {
console.error('鍔犺浇閽卞寘澶辫触:', error)
return
}
if (data) {
balance.value = data.balance || 0
stats.value = {
totalRecharge: data.total_recharge || 0,
totalConsume: data.total_consume || 0,
totalWithdraw: data.total_withdraw || 0
}
}
} catch (err) {
console.error('鍔犺浇閽卞寘寮傚父:', err)
}
}
// 鍔犺浇浜ゆ槗璁板綍
const loadTransactions = async (loadMore: boolean = false) => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
isLoading.value = true
try {
const userId = getCurrentUserId()
if (!userId) return
const page = loadMore ? currentPage.value + 1 : 1
let query = supa
.from('balance_records')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
// 鏍规嵁杩囨护鍣ㄧ瓫閫?
if (activeFilter.value === 'income') {
query = query.gt('change_amount', 0)
} else if (activeFilter.value === 'expense') {
query = query.lt('change_amount', 0)
}
// 鍒嗛〉
query = query.range((page - 1) * pageSize.value, page * pageSize.value - 1)
const { data, error } = await query
if (error !== null) {
console.error('鍔犺浇浜ゆ槗璁板綍澶辫触:', error)
return
}
const newTransactions = data || []
if (loadMore) {
transactions.value.push(...newTransactions)
currentPage.value = page
} else {
transactions.value = newTransactions
currentPage.value = 1
}
hasMore.value = newTransactions.length === pageSize.value
} catch (err) {
console.error('鍔犺浇浜ゆ槗璁板綍寮傚父:', err)
} finally {
isLoading.value = false
}
}
// 鑾峰彇褰撳墠鐢ㄦ埛ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 鑾峰彇浜ゆ槗鍥炬爣
const getTransactionIcon = (type: string): string => {
const icons: Record<string, string> = {
recharge: '馃挸',
consume: '馃洅',
withdraw: '馃彟',
refund: '馃攧',
reward: '馃巵',
income: '馃挵',
expense: '馃摛'
}
return icons[type] || '馃挵'
}
// 鑾峰彇浜ゆ槗鏍囬
const getTransactionTitle = (type: string): string => {
const titles: Record<string, string> = {
recharge: '璐︽埛鍏呭€?,
consume: '鍟嗗搧娑堣垂',
withdraw: '浣欓鎻愮幇',
refund: '璁㈠崟閫€娆?,
reward: '娲诲姩濂栧姳',
income: '鏀跺叆',
expense: '鏀嚭'
}
return titles[type] || '浜ゆ槗'
}
// 鏍煎紡鍖栨椂闂?
const formatTime = (timeStr: string): string => {
const date = new Date(timeStr)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// 鏄剧ず鏇村鎿嶄綔
const showMoreActions = () => {
uni.showActionSheet({
itemList: ['浜ゆ槗璁板綍', '瀹夊叏璁剧疆', '甯姪涓績'],
success: (res) => {
switch (res.tapIndex) {
case 0:
// 浜ゆ槗璁板綍宸茬粡鍦ㄥ綋鍓嶉〉
break
case 1:
uni.navigateTo({
url: '/pages/mall/consumer/settings'
})
break
case 2:
uni.navigateTo({
url: '/pages/info/help'
})
break
}
}
})
}
// 鍏呭€?
const recharge = () => {
showRechargePopup.value = true
rechargeAmount.value = ''
}
// 鎻愮幇
const withdraw = () => {
uni.navigateTo({
url: '/pages/mall/consumer/withdraw'
})
}
// 璺宠浆鍒颁紭鎯犲埜
const goToCoupons = () => {
uni.navigateTo({
url: '/pages/mall/consumer/coupons'
})
}
// 璺宠浆鍒扮孩鍖?
const goToRedPackets = () => {
uni.navigateTo({
url: '/pages/mall/consumer/red-packets'
})
}
// 璺宠浆鍒扮Н鍒?
const goToPoints = () => {
uni.navigateTo({
url: '/pages/mall/consumer/points'
})
}
// 璺宠浆鍒伴摱琛屽崱
const goToBankCards = () => {
uni.navigateTo({
url: '/pages/mall/consumer/bank-cards'
})
}
// 鍒囨崲杩囨护鍣?
const changeFilter = (filter: string) => {
activeFilter.value = filter
}
// 鍔犺浇鏇村
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
loadTransactions(true)
}
}
// 閫夋嫨蹇嵎閲戦
const selectQuickAmount = (amount: number) => {
rechargeAmount.value = amount.toString()
}
// 纭鍏呭€?
const confirmRecharge = async () => {
if (!canRecharge.value) return
const amount = parseFloat(rechargeAmount.value)
if (isNaN(amount)) return
// 杩欓噷搴旇璺宠浆鍒版敮浠橀〉闈㈣繘琛屽厖鍊?
uni.navigateTo({
url: `/pages/mall/consumer/payment?type=recharge&amount=${amount}`
})
closeRechargePopup()
}
// 鍏抽棴鍏呭€煎脊绐?
const closeRechargePopup = () => {
showRechargePopup.value = false
rechargeAmount.value = ''
}
// 杩斿洖
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
/* 鍝嶅簲寮忓竷灞€浼樺寲 */
@media screen and (min-width: 768px) {
.wallet-content {
padding: 20px;
background-color: #f5f5f5;
}
.balance-overview {
border-radius: 12px;
margin-bottom: 20px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.assets-stats, .quick-actions, .transactions-section, .security-tips {
border-radius: 8px;
margin-bottom: 20px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.popup-content {
width: 400px;
left: 50%;
bottom: 50%;
transform: translate(-50%, 50%);
border-radius: 15px;
}
}
@media screen and (min-width: 1024px) {
.wallet-page {
flex-direction: row; /* 澶у睆涓嬫敼涓烘í鍚戝竷灞€ */
}
.wallet-header {
display: none; /* 澶у睆涓嬮殣钘忛《閮ㄦ爮 */
}
.wallet-content {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
}
.wallet-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.wallet-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
}
.wallet-content {
flex: 1;
}
.balance-overview {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px 20px;
color: #ffffff;
}
.balance-label {
display: block;
font-size: 14px;
opacity: 0.9;
margin-bottom: 10px;
text-align: center;
}
.balance-value {
display: block;
font-size: 36px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
}
.balance-actions {
display: flex;
gap: 20px;
}
.action-btn {
flex: 1;
height: 40px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
border: none;
}
.action-btn.recharge {
background-color: #ffffff;
color: #667eea;
}
.action-btn.withdraw {
background-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.5);
}
.assets-stats {
background-color: #ffffff;
padding: 20px;
display: flex;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-item {
flex: 1;
text-align: center;
}
.stat-label {
display: block;
font-size: 12px;
color: #666666;
margin-bottom: 8px;
}
.stat-value {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
}
.quick-actions {
background-color: #ffffff;
margin-top: 10px;
padding: 20px;
}
.action-grid {
display: flex;
justify-content: space-between;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
}
.action-icon {
font-size: 28px;
margin-bottom: 8px;
}
.action-text {
font-size: 12px;
color: #666666;
}
.transactions-section {
background-color: #ffffff;
margin-top: 10px;
padding: 15px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.filter-tabs {
display: flex;
gap: 15px;
}
.filter-tab {
font-size: 14px;
color: #666666;
padding: 5px 0;
position: relative;
}
.filter-tab.active {
color: #007aff;
font-weight: bold;
}
.filter-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #007aff;
}
.empty-transactions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.empty-icon {
font-size: 60px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
}
.transactions-list {
display: flex;
flex-direction: column;
}
.transaction-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.transaction-item:last-child {
border-bottom: none;
}
.transaction-left {
display: flex;
align-items: flex-start;
}
.transaction-icon {
font-size: 24px;
margin-right: 15px;
}
.transaction-info {
display: flex;
flex-direction: column;
}
.transaction-title {
font-size: 14px;
color: #333333;
font-weight: bold;
margin-bottom: 5px;
}
.transaction-time {
font-size: 12px;
color: #999999;
margin-bottom: 3px;
}
.transaction-remark {
font-size: 12px;
color: #666666;
}
.transaction-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.transaction-amount {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.transaction-amount.income {
color: #4caf50;
}
.transaction-amount.expense {
color: #333333;
}
.transaction-balance {
font-size: 12px;
color: #999999;
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.security-tips {
background-color: #ffffff;
margin-top: 10px;
padding: 20px;
}
.tip-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.tip-item {
display: block;
font-size: 12px;
color: #666666;
line-height: 1.6;
margin-bottom: 8px;
}
.tip-item:last-child {
margin-bottom: 0;
}
.recharge-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.popup-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.popup-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
padding: 20px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e5e5e5;
}
.popup-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.popup-close {
font-size: 24px;
color: #999999;
padding: 5px;
}
.popup-body {
margin-bottom: 20px;
}
.amount-label {
display: block;
font-size: 14px;
color: #333333;
margin-bottom: 10px;
}
.amount-input {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.currency-symbol {
font-size: 20px;
color: #333333;
margin-right: 10px;
}
.amount-field {
flex: 1;
font-size: 24px;
font-weight: bold;
color: #333333;
}
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.quick-amount {
padding: 8px 15px;
border: 1px solid #e5e5e5;
border-radius: 15px;
font-size: 14px;
color: #333333;
}
.quick-amount.active {
background-color: #007aff;
color: #ffffff;
border-color: #007aff;
}
.recharge-tip {
display: block;
font-size: 12px;
color: #999999;
}
.popup-footer {
display: flex;
gap: 15px;
}
.cancel-btn,
.confirm-btn {
flex: 1;
height: 45px;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
border: none;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666666;
}
.confirm-btn {
background-color: #007aff;
color: #ffffff;
}
.confirm-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
</style>