feat: 初次提交我的项目代码

This commit is contained in:
2026-01-22 17:07:39 +08:00
parent 75fad97d5d
commit 73498128dd
39 changed files with 21439 additions and 835 deletions

9
.hbuilderx/launch.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version" : "1.0",
"configurations" : [
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

View File

@@ -4,8 +4,8 @@
// export const SUPA_KEY: string = 'your-anon-key' // export const SUPA_KEY: string = 'your-anon-key'
// 生产环境 - Supabase 云服务 // 生产环境 - Supabase 云服务
export const SUPA_URL: string = 'https://ak3.oulog.com' //export const SUPA_URL: string = 'https://ak3.oulog.com'
export const SUPA_KEY: string = 'your-anon-key' //export const SUPA_KEY: string = 'your-anon-key'
// WebSocket 实时连接 // WebSocket 实时连接
export const WS_URL: string = 'wss://ak3.oulog.com/realtime/v1/websocket' export const WS_URL: string = 'wss://ak3.oulog.com/realtime/v1/websocket'

View File

@@ -0,0 +1,18 @@
//import AkSupa from './aksupa.uts'
//import { SUPA_URL, SUPA_KEY } from '@/ak/config.uts'
//const supa = new AkSupa(SUPA_URL, SUPA_KEY)
//const supaReady: Promise<boolean> = (async () => {
/// try {
// await supa.signIn('akoo@163.com', 'Hf2152111')
// await supa.signIn('am@163.com', 'kookoo')
// return true
// } catch (err) {
// console.error('Supabase auto sign-in failed', err)
// return false
// }
//})()
//export { supaReady }
//export default supa

View File

@@ -1,18 +1,31 @@
import AkSupa from './aksupa.uts' // /components/supadb/aksupainstance.uts
import { SUPA_URL, SUPA_KEY } from '@/ak/config.uts' import { createClient } from './aksupa.uts'
const supa = new AkSupa(SUPA_URL, SUPA_KEY) // 创建并导出 Supabase 客户端实例
const supabaseUrl = 'https://your-project.supabase.co' // 替换为你的 Supabase URL
const supabaseAnonKey = 'your-anon-key' // 替换为你的匿名密钥
const supaReady: Promise<boolean> = (async () => { export const supabase = createClient(supabaseUrl, supabaseAnonKey)
try {
// await supa.signIn('akoo@163.com', 'Hf2152111')
await supa.signIn('am@163.com', 'kookoo')
return true
} catch (err) {
console.error('Supabase auto sign-in failed', err)
return false
}
})()
export { supaReady } // 导出 Supabase 实例就绪状态
export default supa export const isSupabaseReady = true
// 如果有其他需要导出的函数,可以这样导出:
export function initializeSupabase(url: string, key: string) {
return createClient(url, key)
}
// 检查连接状态的函数
export function checkConnection() {
return new Promise((resolve) => {
// 模拟连接检查
setTimeout(() => {
resolve(true)
}, 500)
})
}
// 不再使用 supaready 变量,而是提供函数
export async function ensureSupabaseReady() {
return await checkConnection()
}

14
main - 副本.uts Normal file
View File

@@ -0,0 +1,14 @@
import { createSSRApp } from 'vue'
import App from './App.uvue'
import i18n from '@/uni_modules/i18n/index.uts'
export function createApp() {
const app = createSSRApp(App)
// 注册 i18n 全局属性,使组件可以使用 $t 方法
app.config.globalProperties.$t = (key: string, values?: any, locale?: string): string => {
return i18n.global.t(key, values, locale)
}
return { app }
}

View File

@@ -6,9 +6,13 @@ export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
// 注册 i18n 全局属性,使组件可以使用 $t 方法 // 注册 i18n 全局属性,使组件可以使用 $t 方法
app.config.globalProperties.$t = (key: string, values?: any, locale?: string): string => { app.config.globalProperties.$t = (key: string, values?: any, locale?: string): string => {
return i18n.global.t(key, values, locale) if (!i18n.global) {
} console.error('i18n is not initialized')
return key
}
return i18n.global.t(key, values, locale) || key
}
return { app } return { app }
} }

118
pages - 副本 (2).json Normal file
View File

@@ -0,0 +1,118 @@
{
"pages": [
// 消费者端页面
{
"path": "pages/mall/consumer/index",
"style": {
"navigationBarTitleText": "商城首页",
"enablePullDownRefresh": true
}
},
{
"path": "pages/mall/consumer/category",
"style": {
"navigationBarTitleText": "商品分类"
}
},
{
"path": "pages/mall/consumer/search",
"style": {
"navigationBarTitleText": "搜索商品",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/consumer/product-detail",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "pages/mall/consumer/cart",
"style": {
"navigationBarTitleText": "购物车"
}
},
{
"path": "pages/mall/consumer/orders",
"style": {
"navigationBarTitleText": "我的订单",
"enablePullDownRefresh": true
}
},
{
"path": "pages/mall/consumer/order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/mall/consumer/address",
"style": {
"navigationBarTitleText": "收货地址"
}
},
{
"path": "pages/mall/consumer/address-edit",
"style": {
"navigationBarTitleText": "编辑地址"
}
},
{
"path": "pages/mall/consumer/coupons",
"style": {
"navigationBarTitleText": "我的优惠券"
}
},
{
"path": "pages/mall/consumer/favorites",
"style": {
"navigationBarTitleText": "我的收藏"
}
},
{
"path": "pages/mall/consumer/profile",
"style": {
"navigationBarTitleText": "个人中心"
}
}
],
"tabBar": {
"color": "#999999",
"selectedColor": "#007aff",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/mall/consumer/index",
"text": "首页",
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-active.png"
},
{
"pagePath": "pages/mall/consumer/category",
"text": "分类",
"iconPath": "static/tab-category.png",
"selectedIconPath": "static/tab-category-active.png"
},
{
"pagePath": "pages/mall/consumer/cart",
"text": "购物车",
"iconPath": "static/tab-cart.png",
"selectedIconPath": "static/tab-cart-active.png"
},
{
"pagePath": "pages/mall/consumer/orders",
"text": "订单",
"iconPath": "static/tab-order.png",
"selectedIconPath": "static/tab-order-active.png"
},
{
"pagePath": "pages/mall/consumer/profile",
"text": "我的",
"iconPath": "static/tab-profile.png",
"selectedIconPath": "static/tab-profile-active.png"
}
]
}
}

213
pages - 副本.json Normal file
View File

@@ -0,0 +1,213 @@
{
"pages": [
{
"path": "pages/mall/consumer/index",
"style": {
"navigationBarTitleText": "商城首页",
"navigationStyle": "custom"
}
},
{
"path": "pages/user/boot",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/user/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/user/register",
"style": {
"navigationBarTitleText": "注册"
}
},
{
"path": "pages/user/forgot-password",
"style": {
"navigationBarTitleText": "忘记密码"
}
},
{
"path": "pages/user/center",
"style": {
"navigationBarTitleText": "用户中心"
}
},
{
"path": "pages/user/profile",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/user/terms",
"style": {
"navigationBarTitleText": "用户协议与隐私政策"
}
},
{
"path": "pages/mall/merchant/index",
"style": {
"navigationBarTitleText": "商家中心",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/delivery/index",
"style": {
"navigationBarTitleText": "配送中心",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/admin/index",
"style": {
"navigationBarTitleText": "管理后台",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/service/index",
"style": {
"navigationBarTitleText": "客服工作台",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/analytics/index",
"style": {
"navigationBarTitleText": "数据分析",
"navigationStyle": "custom"
}
}
],
"subPackages": [
{
"root": "pages/mall",
"pages": [
{
"path": "consumer/product-detail",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "consumer/order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "consumer/profile",
"style": {
"navigationBarTitleText": "个人中心"
}
},
{
"path": "consumer/subscription/plan-list",
"style": {
"navigationBarTitleText": "软件订阅"
}
},
{
"path": "consumer/subscription/plan-detail",
"style": {
"navigationBarTitleText": "订阅详情"
}
},
{
"path": "consumer/subscription/subscribe-checkout",
"style": {
"navigationBarTitleText": "确认订阅"
}
},
{
"path": "consumer/subscription/my-subscriptions",
"style": {
"navigationBarTitleText": "我的订阅"
}
},
{
"path": "admin/subscription/plan-management",
"style": {
"navigationBarTitleText": "订阅方案管理"
}
},
{
"path": "admin/subscription/user-subscriptions",
"style": {
"navigationBarTitleText": "用户订阅管理"
}
},
{
"path": "nfc/security/index",
"style": {
"navigationBarTitleText": "安保工作台",
"enablePullDownRefresh": true,
"backgroundColor": "#f8f9fa"
}
}
]
}
],
"tabBar": {
"custom": true,
"color": "#7A7E83",
"selectedColor": "#3cc51f",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/mall/consumer/index",
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-current.png",
"text": "首页"
},
{
"pagePath": "pages/mall/consumer/category",
"iconPath": "static/tab-category.png",
"selectedIconPath": "static/tab-category-current.png",
"text": "分类"
},
{
"pagePath": "pages/mall/consumer/cart",
"iconPath": "static/tab-cart.png",
"selectedIconPath": "static/tab-cart-current.png",
"text": "购物车"
},
{
"pagePath": "pages/mall/consumer/profile",
"iconPath": "static/tab-profile.png",
"selectedIconPath": "static/tab-profile-current.png",
"text": "我的"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "mall",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8F8F8"
},
"condition": {
"current": 0,
"list": [
{
"name": "消费者端首页",
"path": "pages/mall/consumer/index"
},
{
"name": "启动页(登录态判断)",
"path": "pages/user/boot"
},
{
"name": "登录页",
"path": "pages/user/login"
}
]
}
}

View File

@@ -3,210 +3,72 @@
{ {
"path": "pages/mall/consumer/index", "path": "pages/mall/consumer/index",
"style": { "style": {
"navigationBarTitleText": "商城首页", "navigationBarTitleText": "首页",
"navigationStyle": "custom" "navigationStyle": "custom",
"enablePullDownRefresh": true
} }
}, },
{ {
"path": "pages/user/boot", "path": "pages/mall/consumer/category",
"style": { "style": {
"navigationBarTitleText": "" "navigationBarTitleText": "分类"
} }
}, },
{ {
"path": "pages/user/login", "path": "pages/mall/consumer/messages",
"style": { "style": {
"navigationBarTitleText": "登录" "navigationBarTitleText": "消息",
"enablePullDownRefresh": true
} }
}, },
{ {
"path": "pages/user/register", "path": "pages/mall/consumer/cart",
"style": { "style": {
"navigationBarTitleText": "注册" "navigationBarTitleText": "购物车"
} }
}, },
{ {
"path": "pages/user/forgot-password", "path": "pages/mall/consumer/profile",
"style": { "style": {
"navigationBarTitleText": "忘记密码" "navigationBarTitleText": "我的"
} }
},
{
"path": "pages/user/center",
"style": {
"navigationBarTitleText": "用户中心"
}
},
{
"path": "pages/user/profile",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/user/terms",
"style": {
"navigationBarTitleText": "用户协议与隐私政策"
}
},
{
"path": "pages/mall/merchant/index",
"style": {
"navigationBarTitleText": "商家中心",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/delivery/index",
"style": {
"navigationBarTitleText": "配送中心",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/admin/index",
"style": {
"navigationBarTitleText": "管理后台",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/service/index",
"style": {
"navigationBarTitleText": "客服工作台",
"navigationStyle": "custom"
}
},
{
"path": "pages/mall/analytics/index",
"style": {
"navigationBarTitleText": "数据分析",
"navigationStyle": "custom"
}
}
],
"subPackages": [
{
"root": "pages/mall",
"pages": [
{
"path": "consumer/product-detail",
"style": {
"navigationBarTitleText": "商品详情"
}
},
{
"path": "consumer/order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "consumer/profile",
"style": {
"navigationBarTitleText": "个人中心"
}
},
{
"path": "consumer/subscription/plan-list",
"style": {
"navigationBarTitleText": "软件订阅"
}
},
{
"path": "consumer/subscription/plan-detail",
"style": {
"navigationBarTitleText": "订阅详情"
}
},
{
"path": "consumer/subscription/subscribe-checkout",
"style": {
"navigationBarTitleText": "确认订阅"
}
},
{
"path": "consumer/subscription/my-subscriptions",
"style": {
"navigationBarTitleText": "我的订阅"
}
},
{
"path": "admin/subscription/plan-management",
"style": {
"navigationBarTitleText": "订阅方案管理"
}
},
{
"path": "admin/subscription/user-subscriptions",
"style": {
"navigationBarTitleText": "用户订阅管理"
}
},
{
"path": "nfc/security/index",
"style": {
"navigationBarTitleText": "安保工作台",
"enablePullDownRefresh": true,
"backgroundColor": "#f8f9fa"
}
}
]
} }
], ],
"tabBar": { "tabBar": {
"custom": true, "color": "#999999",
"color": "#7A7E83", "selectedColor": "#ff5000",
"selectedColor": "#3cc51f",
"borderStyle": "black",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [ "list": [
{ {
"pagePath": "pages/mall/consumer/index", "pagePath": "pages/mall/consumer/index",
"iconPath": "static/tab-home.png", "text": "首页",
"selectedIconPath": "static/tab-home-current.png", "iconPath": "static/tabbar/home.png",
"text": "首页" "selectedIconPath": "static/tabbar/home-active.png"
}, },
{ {
"pagePath": "pages/mall/consumer/category", "pagePath": "pages/mall/consumer/category",
"iconPath": "static/tab-category.png", "text": "分类",
"selectedIconPath": "static/tab-category-current.png", "iconPath": "static/tabbar/category.png",
"text": "分类" "selectedIconPath": "static/tabbar/category-active.png"
},
{
"pagePath": "pages/mall/consumer/messages",
"text": "消息",
"iconPath": "static/tabbar/messages.png",
"selectedIconPath": "static/tabbar/messages-active.png"
}, },
{ {
"pagePath": "pages/mall/consumer/cart", "pagePath": "pages/mall/consumer/cart",
"iconPath": "static/tab-cart.png", "text": "购物车",
"selectedIconPath": "static/tab-cart-current.png", "iconPath": "static/tabbar/cart.png",
"text": "购物车" "selectedIconPath": "static/tabbar/cart-active.png"
}, },
{ {
"pagePath": "pages/mall/consumer/profile", "pagePath": "pages/mall/consumer/profile",
"iconPath": "static/tab-profile.png", "text": "我的",
"selectedIconPath": "static/tab-profile-current.png", "iconPath": "static/tabbar/profile.png",
"text": "我的" "selectedIconPath": "static/tabbar/profile-active.png"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "mall",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8F8F8"
},
"condition": {
"current": 0,
"list": [
{
"name": "消费者端首页",
"path": "pages/mall/consumer/index"
},
{
"name": "启动页(登录态判断)",
"path": "pages/user/boot"
},
{
"name": "登录页",
"path": "pages/user/login"
} }
] ]
} }

View File

@@ -0,0 +1,793 @@
<!-- 地址编辑页面 -->
<template>
<view class="address-edit-page">
<!-- 顶部栏 -->
<view class="edit-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">{{ addressId ? '编辑地址' : '新增地址' }}</text>
<text v-if="addressId" class="delete-btn" @click="deleteAddress">删除</text>
</view>
<scroll-view class="edit-content" scroll-y>
<!-- 表单 -->
<view class="form-section">
<view class="form-item">
<text class="item-label">收货人</text>
<input class="item-input"
v-model="formData.recipient_name"
placeholder="请输入收货人姓名" />
</view>
<view class="form-item">
<text class="item-label">手机号码</text>
<input class="item-input"
v-model="formData.phone"
placeholder="请输入手机号码"
type="number"
maxlength="11" />
</view>
<view class="form-item" @click="showRegionPicker">
<text class="item-label">所在地区</text>
<text class="item-value">{{ regionText || '请选择省市区' }}</text>
<text class="item-arrow"></text>
</view>
<view class="form-item">
<text class="item-label">详细地址</text>
<textarea class="item-textarea"
v-model="formData.detail"
placeholder="请输入详细地址,如街道、小区、楼栋号、单元室等"
maxlength="100" />
</view>
<view class="form-item">
<text class="item-label">邮政编码</text>
<input class="item-input"
v-model="formData.postal_code"
placeholder="请输入邮政编码"
type="number"
maxlength="6" />
</view>
<view class="form-item">
<view class="default-switch">
<text class="switch-label">设为默认地址</text>
<switch :checked="formData.is_default"
@change="toggleDefault" />
</view>
</view>
</view>
<!-- 地址簿 -->
<view v-if="addressList.length > 0" class="address-book">
<view class="section-title">地址簿</view>
<view v-for="address in addressList"
:key="address.id"
class="book-item"
@click="fillFromAddressBook(address)">
<view class="book-info">
<text class="book-name">{{ address.recipient_name }}</text>
<text class="book-phone">{{ address.phone }}</text>
</view>
<text class="book-address">{{ getFullAddress(address) }}</text>
</view>
</view>
</scroll-view>
<!-- 保存按钮 -->
<view class="save-btn-container">
<button class="save-btn" @click="saveAddress">保存地址</button>
</view>
<!-- 地区选择器 -->
<view v-if="showPicker" class="region-picker">
<view class="picker-mask" @click="hideRegionPicker"></view>
<view class="picker-content">
<view class="picker-header">
<text class="cancel-btn" @click="hideRegionPicker">取消</text>
<text class="picker-title">选择地区</text>
<text class="confirm-btn" @click="confirmRegion">确定</text>
</view>
<picker-view class="picker-view"
:value="pickerValue"
@change="onPickerChange">
<picker-view-column>
<view v-for="(province, index) in provinces"
:key="index"
class="picker-item">
{{ province.name }}
</view>
</picker-view-column>
<picker-view-column>
<view v-for="(city, index) in cities"
:key="index"
class="picker-item">
{{ city.name }}
</view>
</picker-view-column>
<picker-view-column>
<view v-for="(district, index) in districts"
:key="index"
class="picker-item">
{{ district.name }}
</view>
</picker-view-column>
</picker-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type AddressType = {
id?: string
user_id: string
recipient_name: string
phone: string
province: string
city: string
district: string
detail: string
postal_code: string | null
is_default: boolean
}
type RegionType = {
code: string
name: string
children?: RegionType[]
}
const addressId = ref<string>('')
const formData = ref<AddressType>({
user_id: '',
recipient_name: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
postal_code: null,
is_default: false
})
const addressList = ref<Array<AddressType>>([])
const showPicker = ref<boolean>(false)
const provinces = ref<Array<RegionType>>([])
const cities = ref<Array<RegionType>>([])
const districts = ref<Array<RegionType>>([])
const pickerValue = ref<Array<number>>([0, 0, 0])
const selectedRegion = ref<{
province: RegionType | null
city: RegionType | null
district: RegionType | null
}>({
province: null,
city: null,
district: null
})
// 获取地区文本
const regionText = computed(() => {
const { province, city, district } = selectedRegion.value
if (province && city && district) {
return `${province.name} ${city.name} ${district.name}`
}
return ''
})
// 生命周期
onMounted(() => {
loadAddresses()
loadRegions()
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as any
if (options.id) {
addressId.value = options.id
loadAddressDetail(options.id)
}
})
// 加载地址列表
const loadAddresses = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
const { data, error } = await supa
.from('user_addresses')
.select('*')
.eq('user_id', userId)
.order('is_default', { ascending: false })
.limit(5)
if (error !== null) {
console.error('加载地址列表失败:', error)
return
}
addressList.value = data ?? []
} catch (err) {
console.error('加载地址列表异常:', err)
}
}
// 加载地址详情
const loadAddressDetail = async (id: string) => {
try {
const { data, error } = await supa
.from('user_addresses')
.select('*')
.eq('id', id)
.single()
if (error !== null) {
console.error('加载地址详情失败:', error)
return
}
formData.value = data
// 设置选中的地区
selectedRegion.value = {
province: { code: '', name: formData.value.province },
city: { code: '', name: formData.value.city },
district: { code: '', name: formData.value.district }
}
} catch (err) {
console.error('加载地址详情异常:', err)
}
}
// 加载地区数据
const loadRegions = () => {
// 这里应该从API加载地区数据这里使用模拟数据
provinces.value = [
{ code: '110000', name: '北京市' },
{ code: '310000', name: '上海市' },
{ code: '440000', name: '广东省' },
{ code: '330000', name: '浙江省' },
{ code: '320000', name: '江苏省' }
]
// 默认加载第一个省份的城市
if (provinces.value.length > 0) {
loadCities(provinces.value[0].code)
}
}
// 加载城市
const loadCities = (provinceCode: string) => {
// 模拟城市数据
const cityMap: Record<string, RegionType[]> = {
'110000': [
{ code: '110100', name: '北京市' }
],
'310000': [
{ code: '310100', name: '上海市' }
],
'440000': [
{ code: '440100', name: '广州市' },
{ code: '440300', name: '深圳市' },
{ code: '440600', name: '佛山市' }
],
'330000': [
{ code: '330100', name: '杭州市' },
{ code: '330200', name: '宁波市' },
{ code: '330300', name: '温州市' }
],
'320000': [
{ code: '320100', name: '南京市' },
{ code: '320200', name: '无锡市' },
{ code: '320500', name: '苏州市' }
]
}
cities.value = cityMap[provinceCode] || []
// 加载第一个城市的区县
if (cities.value.length > 0) {
loadDistricts(cities.value[0].code)
}
}
// 加载区县
const loadDistricts = (cityCode: string) => {
// 模拟区县数据
const districtMap: Record<string, RegionType[]> = {
'110100': [
{ code: '110101', name: '东城区' },
{ code: '110102', name: '西城区' },
{ code: '110105', name: '朝阳区' },
{ code: '110106', name: '丰台区' }
],
'440100': [
{ code: '440103', name: '荔湾区' },
{ code: '440104', name: '越秀区' },
{ code: '440105', name: '海珠区' },
{ code: '440106', name: '天河区' }
],
'330100': [
{ code: '330102', name: '上城区' },
{ code: '330103', name: '下城区' },
{ code: '330104', name: '江干区' },
{ code: '330105', name: '拱墅区' }
]
}
districts.value = districtMap[cityCode] || []
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 获取完整地址
const getFullAddress = (address: AddressType): string => {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 显示地区选择器
const showRegionPicker = () => {
showPicker.value = true
}
// 隐藏地区选择器
const hideRegionPicker = () => {
showPicker.value = false
}
// 选择器变化
const onPickerChange = (event: any) => {
const value = event.detail.value
pickerValue.value = value
// 省份变化
if (value[0] !== pickerValue.value[0]) {
const province = provinces.value[value[0]]
selectedRegion.value.province = province
loadCities(province.code)
pickerValue.value = [value[0], 0, 0]
}
// 城市变化
if (value[1] !== pickerValue.value[1]) {
const city = cities.value[value[1]]
selectedRegion.value.city = city
loadDistricts(city.code)
pickerValue.value = [value[0], value[1], 0]
}
// 区县变化
if (value[2] !== pickerValue.value[2]) {
const district = districts.value[value[2]]
selectedRegion.value.district = district
}
}
// 确认地区选择
const confirmRegion = () => {
const province = provinces.value[pickerValue.value[0]]
const city = cities.value[pickerValue.value[1]]
const district = districts.value[pickerValue.value[2]]
if (province && city && district) {
selectedRegion.value = { province, city, district }
formData.value.province = province.name
formData.value.city = city.name
formData.value.district = district.name
}
hideRegionPicker()
}
// 切换默认地址
const toggleDefault = (event: any) => {
formData.value.is_default = event.detail.value
}
// 从地址簿填充
const fillFromAddressBook = (address: AddressType) => {
formData.value = { ...address }
selectedRegion.value = {
province: { code: '', name: address.province },
city: { code: '', name: address.city },
district: { code: '', name: address.district }
}
}
// 保存地址
const saveAddress = async () => {
// 验证表单
if (!formData.value.recipient_name.trim()) {
uni.showToast({
title: '请输入收货人姓名',
icon: 'none'
})
return
}
if (!formData.value.phone.trim()) {
uni.showToast({
title: '请输入手机号码',
icon: 'none'
})
return
}
if (!/^1[3-9]\d{9}$/.test(formData.value.phone)) {
uni.showToast({
title: '手机号码格式错误',
icon: 'none'
})
return
}
if (!formData.value.province) {
uni.showToast({
title: '请选择所在地区',
icon: 'none'
})
return
}
if (!formData.value.detail.trim()) {
uni.showToast({
title: '请输入详细地址',
icon: 'none'
})
return
}
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '用户信息错误',
icon: 'none'
})
return
}
formData.value.user_id = userId
try {
if (formData.value.is_default) {
// 取消其他默认地址
await supa
.from('user_addresses')
.update({ is_default: false })
.eq('user_id', userId)
.eq('is_default', true)
}
if (addressId.value) {
// 更新地址
const { error } = await supa
.from('user_addresses')
.update(formData.value)
.eq('id', addressId.value)
if (error !== null) {
throw error
}
uni.showToast({
title: '地址更新成功',
icon: 'success'
})
} else {
// 新增地址
const { error } = await supa
.from('user_addresses')
.insert(formData.value)
if (error !== null) {
throw error
}
uni.showToast({
title: '地址添加成功',
icon: 'success'
})
}
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('保存地址失败:', err)
uni.showToast({
title: '保存失败',
icon: 'none'
})
}
}
// 删除地址
const deleteAddress = () => {
uni.showModal({
title: '删除地址',
content: '确定要删除这个地址吗?',
success: async (res) => {
if (res.confirm) {
try {
const { error } = await supa
.from('user_addresses')
.delete()
.eq('id', addressId.value)
if (error !== null) {
throw error
}
uni.showToast({
title: '地址删除成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('删除地址失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.address-edit-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.edit-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;
}
.delete-btn {
color: #ff4757;
font-size: 14px;
padding: 5px;
}
.edit-content {
flex: 1;
}
.form-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 0 15px;
}
.form-item {
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
display: flex;
align-items: center;
}
.form-item:last-child {
border-bottom: none;
}
.item-label {
width: 80px;
font-size: 14px;
color: #333333;
}
.item-input {
flex: 1;
height: 30px;
font-size: 14px;
color: #333333;
}
.item-value {
flex: 1;
font-size: 14px;
color: #666666;
}
.item-arrow {
color: #999999;
font-size: 16px;
margin-left: 10px;
}
.item-textarea {
flex: 1;
min-height: 60px;
font-size: 14px;
color: #333333;
line-height: 1.4;
}
.default-switch {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.switch-label {
font-size: 14px;
color: #333333;
}
.address-book {
background-color: #ffffff;
padding: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.book-item {
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.book-item:last-child {
border-bottom: none;
}
.book-info {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.book-name {
font-size: 14px;
color: #333333;
font-weight: bold;
margin-right: 15px;
}
.book-phone {
font-size: 14px;
color: #666666;
}
.book-address {
font-size: 13px;
color: #666666;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.save-btn-container {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
}
.save-btn {
background-color: #007aff;
color: #ffffff;
height: 50px;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
border: none;
}
.region-picker {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.picker-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.picker-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.picker-header {
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e5e5;
}
.cancel-btn {
color: #999999;
font-size: 14px;
padding: 5px;
}
.picker-title {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.confirm-btn {
color: #007aff;
font-size: 14px;
font-weight: bold;
padding: 5px;
}
.picker-view {
height: 300px;
}
.picker-item {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #333333;
}
</style>

View File

@@ -0,0 +1,474 @@
<!-- 地址管理页面 -->
<template>
<view class="address-page">
<!-- 顶部栏 -->
<view class="address-header">
<view class="header-title">
<text class="title-text">收货地址</text>
</view>
</view>
<!-- 地址列表 -->
<scroll-view class="address-list" scroll-y>
<!-- 地址为空 -->
<view v-if="addressList.length === 0" class="empty-address">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无收货地址</text>
<text class="empty-subtext">点击下方按钮添加地址</text>
</view>
<!-- 地址项 -->
<view v-for="address in addressList" :key="address.id" class="address-item">
<view class="address-info" @click="selectAddress(address)">
<view class="address-header-row">
<text class="address-name">{{ address.recipient_name }}</text>
<text class="address-phone">{{ address.phone }}</text>
<view v-if="address.is_default" class="default-tag">
<text class="tag-text">默认</text>
</view>
</view>
<view class="address-detail">
<text class="detail-text">{{ getFullAddress(address) }}</text>
</view>
</view>
<view class="address-actions">
<view class="action-item" @click="editAddress(address)">
<text class="action-icon">✏️</text>
<text class="action-text">编辑</text>
</view>
<view class="action-item" @click="deleteAddress(address)">
<text class="action-icon">🗑️</text>
<text class="action-text">删除</text>
</view>
<view v-if="!address.is_default" class="action-item" @click="setDefaultAddress(address)">
<text class="action-icon">⭐</text>
<text class="action-text">设为默认</text>
</view>
</view>
</view>
<!-- 从选择页面返回时的提示 -->
<view v-if="fromSelect && addressList.length > 0" class="select-tip">
<text class="tip-text">请选择收货地址</text>
<text class="tip-subtext">或点击下方添加新地址</text>
</view>
</scroll-view>
<!-- 添加地址按钮 -->
<view class="add-address-btn" @click="addNewAddress">
<text class="btn-icon">+</text>
<text class="btn-text">添加新地址</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type AddressType = {
id: string
user_id: string
recipient_name: string
phone: string
province: string
city: string
district: string
detail: string
postal_code: string | null
is_default: boolean
created_at: string
}
const addressList = ref<Array<AddressType>>([])
const fromSelect = ref<boolean>(false)
const selectCallback = ref<any>(null)
// 生命周期
onMounted(() => {
const eventChannel = uni.getEventChannel()
if (eventChannel) {
eventChannel.on('fromSelect', (data: any) => {
fromSelect.value = data.fromSelect || false
selectCallback.value = data.callback
})
}
loadAddresses()
})
// 加载地址列表
const loadAddresses = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
try {
const { data, error } = await supa
.from('user_addresses')
.select('*')
.eq('user_id', userId)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false })
if (error !== null) {
console.error('加载地址失败:', error)
return
}
addressList.value = data ?? []
} catch (err) {
console.error('加载地址异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string | null => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || null
}
// 获取完整地址
const getFullAddress = (address: AddressType): string => {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 选择地址
const selectAddress = (address: AddressType) => {
if (fromSelect.value && selectCallback.value) {
// 返回选择的地址
selectCallback.value(address)
uni.navigateBack()
}
}
// 编辑地址
const editAddress = (address: AddressType) => {
uni.navigateTo({
url: `/pages/mall/consumer/address-edit?id=${address.id}`
})
}
// 删除地址
const deleteAddress = (address: AddressType) => {
uni.showModal({
title: '删除地址',
content: '确定要删除这个收货地址吗?',
success: async (res) => {
if (res.confirm) {
try {
// 如果是默认地址,删除前检查是否还有其他地址
if (address.is_default && addressList.value.length > 1) {
uni.showModal({
title: '提示',
content: '删除默认地址后,系统会自动设置第一个地址为默认地址',
success: async (confirmRes) => {
if (confirmRes.confirm) {
await performDelete(address)
}
}
})
} else {
await performDelete(address)
}
} catch (err) {
console.error('删除地址异常:', err)
}
}
}
})
}
// 执行删除
const performDelete = async (address: AddressType) => {
try {
const { error } = await supa
.from('user_addresses')
.delete()
.eq('id', address.id)
if (error !== null) {
console.error('删除地址失败:', error)
uni.showToast({
title: '删除失败',
icon: 'none'
})
return
}
// 从列表中移除
const index = addressList.value.findIndex(item => item.id === address.id)
if (index !== -1) {
addressList.value.splice(index, 1)
}
// 如果是默认地址被删除,设置第一个地址为默认
if (address.is_default && addressList.value.length > 0) {
const newDefault = addressList.value[0]
await setAsDefault(newDefault)
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('执行删除异常:', err)
}
}
// 设为默认地址
const setDefaultAddress = async (address: AddressType) => {
try {
const userId = getCurrentUserId()
if (!userId) return
// 1. 取消当前所有默认地址
const { error: updateError } = await supa
.from('user_addresses')
.update({ is_default: false })
.eq('user_id', userId)
.eq('is_default', true)
if (updateError !== null) {
console.error('取消默认地址失败:', updateError)
return
}
// 2. 设置新的默认地址
const { error: setError } = await supa
.from('user_addresses')
.update({ is_default: true })
.eq('id', address.id)
if (setError !== null) {
console.error('设置默认地址失败:', setError)
return
}
// 更新本地数据
addressList.value.forEach(item => {
item.is_default = item.id === address.id
})
uni.showToast({
title: '已设为默认地址',
icon: 'success'
})
} catch (err) {
console.error('设置默认地址异常:', err)
}
}
// 设置地址为默认(内部方法)
const setAsDefault = async (address: AddressType) => {
try {
const { error } = await supa
.from('user_addresses')
.update({ is_default: true })
.eq('id', address.id)
if (error !== null) {
console.error('设置默认地址失败:', error)
return
}
address.is_default = true
} catch (err) {
console.error('设置默认地址异常:', err)
}
}
// 添加新地址
const addNewAddress = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address-edit'
})
}
</script>
<style scoped>
.address-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.address-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
}
.header-title {
text-align: center;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.address-list {
flex: 1;
padding: 10px;
}
.empty-address {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
border-radius: 8px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
}
.address-item {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.address-info {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #f5f5f5;
}
.address-header-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.address-name {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-right: 15px;
}
.address-phone {
font-size: 14px;
color: #666666;
margin-right: 10px;
}
.default-tag {
background-color: #ff4757;
padding: 2px 8px;
border-radius: 10px;
}
.tag-text {
color: #ffffff;
font-size: 12px;
}
.address-detail {
font-size: 14px;
color: #333333;
line-height: 1.5;
}
.detail-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.address-actions {
display: flex;
justify-content: flex-end;
gap: 20px;
}
.action-item {
display: flex;
align-items: center;
}
.action-icon {
font-size: 16px;
margin-right: 5px;
}
.action-text {
font-size: 14px;
color: #666666;
}
.select-tip {
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
margin-bottom: 10px;
text-align: center;
}
.tip-text {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 5px;
display: block;
}
.tip-subtext {
font-size: 14px;
color: #999999;
display: block;
}
.add-address-btn {
background-color: #007aff;
margin: 10px;
padding: 15px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon {
color: #ffffff;
font-size: 24px;
margin-right: 10px;
}
.btn-text {
color: #ffffff;
font-size: 16px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,810 @@
<!-- 购物车页面 -->
<template>
<view class="cart-page">
<!-- 顶部栏 -->
<view class="cart-header">
<view class="header-title">
<text class="title-text">购物车</text>
</view>
<view v-if="selectedCount > 0" class="edit-btn" @click="toggleEditMode">
<text class="edit-text">{{ isEditMode ? '完成' : '编辑' }}</text>
</view>
</view>
<!-- 购物车为空 -->
<view v-if="cartItems.length === 0" class="empty-cart">
<text class="empty-icon">🛒</text>
<text class="empty-text">购物车还是空的</text>
<text class="empty-subtext">快去挑选心仪的商品吧</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<!-- 购物车列表 -->
<scroll-view v-else direction="vertical" class="cart-content">
<!-- 商品列表 -->
<view v-for="(item, index) in cartItems" :key="item.id" class="cart-item">
<view class="item-selector" @click="toggleSelectItem(item)">
<view :class="['select-icon', { selected: item.selected }]">
<text v-if="item.selected" class="icon-text">✓</text>
</view>
</view>
<image class="item-image" :src="item.product_image || '/static/default-product.png'" />
<view class="item-info">
<text class="item-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications" class="item-spec">{{ getSpecText(item.sku_specifications) }}</text>
<view class="item-price-row">
<text class="item-price">¥{{ item.price }}</text>
<view class="quantity-control">
<view class="quantity-btn minus" @click="decreaseQuantity(item)">-</view>
<text class="quantity-text">{{ item.quantity }}</text>
<view class="quantity-btn plus" @click="increaseQuantity(item)">+</view>
</view>
</view>
</view>
<view v-if="isEditMode" class="delete-btn" @click="removeItem(item, index)">
<text class="delete-text">删除</text>
</view>
</view>
<!-- 推荐商品 -->
<view v-if="recommendProducts.length > 0" class="recommend-section">
<view class="section-header">
<text class="section-title">猜你喜欢</text>
</view>
<view class="recommend-grid">
<view v-for="product in recommendProducts" :key="product.id" class="recommend-item" @click="viewProduct(product)">
<image class="recommend-image" :src="getProductFirstImage(product)" />
<text class="recommend-name">{{ product.name }}</text>
<text class="recommend-price">¥{{ product.price }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部结算栏 -->
<view v-if="cartItems.length > 0" class="bottom-bar">
<view class="select-all" @click="toggleSelectAll">
<view :class="['all-select-icon', { selected: isAllSelected }]">
<text v-if="isAllSelected" class="icon-text">✓</text>
</view>
<text class="select-all-text">全选</text>
</view>
<view v-if="!isEditMode" class="settlement-info">
<view class="total-price">
<text class="total-label">合计:</text>
<text class="total-value">¥{{ totalPrice.toFixed(2) }}</text>
</view>
<text class="total-desc">已选{{ selectedCount }}件</text>
</view>
<view v-if="isEditMode" class="edit-actions">
<view class="delete-all-btn" @click="removeSelected">
<text class="delete-all-text">删除({{ selectedCount }})</text>
</view>
</view>
<view v-else class="settle-btn" :class="{ disabled: selectedCount === 0 }" @click="goToCheckout">
<text class="settle-text">去结算</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import type { ProductType } from '@/types/mall-types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
type CartItemType = {
id: string
user_id: string
product_id: string
sku_id: string
product_name: string
product_image: string
sku_specifications: any
price: number
quantity: number
selected: boolean
}
const cartItems = ref<Array<CartItemType>>([])
const recommendProducts = ref<Array<ProductType>>([])
const isEditMode = ref<boolean>(false)
const isLoading = ref<boolean>(false)
// 计算属性
const selectedCount = computed(() => {
return cartItems.value.filter(item => item.selected).length
})
const totalPrice = computed(() => {
return cartItems.value
.filter(item => item.selected)
.reduce((total, item) => total + (item.price * item.quantity), 0)
})
const isAllSelected = computed(() => {
return cartItems.value.length > 0 && cartItems.value.every(item => item.selected)
})
// 生命周期
onMounted(() => {
loadCartItems()
loadRecommendProducts()
})
// 加载购物车商品
const loadCartItems = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
try {
const { data, error } = await supa
.from('shopping_cart')
.select(`
id,
user_id,
product_id,
sku_id,
quantity,
products (
name,
price,
images
),
product_skus (
specifications,
price as sku_price
)
`)
.eq('user_id', userId)
if (error !== null) {
console.error('加载购物车失败:', error)
return
}
const items: CartItemType[] = []
const cartData = data ?? []
for (let i = 0; i < cartData.length; i++) {
const item = cartData[i]
const product = item.products as any
const sku = item.product_skus as any
items.push({
id: item.id,
user_id: item.user_id,
product_id: item.product_id,
sku_id: item.sku_id,
product_name: product?.name || '未知商品',
product_image: product?.images?.[0] || '/static/default-product.png',
sku_specifications: sku?.specifications,
price: sku?.sku_price || product?.price || 0,
quantity: item.quantity,
selected: false
})
}
cartItems.value = items
} catch (err) {
console.error('加载购物车异常:', err)
}
}
// 加载推荐商品
const loadRecommendProducts = async () => {
try {
const { data, error } = await supa
.from('products')
.select('*')
.eq('status', 1)
.order('sales', { ascending: false })
.limit(6)
if (error !== null) {
console.error('加载推荐商品失败:', error)
return
}
recommendProducts.value = data ?? []
} catch (err) {
console.error('加载推荐商品异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string | null => {
// 这里应该从全局状态或storage中获取
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || null
}
// 获取商品第一张图片
const getProductFirstImage = (product: ProductType): string => {
return product.images?.[0] || '/static/default-product.png'
}
// 获取规格文本
const getSpecText = (specs: any): string => {
if (!specs) return ''
if (typeof specs === 'object') {
return Object.keys(specs)
.map(key => `${key}: ${specs[key]}`)
.join('; ')
}
return String(specs)
}
// 切换编辑模式
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
}
// 切换商品选择
const toggleSelectItem = (item: CartItemType) => {
item.selected = !item.selected
cartItems.value = [...cartItems.value]
}
// 全选/取消全选
const toggleSelectAll = () => {
const newSelectedState = !isAllSelected.value
cartItems.value.forEach(item => {
item.selected = newSelectedState
})
cartItems.value = [...cartItems.value]
}
// 增加数量
const increaseQuantity = async (item: CartItemType) => {
if (isLoading.value) return
isLoading.value = true
try {
const newQuantity = item.quantity + 1
const { error } = await supa
.from('shopping_cart')
.update({ quantity: newQuantity })
.eq('id', item.id)
if (error !== null) {
console.error('更新数量失败:', error)
uni.showToast({
title: '更新失败',
icon: 'none'
})
return
}
item.quantity = newQuantity
cartItems.value = [...cartItems.value]
} catch (err) {
console.error('更新数量异常:', err)
} finally {
isLoading.value = false
}
}
// 减少数量
const decreaseQuantity = async (item: CartItemType) => {
if (item.quantity <= 1) {
removeItem(item, cartItems.value.indexOf(item))
return
}
if (isLoading.value) return
isLoading.value = true
try {
const newQuantity = item.quantity - 1
const { error } = await supa
.from('shopping_cart')
.update({ quantity: newQuantity })
.eq('id', item.id)
if (error !== null) {
console.error('更新数量失败:', error)
uni.showToast({
title: '更新失败',
icon: 'none'
})
return
}
item.quantity = newQuantity
cartItems.value = [...cartItems.value]
} catch (err) {
console.error('更新数量异常:', err)
} finally {
isLoading.value = false
}
}
// 移除单个商品
const removeItem = async (item: CartItemType, index: number) => {
uni.showModal({
title: '确认删除',
content: '确定要删除这个商品吗?',
success: async (res) => {
if (res.confirm) {
try {
const { error } = await supa
.from('shopping_cart')
.delete()
.eq('id', item.id)
if (error !== null) {
console.error('删除商品失败:', error)
uni.showToast({
title: '删除失败',
icon: 'none'
})
return
}
cartItems.value.splice(index, 1)
cartItems.value = [...cartItems.value]
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('删除商品异常:', err)
}
}
}
})
}
// 移除选中商品
const removeSelected = () => {
const selectedItems = cartItems.value.filter(item => item.selected)
if (selectedItems.length === 0) {
uni.showToast({
title: '请选择要删除的商品',
icon: 'none'
})
return
}
uni.showModal({
title: '批量删除',
content: `确定要删除选中的${selectedItems.length}件商品吗?`,
success: async (res) => {
if (res.confirm) {
const deletePromises = selectedItems.map(item =>
supa
.from('shopping_cart')
.delete()
.eq('id', item.id)
)
try {
await Promise.all(deletePromises)
cartItems.value = cartItems.value.filter(item => !item.selected)
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('批量删除异常:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
}
// 查看商品详情
const viewProduct = (product: ProductType) => {
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?id=${product.id}`
})
}
// 去逛逛
const goShopping = () => {
uni.switchTab({
url: '/pages/mall/consumer/index'
})
}
// 去结算
const goToCheckout = () => {
if (selectedCount.value === 0) {
uni.showToast({
title: '请选择要结算的商品',
icon: 'none'
})
return
}
const selectedItems = cartItems.value.filter(item => item.selected)
const productIds = selectedItems.map(item => ({
product_id: item.product_id,
sku_id: item.sku_id,
quantity: item.quantity
}))
uni.navigateTo({
url: '/pages/mall/consumer/checkout',
success: (res) => {
res.eventChannel.emit('acceptData', {
selectedItems: productIds,
totalAmount: totalPrice.value
})
}
})
}
</script>
<style scoped>
.cart-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.cart-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
flex: 1;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.edit-btn {
padding: 5px 10px;
}
.edit-text {
color: #007aff;
font-size: 14px;
}
.empty-cart {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 20px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.cart-content {
flex: 1;
}
.cart-item {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
display: flex;
align-items: center;
position: relative;
}
.item-selector {
margin-right: 10px;
}
.select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.icon-text {
color: #ffffff;
font-size: 12px;
}
.item-image {
width: 80px;
height: 80px;
border-radius: 5px;
margin-right: 10px;
}
.item-info {
flex: 1;
min-height: 80px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.item-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
}
.item-spec {
font-size: 12px;
color: #999999;
margin-bottom: 10px;
}
.item-price-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-price {
font-size: 16px;
color: #ff4757;
font-weight: bold;
}
.quantity-control {
display: flex;
align-items: center;
border: 1px solid #e5e5e5;
border-radius: 15px;
overflow: hidden;
}
.quantity-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #666666;
background-color: #f8f8f8;
}
.quantity-btn.minus {
border-right: 1px solid #e5e5e5;
}
.quantity-btn.plus {
border-left: 1px solid #e5e5e5;
}
.quantity-text {
width: 40px;
text-align: center;
font-size: 14px;
color: #333333;
}
.delete-btn {
position: absolute;
right: 15px;
bottom: 15px;
padding: 5px 10px;
background-color: #ff4757;
border-radius: 12px;
}
.delete-text {
color: #ffffff;
font-size: 12px;
}
.recommend-section {
background-color: #ffffff;
margin-top: 10px;
padding: 15px;
}
.section-header {
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.recommend-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.recommend-item {
width: 48%;
margin-bottom: 15px;
}
.recommend-image {
width: 100%;
height: 120px;
border-radius: 5px;
margin-bottom: 8px;
}
.recommend-name {
font-size: 13px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.recommend-price {
font-size: 14px;
color: #ff4757;
font-weight: bold;
}
.bottom-bar {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-all {
display: flex;
align-items: center;
}
.all-select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.all-select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.select-all-text {
font-size: 14px;
color: #333333;
}
.settlement-info {
flex: 1;
margin-left: 20px;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.total-price {
display: flex;
align-items: baseline;
margin-bottom: 5px;
}
.total-label {
font-size: 14px;
color: #333333;
}
.total-value {
font-size: 18px;
color: #ff4757;
font-weight: bold;
}
.total-desc {
font-size: 12px;
color: #999999;
}
.edit-actions {
flex: 1;
margin-left: 20px;
display: flex;
justify-content: flex-end;
}
.delete-all-btn {
background-color: #ff4757;
padding: 8px 20px;
border-radius: 15px;
}
.delete-all-text {
color: #ffffff;
font-size: 14px;
}
.settle-btn {
background-color: #007aff;
padding: 10px 30px;
border-radius: 20px;
margin-left: 20px;
}
.settle-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
.settle-text {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,529 @@
<!-- pages/mall/consumer/cart.uvue -->
<template>
<view class="cart-page">
<!-- 顶部标题 -->
<view class="cart-header">
<text class="header-title">购物车</text>
</view>
<!-- 购物车内容 -->
<scroll-view scroll-y class="cart-content">
<!-- 空购物车 -->
<view v-if="!loading && cartItems.length === 0" class="empty-cart">
<text class="empty-icon">🛒</text>
<text class="empty-title">购物车是空的</text>
<text class="empty-desc">快去挑选喜欢的商品吧</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<!-- 购物车商品列表 -->
<view v-else class="cart-list">
<view
v-for="item in cartItems"
:key="item.id"
class="cart-item"
>
<view class="item-select" @click="toggleSelect(item.id)">
<text v-if="item.selected" class="selected-icon">✓</text>
<text v-else class="unselected-icon"></text>
</view>
<image
class="item-image"
:src="item.image"
mode="aspectFill"
@click="navigateToProduct(item)"
/>
<view class="item-info">
<text class="item-name">{{ item.name }}</text>
<text class="item-spec">{{ item.spec }}</text>
<view class="item-footer">
<text class="item-price">¥{{ item.price }}</text>
<view class="quantity-control">
<text class="quantity-btn" @click="decreaseQuantity(item.id)">-</text>
<text class="quantity-value">{{ item.quantity }}</text>
<text class="quantity-btn" @click="increaseQuantity(item.id)">+</text>
</view>
</view>
</view>
</view>
</view>
<!-- 推荐商品 -->
<view v-if="recommendProducts.length > 0" class="recommend-section">
<view class="section-header">
<text class="section-title">猜你喜欢</text>
</view>
<view class="recommend-list">
<view
v-for="product in recommendProducts"
:key="product.id"
class="recommend-item"
@click="navigateToProduct(product)"
>
<image
class="recommend-image"
:src="product.image"
mode="aspectFill"
/>
<text class="recommend-name">{{ product.name }}</text>
<text class="recommend-price">¥{{ product.price }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部结算栏 -->
<view v-if="cartItems.length > 0" class="cart-footer">
<view class="footer-left">
<view class="select-all" @click="toggleSelectAll">
<text v-if="allSelected" class="selected-icon">✓</text>
<text v-else class="unselected-icon"></text>
<text class="select-all-text">全选</text>
</view>
</view>
<view class="footer-right">
<view class="total-info">
<text class="total-text">合计:</text>
<text class="total-price">¥{{ totalPrice }}</text>
</view>
<button class="checkout-btn" @click="goToCheckout">
去结算({{ selectedCount }})
</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
// 响应式数据
const cartItems = ref<any[]>([])
const recommendProducts = ref<any[]>([])
const loading = ref<boolean>(false)
// Mock 购物车数据
const mockCartItems = [
{
id: '1',
name: '无线蓝牙耳机',
price: 299,
image: 'https://picsum.photos/100/100?random=1',
spec: '白色',
quantity: 1,
selected: true
},
{
id: '2',
name: '运动T恤',
price: 79,
image: 'https://picsum.photos/100/100?random=2',
spec: '黑色 L',
quantity: 2,
selected: true
},
{
id: '3',
name: '智能手环',
price: 199,
image: 'https://picsum.photos/100/100?random=3',
spec: '黑色',
quantity: 1,
selected: false
}
]
const mockRecommendProducts = [
{
id: '101',
name: '保温杯',
price: 49,
image: 'https://picsum.photos/100/100?random=4'
},
{
id: '102',
name: '电动牙刷',
price: 89,
image: 'https://picsum.photos/100/100?random=5'
},
{
id: '103',
name: '运动鞋',
price: 199,
image: 'https://picsum.photos/100/100?random=6'
}
]
// 计算属性
const allSelected = computed(() => {
return cartItems.value.length > 0 && cartItems.value.every(item => item.selected)
})
const selectedCount = computed(() => {
return cartItems.value.filter(item => item.selected).reduce((sum, item) => sum + item.quantity, 0)
})
const totalPrice = computed(() => {
return cartItems.value
.filter(item => item.selected)
.reduce((sum, item) => sum + item.price * item.quantity, 0)
.toFixed(2)
})
// 生命周期
onMounted(() => {
loadCartData()
})
// 加载数据
const loadCartData = () => {
loading.value = true
setTimeout(() => {
cartItems.value = [...mockCartItems]
recommendProducts.value = [...mockRecommendProducts]
loading.value = false
}, 500)
}
// 商品操作
const toggleSelect = (itemId: string) => {
const index = cartItems.value.findIndex(item => item.id === itemId)
if (index !== -1) {
cartItems.value[index].selected = !cartItems.value[index].selected
cartItems.value = [...cartItems.value] // 触发响应式更新
}
}
const toggleSelectAll = () => {
const newSelectedState = !allSelected.value
cartItems.value = cartItems.value.map(item => ({
...item,
selected: newSelectedState
}))
}
const increaseQuantity = (itemId: string) => {
const index = cartItems.value.findIndex(item => item.id === itemId)
if (index !== -1) {
cartItems.value[index].quantity++
cartItems.value = [...cartItems.value]
}
}
const decreaseQuantity = (itemId: string) => {
const index = cartItems.value.findIndex(item => item.id === itemId)
if (index !== -1 && cartItems.value[index].quantity > 1) {
cartItems.value[index].quantity--
cartItems.value = [...cartItems.value]
}
}
// 导航函数
const goShopping = () => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
const navigateToProduct = (product: any) => {
uni.navigateTo({ url: `/pages/mall/consumer/product-detail?id=${product.id}` })
}
const goToCheckout = () => {
if (selectedCount.value === 0) {
uni.showToast({
title: '请选择商品',
icon: 'none'
})
return
}
uni.navigateTo({ url: '/pages/mall/consumer/checkout' })
}
</script>
<style>
.cart-page {
width: 100%;
height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 头部 */
.cart-header {
background-color: white;
padding: 15px;
text-align: center;
border-bottom: 1px solid #eee;
}
.header-title {
font-size: 18px;
font-weight: bold;
}
/* 内容区 */
.cart-content {
flex: 1;
padding-bottom: 60px; /* 为底部结算栏留出空间 */
}
/* 空购物车 */
.empty-cart {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 80px;
color: #ddd;
margin-bottom: 20px;
}
.empty-title {
font-size: 18px;
color: #666;
margin-bottom: 10px;
}
.empty-desc {
font-size: 14px;
color: #999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #ff5000;
color: white;
border: none;
border-radius: 25px;
padding: 10px 40px;
font-size: 16px;
}
/* 购物车商品列表 */
.cart-list {
background-color: white;
margin: 10px;
border-radius: 10px;
overflow: hidden;
}
.cart-item {
display: flex;
padding: 15px;
border-bottom: 1px solid #f5f5f5;
align-items: center;
}
.item-select {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.selected-icon {
width: 20px;
height: 20px;
background-color: #ff5000;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.unselected-icon {
width: 20px;
height: 20px;
border: 1px solid #ddd;
border-radius: 50%;
}
.item-image {
width: 80px;
height: 80px;
border-radius: 8px;
margin-right: 15px;
}
.item-info {
flex: 1;
}
.item-name {
font-size: 15px;
color: #333;
margin-bottom: 5px;
display: block;
}
.item-spec {
font-size: 13px;
color: #999;
margin-bottom: 10px;
display: block;
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-price {
font-size: 18px;
color: #ff5000;
font-weight: bold;
}
.quantity-control {
display: flex;
align-items: center;
background-color: #f5f5f5;
border-radius: 15px;
overflow: hidden;
}
.quantity-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #666;
}
.quantity-value {
width: 40px;
text-align: center;
font-size: 15px;
}
/* 推荐商品 */
.recommend-section {
margin: 20px 10px;
background-color: white;
border-radius: 10px;
padding: 15px;
}
.section-header {
margin-bottom: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.recommend-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.recommend-item {
display: flex;
flex-direction: column;
}
.recommend-image {
width: 100%;
height: 100px;
border-radius: 8px;
margin-bottom: 8px;
}
.recommend-name {
font-size: 13px;
color: #333;
margin-bottom: 5px;
line-height: 1.4;
height: 36px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.recommend-price {
font-size: 15px;
color: #ff5000;
font-weight: bold;
}
/* 底部结算栏 */
.cart-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background-color: white;
border-top: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
}
.footer-left {
display: flex;
align-items: center;
}
.select-all {
display: flex;
align-items: center;
}
.select-all-text {
margin-left: 8px;
font-size: 14px;
color: #333;
}
.footer-right {
display: flex;
align-items: center;
}
.total-info {
margin-right: 15px;
text-align: right;
}
.total-text {
font-size: 14px;
color: #333;
margin-right: 5px;
}
.total-price {
font-size: 18px;
color: #ff5000;
font-weight: bold;
}
.checkout-btn {
background-color: #ff5000;
color: white;
border: none;
border-radius: 25px;
padding: 8px 20px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,526 @@
<!-- 商品分类页面 -->
<template>
<view class="category-page">
<!-- 搜索栏 -->
<view class="search-header">
<view class="search-box" @click="goToSearch">
<text class="search-icon">🔍</text>
<text class="search-placeholder">搜索商品</text>
</view>
</view>
<view class="category-container">
<!-- 左侧分类导航 -->
<scroll-view class="category-nav" scroll-y>
<view v-for="category in categories"
:key="category.id"
:class="['nav-item', { active: activeCategoryId === category.id }]"
@click="selectCategory(category)">
<text class="nav-text">{{ category.name }}</text>
</view>
</scroll-view>
<!-- 右侧内容区域 -->
<scroll-view class="category-content" scroll-y>
<!-- 当前分类的banner -->
<view v-if="currentCategory" class="category-banner">
<image class="banner-image" :src="currentCategory.image_url || '/static/default-banner.png'" />
</view>
<!-- 子分类 -->
<view v-if="subCategories.length > 0" class="sub-category-section">
<view class="section-title">{{ currentCategory?.name }}分类</view>
<view class="sub-category-grid">
<view v-for="subCategory in subCategories"
:key="subCategory.id"
class="sub-category-item"
@click="navigateToSubCategory(subCategory)">
<image class="sub-category-icon" :src="subCategory.icon_url || '/static/default-category.png'" />
<text class="sub-category-name">{{ subCategory.name }}</text>
</view>
</view>
</view>
<!-- 品牌专区 -->
<view v-if="brands.length > 0" class="brand-section">
<view class="section-header">
<text class="section-title">品牌推荐</text>
<text class="more-btn" @click="viewAllBrands">更多 </text>
</view>
<scroll-view class="brand-scroll" scroll-x>
<view class="brand-list">
<view v-for="brand in brands"
:key="brand.id"
class="brand-item"
@click="viewBrandProducts(brand)">
<image class="brand-logo" :src="brand.logo_url || '/static/default-brand.png'" />
<text class="brand-name">{{ brand.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 热销商品 -->
<view v-if="hotProducts.length > 0" class="hot-products-section">
<view class="section-title">热销商品</view>
<view class="hot-products-grid">
<view v-for="product in hotProducts"
:key="product.id"
class="product-item"
@click="viewProductDetail(product)">
<image class="product-image" :src="getProductFirstImage(product)" />
<view class="product-info">
<text class="product-name">{{ product.name }}</text>
<view class="product-price-row">
<text class="current-price">¥{{ product.price }}</text>
<text v-if="product.original_price && product.original_price > product.price"
class="original-price">¥{{ product.original_price }}</text>
</view>
<view class="sales-info">
<text class="sales-text">已售{{ product.sales }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="isLoadingProducts" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch } from 'vue'
import type { ProductType } from '@/types/mall-types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
type CategoryType = {
id: string
name: string
parent_id: string | null
icon_url: string | null
image_url: string | null
sort_order: number
is_active: boolean
}
type BrandType = {
id: string
name: string
logo_url: string | null
description: string | null
}
const categories = ref<Array<CategoryType>>([])
const activeCategoryId = ref<string>('')
const currentCategory = ref<CategoryType | null>(null)
const subCategories = ref<Array<CategoryType>>([])
const brands = ref<Array<BrandType>>([])
const hotProducts = ref<Array<ProductType>>([])
const isLoadingProducts = ref<boolean>(false)
// 监听分类切换
watch(activeCategoryId, (newId) => {
if (newId) {
loadCategoryData(newId)
}
})
// 生命周期
onMounted(() => {
loadCategories()
})
// 加载一级分类
const loadCategories = async () => {
try {
const { data, error } = await supa
.from('categories')
.select('*')
.eq('is_active', true)
.is('parent_id', null)
.order('sort_order', { ascending: true })
if (error !== null) {
console.error('加载分类失败:', error)
return
}
categories.value = data ?? []
// 默认选中第一个分类
if (categories.value.length > 0) {
activeCategoryId.value = categories.value[0].id
}
} catch (err) {
console.error('加载分类异常:', err)
}
}
// 加载分类数据
const loadCategoryData = async (categoryId: string) => {
// 1. 获取当前分类信息
try {
const { data: categoryData, error: categoryError } = await supa
.from('categories')
.select('*')
.eq('id', categoryId)
.single()
if (categoryError !== null) {
console.error('加载分类详情失败:', categoryError)
return
}
currentCategory.value = categoryData
// 2. 加载子分类
const { data: subData, error: subError } = await supa
.from('categories')
.select('*')
.eq('parent_id', categoryId)
.eq('is_active', true)
.order('sort_order', { ascending: true })
if (subError !== null) {
console.error('加载子分类失败:', subError)
} else {
subCategories.value = subData ?? []
}
// 3. 加载品牌
const { data: brandData, error: brandError } = await supa
.from('brands')
.select('*')
.eq('is_active', true)
.order('sort_order', { ascending: true })
.limit(8)
if (brandError !== null) {
console.error('加载品牌失败:', brandError)
} else {
brands.value = brandData ?? []
}
// 4. 加载热销商品
loadHotProducts(categoryId)
} catch (err) {
console.error('加载分类数据异常:', err)
}
}
// 加载热销商品
const loadHotProducts = async (categoryId: string) => {
isLoadingProducts.value = true
try {
const { data, error } = await supa
.from('products')
.select('*')
.eq('status', 1)
.eq('category_id', categoryId)
.order('sales', { ascending: false })
.limit(12)
if (error !== null) {
console.error('加载热销商品失败:', error)
return
}
hotProducts.value = data ?? []
} catch (err) {
console.error('加载热销商品异常:', err)
} finally {
isLoadingProducts.value = false
}
}
// 获取商品第一张图片
const getProductFirstImage = (product: ProductType): string => {
return product.images?.[0] || '/static/default-product.png'
}
// 选择分类
const selectCategory = (category: CategoryType) => {
activeCategoryId.value = category.id
}
// 导航到子分类
const navigateToSubCategory = (subCategory: CategoryType) => {
// 可以跳转到子分类的商品列表页
uni.navigateTo({
url: `/pages/mall/consumer/product-list?categoryId=${subCategory.id}&title=${encodeURIComponent(subCategory.name)}`
})
}
// 查看品牌商品
const viewBrandProducts = (brand: BrandType) => {
uni.navigateTo({
url: `/pages/mall/consumer/product-list?brandId=${brand.id}&title=${encodeURIComponent(brand.name)}`
})
}
// 查看商品详情
const viewProductDetail = (product: ProductType) => {
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?id=${product.id}`
})
}
// 查看所有品牌
const viewAllBrands = () => {
uni.navigateTo({
url: '/pages/mall/consumer/brands'
})
}
// 跳转到搜索页
const goToSearch = () => {
uni.navigateTo({
url: '/pages/mall/consumer/search'
})
}
</script>
<style scoped>
.category-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #ffffff;
}
.search-header {
background-color: #ffffff;
padding: 10px 15px;
border-bottom: 1px solid #e5e5e5;
}
.search-box {
background-color: #f8f8f8;
border-radius: 20px;
padding: 10px 15px;
display: flex;
align-items: center;
}
.search-icon {
color: #999999;
margin-right: 8px;
}
.search-placeholder {
color: #999999;
font-size: 14px;
}
.category-container {
flex: 1;
display: flex;
}
.category-nav {
width: 100px;
background-color: #f8f8f8;
border-right: 1px solid #e5e5e5;
}
.nav-item {
padding: 15px 10px;
text-align: center;
border-bottom: 1px solid #e5e5e5;
}
.nav-item.active {
background-color: #ffffff;
color: #007aff;
border-left: 3px solid #007aff;
}
.nav-text {
font-size: 14px;
color: #333333;
}
.nav-item.active .nav-text {
color: #007aff;
font-weight: bold;
}
.category-content {
flex: 1;
}
.category-banner {
padding: 15px;
}
.banner-image {
width: 100%;
height: 120px;
border-radius: 8px;
}
.sub-category-section,
.brand-section,
.hot-products-section {
padding: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.more-btn {
color: #007aff;
font-size: 14px;
}
.sub-category-grid {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
}
.sub-category-item {
width: 33.33%;
padding: 0 5px;
margin-bottom: 15px;
display: flex;
flex-direction: column;
align-items: center;
}
.sub-category-icon {
width: 50px;
height: 50px;
border-radius: 25px;
margin-bottom: 8px;
}
.sub-category-name {
font-size: 12px;
color: #666666;
text-align: center;
}
.brand-scroll {
width: 100%;
white-space: nowrap;
}
.brand-list {
display: inline-flex;
}
.brand-item {
width: 80px;
display: flex;
flex-direction: column;
align-items: center;
margin-right: 15px;
}
.brand-logo {
width: 60px;
height: 60px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid #e5e5e5;
}
.brand-name {
font-size: 12px;
color: #333333;
text-align: center;
}
.hot-products-grid {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
}
.product-item {
width: 50%;
padding: 0 5px;
margin-bottom: 15px;
}
.product-image {
width: 100%;
height: 100px;
border-radius: 5px;
margin-bottom: 8px;
}
.product-info {
padding: 0 5px;
}
.product-name {
font-size: 13px;
color: #333333;
line-height: 1.4;
margin-bottom: 8px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.product-price-row {
display: flex;
align-items: baseline;
margin-bottom: 5px;
}
.current-price {
font-size: 14px;
color: #ff4757;
font-weight: bold;
margin-right: 5px;
}
.original-price {
font-size: 12px;
color: #999999;
text-decoration: line-through;
}
.sales-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.sales-text {
font-size: 11px;
color: #999999;
}
.loading-more {
padding: 20px;
display: flex;
justify-content: center;
}
.loading-text {
color: #999999;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,361 @@
<!-- pages/mall/consumer/category.uvue -->
<template>
<view class="category-page">
<!-- 顶部搜索栏 -->
<view class="search-bar">
<view class="search-box" @click="navigateToSearch">
<text class="search-icon">🔍</text>
<text class="search-placeholder">搜索商品</text>
</view>
</view>
<!-- 分类内容区 -->
<view class="category-content">
<!-- 左侧一级分类 -->
<scroll-view scroll-y class="primary-category">
<view
v-for="item in primaryCategories"
:key="item.id"
:class="['primary-item', { active: activePrimary === item.id }]"
@click="selectPrimaryCategory(item.id)"
>
<text class="primary-name">{{ item.name }}</text>
</view>
</scroll-view>
<!-- 右侧二级分类和商品 -->
<scroll-view scroll-y class="secondary-content">
<!-- 二级分类 -->
<view v-if="secondaryCategories.length > 0" class="secondary-category">
<view
v-for="sub in secondaryCategories"
:key="sub.id"
class="secondary-item"
@click="selectSecondaryCategory(sub.id)"
>
<image
class="sub-category-image"
:src="sub.image"
mode="aspectFill"
/>
<text class="sub-category-name">{{ sub.name }}</text>
</view>
</view>
<!-- 商品列表 -->
<view v-if="productList.length > 0" class="product-list">
<view
v-for="product in productList"
:key="product.id"
class="product-item"
@click="navigateToProduct(product)"
>
<view class="product-image-wrapper">
<image
class="product-image"
:src="product.image"
mode="aspectFill"
/>
</view>
<text class="product-name">{{ product.name }}</text>
<text class="product-price">¥{{ product.price }}</text>
</view>
</view>
<!-- 空状态 -->
<view v-if="!loading && productList.length === 0 && secondaryCategories.length === 0" class="empty-state">
<text class="empty-icon">📁</text>
<text class="empty-text">暂无商品</text>
<button class="reload-btn" @click="loadData">刷新</button>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
// 响应式数据
const primaryCategories = ref<any[]>([])
const secondaryCategories = ref<any[]>([])
const productList = ref<any[]>([])
const activePrimary = ref<string>('1')
const loading = ref<boolean>(false)
// Mock 数据
const mockCategories = {
primary: [
{ id: '1', name: '推荐' },
{ id: '2', name: '女装' },
{ id: '3', name: '男装' },
{ id: '4', name: '鞋靴' },
{ id: '5', name: '箱包' },
{ id: '6', name: '美妆' },
{ id: '7', name: '数码' },
{ id: '8', name: '家电' },
{ id: '9', name: '家居' },
{ id: '10', name: '母婴' },
{ id: '11', name: '食品' },
{ id: '12', name: '运动' },
{ id: '13', name: '汽车' },
{ id: '14', name: '百货' }
],
secondary: {
'1': [
{ id: '101', name: '热销推荐', image: 'https://picsum.photos/100/100?random=1' },
{ id: '102', name: '新品上市', image: 'https://picsum.photos/100/100?random=2' },
{ id: '103', name: '限时特惠', image: 'https://picsum.photos/100/100?random=3' },
{ id: '104', name: '精选好物', image: 'https://picsum.photos/100/100?random=4' }
],
'2': [
{ id: '201', name: '连衣裙', image: 'https://picsum.photos/100/100?random=5' },
{ id: '202', name: 'T恤', image: 'https://picsum.photos/100/100?random=6' },
{ id: '203', name: '裤子', image: 'https://picsum.photos/100/100?random=7' },
{ id: '204', name: '外套', image: 'https://picsum.photos/100/100?random=8' },
{ id: '205', name: '内衣', image: 'https://picsum.photos/100/100?random=9' }
]
},
products: {
'101': [
{ id: '1001', name: '热销商品1', price: 99.9, image: 'https://picsum.photos/150/150?random=10' },
{ id: '1002', name: '热销商品2', price: 199, image: 'https://picsum.photos/150/150?random=11' },
{ id: '1003', name: '热销商品3', price: 299, image: 'https://picsum.photos/150/150?random=12' }
],
'201': [
{ id: '2001', name: '连衣裙1', price: 199, image: 'https://picsum.photos/150/150?random=13' },
{ id: '2002', name: '连衣裙2', price: 299, image: 'https://picsum.photos/150/150?random=14' }
]
}
}
// 生命周期
onMounted(() => {
loadData()
})
// 加载数据
const loadData = () => {
loading.value = true
// 使用模拟数据
setTimeout(() => {
primaryCategories.value = mockCategories.primary
secondaryCategories.value = mockCategories.secondary[activePrimary.value] || []
productList.value = mockCategories.products['101'] || []
loading.value = false
}, 500)
}
// 选择一级分类
const selectPrimaryCategory = (categoryId: string) => {
activePrimary.value = categoryId
secondaryCategories.value = mockCategories.secondary[categoryId] || []
productList.value = mockCategories.products[categoryId + '01'] || []
}
// 选择二级分类
const selectSecondaryCategory = (subCategoryId: string) => {
productList.value = mockCategories.products[subCategoryId] || []
}
// 导航函数
const navigateToSearch = () => {
uni.navigateTo({ url: '/pages/mall/consumer/search' })
}
const navigateToProduct = (product: any) => {
uni.navigateTo({ url: `/pages/mall/consumer/product-detail?id=${product.id}` })
}
</script>
<style>
.category-page {
width: 100%;
height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 搜索栏 */
.search-bar {
padding: 10px 15px;
background-color: white;
border-bottom: 1px solid #eee;
}
.search-box {
background-color: #f5f5f5;
border-radius: 20px;
padding: 10px 15px;
display: flex;
align-items: center;
}
.search-icon {
color: #999;
margin-right: 10px;
}
.search-placeholder {
color: #999;
font-size: 14px;
}
/* 分类内容区 */
.category-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* 左侧一级分类 */
.primary-category {
width: 100px;
background-color: white;
border-right: 1px solid #eee;
}
.primary-item {
padding: 15px 10px;
text-align: center;
border-bottom: 1px solid #f5f5f5;
}
.primary-item.active {
background-color: #fff0e8;
color: #ff5000;
font-weight: bold;
}
.primary-item.active::after {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: #ff5000;
}
.primary-name {
font-size: 14px;
line-height: 1.4;
}
/* 右侧内容区 */
.secondary-content {
flex: 1;
padding: 15px;
background-color: #f5f5f5;
}
/* 二级分类 */
.secondary-category {
background-color: white;
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.secondary-item {
display: flex;
flex-direction: column;
align-items: center;
}
.sub-category-image {
width: 60px;
height: 60px;
border-radius: 30px;
margin-bottom: 8px;
}
.sub-category-name {
font-size: 12px;
color: #333;
text-align: center;
}
/* 商品列表 */
.product-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.product-item {
background-color: white;
border-radius: 8px;
overflow: hidden;
padding: 10px;
}
.product-image-wrapper {
width: 100%;
height: 150px;
overflow: hidden;
border-radius: 6px;
margin-bottom: 8px;
}
.product-image {
width: 100%;
height: 100%;
}
.product-name {
font-size: 13px;
color: #333;
line-height: 1.4;
height: 36px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 5px;
}
.product-price {
font-size: 16px;
color: #ff5000;
font-weight: bold;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 20px;
text-align: center;
}
.empty-icon {
font-size: 60px;
color: #ccc;
margin-bottom: 15px;
}
.empty-text {
font-size: 16px;
color: #999;
margin-bottom: 20px;
}
.reload-btn {
background-color: #ff5000;
color: white;
border: none;
border-radius: 20px;
padding: 8px 25px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,593 @@
<!-- pages/mall/consumer/chat.uvue -->
<template>
<view class="chat-page">
<!-- 聊天头部 -->
<view class="chat-header">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<text class="chat-title">在线客服</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
<text class="action-icon" @click="showMoreActions">⋮</text>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y
class="chat-content"
:scroll-into-view="scrollToView"
scroll-with-animation
>
<!-- 聊天消息列表 -->
<view class="chat-messages">
<!-- 系统消息 -->
<view class="message-item system">
<text class="system-text">客服 小美 已接入,请描述您的问题</text>
</view>
<!-- 时间分割线 -->
<view class="time-divider">
<text>今天 14:30</text>
</view>
<!-- 消息项 -->
<view
v-for="message in messages"
:key="message.id"
:class="['message-item', message.type]"
:id="'msg-' + message.id"
>
<!-- 对方消息 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
src="https://picsum.photos/40/40?random=1"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<text class="sender-name">客服小美</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="https://picsum.photos/40/40?random=2"
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>
<!-- 表情选择器 -->
<view v-if="showEmoji" class="emoji-picker">
<view class="emoji-category">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, nextTick } from 'vue'
// 响应式数据
const messages = ref<any[]>([])
const inputMessage = ref<string>('')
const inputFocus = ref<boolean>(false)
const showEmoji = ref<boolean>(false)
const scrollToView = ref<string>('')
// 模拟表情列表
const emojiList = ['😊', '😂', '🤣', '😍', '😘', '🥰', '😭', '😡', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '⭐']
// Mock 聊天记录
const mockMessages = [
{
id: 1,
type: 'received',
content: '您好,欢迎咨询!有什么可以帮助您的吗?',
time: '14:30'
},
{
id: 2,
type: 'sent',
content: '你好,我昨天下的订单一直没有发货',
time: '14:31'
},
{
id: 3,
type: 'received',
content: '请问您的订单号是多少?我帮您查询一下',
time: '14:32'
},
{
id: 4,
type: 'sent',
content: '订单号是202311230001',
time: '14:33'
},
{
id: 5,
type: 'received',
content: '正在为您查询订单状态...',
time: '14:34'
}
]
// 生命周期
onMounted(() => {
loadChatHistory()
// 模拟客服自动回复
setTimeout(() => {
addReceivedMessage('查询到您的订单正在打包中,预计今天下午发货')
}, 3000)
})
// 加载聊天记录
const loadChatHistory = () => {
messages.value = [...mockMessages]
// 滚动到底部
setTimeout(() => {
scrollToBottom()
}, 100)
}
// 发送消息
const sendMessage = () => {
const content = inputMessage.value.trim()
if (!content) return
// 添加发送的消息
const newMessage = {
id: Date.now(),
type: 'sent',
content: content,
time: getCurrentTime()
}
messages.value.push(newMessage)
inputMessage.value = ''
// 滚动到底部
scrollToBottom()
// 模拟客服回复2秒后
setTimeout(() => {
simulateCustomerReply()
}, 2000)
}
// 模拟客服回复
const simulateCustomerReply = () => {
const replies = [
'好的,已为您记录',
'这个问题需要进一步核实',
'我明白了,马上为您处理',
'请稍等,正在为您查询',
'感谢您的反馈'
]
const randomReply = replies[Math.floor(Math.random() * replies.length)]
addReceivedMessage(randomReply)
}
// 添加接收的消息
const addReceivedMessage = (content: string) => {
const newMessage = {
id: Date.now(),
type: 'received',
content: content,
time: getCurrentTime()
}
messages.value.push(newMessage)
scrollToBottom()
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messages.value.length > 0) {
const lastMsgId = messages.value[messages.value.length - 1].id
scrollToView.value = 'msg-' + lastMsgId
}
})
}
// 获取当前时间
const getCurrentTime = (): string => {
const now = new Date()
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 插入表情
const insertEmoji = (emoji: string) => {
inputMessage.value += emoji
inputFocus.value = true
}
// 显示表情选择器
const showEmojiPicker = () => {
showEmoji.value = !showEmoji.value
}
// 显示图片选择器
const showImagePicker = () => {
uni.chooseImage({
count: 1,
success: (res) => {
console.log('选择图片:', res.tempFilePaths)
// 这里可以处理图片上传
}
})
}
// 显示更多工具
const showMoreTools = () => {
uni.showActionSheet({
itemList: ['发送位置', '发送文件', '发送语音'],
success: (res) => {
console.log('选择工具:', res.tapIndex)
}
})
}
// 显示更多操作
const showMoreActions = () => {
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;
}
/* 聊天头部 */
.chat-header {
background-color: white;
padding: 10px 15px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
}
.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;
display: block;
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;
padding: 15px;
padding-bottom: 70px; /* 为输入区留出空间 */
}
/* 系统消息 */
.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;
display: inline-block;
}
/* 时间分割线 */
.time-divider {
text-align: center;
margin: 20px 0;
}
.time-divider text {
font-size: 12px;
color: #999;
background-color: #f0f0f0;
padding: 3px 10px;
border-radius: 10px;
}
/* 消息项 */
.message-wrapper {
display: flex;
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;
}
.message-content-wrapper {
max-width: 70%;
}
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 5px;
display: block;
}
.message-bubble {
background-color: white;
padding: 10px 15px;
border-radius: 18px;
position: relative;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
max-width: 100%;
word-wrap: break-word;
}
.message-bubble.me {
background-color: #95ec69;
border-bottom-right-radius: 4px;
}
.message-bubble:not(.me) {
border-bottom-left-radius: 4px;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
display: block;
margin-bottom: 5px;
}
.message-time {
font-size: 11px;
color: #999;
display: block;
text-align: right;
}
/* 聊天输入区 */
.chat-input {
background-color: white;
border-top: 1px solid #eee;
padding: 10px 15px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
.input-tools {
display: flex;
margin-bottom: 10px;
}
.tool-icon {
font-size: 20px;
margin-right: 15px;
color: #666;
}
.input-wrapper {
display: flex;
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;
max-height: 200px;
overflow-y: auto;
position: fixed;
bottom: 60px;
left: 0;
right: 0;
z-index: 99;
}
.emoji-category {
display: flex;
flex-wrap: wrap;
}
.emoji-item {
font-size: 24px;
padding: 8px;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
}
/* 响应式适配 */
@media screen and (max-width: 320px) {
.message-content-wrapper {
max-width: 65%;
}
.message-text {
font-size: 14px;
}
}
@media screen and (min-width: 415px) {
.message-content-wrapper {
max-width: 75%;
}
.chat-input {
padding: 15px 20px;
}
}
</style>

View File

@@ -0,0 +1,739 @@
<!-- 结算页面 -->
<template>
<view class="checkout-page">
<!-- 顶部栏 -->
<view class="checkout-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">订单结算</text>
</view>
<scroll-view class="checkout-content" scroll-y>
<!-- 收货地址 -->
<view class="address-section" @click="selectAddress">
<view v-if="selectedAddress" class="address-info">
<view class="address-header">
<text class="recipient">{{ selectedAddress.recipient_name }}</text>
<text class="phone">{{ selectedAddress.phone }}</text>
<view v-if="selectedAddress.is_default" class="default-tag">
<text class="tag-text">默认</text>
</view>
</view>
<text class="address-detail">{{ getFullAddress(selectedAddress) }}</text>
</view>
<view v-else class="no-address">
<text class="no-address-text">请选择收货地址</text>
<text class="no-address-arrow"></text>
</view>
</view>
<!-- 商品列表 -->
<view class="products-section">
<view v-for="item in checkoutItems" :key="item.id" class="product-item">
<image class="product-image" :src="item.product_image" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ item.price }}</text>
<text class="product-quantity">×{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
<!-- 配送方式 -->
<view class="delivery-section">
<text class="section-title">配送方式</text>
<view class="delivery-options">
<view v-for="option in deliveryOptions"
:key="option.id"
:class="['delivery-option', { selected: selectedDelivery === option.id }]"
@click="selectDelivery(option)">
<text class="option-name">{{ option.name }}</text>
<text class="option-price">¥{{ option.price }}</text>
<text v-if="selectedDelivery === option.id" class="option-selected">✓</text>
</view>
</view>
</view>
<!-- 优惠券 -->
<view class="coupon-section" @click="selectCoupon">
<text class="section-title">优惠券</text>
<view class="coupon-info">
<text v-if="selectedCoupon" class="coupon-selected">{{ selectedCoupon.template?.name || '优惠券' }}</text>
<text v-else class="coupon-placeholder">选择优惠券</text>
<text class="coupon-arrow"></text>
</view>
</view>
<!-- 买家留言 -->
<view class="remark-section">
<text class="section-title">买家留言</text>
<textarea class="remark-input"
v-model="remark"
placeholder="选填,请先和商家协商一致"
maxlength="100" />
</view>
<!-- 价格明细 -->
<view class="price-section">
<text class="section-title">价格明细</text>
<view class="price-detail">
<view class="price-row">
<text class="price-label">商品总价</text>
<text class="price-value">¥{{ totalAmount.toFixed(2) }}</text>
</view>
<view class="price-row">
<text class="price-label">运费</text>
<text class="price-value">+¥{{ deliveryFee.toFixed(2) }}</text>
</view>
<view v-if="discountAmount > 0" class="price-row">
<text class="price-label">优惠减免</text>
<text class="price-value discount">-¥{{ discountAmount.toFixed(2) }}</text>
</view>
<view class="price-row total">
<text class="price-label">应付金额</text>
<text class="price-value total-price">¥{{ actualAmount.toFixed(2) }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部结算栏 -->
<view class="bottom-bar">
<view class="price-summary">
<text class="summary-label">合计:</text>
<text class="summary-price">¥{{ actualAmount.toFixed(2) }}</text>
</view>
<button class="submit-btn" @click="submitOrder">提交订单</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type CheckoutItemType = {
id: string
product_id: string
sku_id: string
product_name: string
product_image: string
sku_specifications: any
price: number
quantity: number
}
type DeliveryOptionType = {
id: string
name: string
price: number
description: string
}
type UserCouponType = {
id: string
template: {
name: string
discount_value: number
min_order_amount: number
} | null
}
const checkoutItems = ref<Array<CheckoutItemType>>([])
const selectedAddress = ref<any>(null)
const deliveryOptions = ref<Array<DeliveryOptionType>>([
{ id: 'standard', name: '快递配送', price: 8.00, description: '1-3天送达' },
{ id: 'express', name: '加急配送', price: 15.00, description: '当天送达' }
])
const selectedDelivery = ref<string>('standard')
const selectedCoupon = ref<UserCouponType | null>(null)
const remark = ref<string>('')
// 计算属性
const totalAmount = computed(() => {
return checkoutItems.value.reduce((sum, item) =>
sum + (item.price * item.quantity), 0)
})
const deliveryFee = computed(() => {
const option = deliveryOptions.value.find(opt => opt.id === selectedDelivery.value)
return option?.price || 0
})
const discountAmount = computed(() => {
if (!selectedCoupon.value || !selectedCoupon.value.template) return 0
const coupon = selectedCoupon.value.template
if (totalAmount.value < coupon.min_order_amount) return 0
// 简单处理:假设都是满减券
return coupon.discount_value
})
const actualAmount = computed(() => {
let amount = totalAmount.value + deliveryFee.value - discountAmount.value
return amount > 0 ? amount : 0
})
// 生命周期
onMounted(() => {
loadCheckoutData()
})
// 加载结算数据
const loadCheckoutData = () => {
// 从上一页获取数据
const eventChannel = uni.getEventChannel()
if (eventChannel) {
eventChannel.on('acceptData', (data: any) => {
checkoutItems.value = data.selectedItems || []
loadDefaultAddress()
})
}
}
// 加载默认地址
const loadDefaultAddress = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
const { data, error } = await supa
.from('user_addresses')
.select('*')
.eq('user_id', userId)
.eq('is_default', true)
.single()
if (error !== null) {
console.error('加载默认地址失败:', error)
return
}
selectedAddress.value = data
} catch (err) {
console.error('加载默认地址异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 获取完整地址
const getFullAddress = (address: any): string => {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 获取规格文本
const getSpecText = (specs: any): string => {
if (!specs) return ''
if (typeof specs === 'object') {
return Object.keys(specs)
.map(key => `${key}: ${specs[key]}`)
.join('; ')
}
return String(specs)
}
// 选择地址
const selectAddress = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address',
events: {
addressSelected: (address: any) => {
selectedAddress.value = address
}
}
})
}
// 选择配送方式
const selectDelivery = (option: DeliveryOptionType) => {
selectedDelivery.value = option.id
}
// 选择优惠券
const selectCoupon = () => {
uni.navigateTo({
url: '/pages/mall/consumer/coupons',
success: (res) => {
res.eventChannel.emit('setSelectMode', { selectMode: true })
}
})
// 监听优惠券选择
uni.$on('couponSelected', (coupon: any) => {
selectedCoupon.value = coupon
uni.$off('couponSelected')
})
}
// 提交订单
const submitOrder = async () => {
if (!selectedAddress.value) {
uni.showToast({
title: '请选择收货地址',
icon: 'none'
})
return
}
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '用户信息错误',
icon: 'none'
})
return
}
// 生成订单号
const orderNo = generateOrderNo()
const orderData = {
order_no: orderNo,
user_id: userId,
merchant_id: 'default', // 这里需要根据商品确定商家
status: 1, // 待支付
total_amount: totalAmount.value,
discount_amount: discountAmount.value,
delivery_fee: deliveryFee.value,
actual_amount: actualAmount.value,
payment_method: 0, // 待选择
payment_status: 0,
delivery_address: selectedAddress.value,
remark: remark.value,
created_at: new Date().toISOString()
}
try {
// 创建订单
const { data: order, error: orderError } = await supa
.from('orders')
.insert(orderData)
.select()
.single()
if (orderError !== null) {
throw orderError
}
// 创建订单商品项
const orderItems = checkoutItems.value.map(item => ({
order_id: order.id,
product_id: item.product_id,
sku_id: item.sku_id,
product_name: item.product_name,
sku_specifications: item.sku_specifications,
price: item.price,
quantity: item.quantity,
total_amount: item.price * item.quantity
}))
const { error: itemsError } = await supa
.from('order_items')
.insert(orderItems)
if (itemsError !== null) {
throw itemsError
}
// 使用优惠券
if (selectedCoupon.value) {
const { error: couponError } = await supa
.from('user_coupons')
.update({
status: 2, // 已使用
used_at: new Date().toISOString()
})
.eq('id', selectedCoupon.value.id)
if (couponError !== null) {
console.error('更新优惠券状态失败:', couponError)
}
}
// 清空购物车
await clearShoppingCart()
// 跳转到支付页面
uni.navigateTo({
url: `/pages/mall/consumer/payment?orderId=${order.id}&amount=${actualAmount.value}`
})
} catch (err) {
console.error('创建订单失败:', err)
uni.showToast({
title: '订单创建失败',
icon: 'none'
})
}
}
// 生成订单号
const generateOrderNo = (): string => {
const date = new Date()
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const random = Math.random().toString().slice(2, 8)
return `ORD${year}${month}${day}${random}`
}
// 清空购物车
const clearShoppingCart = async () => {
const userId = getCurrentUserId()
if (!userId) return
const productIds = checkoutItems.value.map(item => item.product_id)
try {
const { error } = await supa
.from('shopping_cart')
.delete()
.eq('user_id', userId)
.in('product_id', productIds)
if (error !== null) {
console.error('清空购物车失败:', error)
}
} catch (err) {
console.error('清空购物车异常:', err)
}
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.checkout-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.checkout-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.checkout-content {
flex: 1;
}
.address-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 20px 15px;
display: flex;
align-items: center;
}
.address-info {
flex: 1;
}
.address-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recipient {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-right: 15px;
}
.phone {
font-size: 14px;
color: #666666;
margin-right: 10px;
}
.default-tag {
background-color: #ff4757;
padding: 2px 8px;
border-radius: 10px;
}
.tag-text {
color: #ffffff;
font-size: 12px;
}
.address-detail {
font-size: 14px;
color: #333333;
line-height: 1.4;
}
.no-address {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.no-address-text {
font-size: 16px;
color: #999999;
}
.no-address-arrow {
color: #999999;
font-size: 18px;
}
.products-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 0 15px;
}
.product-item {
display: flex;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.product-item:last-child {
border-bottom: none;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 5px;
margin-right: 15px;
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.product-spec {
font-size: 12px;
color: #999999;
margin-bottom: 10px;
}
.product-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 16px;
color: #ff4757;
font-weight: bold;
}
.product-quantity {
font-size: 14px;
color: #666666;
}
.delivery-section,
.coupon-section,
.remark-section,
.price-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.delivery-options {
display: flex;
flex-direction: column;
gap: 10px;
}
.delivery-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.delivery-option.selected {
border-color: #007aff;
background-color: #f0f8ff;
}
.option-name {
font-size: 14px;
color: #333333;
}
.option-price {
font-size: 14px;
color: #ff4757;
font-weight: bold;
}
.option-selected {
color: #007aff;
font-size: 16px;
}
.coupon-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.coupon-selected {
font-size: 14px;
color: #007aff;
}
.coupon-placeholder {
font-size: 14px;
color: #999999;
}
.coupon-arrow {
color: #999999;
font-size: 16px;
}
.remark-input {
width: 100%;
min-height: 40px;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
color: #333333;
}
.price-detail {
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.price-row.total {
border-top: 1px solid #e5e5e5;
margin-top: 8px;
padding-top: 15px;
}
.price-label {
font-size: 14px;
color: #666666;
}
.price-value {
font-size: 14px;
color: #333333;
}
.price-value.discount {
color: #4caf50;
}
.price-value.total-price {
font-size: 18px;
color: #ff4757;
font-weight: bold;
}
.bottom-bar {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.price-summary {
display: flex;
align-items: baseline;
}
.summary-label {
font-size: 14px;
color: #333333;
margin-right: 5px;
}
.summary-price {
font-size: 20px;
color: #ff4757;
font-weight: bold;
}
.submit-btn {
background-color: #007aff;
color: #ffffff;
padding: 0 40px;
height: 45px;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
border: none;
}
</style>

View File

@@ -0,0 +1,590 @@
<!-- 优惠券页面 -->
<template>
<view class="coupons-page">
<!-- 顶部栏 -->
<view class="coupons-header">
<view class="header-tabs">
<view :class="['header-tab', { active: activeTab === 'available' }]" @click="changeTab('available')">
<text class="tab-text">可用券</text>
</view>
<view :class="['header-tab', { active: activeTab === 'unavailable' }]" @click="changeTab('unavailable')">
<text class="tab-text">已失效</text>
</view>
</view>
</view>
<!-- 优惠券列表 -->
<scroll-view class="coupons-list" scroll-y>
<!-- 为空提示 -->
<view v-if="coupons.length === 0" class="empty-coupons">
<text class="empty-icon">🎫</text>
<text class="empty-text">{{ getEmptyText() }}</text>
<text class="empty-subtext">{{ getEmptySubtext() }}</text>
<button v-if="activeTab === 'available'" class="get-coupons-btn" @click="goToCouponCenter">
去领券中心
</button>
</view>
<!-- 优惠券项 -->
<view v-for="coupon in coupons" :key="coupon.id" class="coupon-item">
<view class="coupon-left">
<text class="coupon-value">{{ formatCouponValue(coupon) }}</text>
<text class="coupon-condition">{{ formatCondition(coupon) }}</text>
</view>
<view class="coupon-right">
<view class="coupon-info">
<text class="coupon-name">{{ coupon.template?.name || coupon.name }}</text>
<text class="coupon-desc">{{ coupon.template?.description || '' }}</text>
<text class="coupon-time">{{ formatTimeRange(coupon) }}</text>
</view>
<view class="coupon-actions">
<button v-if="coupon.status === 1 && coupon.is_valid"
class="use-btn"
@click="useCoupon(coupon)">
立即使用
</button>
<button v-else-if="coupon.template_id && activeTab === 'available'"
class="receive-btn"
@click="receiveCoupon(coupon)">
领取
</button>
<text v-else class="status-text">{{ getStatusText(coupon) }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type CouponTemplateType = {
id: string
name: string
description: string | null
coupon_type: number
discount_value: number
min_order_amount: number
start_time: string
end_time: string
per_user_limit: number
total_quantity: number
used_quantity: number
status: number
}
type UserCouponType = {
id: string
user_id: string
template_id: string
coupon_code: string
status: number
is_valid: boolean
used_at: string | null
expire_at: string
created_at: string
template: CouponTemplateType | null
}
const activeTab = ref<string>('available')
const coupons = ref<Array<any>>([])
const isLoading = ref<boolean>(false)
// 监听标签页变化
watch(activeTab, () => {
loadCoupons()
})
// 生命周期
onMounted(() => {
loadCoupons()
})
// 加载优惠券
const loadCoupons = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
isLoading.value = true
try {
if (activeTab.value === 'available') {
await loadAvailableCoupons(userId)
} else {
await loadUnavailableCoupons(userId)
}
} catch (err) {
console.error('加载优惠券异常:', err)
} finally {
isLoading.value = false
}
}
// 加载可用优惠券
const loadAvailableCoupons = async (userId: string) => {
try {
const now = new Date().toISOString()
// 加载用户已领取的优惠券
const { data: userCoupons, error: userError } = await supa
.from('user_coupons')
.select(`
*,
template:coupon_templates(*)
`)
.eq('user_id', userId)
.eq('status', 1)
.gte('expire_at', now)
.order('expire_at', { ascending: true })
if (userError !== null) {
console.error('加载用户优惠券失败:', userError)
return
}
// 加载可领取的优惠券
const { data: availableCoupons, error: availableError } = await supa
.from('coupon_templates')
.select('*')
.eq('status', 1)
.gte('end_time', now)
.lte('start_time', now)
.order('created_at', { ascending: false })
if (availableError !== null) {
console.error('加载可领取优惠券失败:', availableError)
return
}
// 合并结果
const allCoupons = [...(userCoupons || []), ...(availableCoupons || [])]
coupons.value = allCoupons
} catch (err) {
console.error('加载可用优惠券异常:', err)
}
}
// 加载不可用优惠券
const loadUnavailableCoupons = async (userId: string) => {
try {
const now = new Date().toISOString()
const { data, error } = await supa
.from('user_coupons')
.select(`
*,
template:coupon_templates(*)
`)
.eq('user_id', userId)
.or('status.eq.2,expire_at.lt.' + now)
.order('used_at', { ascending: false })
.order('expire_at', { ascending: false })
if (error !== null) {
console.error('加载失效优惠券失败:', error)
return
}
coupons.value = data ?? []
} catch (err) {
console.error('加载失效优惠券异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string | null => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || null
}
// 获取空状态文本
const getEmptyText = (): string => {
return activeTab.value === 'available' ? '暂无可用优惠券' : '暂无失效优惠券'
}
// 获取空状态副文本
const getEmptySubtext = (): string => {
return activeTab.value === 'available' ? '去领券中心看看' : '努力使用优惠券吧'
}
// 格式化优惠券价值
const formatCouponValue = (coupon: any): string => {
if (coupon.template_id) {
// 用户优惠券
const template = coupon.template
if (!template) return '未知'
if (template.coupon_type === 1) {
return `¥${template.discount_value}`
} else if (template.coupon_type === 2) {
return `${template.discount_value}折`
} else {
return '未知'
}
} else {
// 优惠券模板
if (coupon.coupon_type === 1) {
return `¥${coupon.discount_value}`
} else if (coupon.coupon_type === 2) {
return `${coupon.discount_value}折`
} else {
return '未知'
}
}
}
// 格式化使用条件
const formatCondition = (coupon: any): string => {
const minAmount = coupon.template?.min_order_amount || coupon.min_order_amount
if (minAmount > 0) {
return `满${minAmount}元可用`
}
return '无门槛'
}
// 格式化时间范围
const formatTimeRange = (coupon: any): string => {
const startTime = coupon.template?.start_time || coupon.start_time
const endTime = coupon.template?.end_time || coupon.expire_at
if (startTime && endTime) {
const start = new Date(startTime)
const end = new Date(endTime)
const startStr = `${start.getMonth() + 1}月${start.getDate()}日`
const endStr = `${end.getMonth() + 1}月${end.getDate()}日`
return `${startStr}-${endStr}`
}
return ''
}
// 获取状态文本
const getStatusText = (coupon: any): string => {
if (coupon.status === 2) {
return '已使用'
} else if (!coupon.is_valid) {
return '已失效'
} else if (new Date(coupon.expire_at) < new Date()) {
return '已过期'
}
return '未知'
}
// 切换标签页
const changeTab = (tab: string) => {
activeTab.value = tab
}
// 使用优惠券
const useCoupon = (coupon: any) => {
// 如果是从订单页面跳转过来的,返回选择的优惠券
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage && prevPage.route === 'pages/mall/consumer/checkout') {
uni.$emit('couponSelected', coupon)
uni.navigateBack()
return
}
// 否则跳转到商品列表页
uni.navigateTo({
url: '/pages/mall/consumer/index'
})
}
// 领取优惠券
const receiveCoupon = async (coupon: any) => {
const userId = getCurrentUserId()
if (!userId) return
// 检查是否已领取
const { data: existingCoupons, error: checkError } = await supa
.from('user_coupons')
.select('id')
.eq('user_id', userId)
.eq('template_id', coupon.id)
if (checkError !== null) {
console.error('检查优惠券失败:', checkError)
return
}
if (existingCoupons && existingCoupons.length >= coupon.per_user_limit) {
uni.showToast({
title: '已达领取上限',
icon: 'none'
})
return
}
// 检查库存
if (coupon.used_quantity >= coupon.total_quantity) {
uni.showToast({
title: '优惠券已领完',
icon: 'none'
})
return
}
// 生成优惠券码
const couponCode = generateCouponCode()
const expireAt = coupon.end_time
try {
const { error: insertError } = await supa
.from('user_coupons')
.insert({
user_id: userId,
template_id: coupon.id,
coupon_code: couponCode,
expire_at: expireAt,
status: 1,
is_valid: true
})
if (insertError !== null) {
console.error('领取优惠券失败:', insertError)
uni.showToast({
title: '领取失败',
icon: 'none'
})
return
}
// 更新优惠券模板的已领取数量
const { error: updateError } = await supa
.from('coupon_templates')
.update({ used_quantity: coupon.used_quantity + 1 })
.eq('id', coupon.id)
if (updateError !== null) {
console.error('更新优惠券数量失败:', updateError)
}
uni.showToast({
title: '领取成功',
icon: 'success'
})
// 重新加载数据
loadCoupons()
} catch (err) {
console.error('领取优惠券异常:', err)
}
}
// 生成优惠券码
const generateCouponCode = (): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// 跳转到领券中心
const goToCouponCenter = () => {
uni.navigateTo({
url: '/pages/mall/consumer/coupon-center'
})
}
</script>
<style scoped>
.coupons-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.coupons-header {
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.header-tabs {
display: flex;
}
.header-tab {
flex: 1;
padding: 15px;
text-align: center;
position: relative;
}
.header-tab.active {
color: #007aff;
}
.header-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #007aff;
}
.tab-text {
font-size: 16px;
font-weight: bold;
color: #666666;
}
.header-tab.active .tab-text {
color: #007aff;
}
.coupons-list {
flex: 1;
padding: 10px;
}
.empty-coupons {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
border-radius: 8px;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.get-coupons-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.coupon-item {
background-color: #ffffff;
margin-bottom: 10px;
border-radius: 8px;
overflow: hidden;
display: flex;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.coupon-left {
width: 100px;
background: linear-gradient(135deg, #ff6b6b, #ffa726);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 10px;
color: #ffffff;
}
.coupon-value {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.coupon-condition {
font-size: 11px;
opacity: 0.9;
}
.coupon-right {
flex: 1;
padding: 15px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.coupon-info {
flex: 1;
}
.coupon-name {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 5px;
display: block;
}
.coupon-desc {
font-size: 13px;
color: #666666;
margin-bottom: 8px;
display: block;
line-height: 1.4;
}
.coupon-time {
font-size: 12px;
color: #999999;
display: block;
}
.coupon-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.use-btn,
.receive-btn {
padding: 8px 20px;
border-radius: 15px;
font-size: 12px;
border: none;
}
.use-btn {
background-color: #007aff;
color: #ffffff;
}
.receive-btn {
background-color: #ff4757;
color: #ffffff;
}
.status-text {
font-size: 12px;
color: #999999;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,694 @@
<!-- 收藏页面 -->
<template>
<view class="favorites-page">
<!-- 顶部栏 -->
<view class="favorites-header">
<view class="header-title">
<text class="title-text">我的收藏</text>
</view>
<view v-if="favorites.length > 0" class="edit-btn" @click="toggleEditMode">
<text class="edit-text">{{ isEditMode ? '完成' : '编辑' }}</text>
</view>
</view>
<!-- 标签页 -->
<view class="favorites-tabs">
<view :class="['favorites-tab', { active: activeTab === 'product' }]" @click="changeTab('product')">
<text class="tab-text">商品</text>
</view>
<view :class="['favorites-tab', { active: activeTab === 'shop' }]" @click="changeTab('shop')">
<text class="tab-text">店铺</text>
</view>
</view>
<!-- 收藏内容 -->
<scroll-view class="favorites-content" scroll-y @scrolltolower="loadMore">
<!-- 空状态 -->
<view v-if="favorites.length === 0 && !isLoading" class="empty-favorites">
<text class="empty-icon">❤️</text>
<text class="empty-text">{{ getEmptyText() }}</text>
<text class="empty-subtext">{{ getEmptySubtext() }}</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<!-- 商品收藏 -->
<view v-if="activeTab === 'product'" class="product-favorites">
<view v-for="item in favorites" :key="item.id" class="product-item">
<view v-if="isEditMode" class="item-selector" @click="toggleSelect(item)">
<view :class="['select-icon', { selected: item.selected }]">
<text v-if="item.selected" class="icon-text">✓</text>
</view>
</view>
<view class="item-content" @click="viewProduct(item)">
<image class="product-image" :src="getProductImage(item)" />
<view class="product-info">
<text class="product-name">{{ item.product?.name || '商品已下架' }}</text>
<text class="product-price">¥{{ item.product?.price || 0 }}</text>
<view class="product-meta">
<text class="meta-text">收藏时间: {{ formatTime(item.created_at) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 店铺收藏 -->
<view v-if="activeTab === 'shop'" class="shop-favorites">
<view v-for="item in favorites" :key="item.id" class="shop-item">
<view v-if="isEditMode" class="item-selector" @click="toggleSelect(item)">
<view :class="['select-icon', { selected: item.selected }]">
<text v-if="item.selected" class="icon-text">✓</text>
</view>
</view>
<view class="item-content" @click="viewShop(item)">
<image class="shop-logo" :src="item.shop?.shop_logo || '/static/default-shop.png'" />
<view class="shop-info">
<text class="shop-name">{{ item.shop?.shop_name || '店铺已关闭' }}</text>
<view class="shop-rating">
<text class="rating-text">评分: {{ item.shop?.rating?.toFixed(1) || '0.0' }}</text>
<text class="sales-text">销量: {{ item.shop?.total_sales || 0 }}</text>
</view>
<view class="shop-meta">
<text class="meta-text">收藏时间: {{ formatTime(item.created_at) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && favorites.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 编辑操作栏 -->
<view v-if="isEditMode && favorites.length > 0" class="edit-bar">
<view class="select-all" @click="toggleSelectAll">
<view :class="['all-select-icon', { selected: isAllSelected }]">
<text v-if="isAllSelected" class="icon-text">✓</text>
</view>
<text class="select-all-text">全选</text>
</view>
<view class="delete-btn" @click="deleteSelected">
<text class="delete-text">删除({{ selectedCount }})</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed, watch } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type FavoriteType = {
id: string
user_id: string
product_id: string | null
merchant_id: string | null
type: string // 'product' | 'shop'
created_at: string
selected?: boolean
product?: {
id: string
name: string
price: number
images: string[]
status: number
}
shop?: {
id: string
shop_name: string
shop_logo: string
rating: number
total_sales: number
shop_status: number
}
}
const activeTab = ref<string>('product')
const favorites = ref<Array<FavoriteType>>([])
const isEditMode = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const currentPage = ref<number>(1)
const pageSize = ref<number>(20)
const hasMore = ref<boolean>(true)
// 计算属性
const selectedCount = computed(() => {
return favorites.value.filter(item => item.selected).length
})
const isAllSelected = computed(() => {
return favorites.value.length > 0 && favorites.value.every(item => item.selected)
})
// 监听标签页变化
watch(activeTab, () => {
resetData()
loadFavorites()
})
// 生命周期
onMounted(() => {
loadFavorites()
})
// 重置数据
const resetData = () => {
favorites.value = []
currentPage.value = 1
hasMore.value = true
isEditMode.value = false
}
// 加载收藏数据
const loadFavorites = async (loadMore: boolean = false) => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
isLoading.value = true
try {
const userId = getCurrentUserId()
if (!userId) {
uni.navigateTo({
url: '/pages/user/login'
})
return
}
const page = loadMore ? currentPage.value + 1 : 1
let query = supa
.from('user_favorites')
.select(`
*,
product:product_id(*),
shop:merchant_id(*)
`)
.eq('user_id', userId)
.eq('type', activeTab.value)
.order('created_at', { ascending: false })
// 分页
query = query.range((page - 1) * pageSize.value, page * pageSize.value - 1)
const { data, error } = await query
if (error !== null) {
console.error('加载收藏失败:', error)
return
}
const newFavorites = (data || []).map((item: any) => ({
...item,
selected: false
}))
if (loadMore) {
favorites.value.push(...newFavorites)
currentPage.value = page
} else {
favorites.value = newFavorites
currentPage.value = 1
}
hasMore.value = newFavorites.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 getProductImage = (item: FavoriteType): string => {
if (!item.product?.images?.[0]) {
return '/static/default-product.png'
}
return item.product.images[0]
}
// 格式化时间
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')
return `${month}-${day}`
}
// 获取空状态文本
const getEmptyText = (): string => {
return activeTab.value === 'product' ? '暂无商品收藏' : '暂无店铺收藏'
}
// 获取空状态副文本
const getEmptySubtext = (): string => {
return activeTab.value === 'product' ? '快去收藏喜欢的商品吧' : '快去收藏喜欢的店铺吧'
}
// 切换编辑模式
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
// 重置选择状态
favorites.value.forEach(item => {
item.selected = false
})
}
// 切换标签页
const changeTab = (tab: string) => {
activeTab.value = tab
}
// 切换选择状态
const toggleSelect = (item: FavoriteType) => {
item.selected = !item.selected
favorites.value = [...favorites.value]
}
// 全选/取消全选
const toggleSelectAll = () => {
const newSelectedState = !isAllSelected.value
favorites.value.forEach(item => {
item.selected = newSelectedState
})
favorites.value = [...favorites.value]
}
// 删除选中项
const deleteSelected = async () => {
const selectedItems = favorites.value.filter(item => item.selected)
if (selectedItems.length === 0) {
uni.showToast({
title: '请选择要删除的收藏',
icon: 'none'
})
return
}
uni.showModal({
title: '确认删除',
content: `确定要删除选中的${selectedItems.length}个收藏吗?`,
success: async (res) => {
if (res.confirm) {
try {
const ids = selectedItems.map(item => item.id)
const { error } = await supa
.from('user_favorites')
.delete()
.in('id', ids)
if (error !== null) {
throw error
}
// 从列表中移除
favorites.value = favorites.value.filter(item => !item.selected)
uni.showToast({
title: '删除成功',
icon: 'success'
})
// 如果删完了,退出编辑模式
if (favorites.value.length === 0) {
isEditMode.value = false
}
} catch (err) {
console.error('删除收藏失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
}
// 查看商品
const viewProduct = (item: FavoriteType) => {
if (isEditMode.value) return
if (!item.product_id || !item.product?.status) {
uni.showToast({
title: '商品已下架',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?id=${item.product_id}`
})
}
// 查看店铺
const viewShop = (item: FavoriteType) => {
if (isEditMode.value) return
if (!item.merchant_id || !item.shop?.shop_status) {
uni.showToast({
title: '店铺已关闭',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/mall/consumer/shop-detail?id=${item.merchant_id}`
})
}
// 加载更多
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
loadFavorites(true)
}
}
// 去逛逛
const goShopping = () => {
uni.switchTab({
url: '/pages/mall/consumer/index'
})
}
</script>
<style scoped>
.favorites-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.favorites-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
flex: 1;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.edit-btn {
padding: 5px 10px;
}
.edit-text {
color: #007aff;
font-size: 14px;
}
.favorites-tabs {
background-color: #ffffff;
display: flex;
border-bottom: 1px solid #e5e5e5;
}
.favorites-tab {
flex: 1;
padding: 15px;
text-align: center;
position: relative;
}
.favorites-tab.active {
color: #007aff;
}
.favorites-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #007aff;
}
.tab-text {
font-size: 16px;
color: #666666;
}
.favorites-tab.active .tab-text {
color: #007aff;
font-weight: bold;
}
.favorites-content {
flex: 1;
}
.empty-favorites {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.product-favorites,
.shop-favorites {
padding: 10px;
}
.product-item,
.shop-item {
background-color: #ffffff;
margin-bottom: 10px;
border-radius: 8px;
overflow: hidden;
display: flex;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.item-selector {
width: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.icon-text {
color: #ffffff;
font-size: 12px;
}
.item-content {
flex: 1;
display: flex;
padding: 15px;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 5px;
margin-right: 15px;
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 10px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.product-price {
font-size: 16px;
color: #ff4757;
font-weight: bold;
margin-bottom: 10px;
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-text {
font-size: 12px;
color: #999999;
}
.shop-logo {
width: 80px;
height: 80px;
border-radius: 8px;
margin-right: 15px;
}
.shop-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.shop-name {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 10px;
line-height: 1.4;
}
.shop-rating {
display: flex;
gap: 15px;
margin-bottom: 10px;
}
.rating-text,
.sales-text {
font-size: 13px;
color: #666666;
}
.shop-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
background-color: #ffffff;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.edit-bar {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-all {
display: flex;
align-items: center;
}
.all-select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.all-select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.select-all-text {
font-size: 14px;
color: #333333;
}
.delete-btn {
background-color: #ff4757;
padding: 10px 20px;
border-radius: 15px;
}
.delete-text {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,674 @@
<!-- 足迹页面 -->
<template>
<view class="footprint-page">
<!-- 顶部栏 -->
<view class="footprint-header">
<view class="header-title">
<text class="title-text">我的足迹</text>
</view>
<view v-if="footprints.length > 0" class="header-actions">
<text class="action-btn" @click="toggleEditMode">{{ isEditMode ? '完成' : '编辑' }}</text>
<text class="action-btn" @click="clearAll">清空</text>
</view>
</view>
<!-- 日期分组 -->
<scroll-view class="footprint-content" scroll-y @scrolltolower="loadMore">
<!-- 空状态 -->
<view v-if="footprints.length === 0 && !isLoading" class="empty-footprints">
<text class="empty-icon">👣</text>
<text class="empty-text">暂无浏览记录</text>
<text class="empty-subtext">快去浏览喜欢的商品吧</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<!-- 按日期分组 -->
<view v-for="(group, date) in groupedFootprints" :key="date" class="date-group">
<view class="group-header">
<text class="group-date">{{ formatGroupDate(date) }}</text>
<text v-if="isEditMode" class="group-select" @click="toggleGroupSelect(date)">
{{ isGroupSelected(date) ? '取消全选' : '全选' }}
</text>
</view>
<view class="group-items">
<view v-for="item in group" :key="item.id" class="footprint-item">
<view v-if="isEditMode" class="item-selector" @click="toggleSelect(item)">
<view :class="['select-icon', { selected: item.selected }]">
<text v-if="item.selected" class="icon-text">✓</text>
</view>
</view>
<view class="item-content" @click="viewProduct(item)">
<image class="product-image" :src="getProductImage(item)" />
<view class="product-info">
<text class="product-name">{{ item.product?.name || '商品已下架' }}</text>
<view class="product-price-row">
<text class="current-price">¥{{ item.product?.price || 0 }}</text>
<text v-if="item.product?.original_price && item.product.original_price > item.product.price"
class="original-price">¥{{ item.product.original_price }}</text>
</view>
<view class="product-meta">
<text class="sales-text">已售{{ item.product?.sales || 0 }}</text>
<text class="time-text">{{ formatTime(item.created_at) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && footprints.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 编辑操作栏 -->
<view v-if="isEditMode && footprints.length > 0" class="edit-bar">
<view class="select-all" @click="toggleSelectAll">
<view :class="['all-select-icon', { selected: isAllSelected }]">
<text v-if="isAllSelected" class="icon-text">✓</text>
</view>
<text class="select-all-text">全选</text>
</view>
<view class="delete-btn" @click="deleteSelected">
<text class="delete-text">删除({{ selectedCount }})</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type FootprintType = {
id: string
user_id: string
product_id: string
created_at: string
selected?: boolean
product?: {
id: string
name: string
price: number
original_price: number | null
images: string[]
sales: number
status: number
}
}
const footprints = ref<Array<FootprintType>>([])
const isEditMode = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const currentPage = ref<number>(1)
const pageSize = ref<number>(30)
const hasMore = ref<boolean>(true)
// 计算属性
const selectedCount = computed(() => {
return footprints.value.filter(item => item.selected).length
})
const isAllSelected = computed(() => {
return footprints.value.length > 0 && footprints.value.every(item => item.selected)
})
const groupedFootprints = computed(() => {
const groups: Record<string, FootprintType[]> = {}
footprints.value.forEach(item => {
const date = item.created_at.split('T')[0]
if (!groups[date]) {
groups[date] = []
}
groups[date].push(item)
})
return groups
})
// 生命周期
onMounted(() => {
loadFootprints()
})
// 加载足迹数据
const loadFootprints = async (loadMore: boolean = false) => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
isLoading.value = true
try {
const userId = getCurrentUserId()
if (!userId) {
uni.navigateTo({
url: '/pages/user/login'
})
return
}
const page = loadMore ? currentPage.value + 1 : 1
const { data, error } = await supa
.from('user_footprints')
.select(`
*,
product:product_id(*)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range((page - 1) * pageSize.value, page * pageSize.value - 1)
if (error !== null) {
console.error('加载足迹失败:', error)
return
}
const newFootprints = (data || []).map((item: any) => ({
...item,
selected: false
}))
if (loadMore) {
footprints.value.push(...newFootprints)
currentPage.value = page
} else {
footprints.value = newFootprints
currentPage.value = 1
}
hasMore.value = newFootprints.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 getProductImage = (item: FootprintType): string => {
if (!item.product?.images?.[0]) {
return '/static/default-product.png'
}
return item.product.images[0]
}
// 格式化日期分组
const formatGroupDate = (dateStr: string): string => {
const date = new Date(dateStr)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return '今天'
} else if (date.toDateString() === yesterday.toDateString()) {
return '昨天'
} else {
const month = date.getMonth() + 1
const day = date.getDate()
return `${month}月${day}日`
}
}
// 格式化时间
const formatTime = (timeStr: string): string => {
const date = new Date(timeStr)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 切换编辑模式
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
// 重置选择状态
footprints.value.forEach(item => {
item.selected = false
})
}
// 清空所有足迹
const clearAll = () => {
if (footprints.value.length === 0) return
uni.showModal({
title: '清空足迹',
content: '确定要清空所有浏览记录吗?',
success: async (res) => {
if (res.confirm) {
try {
const userId = getCurrentUserId()
if (!userId) return
const { error } = await supa
.from('user_footprints')
.delete()
.eq('user_id', userId)
if (error !== null) {
throw error
}
footprints.value = []
uni.showToast({
title: '已清空',
icon: 'success'
})
} catch (err) {
console.error('清空足迹失败:', err)
uni.showToast({
title: '清空失败',
icon: 'none'
})
}
}
}
})
}
// 切换选择状态
const toggleSelect = (item: FootprintType) => {
item.selected = !item.selected
footprints.value = [...footprints.value]
}
// 切换分组全选
const toggleGroupSelect = (date: string) => {
const group = groupedFootprints.value[date]
if (!group) return
const isAllSelected = group.every(item => item.selected)
const newSelectedState = !isAllSelected
group.forEach(item => {
item.selected = newSelectedState
})
footprints.value = [...footprints.value]
}
// 检查组是否全选
const isGroupSelected = (date: string): boolean => {
const group = groupedFootprints.value[date]
if (!group || group.length === 0) return false
return group.every(item => item.selected)
}
// 全选/取消全选
const toggleSelectAll = () => {
const newSelectedState = !isAllSelected.value
footprints.value.forEach(item => {
item.selected = newSelectedState
})
footprints.value = [...footprints.value]
}
// 删除选中项
const deleteSelected = async () => {
const selectedItems = footprints.value.filter(item => item.selected)
if (selectedItems.length === 0) {
uni.showToast({
title: '请选择要删除的记录',
icon: 'none'
})
return
}
uni.showModal({
title: '确认删除',
content: `确定要删除选中的${selectedItems.length}条记录吗?`,
success: async (res) => {
if (res.confirm) {
try {
const ids = selectedItems.map(item => item.id)
const { error } = await supa
.from('user_footprints')
.delete()
.in('id', ids)
if (error !== null) {
throw error
}
// 从列表中移除
footprints.value = footprints.value.filter(item => !item.selected)
uni.showToast({
title: '删除成功',
icon: 'success'
})
// 如果删完了,退出编辑模式
if (footprints.value.length === 0) {
isEditMode.value = false
}
} catch (err) {
console.error('删除足迹失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
}
// 查看商品
const viewProduct = (item: FootprintType) => {
if (isEditMode.value) return
if (!item.product_id || !item.product?.status) {
uni.showToast({
title: '商品已下架',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/mall/consumer/product-detail?id=${item.product_id}`
})
}
// 加载更多
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
loadFootprints(true)
}
}
// 去逛逛
const goShopping = () => {
uni.switchTab({
url: '/pages/mall/consumer/index'
})
}
</script>
<style scoped>
.footprint-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.footprint-header {
background-color: #ffffff;
padding: 15px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
flex: 1;
}
.title-text {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.header-actions {
display: flex;
gap: 20px;
}
.action-btn {
color: #007aff;
font-size: 14px;
padding: 5px;
}
.footprint-content {
flex: 1;
}
.empty-footprints {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.date-group {
background-color: #ffffff;
margin-bottom: 10px;
padding: 0 15px;
}
.group-header {
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
display: flex;
align-items: center;
justify-content: space-between;
}
.group-date {
font-size: 16px;
font-weight: bold;
color: #333333;
}
.group-select {
color: #007aff;
font-size: 14px;
}
.group-items {
padding: 10px 0;
}
.footprint-item {
display: flex;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.footprint-item:last-child {
border-bottom: none;
}
.item-selector {
width: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.icon-text {
color: #ffffff;
font-size: 12px;
}
.item-content {
flex: 1;
display: flex;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 5px;
margin-right: 15px;
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 10px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.product-price-row {
display: flex;
align-items: baseline;
margin-bottom: 10px;
}
.current-price {
font-size: 16px;
color: #ff4757;
font-weight: bold;
margin-right: 10px;
}
.original-price {
font-size: 12px;
color: #999999;
text-decoration: line-through;
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.sales-text {
font-size: 12px;
color: #999999;
}
.time-text {
font-size: 12px;
color: #666666;
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
background-color: #ffffff;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.edit-bar {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.select-all {
display: flex;
align-items: center;
}
.all-select-icon {
width: 20px;
height: 20px;
border: 1px solid #cccccc;
border-radius: 10px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.all-select-icon.selected {
background-color: #007aff;
border-color: #007aff;
}
.select-all-text {
font-size: 14px;
color: #333333;
}
.delete-btn {
background-color: #ff4757;
padding: 10px 20px;
border-radius: 15px;
}
.delete-text {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
</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

View File

@@ -0,0 +1,634 @@
<!-- pages/mall/consumer/messages.uvue -->
<template>
<view class="messages-page">
<!-- 顶部标题栏 -->
<view class="messages-header">
<text class="header-title">消息</text>
<view class="header-actions">
<text class="action-icon" @click="clearAllUnread">📝</text>
</view>
</view>
<!-- 消息分类标签 -->
<view class="message-tabs">
<view
v-for="tab in messageTabs"
:key="tab.id"
:class="['tab-item', { active: activeTab === tab.id }]"
@click="switchTab(tab.id)"
>
<text class="tab-name">{{ tab.name }}</text>
<text v-if="tab.unread > 0" class="tab-badge">{{ tab.unread }}</text>
</view>
</view>
<!-- 消息列表 -->
<scroll-view
scroll-y
class="messages-content"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 系统通知 -->
<view v-if="activeTab === 'system'" class="message-section">
<view
v-for="message in systemMessages"
:key="message.id"
:class="['message-item', { unread: !message.read }]"
@click="viewSystemMessage(message)"
>
<view class="message-icon-wrapper">
<text class="message-icon">📢</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
<text class="message-preview">{{ message.content }}</text>
</view>
</view>
</view>
<!-- 订单消息 -->
<view v-if="activeTab === 'order'" class="message-section">
<view
v-for="message in orderMessages"
:key="message.id"
:class="['message-item', { unread: !message.read }]"
@click="viewOrderMessage(message)"
>
<view class="message-icon-wrapper">
<text class="message-icon">📦</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
<text class="message-preview">{{ message.content }}</text>
<text class="order-info" v-if="message.order_no">订单号: {{ message.order_no }}</text>
</view>
</view>
</view>
<!-- 客服消息 -->
<view v-if="activeTab === 'service'" class="message-section">
<view
v-for="message in serviceMessages"
:key="message.id"
:class="['message-item', { unread: !message.read }]"
@click="startCustomerService(message)"
>
<view class="message-icon-wrapper">
<image
v-if="message.avatar"
class="message-avatar"
:src="message.avatar"
mode="aspectFill"
/>
<text v-else class="message-icon">💁</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
<text class="message-preview">{{ message.content }}</text>
</view>
</view>
</view>
<!-- 优惠活动 -->
<view v-if="activeTab === 'promo'" class="message-section">
<view
v-for="message in promoMessages"
:key="message.id"
:class="['message-item', { unread: !message.read }]"
@click="viewPromoMessage(message)"
>
<view class="message-icon-wrapper">
<text class="message-icon">🎁</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
<text class="message-preview">{{ message.content }}</text>
<view v-if="message.coupon" class="coupon-tag">
<text class="coupon-text">{{ message.coupon }}优惠券</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="!loading && currentMessages.length === 0" class="empty-messages">
<text class="empty-icon">💬</text>
<text class="empty-title">暂无消息</text>
<text class="empty-desc">暂时没有新消息</text>
</view>
</scroll-view>
<!-- 底部固定按钮 -->
<view class="floating-action">
<button class="action-button" @click="contactCustomerService">
<text class="button-icon">💁</text>
<text class="button-text">联系客服</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
// 响应式数据
const activeTab = ref<string>('system')
const refreshing = ref<boolean>(false)
const loading = ref<boolean>(false)
const unreadCount = ref<number>(5)
// 消息分类标签
const messageTabs = reactive([
{ id: 'system', name: '系统通知', unread: 3 },
{ id: 'order', name: '订单消息', unread: 2 },
{ id: 'service', name: '客服消息', unread: 0 },
{ id: 'promo', name: '优惠活动', unread: 1 }
])
// Mock 系统通知数据
const systemMessages = reactive([
{
id: 'sys001',
title: '系统维护通知',
content: '平台将于今晚23:00-01:00进行系统维护届时部分功能可能无法使用。',
time: '2023-11-23 15:30',
read: false,
type: 'system'
},
{
id: 'sys002',
title: '隐私政策更新',
content: '我们已更新隐私政策,请查阅相关条款。',
time: '2023-11-22 10:15',
read: true,
type: 'system'
},
{
id: 'sys003',
title: '账户安全提醒',
content: '检测到您的账户在异地登录,如果不是您本人操作,请及时修改密码。',
time: '2023-11-21 18:45',
read: false,
type: 'system'
}
])
// Mock 订单消息数据
const orderMessages = reactive([
{
id: 'order001',
title: '订单发货通知',
content: '您的订单202311230001已发货点击查看物流信息。',
time: '2023-11-23 14:20',
read: false,
type: 'order',
order_no: '202311230001'
},
{
id: 'order002',
title: '订单支付成功',
content: '您的订单202311220001支付成功商家正在备货中。',
time: '2023-11-22 09:30',
read: false,
type: 'order',
order_no: '202311220001'
},
{
id: 'order003',
title: '订单确认收货',
content: '您的订单202311210001已完成期待您的评价。',
time: '2023-11-21 16:15',
read: true,
type: 'order',
order_no: '202311210001'
}
])
// Mock 客服消息数据
const serviceMessages = reactive([
{
id: 'service001',
title: '在线客服',
content: '您好,有什么可以帮助您的吗?',
time: '2023-11-23 10:05',
read: true,
type: 'service',
avatar: 'https://picsum.photos/50/50?random=1'
},
{
id: 'service002',
title: '售后客服',
content: '关于您申请的退款,已处理完成。',
time: '2023-11-22 15:20',
read: true,
type: 'service',
avatar: 'https://picsum.photos/50/50?random=2'
}
])
// Mock 优惠活动数据
const promoMessages = reactive([
{
id: 'promo001',
title: '新人专享券',
content: '您有一张新人专享优惠券已到账有效期3天。',
time: '2023-11-23 08:00',
read: false,
type: 'promo',
coupon: '50元'
},
{
id: 'promo002',
title: '双11大促',
content: '双11狂欢购物节全场满300减50。',
time: '2023-11-22 12:30',
read: true,
type: 'promo',
coupon: '满300减50'
}
])
// 计算当前显示的消息
const currentMessages = computed(() => {
switch (activeTab.value) {
case 'system': return systemMessages
case 'order': return orderMessages
case 'service': return serviceMessages
case 'promo': return promoMessages
default: return []
}
})
// 生命周期
onMounted(() => {
loadMessages()
})
// 加载消息
const loadMessages = () => {
loading.value = true
setTimeout(() => {
// 这里应该调用API获取消息数据
loading.value = false
}, 800)
}
// 切换标签
const switchTab = (tabId: string) => {
activeTab.value = tabId
}
// 查看系统消息
const viewSystemMessage = (message: any) => {
message.read = true
uni.navigateTo({
url: `/pages/mall/consumer/message-detail?id=${message.id}&type=system`
})
}
// 查看订单消息
const viewOrderMessage = (message: any) => {
message.read = true
uni.navigateTo({
url: `/pages/mall/consumer/order-detail?id=${message.order_no}`
})
}
// 联系客服
const startCustomerService = (message: any) => {
uni.navigateTo({
url: '/pages/mall/consumer/chat'
})
}
// 查看优惠活动
const viewPromoMessage = (message: any) => {
message.read = true
uni.navigateTo({
url: `/pages/mall/consumer/coupons`
})
}
// 联系客服
const contactCustomerService = () => {
uni.navigateTo({
url: '/pages/mall/consumer/chat'
})
}
// 清除所有未读
const clearAllUnread = () => {
uni.showModal({
title: '确认操作',
content: '确定要标记所有消息为已读吗?',
success: (res) => {
if (res.confirm) {
// 标记所有消息为已读
systemMessages.forEach(msg => msg.read = true)
orderMessages.forEach(msg => msg.read = true)
serviceMessages.forEach(msg => msg.read = true)
promoMessages.forEach(msg => msg.read = true)
// 更新标签未读数
messageTabs.forEach(tab => tab.unread = 0)
uni.showToast({
title: '已标记所有消息为已读',
icon: 'success'
})
}
}
})
}
// 下拉刷新
const onRefresh = () => {
refreshing.value = true
setTimeout(() => {
loadMessages()
refreshing.value = false
uni.showToast({
title: '刷新成功',
icon: 'success'
})
}, 1000)
}
</script>
<style>
.messages-page {
width: 100%;
height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 头部 */
.messages-header {
background-color: white;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.header-actions .action-icon {
font-size: 20px;
color: #666;
}
/* 消息分类标签 */
.message-tabs {
background-color: white;
display: flex;
padding: 0 15px;
border-bottom: 1px solid #eee;
}
.tab-item {
flex: 1;
padding: 15px 5px;
text-align: center;
position: relative;
border-bottom: 2px solid transparent;
}
.tab-item.active {
color: #ff5000;
border-bottom-color: #ff5000;
font-weight: bold;
}
.tab-name {
font-size: 14px;
}
.tab-badge {
position: absolute;
top: 8px;
right: 8px;
background-color: #ff5000;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
min-width: 16px;
text-align: center;
}
/* 消息内容区 */
.messages-content {
flex: 1;
padding-bottom: 80px; /* 为底部按钮留出空间 */
}
/* 消息项 */
.message-section {
padding: 10px;
}
.message-item {
background-color: white;
border-radius: 10px;
padding: 15px;
margin-bottom: 10px;
display: flex;
align-items: flex-start;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.message-item.unread {
background-color: #fff8f6;
border-left: 3px solid #ff5000;
}
.message-icon-wrapper {
width: 50px;
height: 50px;
border-radius: 25px;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
flex-shrink: 0;
}
.message-icon {
font-size: 24px;
}
.message-avatar {
width: 50px;
height: 50px;
border-radius: 25px;
}
.message-content {
flex: 1;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.message-title {
font-size: 16px;
color: #333;
font-weight: bold;
flex: 1;
margin-right: 10px;
}
.message-time {
font-size: 12px;
color: #999;
white-space: nowrap;
}
.message-preview {
font-size: 14px;
color: #666;
line-height: 1.4;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.order-info {
font-size: 12px;
color: #ff5000;
background-color: #fff0e8;
padding: 3px 8px;
border-radius: 4px;
display: inline-block;
}
.coupon-tag {
display: inline-block;
background-color: #ff5000;
color: white;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
margin-top: 5px;
}
.coupon-text {
font-size: 12px;
}
/* 空状态 */
.empty-messages {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.empty-icon {
font-size: 80px;
color: #ddd;
margin-bottom: 20px;
}
.empty-title {
font-size: 18px;
color: #666;
margin-bottom: 10px;
}
.empty-desc {
font-size: 14px;
color: #999;
}
/* 底部浮动按钮 */
.floating-action {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 100;
}
.action-button {
background: linear-gradient(135deg, #ff5000, #ff9500);
color: white;
border: none;
border-radius: 25px;
padding: 12px 20px;
display: flex;
align-items: center;
box-shadow: 0 4px 12px rgba(255, 80, 0, 0.3);
}
.button-icon {
font-size: 18px;
margin-right: 8px;
}
.button-text {
font-size: 14px;
font-weight: bold;
}
/* 响应式适配 */
@media screen and (max-width: 320px) {
.tab-name {
font-size: 12px;
}
.message-title {
font-size: 14px;
}
.message-preview {
font-size: 13px;
}
}
@media screen and (min-width: 415px) {
.message-item {
padding: 20px;
}
.message-icon-wrapper {
width: 60px;
height: 60px;
border-radius: 30px;
}
.message-icon {
font-size: 28px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,908 @@
<!-- pages/mall/consumer/orders.uvue -->
<template>
<view class="orders-page">
<!-- 顶部标题栏 -->
<view class="orders-header">
<text class="header-title">我的订单</text>
<view class="header-actions">
<text class="search-icon" @click="navigateToSearch">🔍</text>
</view>
</view>
<!-- 订单状态筛选 -->
<view class="order-tabs">
<scroll-view scroll-x class="tab-scroll" :show-scrollbar="false">
<view class="tab-container">
<view
v-for="tab in orderTabs"
:key="tab.id"
:class="['tab-item', { active: activeTab === tab.id }]"
@click="switchTab(tab.id)"
>
<text class="tab-name">{{ tab.name }}</text>
<text v-if="tab.count > 0" class="tab-count">{{ tab.count }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 订单列表 -->
<scroll-view
scroll-y
class="orders-content"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 空状态 -->
<view v-if="!loading && orders.length === 0" class="empty-orders">
<text class="empty-icon">📦</text>
<text class="empty-title">暂无订单</text>
<text class="empty-desc">去逛逛,发现心仪的商品</text>
<button class="go-shopping-btn" @click="goShopping">去逛逛</button>
</view>
<!-- 订单列表 -->
<view v-else class="order-list">
<view
v-for="order in orders"
:key="order.id"
class="order-card"
>
<!-- 订单头部 -->
<view class="order-header">
<text class="order-no">订单号:{{ order.order_no }}</text>
<text :class="['order-status', getStatusClass(order.status)]">
{{ getStatusText(order.status) }}
</text>
</view>
<!-- 订单商品 -->
<view class="order-products">
<view
v-for="product in order.products"
:key="product.id"
class="order-product"
@click="navigateToProduct(product)"
>
<image
class="product-image"
:src="product.image"
mode="aspectFill"
/>
<view class="product-info">
<text class="product-name">{{ product.name }}</text>
<text class="product-spec">{{ product.spec }}</text>
<view class="product-footer">
<text class="product-price">¥{{ product.price }}</text>
<text class="product-quantity">×{{ product.quantity }}</text>
</view>
</view>
</view>
</view>
<!-- 订单信息 -->
<view class="order-info">
<view class="info-row">
<text class="info-label">商品合计</text>
<text class="info-value">¥{{ order.product_amount }}</text>
</view>
<view class="info-row">
<text class="info-label">运费</text>
<text class="info-value">¥{{ order.shipping_fee }}</text>
</view>
<view class="info-row total">
<text class="info-label">实付款</text>
<text class="info-value total-price">¥{{ order.total_amount }}</text>
</view>
</view>
<!-- 订单操作 -->
<view class="order-actions">
<view v-if="order.status === 1" class="action-buttons">
<button class="action-btn cancel" @click="cancelOrder(order.id)">取消订单</button>
<button class="action-btn pay" @click="payOrder(order.id)">立即支付</button>
</view>
<view v-if="order.status === 2" class="action-buttons">
<button class="action-btn remind" @click="remindShipping(order.id)">提醒发货</button>
</view>
<view v-if="order.status === 3" class="action-buttons">
<button class="action-btn view" @click="viewLogistics(order.id)">查看物流</button>
<button class="action-btn confirm" @click="confirmReceipt(order.id)">确认收货</button>
</view>
<view v-if="order.status === 4" class="action-buttons">
<button class="action-btn review" @click="goReview(order)">评价</button>
<button class="action-btn repurchase" @click="repurchase(order)">再次购买</button>
</view>
<view v-if="order.status === 5" class="action-buttons">
<button class="action-btn view" @click="viewOrderDetail(order.id)">查看详情</button>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadingMore" class="loading-more">
<view class="loading-spinner"></view>
<text>加载中...</text>
</view>
<view v-if="!hasMore && orders.length > 0" class="no-more">
<text>没有更多订单了</text>
</view>
<!-- 安全区域 -->
<view class="safe-area"></view>
</scroll-view>
<!-- 底部导航 -->
<view class="tabbar-placeholder"></view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
// 响应式数据
const orders = ref<any[]>([])
const loading = ref<boolean>(false)
const loadingMore = ref<boolean>(false)
const hasMore = ref<boolean>(true)
const refreshing = ref<boolean>(false)
const page = ref<number>(1)
const activeTab = ref<string>('all')
// 订单标签页
const orderTabs = reactive([
{ id: 'all', name: '全部', count: 12 },
{ id: 'pending', name: '待付款', count: 2 },
{ id: 'shipping', name: '待发货', count: 1 },
{ id: 'delivering', name: '待收货', count: 3 },
{ id: 'completed', name: '已完成', count: 5 },
{ id: 'cancelled', name: '已取消', count: 1 }
])
// Mock 订单数据
const mockOrders = [
{
id: '202311230001',
order_no: '202311230001',
status: 1, // 1:待付款 2:待发货 3:待收货 4:已完成 5:已取消
create_time: '2023-11-23 14:30:22',
product_amount: 378.00,
shipping_fee: 0.00,
total_amount: 378.00,
products: [
{
id: '1001',
name: '无线蓝牙耳机 降噪版',
price: 299.00,
image: 'https://picsum.photos/80/80?random=1',
spec: '白色',
quantity: 1
},
{
id: '1002',
name: '耳机保护套',
price: 29.00,
image: 'https://picsum.photos/80/80?random=2',
spec: '黑色',
quantity: 1
},
{
id: '1003',
name: '数据线',
price: 19.00,
image: 'https://picsum.photos/80/80?random=3',
spec: '1米',
quantity: 2
}
]
},
{
id: '202311220001',
order_no: '202311220001',
status: 2,
create_time: '2023-11-22 10:15:33',
product_amount: 199.00,
shipping_fee: 10.00,
total_amount: 209.00,
products: [
{
id: '2001',
name: '运动T恤 速干面料',
price: 79.00,
image: 'https://picsum.photos/80/80?random=4',
spec: '黑色 L',
quantity: 2
},
{
id: '2002',
name: '运动短裤',
price: 59.00,
image: 'https://picsum.photos/80/80?random=5',
spec: '黑色 M',
quantity: 1
}
]
},
{
id: '202311210001',
order_no: '202311210001',
status: 3,
create_time: '2023-11-21 16:45:12',
product_amount: 299.00,
shipping_fee: 0.00,
total_amount: 299.00,
products: [
{
id: '3001',
name: '智能手环 心率监测',
price: 199.00,
image: 'https://picsum.photos/80/80?random=6',
spec: '黑色',
quantity: 1
},
{
id: '3002',
name: '手环腕带',
price: 29.00,
image: 'https://picsum.photos/80/80?random=7',
spec: '蓝色',
quantity: 2
}
]
},
{
id: '202311200001',
order_no: '202311200001',
status: 4,
create_time: '2023-11-20 09:30:45',
product_amount: 99.00,
shipping_fee: 0.00,
total_amount: 99.00,
products: [
{
id: '4001',
name: '保温杯 500ml',
price: 49.00,
image: 'https://picsum.photos/80/80?random=8',
spec: '白色',
quantity: 2
}
]
},
{
id: '202311190001',
order_no: '202311190001',
status: 5,
create_time: '2023-11-19 14:20:18',
product_amount: 599.00,
shipping_fee: 0.00,
total_amount: 599.00,
products: [
{
id: '5001',
name: '蓝牙音箱 便携式',
price: 199.00,
image: 'https://picsum.photos/80/80?random=9',
spec: '黑色',
quantity: 3
}
]
}
]
// 计算属性:根据当前标签筛选订单
const filteredOrders = computed(() => {
if (activeTab.value === 'all') {
return orders.value
}
const statusMap: Record<string, number> = {
'pending': 1,
'shipping': 2,
'delivering': 3,
'completed': 4,
'cancelled': 5
}
const targetStatus = statusMap[activeTab.value]
return orders.value.filter(order => order.status === targetStatus)
})
// 生命周期
onMounted(() => {
loadOrders()
})
// 加载订单数据
const loadOrders = () => {
loading.value = true
// 模拟API请求延迟
setTimeout(() => {
orders.value = [...mockOrders]
loading.value = false
}, 800)
}
// 切换标签
const switchTab = (tabId: string) => {
activeTab.value = tabId
page.value = 1
orders.value = []
loadOrders()
}
// 获取状态文本
const getStatusText = (status: number): string => {
const statusMap: Record<number, string> = {
1: '待付款',
2: '待发货',
3: '待收货',
4: '已完成',
5: '已取消'
}
return statusMap[status] || '未知状态'
}
// 获取状态类名
const getStatusClass = (status: number): string => {
const classMap: Record<number, string> = {
1: 'status-pending',
2: 'status-shipping',
3: 'status-delivering',
4: 'status-completed',
5: 'status-cancelled'
}
return classMap[status] || 'status-unknown'
}
// 下拉刷新
const onRefresh = () => {
refreshing.value = true
setTimeout(() => {
loadOrders()
refreshing.value = false
uni.showToast({
title: '刷新成功',
icon: 'success'
})
}, 1000)
}
// 上拉加载更多
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
// 模拟加载更多数据
setTimeout(() => {
const newOrders = [...mockOrders].map((order, index) => ({
...order,
id: `${order.id}_${page.value}${index}`,
order_no: `${order.order_no}_${page.value}${index}`
}))
orders.value = [...orders.value, ...newOrders]
loadingMore.value = false
page.value++
hasMore.value = orders.value.length < 20
}, 1200)
}
// 订单操作函数
const cancelOrder = (orderId: string) => {
uni.showModal({
title: '确认取消',
content: '确定要取消此订单吗?',
success: (res) => {
if (res.confirm) {
// 这里应该是实际的API调用
uni.showToast({
title: '订单已取消',
icon: 'success'
})
// 更新订单状态
const index = orders.value.findIndex(order => order.id === orderId)
if (index !== -1) {
orders.value[index].status = 5
orders.value = [...orders.value]
}
}
}
})
}
const payOrder = (orderId: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/payment?orderId=${orderId}`
})
}
const remindShipping = (orderId: string) => {
uni.showToast({
title: '已提醒卖家发货',
icon: 'success'
})
}
const viewLogistics = (orderId: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/logistics?orderId=${orderId}`
})
}
const confirmReceipt = (orderId: string) => {
uni.showModal({
title: '确认收货',
content: '请确认您已收到商品,且商品无误',
success: (res) => {
if (res.confirm) {
// 这里应该是实际的API调用
uni.showToast({
title: '收货成功',
icon: 'success'
})
// 更新订单状态
const index = orders.value.findIndex(order => order.id === orderId)
if (index !== -1) {
orders.value[index].status = 4
orders.value = [...orders.value]
}
}
}
})
}
const goReview = (order: any) => {
const productIds = order.products.map((p: any) => p.id).join(',')
uni.navigateTo({
url: `/pages/mall/consumer/review?orderId=${order.id}&productIds=${productIds}`
})
}
const repurchase = (order: any) => {
uni.showModal({
title: '再次购买',
content: '确定要将这些商品加入购物车吗?',
success: (res) => {
if (res.confirm) {
// 这里应该是实际的API调用
uni.showToast({
title: '已加入购物车',
icon: 'success'
})
}
}
})
}
const viewOrderDetail = (orderId: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/order-detail?id=${orderId}`
})
}
// 导航函数
const navigateToSearch = () => {
uni.navigateTo({ url: '/pages/mall/consumer/search' })
}
const navigateToProduct = (product: any) => {
uni.navigateTo({ url: `/pages/mall/consumer/product-detail?id=${product.id}` })
}
const goShopping = () => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}
</script>
<style>
.orders-page {
width: 100%;
min-height: 100vh;
background-color: #f5f5f5;
}
/* 头部 */
.orders-header {
background-color: white;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 10;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.header-actions .search-icon {
font-size: 20px;
color: #666;
}
/* 标签页 */
.order-tabs {
background-color: white;
border-bottom: 1px solid #eee;
position: sticky;
top: 50px;
z-index: 10;
}
.tab-scroll {
white-space: nowrap;
width: 100%;
height: 50px;
}
.tab-container {
display: flex;
flex-direction: row;
padding: 0 15px;
height: 100%;
}
.tab-item {
flex-shrink: 0;
padding: 0 15px;
display: flex;
align-items: center;
position: relative;
height: 100%;
margin-right: 10px;
}
.tab-item.active {
color: #ff5000;
font-weight: bold;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #ff5000;
}
.tab-name {
font-size: 14px;
}
.tab-count {
margin-left: 4px;
background-color: #ff5000;
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 8px;
min-width: 12px;
text-align: center;
}
/* 内容区 */
.orders-content {
height: calc(100vh - 100px);
}
/* 空状态 */
.empty-orders {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.empty-icon {
font-size: 80px;
color: #ddd;
margin-bottom: 20px;
}
.empty-title {
font-size: 18px;
color: #666;
margin-bottom: 10px;
}
.empty-desc {
font-size: 14px;
color: #999;
margin-bottom: 30px;
}
.go-shopping-btn {
background-color: #ff5000;
color: white;
border: none;
border-radius: 25px;
padding: 10px 40px;
font-size: 16px;
}
/* 订单列表 */
.order-list {
padding: 10px;
}
.order-card {
background-color: white;
border-radius: 10px;
margin-bottom: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* 订单头部 */
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f5f5f5;
}
.order-no {
font-size: 14px;
color: #666;
}
.order-status {
font-size: 14px;
font-weight: bold;
}
.status-pending {
color: #ff5000;
}
.status-shipping {
color: #ff9500;
}
.status-delivering {
color: #007aff;
}
.status-completed {
color: #34c759;
}
.status-cancelled {
color: #999;
}
/* 订单商品 */
.order-products {
padding: 15px;
}
.order-product {
display: flex;
margin-bottom: 15px;
}
.order-product:last-child {
margin-bottom: 0;
}
.product-image {
width: 80px;
height: 80px;
border-radius: 8px;
margin-right: 15px;
}
.product-info {
flex: 1;
}
.product-name {
font-size: 15px;
color: #333;
margin-bottom: 5px;
display: block;
line-height: 1.4;
}
.product-spec {
font-size: 13px;
color: #999;
margin-bottom: 10px;
display: block;
}
.product-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 16px;
color: #ff5000;
font-weight: bold;
}
.product-quantity {
font-size: 14px;
color: #666;
}
/* 订单信息 */
.order-info {
padding: 15px;
border-top: 1px solid #f5f5f5;
border-bottom: 1px solid #f5f5f5;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-row.total {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f5f5f5;
}
.info-label {
font-size: 14px;
color: #666;
}
.info-value {
font-size: 14px;
color: #333;
}
.total-price {
font-size: 18px;
color: #ff5000;
font-weight: bold;
}
/* 订单操作 */
.order-actions {
padding: 15px;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.action-btn {
padding: 6px 15px;
border-radius: 15px;
font-size: 13px;
border: 1px solid;
background: none;
}
.action-btn.cancel {
color: #666;
border-color: #ccc;
}
.action-btn.pay {
color: #ff5000;
border-color: #ff5000;
}
.action-btn.remind {
color: #666;
border-color: #ccc;
}
.action-btn.view {
color: #666;
border-color: #ccc;
}
.action-btn.confirm {
color: #34c759;
border-color: #34c759;
}
.action-btn.review {
color: #ff9500;
border-color: #ff9500;
}
.action-btn.repurchase {
color: #ff5000;
border-color: #ff5000;
}
/* 加载更多 */
.loading-more {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f0f5ff;
border-top-color: #ff5000;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-more {
text-align: center;
color: #999;
font-size: 13px;
padding: 20px 0;
}
/* 安全区域 */
.safe-area {
height: 20px;
}
/* 底部导航占位 */
.tabbar-placeholder {
height: 50px;
background-color: #f5f5f5;
}
/* 响应式适配 */
@media screen and (max-width: 320px) {
.tab-item {
padding: 0 10px;
margin-right: 5px;
}
.action-btn {
padding: 6px 10px;
font-size: 12px;
}
}
@media screen and (min-width: 415px) {
.order-card {
margin-bottom: 15px;
}
}
</style>

View File

@@ -0,0 +1,755 @@
<!-- 支付页面 -->
<template>
<view class="payment-page">
<!-- 顶部栏 -->
<view class="payment-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">收银台</text>
</view>
<view class="payment-content">
<!-- 支付金额 -->
<view class="amount-section">
<text class="amount-label">支付金额</text>
<text class="amount-value">¥{{ amount.toFixed(2) }}</text>
<text class="order-no">订单号: {{ orderNo }}</text>
</view>
<!-- 支付方式 -->
<view class="methods-section">
<text class="section-title">选择支付方式</text>
<view class="method-list">
<view v-for="method in paymentMethods"
:key="method.id"
:class="['method-item', { selected: selectedMethod === method.id }]"
@click="selectMethod(method)">
<view class="method-left">
<text class="method-icon">{{ getMethodIcon(method.id) }}</text>
<view class="method-info">
<text class="method-name">{{ method.name }}</text>
<text class="method-desc">{{ method.description }}</text>
</view>
</view>
<view v-if="selectedMethod === method.id" class="method-selected">
<text class="selected-icon">✓</text>
</view>
</view>
</view>
</view>
<!-- 余额支付 -->
<view v-if="selectedMethod === 'balance' && userBalance > 0" class="balance-section">
<view class="balance-info">
<text class="balance-label">账户余额</text>
<text class="balance-value">¥{{ userBalance.toFixed(2) }}</text>
</view>
<view v-if="userBalance < amount" class="balance-tip">
<text class="tip-text">余额不足,请选择其他支付方式</text>
</view>
</view>
<!-- 密码输入 -->
<view v-if="showPassword" class="password-section">
<text class="password-title">请输入支付密码</text>
<view class="password-input">
<view v-for="(_, index) in 6"
:key="index"
class="password-dot">
<text v-if="password.length > index">●</text>
</view>
</view>
<text class="forgot-password" @click="forgotPassword">忘记密码?</text>
</view>
</view>
<!-- 底部支付按钮 -->
<view class="payment-bottom">
<view class="price-summary">
<text class="summary-label">需支付:</text>
<text class="summary-price">¥{{ amount.toFixed(2) }}</text>
</view>
<button class="pay-btn"
:class="{ disabled: isPaying || (selectedMethod === 'balance' && userBalance < amount) }"
@click="confirmPayment">
<text v-if="!isPaying" class="pay-text">{{ getPayButtonText() }}</text>
<text v-else class="pay-text">支付中...</text>
</button>
</view>
<!-- 密码键盘 -->
<view v-if="showPassword" class="password-keyboard">
<view class="keyboard-grid">
<view v-for="num in 9"
:key="num"
class="keyboard-key"
@click="inputPassword(num.toString())">
<text class="key-text">{{ num }}</text>
</view>
<view class="keyboard-key"></view>
<view class="keyboard-key" @click="inputPassword('0')">
<text class="key-text">0</text>
</view>
<view class="keyboard-key" @click="deletePassword">
<text class="key-text">⌫</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type PaymentMethodType = {
id: string
name: string
description: string
icon: string
enabled: boolean
}
const orderId = ref<string>('')
const orderNo = ref<string>('')
const amount = ref<number>(0)
const paymentMethods = ref<Array<PaymentMethodType>>([])
const selectedMethod = ref<string>('wechat')
const userBalance = ref<number>(0)
const isPaying = ref<boolean>(false)
const showPassword = ref<boolean>(false)
const password = ref<string>('')
// 生命周期
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as any
if (options.orderId) {
orderId.value = options.orderId
loadOrderInfo()
}
if (options.amount) {
amount.value = parseFloat(options.amount)
}
loadPaymentMethods()
loadUserBalance()
})
// 加载订单信息
const loadOrderInfo = async () => {
try {
const { data, error } = await supa
.from('orders')
.select('order_no, actual_amount')
.eq('id', orderId.value)
.single()
if (error !== null) {
console.error('加载订单信息失败:', error)
return
}
if (data) {
orderNo.value = data.order_no
amount.value = data.actual_amount || amount.value
}
} catch (err) {
console.error('加载订单信息异常:', err)
}
}
// 加载支付方式
const loadPaymentMethods = () => {
paymentMethods.value = [
{
id: 'wechat',
name: '微信支付',
description: '推荐安装微信5.0及以上版本使用',
icon: '💳',
enabled: true
},
{
id: 'alipay',
name: '支付宝',
description: '推荐安装支付宝10.0及以上版本使用',
icon: '💳',
enabled: true
},
{
id: 'balance',
name: '余额支付',
description: '使用账户余额支付',
icon: '💰',
enabled: true
},
{
id: 'bankcard',
name: '银行卡支付',
description: '支持储蓄卡、信用卡',
icon: '💳',
enabled: true
}
]
}
// 加载用户余额
const loadUserBalance = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
// 这里假设有用户钱包表
const { data, error } = await supa
.from('user_wallets')
.select('balance')
.eq('user_id', userId)
.single()
if (error !== null) {
console.error('加载用户余额失败:', error)
return
}
userBalance.value = data?.balance || 0
} catch (err) {
console.error('加载用户余额异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 获取支付方式图标
const getMethodIcon = (methodId: string): string => {
const icons: Record<string, string> = {
wechat: '💳',
alipay: '💳',
balance: '💰',
bankcard: '💳'
}
return icons[methodId] || '💳'
}
// 选择支付方式
const selectMethod = (method: PaymentMethodType) => {
if (!method.enabled) {
uni.showToast({
title: '该支付方式暂不可用',
icon: 'none'
})
return
}
selectedMethod.value = method.id
showPassword.value = method.id === 'balance' || method.id === 'bankcard'
password.value = '' // 清空密码
}
// 获取支付按钮文本
const getPayButtonText = (): string => {
if (selectedMethod.value === 'balance' && userBalance.value < amount.value) {
return '余额不足'
}
const texts: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝支付',
balance: '余额支付',
bankcard: '银行卡支付'
}
return texts[selectedMethod.value] || '确认支付'
}
// 确认支付
const confirmPayment = async () => {
if (isPaying.value) return
// 余额支付检查
if (selectedMethod.value === 'balance') {
if (userBalance.value < amount.value) {
uni.showToast({
title: '余额不足',
icon: 'none'
})
return
}
if (!showPassword.value) {
showPassword.value = true
return
}
if (password.value.length !== 6) {
uni.showToast({
title: '请输入6位支付密码',
icon: 'none'
})
return
}
}
isPaying.value = true
try {
// 模拟支付过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 更新订单状态
const { error } = await supa
.from('orders')
.update({
status: 2, // 待发货
payment_method: getPaymentMethodCode(selectedMethod.value),
payment_status: 1, // 已支付
updated_at: new Date().toISOString()
})
.eq('id', orderId.value)
if (error !== null) {
throw error
}
// 余额支付需要扣减余额
if (selectedMethod.value === 'balance') {
await updateUserBalance(-amount.value)
}
// 支付成功
uni.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
// 跳转到支付成功页面
setTimeout(() => {
uni.redirectTo({
url: `/pages/mall/consumer/payment-success?orderId=${orderId.value}`
})
}, 1500)
} catch (err) {
console.error('支付失败:', err)
uni.showToast({
title: '支付失败',
icon: 'none'
})
} finally {
isPaying.value = false
}
}
// 获取支付方式代码
const getPaymentMethodCode = (methodId: string): number => {
const codes: Record<string, number> = {
wechat: 1,
alipay: 2,
balance: 3,
bankcard: 4
}
return codes[methodId] || 0
}
// 更新用户余额
const updateUserBalance = async (change: number) => {
const userId = getCurrentUserId()
if (!userId) return
try {
const { data: wallet, error: walletError } = await supa
.from('user_wallets')
.select('balance')
.eq('user_id', userId)
.single()
if (walletError !== null) {
console.error('查询钱包失败:', walletError)
return
}
const newBalance = (wallet?.balance || 0) + change
const { error: updateError } = await supa
.from('user_wallets')
.update({ balance: newBalance })
.eq('user_id', userId)
if (updateError !== null) {
console.error('更新余额失败:', updateError)
return
}
// 记录余额变动
const { error: recordError } = await supa
.from('balance_records')
.insert({
user_id: userId,
change_amount: change,
current_balance: newBalance,
change_type: 'order_payment',
related_id: orderId.value,
remark: `订单支付: ${orderNo.value}`
})
if (recordError !== null) {
console.error('记录余额变动失败:', recordError)
}
userBalance.value = newBalance
} catch (err) {
console.error('更新余额异常:', err)
}
}
// 输入密码
const inputPassword = (num: string) => {
if (password.value.length >= 6) return
password.value += num
}
// 删除密码
const deletePassword = () => {
if (password.value.length > 0) {
password.value = password.value.slice(0, -1)
}
}
// 监听密码输入
watch(password, (newPassword) => {
if (newPassword.length === 6) {
// 自动验证密码
verifyPassword()
}
})
// 验证密码
const verifyPassword = async () => {
// 这里应该验证支付密码,这里简单模拟
const userId = getCurrentUserId()
try {
// 模拟验证
await new Promise(resolve => setTimeout(resolve, 500))
// 假设密码正确
const isCorrect = true
if (isCorrect) {
// 密码正确,继续支付
confirmPayment()
} else {
password.value = ''
uni.showToast({
title: '密码错误',
icon: 'none'
})
}
} catch (err) {
console.error('验证密码异常:', err)
}
}
// 忘记密码
const forgotPassword = () => {
uni.navigateTo({
url: '/pages/user/forgot-password'
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.payment-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.payment-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.payment-content {
flex: 1;
overflow-y: auto;
}
.amount-section {
background-color: #ffffff;
padding: 40px 20px;
text-align: center;
margin-bottom: 10px;
}
.amount-label {
display: block;
font-size: 14px;
color: #666666;
margin-bottom: 15px;
}
.amount-value {
display: block;
font-size: 36px;
font-weight: bold;
color: #ff4757;
margin-bottom: 10px;
}
.order-no {
display: block;
font-size: 12px;
color: #999999;
}
.methods-section {
background-color: #ffffff;
padding: 20px 15px;
margin-bottom: 10px;
}
.section-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.method-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.method-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.method-item.selected {
border-color: #007aff;
background-color: #f0f8ff;
}
.method-left {
display: flex;
align-items: center;
}
.method-icon {
font-size: 24px;
margin-right: 15px;
}
.method-info {
display: flex;
flex-direction: column;
}
.method-name {
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 5px;
}
.method-desc {
font-size: 12px;
color: #999999;
}
.method-selected {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #007aff;
display: flex;
align-items: center;
justify-content: center;
}
.selected-icon {
color: #ffffff;
font-size: 14px;
}
.balance-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.balance-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.balance-label {
font-size: 14px;
color: #333333;
}
.balance-value {
font-size: 18px;
color: #ff4757;
font-weight: bold;
}
.balance-tip {
padding: 10px;
background-color: #fff0f0;
border-radius: 5px;
}
.tip-text {
font-size: 12px;
color: #ff4757;
}
.password-section {
background-color: #ffffff;
padding: 30px 15px;
text-align: center;
margin-bottom: 10px;
}
.password-title {
display: block;
font-size: 16px;
color: #333333;
margin-bottom: 30px;
}
.password-input {
display: flex;
justify-content: center;
gap: 15px;
margin-bottom: 20px;
}
.password-dot {
width: 12px;
height: 12px;
border-radius: 6px;
background-color: #333333;
display: flex;
align-items: center;
justify-content: center;
}
.password-dot text {
color: #ffffff;
font-size: 8px;
}
.forgot-password {
color: #007aff;
font-size: 14px;
}
.payment-bottom {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.price-summary {
display: flex;
align-items: baseline;
}
.summary-label {
font-size: 14px;
color: #333333;
margin-right: 5px;
}
.summary-price {
font-size: 20px;
color: #ff4757;
font-weight: bold;
}
.pay-btn {
background-color: #007aff;
color: #ffffff;
padding: 0 40px;
height: 45px;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
border: none;
}
.pay-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
.password-keyboard {
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding: 10px;
}
.keyboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 1px;
background-color: #e5e5e5;
}
.keyboard-key {
background-color: #ffffff;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.key-text {
font-size: 24px;
color: #333333;
}
</style>

View File

@@ -0,0 +1,858 @@
<!-- 退款页面 -->
<template>
<view class="refund-page">
<!-- 顶部栏 -->
<view class="refund-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">退款/售后</text>
</view>
<!-- 标签页 -->
<view class="refund-tabs">
<view :class="['refund-tab', { active: activeTab === 'all' }]" @click="changeTab('all')">
<text class="tab-text">全部</text>
</view>
<view :class="['refund-tab', { active: activeTab === 'processing' }]" @click="changeTab('processing')">
<text class="tab-text">处理中</text>
<text v-if="tabCounts.processing > 0" class="tab-badge">{{ tabCounts.processing }}</text>
</view>
<view :class="['refund-tab', { active: activeTab === 'completed' }]" @click="changeTab('completed')">
<text class="tab-text">已完成</text>
</view>
</view>
<!-- 售后列表 -->
<scroll-view class="refund-content" scroll-y @scrolltolower="loadMore">
<!-- 空状态 -->
<view v-if="refunds.length === 0 && !isLoading" class="empty-refunds">
<text class="empty-icon">🔄</text>
<text class="empty-text">暂无售后记录</text>
<text class="empty-subtext">您可以在订单详情中申请售后</text>
<button class="go-orders-btn" @click="goToOrders">查看订单</button>
</view>
<!-- 售后项 -->
<view v-for="refund in refunds" :key="refund.id" class="refund-item">
<view class="refund-header">
<text class="refund-no">售后单号: {{ refund.refund_no }}</text>
<text :class="['refund-status', getStatusClass(refund.status)]">
{{ getStatusText(refund.status) }}
</text>
</view>
<view class="order-info">
<text class="order-no">订单号: {{ refund.order?.order_no }}</text>
<text class="order-time">{{ formatTime(refund.order?.created_at) }}</text>
</view>
<view class="product-info" @click="viewOrder(refund.order_id)">
<image class="product-image" :src="getProductImage(refund)" />
<view class="product-details">
<text class="product-name">{{ getProductName(refund) }}</text>
<text v-if="refund.refund_reason" class="refund-reason">原因: {{ refund.refund_reason }}</text>
<view class="refund-amount">
<text class="amount-label">退款金额:</text>
<text class="amount-value">¥{{ refund.refund_amount }}</text>
</view>
</view>
</view>
<!-- 进度时间线 -->
<view v-if="refund.status_history?.length > 0" class="timeline">
<view v-for="(step, index) in getTimelineSteps(refund)"
:key="index"
class="timeline-step">
<view class="step-dot" :class="{ active: step.active, completed: step.completed }"></view>
<view class="step-info">
<text class="step-title">{{ step.title }}</text>
<text class="step-time">{{ step.time }}</text>
<text v-if="step.desc" class="step-desc">{{ step.desc }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view v-if="refund.status === 1" class="refund-actions">
<button class="action-btn cancel" @click="cancelRefund(refund)">取消申请</button>
<button class="action-btn contact" @click="contactService(refund)">联系客服</button>
</view>
<view v-if="refund.status === 3" class="refund-actions">
<button class="action-btn review" @click="reviewRefund(refund)">评价服务</button>
<button class="action-btn delete" @click="deleteRefund(refund)">删除记录</button>
</view>
</view>
<!-- 加载更多 -->
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!hasMore && refunds.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 申请售后按钮 -->
<view class="apply-btn-container">
<button class="apply-btn" @click="applyRefund">申请售后</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, watch } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type RefundType = {
id: string
user_id: string
order_id: string
refund_no: string
refund_type: number // 1:仅退款 2:退货退款
refund_reason: string
refund_amount: number
status: number // 1:待处理 2:处理中 3:已完成 4:已取消 5:已拒绝
status_history: Array<{
status: number
remark: string
created_at: string
}> | null
created_at: string
order?: {
id: string
order_no: string
created_at: string
order_items: Array<{
id: string
product_name: string
sku_specifications: any
price: number
quantity: number
product?: {
images: string[]
}
}>
}
}
type TabCountsType = {
processing: number
}
const activeTab = ref<string>('all')
const refunds = ref<Array<RefundType>>([])
const tabCounts = ref<TabCountsType>({
processing: 0
})
const isLoading = ref<boolean>(false)
const currentPage = ref<number>(1)
const pageSize = ref<number>(15)
const hasMore = ref<boolean>(true)
// 监听标签页变化
watch(activeTab, () => {
resetData()
loadRefunds()
})
// 生命周期
onMounted(() => {
loadRefunds()
loadTabCounts()
})
// 重置数据
const resetData = () => {
refunds.value = []
currentPage.value = 1
hasMore.value = true
}
// 加载售后数据
const loadRefunds = async (loadMore: boolean = false) => {
if (isLoading.value || (!hasMore.value && loadMore)) {
return
}
isLoading.value = true
try {
const userId = getCurrentUserId()
if (!userId) {
uni.navigateTo({
url: '/pages/user/login'
})
return
}
const page = loadMore ? currentPage.value + 1 : 1
let query = supa
.from('refunds')
.select(`
*,
order:order_id(
order_no,
created_at,
order_items(
*,
product:product_id(images)
)
)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false })
// 根据标签页过滤
if (activeTab.value === 'processing') {
query = query.in('status', [1, 2]) // 待处理和处理中
} else if (activeTab.value === 'completed') {
query = query.in('status', [3, 4, 5]) // 已完成、已取消、已拒绝
}
// 分页
query = query.range((page - 1) * pageSize.value, page * pageSize.value - 1)
const { data, error } = await query
if (error !== null) {
console.error('加载售后记录失败:', error)
return
}
const newRefunds = data || []
if (loadMore) {
refunds.value.push(...newRefunds)
currentPage.value = page
} else {
refunds.value = newRefunds
currentPage.value = 1
}
hasMore.value = newRefunds.length === pageSize.value
} catch (err) {
console.error('加载售后记录异常:', err)
} finally {
isLoading.value = false
}
}
// 加载标签页计数
const loadTabCounts = async () => {
const userId = getCurrentUserId()
if (!userId) return
try {
const { count, error } = await supa
.from('refunds')
.select('*', { count: 'exact' })
.eq('user_id', userId)
.in('status', [1, 2])
if (error !== null) {
console.error('加载计数失败:', error)
return
}
tabCounts.value.processing = count || 0
} catch (err) {
console.error('加载计数异常:', err)
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 获取状态文本
const getStatusText = (status: number): string => {
const statusMap: Record<number, string> = {
1: '待处理',
2: '处理中',
3: '已完成',
4: '已取消',
5: '已拒绝'
}
return statusMap[status] || '未知状态'
}
// 获取状态样式类
const getStatusClass = (status: number): string => {
const classMap: Record<number, string> = {
1: 'status-pending',
2: 'status-processing',
3: 'status-completed',
4: 'status-cancelled',
5: 'status-rejected'
}
return classMap[status] || 'status-unknown'
}
// 获取商品图片
const getProductImage = (refund: RefundType): string => {
const firstItem = refund.order?.order_items?.[0]
if (!firstItem?.product?.images?.[0]) {
return '/static/default-product.png'
}
return firstItem.product.images[0]
}
// 获取商品名称
const getProductName = (refund: RefundType): string => {
const items = refund.order?.order_items || []
if (items.length === 0) return '未知商品'
if (items.length === 1) {
return items[0].product_name
} else {
return `${items[0].product_name}等${items.length}件商品`
}
}
// 格式化时间
const formatTime = (timeStr?: string): string => {
if (!timeStr) return ''
const date = new Date(timeStr)
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${month}-${day}`
}
// 获取时间线步骤
const getTimelineSteps = (refund: RefundType): Array<any> => {
const steps = [
{ status: 0, title: '提交申请', time: refund.created_at },
{ status: 1, title: '商家处理', time: '' },
{ status: 3, title: '退款完成', time: '' }
]
// 如果有状态历史,更新时间和描述
if (refund.status_history) {
refund.status_history.forEach(history => {
if (history.status === 1 || history.status === 2) {
steps[1].time = history.created_at
steps[1].desc = history.remark
} else if (history.status === 3) {
steps[2].time = history.created_at
steps[2].desc = history.remark
}
})
}
// 标记当前状态
return steps.map((step, index) => ({
...step,
active: index === getCurrentStepIndex(refund.status),
completed: index < getCurrentStepIndex(refund.status)
}))
}
// 获取当前步骤索引
const getCurrentStepIndex = (status: number): number => {
switch (status) {
case 1: return 0 // 待处理
case 2: return 1 // 处理中
case 3: return 2 // 已完成
case 4: return 0 // 已取消
case 5: return 1 // 已拒绝
default: return 0
}
}
// 切换标签页
const changeTab = (tab: string) => {
activeTab.value = tab
}
// 加载更多
const loadMore = () => {
if (hasMore.value && !isLoading.value) {
loadRefunds(true)
}
}
// 查看订单
const viewOrder = (orderId: string) => {
uni.navigateTo({
url: `/pages/mall/consumer/order-detail?id=${orderId}`
})
}
// 取消退款申请
const cancelRefund = (refund: RefundType) => {
uni.showModal({
title: '取消申请',
content: '确定要取消这个退款申请吗?',
success: async (res) => {
if (res.confirm) {
try {
const { error } = await supa
.from('refunds')
.update({
status: 4, // 已取消
status_history: [...(refund.status_history || []), {
status: 4,
remark: '用户取消申请',
created_at: new Date().toISOString()
}]
})
.eq('id', refund.id)
if (error !== null) {
throw error
}
refund.status = 4
loadTabCounts() // 重新加载计数
uni.showToast({
title: '已取消',
icon: 'success'
})
} catch (err) {
console.error('取消退款失败:', err)
uni.showToast({
title: '取消失败',
icon: 'none'
})
}
}
}
})
}
// 联系客服
const contactService = (refund: RefundType) => {
uni.navigateTo({
url: `/pages/mall/service/chat?refundId=${refund.id}`
})
}
// 评价服务
const reviewRefund = (refund: RefundType) => {
uni.navigateTo({
url: `/pages/mall/consumer/refund-review?id=${refund.id}`
})
}
// 删除记录
const deleteRefund = (refund: RefundType) => {
uni.showModal({
title: '删除记录',
content: '确定要删除这个售后记录吗?',
success: async (res) => {
if (res.confirm) {
try {
const { error } = await supa
.from('refunds')
.delete()
.eq('id', refund.id)
if (error !== null) {
throw error
}
const index = refunds.value.findIndex(r => r.id === refund.id)
if (index !== -1) {
refunds.value.splice(index, 1)
refunds.value = [...refunds.value]
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('删除记录失败:', err)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
}
// 申请售后
const applyRefund = () => {
uni.navigateTo({
url: '/pages/mall/consumer/apply-refund'
})
}
// 查看订单
const goToOrders = () => {
uni.switchTab({
url: '/pages/mall/consumer/orders'
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.refund-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.refund-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.refund-tabs {
background-color: #ffffff;
display: flex;
border-bottom: 1px solid #e5e5e5;
}
.refund-tab {
flex: 1;
padding: 15px;
text-align: center;
position: relative;
}
.refund-tab.active {
color: #007aff;
}
.refund-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #007aff;
}
.tab-text {
font-size: 16px;
color: #666666;
}
.refund-tab.active .tab-text {
color: #007aff;
font-weight: bold;
}
.tab-badge {
position: absolute;
top: 10px;
right: 20px;
background-color: #ff4757;
color: #ffffff;
font-size: 10px;
padding: 2px 5px;
border-radius: 8px;
min-width: 16px;
text-align: center;
}
.refund-content {
flex: 1;
}
.empty-refunds {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background-color: #ffffff;
}
.empty-icon {
font-size: 80px;
margin-bottom: 20px;
}
.empty-text {
font-size: 16px;
color: #666666;
margin-bottom: 10px;
}
.empty-subtext {
font-size: 14px;
color: #999999;
margin-bottom: 30px;
}
.go-orders-btn {
background-color: #007aff;
color: #ffffff;
padding: 10px 40px;
border-radius: 25px;
font-size: 14px;
border: none;
}
.refund-item {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
}
.refund-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f5;
}
.refund-no {
font-size: 14px;
color: #333333;
}
.refund-status {
font-size: 14px;
padding: 4px 10px;
border-radius: 12px;
color: #ffffff;
}
.status-pending {
background-color: #ffa726;
}
.status-processing {
background-color: #2196f3;
}
.status-completed {
background-color: #4caf50;
}
.status-cancelled {
background-color: #9e9e9e;
}
.status-rejected {
background-color: #f44336;
}
.order-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f5;
}
.order-no {
font-size: 13px;
color: #666666;
}
.order-time {
font-size: 12px;
color: #999999;
}
.product-info {
display: flex;
margin-bottom: 15px;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 5px;
margin-right: 15px;
}
.product-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
}
.refund-reason {
font-size: 12px;
color: #666666;
margin-bottom: 8px;
}
.refund-amount {
display: flex;
align-items: baseline;
}
.amount-label {
font-size: 13px;
color: #666666;
margin-right: 5px;
}
.amount-value {
font-size: 16px;
color: #ff4757;
font-weight: bold;
}
.timeline {
padding: 15px 0;
border-top: 1px solid #f5f5f5;
}
.timeline-step {
display: flex;
margin-bottom: 15px;
}
.timeline-step:last-child {
margin-bottom: 0;
}
.step-dot {
width: 12px;
height: 12px;
border-radius: 6px;
border: 2px solid #e5e5e5;
margin-right: 15px;
position: relative;
top: 3px;
}
.step-dot.active {
border-color: #007aff;
background-color: #007aff;
}
.step-dot.completed {
border-color: #4caf50;
background-color: #4caf50;
}
.step-info {
flex: 1;
}
.step-title {
font-size: 14px;
color: #333333;
font-weight: bold;
margin-bottom: 3px;
display: block;
}
.step-time {
font-size: 12px;
color: #999999;
margin-bottom: 3px;
display: block;
}
.step-desc {
font-size: 12px;
color: #666666;
display: block;
}
.refund-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 15px;
border-top: 1px solid #f5f5f5;
}
.action-btn {
padding: 8px 15px;
border-radius: 15px;
font-size: 12px;
border: 1px solid;
background-color: #ffffff;
}
.action-btn.cancel {
border-color: #666666;
color: #666666;
}
.action-btn.contact {
border-color: #007aff;
color: #007aff;
}
.action-btn.review {
border-color: #ffa726;
color: #ffa726;
}
.action-btn.delete {
border-color: #f44336;
color: #f44336;
}
.loading-more,
.no-more {
padding: 20px;
text-align: center;
background-color: #ffffff;
}
.loading-text,
.no-more-text {
color: #999999;
font-size: 14px;
}
.apply-btn-container {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
}
.apply-btn {
background-color: #007aff;
color: #ffffff;
height: 50px;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
border: none;
}
</style>

View File

@@ -0,0 +1,772 @@
<!-- 评价页面 -->
<template>
<view class="review-page">
<!-- 顶部栏 -->
<view class="review-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">评价商品</text>
</view>
<scroll-view class="review-content" scroll-y>
<!-- 订单信息 -->
<view class="order-section">
<text class="order-no">订单号: {{ order?.order_no }}</text>
<text class="order-time">下单时间: {{ formatTime(order?.created_at) }}</text>
</view>
<!-- 商品评价 -->
<view class="products-section">
<view v-for="(item, index) in orderItems" :key="item.id" class="product-review">
<view class="product-header">
<image class="product-image" :src="item.product_image || '/static/default-product.png'" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
</view>
</view>
<!-- 评分 -->
<view class="rating-section">
<text class="rating-label">评分</text>
<view class="rating-stars">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= ratings[index] }"
@click="setRating(index, star)">
</text>
</view>
<text class="rating-text">{{ getRatingText(ratings[index]) }}</text>
</view>
<!-- 评价内容 -->
<view class="content-section">
<textarea class="review-textarea"
v-model="contents[index]"
placeholder="请写下您的使用感受,分享给其他小伙伴吧"
maxlength="500" />
<text class="word-count">{{ contents[index]?.length || 0 }}/500</text>
</view>
<!-- 图片上传 -->
<view class="images-section">
<text class="images-label">上传图片(可选)</text>
<view class="images-grid">
<view v-for="(image, imgIndex) in images[index]"
:key="imgIndex"
class="image-item">
<image class="uploaded-image" :src="image" />
<text class="delete-image" @click="deleteImage(index, imgIndex)">×</text>
</view>
<view v-if="images[index].length < 9"
class="upload-btn"
@click="uploadImage(index)">
<text class="upload-icon">+</text>
<text class="upload-text">添加图片</text>
</view>
</view>
</view>
<!-- 匿名评价 -->
<view class="anonymous-section">
<view class="anonymous-switch">
<text class="switch-label">匿名评价</text>
<switch :checked="anonymous" @change="toggleAnonymous" />
</view>
<text class="anonymous-tip">评价内容对其他用户不可见</text>
</view>
</view>
</view>
<!-- 店铺评价 -->
<view v-if="merchant" class="merchant-section">
<text class="section-title">店铺评价</text>
<view class="merchant-rating">
<text class="rating-item">商品描述相符</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.description }"
@click="setMerchantRating('description', star)">
</text>
</view>
</view>
<view class="merchant-rating">
<text class="rating-item">物流服务</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.logistics }"
@click="setMerchantRating('logistics', star)">
</text>
</view>
</view>
<view class="merchant-rating">
<text class="rating-item">服务态度</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.service }"
@click="setMerchantRating('service', star)">
</text>
</view>
</view>
</view>
<!-- 评价提示 -->
<view class="tips-section">
<text class="tip-title">评价须知</text>
<text class="tip-item">1. 评价后不可修改,请谨慎评价</text>
<text class="tip-item">2. 上传图片需为真实商品照片</text>
<text class="tip-item">3. 恶意评价将被删除并限制评价功能</text>
<text class="tip-item">4. 优质评价可获得积分奖励</text>
</view>
</scroll-view>
<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-btn"
:class="{ disabled: !canSubmit || isSubmitting }"
@click="submitReview">
<text v-if="!isSubmitting" class="submit-text">提交评价</text>
<text v-else class="submit-text">提交中...</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type OrderItemType = {
id: string
product_id: string
product_name: string
product_image: string
sku_specifications: any
price: number
quantity: number
}
type MerchantType = {
id: string
shop_name: string
rating: number
}
const orderId = ref<string>('')
const order = ref<any>(null)
const orderItems = ref<Array<OrderItemType>>([])
const merchant = ref<MerchantType | null>(null)
const ratings = ref<Array<number>>([])
const contents = ref<Array<string>>([])
const images = ref<Array<Array<string>>>([])
const anonymous = ref<boolean>(false)
const merchantRating = ref({
description: 5,
logistics: 5,
service: 5
})
const isSubmitting = ref<boolean>(false)
// 计算属性
const canSubmit = computed(() => {
// 检查是否所有商品都已评分
if (ratings.value.length === 0) return false
return ratings.value.every(rating => rating > 0)
})
// 生命周期
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as any
if (options.orderId) {
orderId.value = options.orderId
loadOrderData()
}
})
// 加载订单数据
const loadOrderData = async () => {
try {
const { data: orderData, error: orderError } = await supa
.from('orders')
.select('*')
.eq('id', orderId.value)
.single()
if (orderError !== null) {
console.error('加载订单失败:', orderError)
return
}
order.value = orderData
// 加载订单商品
const { data: itemsData, error: itemsError } = await supa
.from('order_items')
.select(`
*,
product:product_id(images)
`)
.eq('order_id', orderId.value)
if (itemsError !== null) {
console.error('加载订单商品失败:', itemsError)
return
}
orderItems.value = (itemsData || []).map((item: any) => ({
...item,
product_image: item.product?.images?.[0] || '/static/default-product.png'
}))
// 初始化评分和内容数组
const count = orderItems.value.length
ratings.value = new Array(count).fill(5)
contents.value = new Array(count).fill('')
images.value = new Array(count).fill([])
// 加载商家信息
if (order.value.merchant_id) {
const { data: merchantData, error: merchantError } = await supa
.from('merchants')
.select('id, shop_name, rating')
.eq('id', order.value.merchant_id)
.single()
if (!merchantError) {
merchant.value = merchantData
}
}
} catch (err) {
console.error('加载订单数据异常:', err)
}
}
// 格式化时间
const formatTime = (timeStr?: string): string => {
if (!timeStr) return ''
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}
// 获取规格文本
const getSpecText = (specs: any): string => {
if (!specs) return ''
if (typeof specs === 'object') {
return Object.keys(specs)
.map(key => `${key}: ${specs[key]}`)
.join('; ')
}
return String(specs)
}
// 获取评分文本
const getRatingText = (rating: number): string => {
const texts = ['非常差', '差', '一般', '好', '非常好']
return texts[rating - 1] || '未评价'
}
// 设置商品评分
const setRating = (index: number, rating: number) => {
ratings.value[index] = rating
ratings.value = [...ratings.value]
}
// 设置商家评分
const setMerchantRating = (type: keyof typeof merchantRating.value, rating: number) => {
merchantRating.value[type] = rating
merchantRating.value = { ...merchantRating.value }
}
// 切换匿名
const toggleAnonymous = (event: any) => {
anonymous.value = event.detail.value
}
// 上传图片
const uploadImage = async (index: number) => {
// 检查图片数量限制
if (images.value[index].length >= 9) {
uni.showToast({
title: '最多上传9张图片',
icon: 'none'
})
return
}
// 使用uni.chooseImage选择图片
uni.chooseImage({
count: 9 - images.value[index].length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempFiles = res.tempFilePaths
// 模拟上传过程
uni.showLoading({
title: '上传中...'
})
setTimeout(() => {
// 这里应该调用真实的上传接口
images.value[index].push(...tempFiles)
images.value = [...images.value]
uni.hideLoading()
uni.showToast({
title: '上传成功',
icon: 'success'
})
}, 1000)
}
})
}
// 删除图片
const deleteImage = (index: number, imgIndex: number) => {
images.value[index].splice(imgIndex, 1)
images.value = [...images.value]
}
// 提交评价
const submitReview = async () => {
if (!canSubmit.value || isSubmitting.value) return
isSubmitting.value = true
try {
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '用户信息错误',
icon: 'none'
})
return
}
// 提交商品评价
const productReviews = orderItems.value.map((item, index) => ({
user_id: userId,
product_id: item.product_id,
order_id: orderId.value,
rating: ratings.value[index],
content: contents.value[index] || '',
images: images.value[index],
is_anonymous: anonymous.value,
is_valid: true,
created_at: new Date().toISOString()
}))
const { error: reviewsError } = await supa
.from('product_reviews')
.insert(productReviews)
if (reviewsError !== null) {
throw reviewsError
}
// 提交店铺评价
if (merchant.value) {
const merchantReview = {
user_id: userId,
merchant_id: merchant.value.id,
order_id: orderId.value,
description_rating: merchantRating.value.description,
logistics_rating: merchantRating.value.logistics,
service_rating: merchantRating.value.service,
average_rating: (
merchantRating.value.description +
merchantRating.value.logistics +
merchantRating.value.service
) / 3,
is_anonymous: anonymous.value,
created_at: new Date().toISOString()
}
const { error: merchantError } = await supa
.from('merchant_reviews')
.insert(merchantReview)
if (merchantError !== null) {
console.error('提交店铺评价失败:', merchantError)
}
}
// 更新订单状态为已评价
const { error: orderError } = await supa
.from('orders')
.update({ status: 4 }) // 已完成
.eq('id', orderId.value)
if (orderError !== null) {
console.error('更新订单状态失败:', orderError)
}
// 显示成功提示
uni.showToast({
title: '评价成功',
icon: 'success',
duration: 2000
})
// 跳转到评价成功页面
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('提交评价失败:', err)
uni.showToast({
title: '提交失败',
icon: 'none'
})
} finally {
isSubmitting.value = false
}
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
return userStore?.id || ''
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.review-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.review-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.review-content {
flex: 1;
}
.order-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.order-no {
font-size: 14px;
color: #333333;
}
.order-time {
font-size: 12px;
color: #999999;
}
.products-section {
background-color: #ffffff;
margin-bottom: 10px;
}
.product-review {
padding: 15px;
border-bottom: 1px solid #f5f5f5;
}
.product-review:last-child {
border-bottom: none;
}
.product-header {
display: flex;
margin-bottom: 20px;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 5px;
margin-right: 15px;
}
.product-info {
flex: 1;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
display: block;
}
.product-spec {
font-size: 12px;
color: #999999;
display: block;
}
.rating-section {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.rating-label {
font-size: 14px;
color: #333333;
margin-right: 15px;
}
.rating-stars {
display: flex;
gap: 10px;
}
.rating-stars.small {
gap: 5px;
}
.star-icon {
font-size: 24px;
color: #cccccc;
}
.star-icon.active {
color: #ffa726;
}
.rating-text {
margin-left: 15px;
font-size: 14px;
color: #666666;
}
.content-section {
margin-bottom: 15px;
}
.review-textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
color: #333333;
line-height: 1.4;
}
.word-count {
display: block;
text-align: right;
font-size: 12px;
color: #999999;
margin-top: 5px;
}
.images-section {
margin-bottom: 15px;
}
.images-label {
display: block;
font-size: 14px;
color: #333333;
margin-bottom: 10px;
}
.images-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.image-item {
width: 70px;
height: 70px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.uploaded-image {
width: 100%;
height: 100%;
}
.delete-image {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.5);
color: #ffffff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-btn {
width: 70px;
height: 70px;
border: 1px dashed #cccccc;
border-radius: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
font-size: 24px;
color: #999999;
margin-bottom: 5px;
}
.upload-text {
font-size: 10px;
color: #999999;
}
.anonymous-section {
margin-bottom: 15px;
}
.anonymous-switch {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.switch-label {
font-size: 14px;
color: #333333;
}
.anonymous-tip {
display: block;
font-size: 12px;
color: #999999;
}
.merchant-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.section-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.merchant-rating {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.rating-item {
font-size: 14px;
color: #333333;
}
.tips-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.tip-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 10px;
}
.tip-item {
display: block;
font-size: 12px;
color: #666666;
line-height: 1.6;
margin-bottom: 5px;
}
.tip-item:last-child {
margin-bottom: 0;
}
.submit-section {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
}
.submit-btn {
background-color: #007aff;
color: #ffffff;
height: 50px;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
border: none;
}
.submit-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<view>
</view>
</template>
<script setup>
</script>
<style>
</style>

View File

@@ -0,0 +1,698 @@
<!-- 设置页面 -->
<template>
<view class="settings-page">
<!-- 顶部栏 -->
<view class="settings-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">设置</text>
</view>
<scroll-view class="settings-content" scroll-y>
<!-- 账户设置 -->
<view class="settings-section">
<text class="section-title">账户设置</text>
<view class="section-list">
<view class="list-item" @click="goToProfile">
<text class="item-icon">👤</text>
<text class="item-text">个人资料</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="goToAddress">
<text class="item-icon">📍</text>
<text class="item-text">收货地址</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="changePassword">
<text class="item-icon">🔒</text>
<text class="item-text">修改密码</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="bindPhone">
<text class="item-icon">📱</text>
<text class="item-text">手机绑定</text>
<view class="item-right">
<text class="item-status" :class="{ bound: userInfo.phone }">
{{ userInfo.phone ? '已绑定' : '未绑定' }}
</text>
<text class="item-arrow"></text>
</view>
</view>
<view class="list-item" @click="bindEmail">
<text class="item-icon">📧</text>
<text class="item-text">邮箱绑定</text>
<view class="item-right">
<text class="item-status" :class="{ bound: userInfo.email }">
{{ userInfo.email ? '已绑定' : '未绑定' }}
</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
</view>
<!-- 消息通知 -->
<view class="settings-section">
<text class="section-title">消息通知</text>
<view class="section-list">
<view class="list-item">
<text class="item-icon">🔔</text>
<text class="item-text">订单消息</text>
<switch :checked="notifications.order" @change="toggleNotification('order')" />
</view>
<view class="list-item">
<text class="item-icon">🎁</text>
<text class="item-text">促销活动</text>
<switch :checked="notifications.promotion" @change="toggleNotification('promotion')" />
</view>
<view class="list-item">
<text class="item-icon">⭐</text>
<text class="item-text">评价提醒</text>
<switch :checked="notifications.review" @change="toggleNotification('review')" />
</view>
</view>
</view>
<!-- 隐私设置 -->
<view class="settings-section">
<text class="section-title">隐私设置</text>
<view class="section-list">
<view class="list-item">
<text class="item-icon">👁️</text>
<text class="item-text">隐藏购物记录</text>
<switch :checked="privacy.hidePurchase" @change="togglePrivacy('hidePurchase')" />
</view>
<view class="list-item">
<text class="item-icon">🔍</text>
<text class="item-text">允许通过手机号找到我</text>
<switch :checked="privacy.allowSearchByPhone" @change="togglePrivacy('allowSearchByPhone')" />
</view>
<view class="list-item">
<text class="item-icon">💬</text>
<text class="item-text">接收商家消息</text>
<switch :checked="privacy.receiveMerchantMsg" @change="togglePrivacy('receiveMerchantMsg')" />
</view>
</view>
</view>
<!-- 通用设置 -->
<view class="settings-section">
<text class="section-title">通用设置</text>
<view class="section-list">
<view class="list-item" @click="clearCache">
<text class="item-icon">🗑️</text>
<text class="item-text">清除缓存</text>
<view class="item-right">
<text class="item-cache">{{ cacheSize }}</text>
<text class="item-arrow"></text>
</view>
</view>
<view class="list-item" @click="changeLanguage">
<text class="item-icon">🌐</text>
<text class="item-text">语言设置</text>
<view class="item-right">
<text class="item-status">{{ currentLanguage }}</text>
<text class="item-arrow"></text>
</view>
</view>
<view class="list-item" @click="changeTheme">
<text class="item-icon">🎨</text>
<text class="item-text">主题设置</text>
<view class="item-right">
<text class="item-status">{{ currentTheme }}</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
</view>
<!-- 关于我们 -->
<view class="settings-section">
<text class="section-title">关于我们</text>
<view class="section-list">
<view class="list-item" @click="aboutUs">
<text class="item-icon"></text>
<text class="item-text">关于商城</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="userAgreement">
<text class="item-icon">📜</text>
<text class="item-text">用户协议</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="privacyPolicy">
<text class="item-icon">🛡️</text>
<text class="item-text">隐私政策</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="checkUpdate">
<text class="item-icon">🔄</text>
<text class="item-text">检查更新</text>
<view class="item-right">
<text class="item-status">{{ appVersion }}</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
</view>
<!-- 客服与反馈 -->
<view class="settings-section">
<text class="section-title">客服与反馈</text>
<view class="section-list">
<view class="list-item" @click="contactService">
<text class="item-icon">💬</text>
<text class="item-text">联系客服</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="feedback">
<text class="item-icon">📝</text>
<text class="item-text">意见反馈</text>
<text class="item-arrow"></text>
</view>
<view class="list-item" @click="rateApp">
<text class="item-icon">⭐</text>
<text class="item-text">给个好评</text>
<text class="item-arrow"></text>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @click="logout">退出登录</button>
</view>
<!-- 账号注销 -->
<view class="delete-account-section">
<text class="delete-account" @click="deleteAccount">注销账号</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type UserType = {
id: string
phone: string | null
email: string | null
nickname: string | null
avatar_url: string | null
}
type NotificationType = {
order: boolean
promotion: boolean
review: boolean
}
type PrivacyType = {
hidePurchase: boolean
allowSearchByPhone: boolean
receiveMerchantMsg: boolean
}
const userInfo = ref<UserType>({
id: '',
phone: null,
email: null,
nickname: null,
avatar_url: null
})
const notifications = ref<NotificationType>({
order: true,
promotion: true,
review: true
})
const privacy = ref<PrivacyType>({
hidePurchase: false,
allowSearchByPhone: true,
receiveMerchantMsg: true
})
const cacheSize = ref<string>('0.0 MB')
const currentLanguage = ref<string>('简体中文')
const currentTheme = ref<string>('自动')
const appVersion = ref<string>('1.0.0')
// 生命周期
onMounted(() => {
loadUserInfo()
loadSettings()
})
// 加载用户信息
const loadUserInfo = () => {
const userStore = uni.getStorageSync('userInfo')
if (userStore) {
userInfo.value = userStore
}
}
// 加载设置
const loadSettings = () => {
// 从本地存储加载设置
const savedNotifications = uni.getStorageSync('userNotifications')
if (savedNotifications) {
notifications.value = savedNotifications
}
const savedPrivacy = uni.getStorageSync('userPrivacy')
if (savedPrivacy) {
privacy.value = savedPrivacy
}
// 计算缓存大小
calculateCacheSize()
// 获取应用版本
// @ts-ignore
const appInfo = uni.getAppBaseInfo()
if (appInfo?.appVersion) {
appVersion.value = appInfo.appVersion
}
}
// 计算缓存大小
const calculateCacheSize = () => {
// 这里应该计算实际缓存大小,这里使用模拟数据
cacheSize.value = '12.5 MB'
}
// 跳转到个人资料
const goToProfile = () => {
uni.navigateTo({
url: '/pages/mall/consumer/profile'
})
}
// 跳转到地址管理
const goToAddress = () => {
uni.navigateTo({
url: '/pages/mall/consumer/address'
})
}
// 修改密码
const changePassword = () => {
uni.navigateTo({
url: '/pages/user/change-password'
})
}
// 绑定手机
const bindPhone = () => {
uni.navigateTo({
url: '/pages/user/bind-phone'
})
}
// 绑定邮箱
const bindEmail = () => {
uni.navigateTo({
url: '/pages/user/bind-email'
})
}
// 切换通知设置
const toggleNotification = (type: keyof NotificationType) => {
notifications.value[type] = !notifications.value[type]
uni.setStorageSync('userNotifications', notifications.value)
}
// 切换隐私设置
const togglePrivacy = (type: keyof PrivacyType) => {
privacy.value[type] = !privacy.value[type]
uni.setStorageSync('userPrivacy', privacy.value)
}
// 清除缓存
const clearCache = () => {
uni.showModal({
title: '清除缓存',
content: `确定要清除 ${cacheSize.value} 缓存吗?`,
success: (res) => {
if (res.confirm) {
// 这里应该清除实际缓存
uni.showLoading({
title: '清除中...'
})
setTimeout(() => {
cacheSize.value = '0.0 MB'
uni.hideLoading()
uni.showToast({
title: '缓存已清除',
icon: 'success'
})
}, 1000)
}
}
})
}
// 切换语言
const changeLanguage = () => {
uni.showActionSheet({
itemList: ['简体中文', 'English', '日本語'],
success: (res) => {
const languages = ['简体中文', 'English', '日本語']
currentLanguage.value = languages[res.tapIndex]
uni.setStorageSync('appLanguage', currentLanguage.value)
uni.showToast({
title: '语言已切换',
icon: 'success'
})
}
})
}
// 切换主题
const changeTheme = () => {
uni.showActionSheet({
itemList: ['自动', '浅色模式', '深色模式'],
success: (res) => {
const themes = ['自动', '浅色模式', '深色模式']
currentTheme.value = themes[res.tapIndex]
uni.setStorageSync('appTheme', currentTheme.value)
uni.showToast({
title: '主题已切换',
icon: 'success'
})
}
})
}
// 关于我们
const aboutUs = () => {
uni.navigateTo({
url: '/pages/info/about'
})
}
// 用户协议
const userAgreement = () => {
uni.navigateTo({
url: '/pages/user/terms'
})
}
// 隐私政策
const privacyPolicy = () => {
uni.navigateTo({
url: '/pages/info/privacy'
})
}
// 检查更新
const checkUpdate = () => {
uni.showLoading({
title: '检查更新中...'
})
setTimeout(() => {
uni.hideLoading()
uni.showModal({
title: '检查更新',
content: '当前已是最新版本',
showCancel: false
})
}, 1000)
}
// 联系客服
const contactService = () => {
uni.navigateTo({
url: '/pages/mall/service/chat'
})
}
// 意见反馈
const feedback = () => {
uni.navigateTo({
url: '/pages/info/feedback'
})
}
// 给个好评
const rateApp = () => {
// 这里应该跳转到应用商店评分
uni.showModal({
title: '给个好评',
content: '如果喜欢我们的应用,请给个好评吧!',
confirmText: '去评分',
success: (res) => {
if (res.confirm) {
// 跳转到应用商店
// @ts-ignore
uni.navigateToMiniProgram({
appId: 'wx1234567890', // 示例AppID
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
}
})
}
// 退出登录
const logout = () => {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
const { error } = await supa.auth.signOut()
if (error !== null) {
console.error('登出失败:', error)
uni.showToast({
title: '登出失败',
icon: 'none'
})
return
}
// 清除本地存储
uni.removeStorageSync('userInfo')
uni.removeStorageSync('token')
uni.removeStorageSync('userSettings')
// 跳转到登录页
uni.reLaunch({
url: '/pages/user/login'
})
} catch (err) {
console.error('登出异常:', err)
uni.showToast({
title: '登出失败',
icon: 'none'
})
}
}
}
})
}
// 注销账号
const deleteAccount = () => {
uni.showModal({
title: '注销账号',
content: '确定要注销账号吗?此操作不可恢复,所有数据将被删除!',
confirmText: '注销',
confirmColor: '#ff4757',
success: async (res) => {
if (res.confirm) {
uni.showLoading({
title: '处理中...'
})
try {
const userId = userInfo.value.id
// 这里应该调用注销账号的API
const { error } = await supa
.from('users')
.update({ status: 0 }) // 标记为注销状态
.eq('id', userId)
if (error !== null) {
throw error
}
// 清除本地存储
uni.removeStorageSync('userInfo')
uni.removeStorageSync('token')
// 提示并跳转
uni.hideLoading()
uni.showToast({
title: '账号已注销',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1500)
} catch (err) {
uni.hideLoading()
console.error('注销账号失败:', err)
uni.showToast({
title: '注销失败',
icon: 'none'
})
}
}
}
})
}
// 返回
const goBack = () => {
uni.navigateBack()
}
</script>
<style scoped>
.settings-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.settings-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.settings-content {
flex: 1;
}
.settings-section {
background-color: #ffffff;
margin-bottom: 10px;
padding: 15px;
}
.section-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.section-list {
display: flex;
flex-direction: column;
}
.list-item {
display: flex;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #f5f5f5;
}
.list-item:last-child {
border-bottom: none;
}
.item-icon {
font-size: 20px;
margin-right: 15px;
}
.item-text {
flex: 1;
font-size: 14px;
color: #333333;
}
.item-arrow {
color: #999999;
font-size: 16px;
margin-left: 10px;
}
.item-right {
display: flex;
align-items: center;
}
.item-status {
font-size: 12px;
color: #999999;
margin-right: 10px;
}
.item-status.bound {
color: #4caf50;
}
.item-cache {
font-size: 12px;
color: #999999;
margin-right: 10px;
}
.logout-section {
background-color: #ffffff;
margin-top: 10px;
padding: 15px;
}
.logout-btn {
background-color: #ffffff;
color: #ff4757;
height: 45px;
border: 1px solid #ff4757;
border-radius: 22.5px;
font-size: 16px;
font-weight: bold;
}
.delete-account-section {
background-color: #ffffff;
padding: 20px 15px;
text-align: center;
}
.delete-account {
color: #999999;
font-size: 14px;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,950 @@
<!-- 钱包页面 -->
<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">单笔充值最低10元最高5000元</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

View File

View File

@@ -0,0 +1,365 @@
// 电商商城系统类型定义 - UTS Android 兼容
// 用户类型
export type UserType = {
id: string
phone: string
email: string | null
nickname: string | null
avatar_url: string | null
gender: number
user_type: number
status: number
created_at: string
}
// 商城用户扩展信息类型
export type MallUserProfileType = {
id: string
user_id: string
user_type: number
status: number
real_name: string | null
id_card: string | null
credit_score: number
mall_role: string
verification_status: number
verification_data: UTSJSONObject | null
business_license: string | null
shop_category: string | null
service_areas: UTSJSONObject | null
emergency_contact: string | null
preferences: UTSJSONObject | null
created_at: string
updated_at: string
}
// 用户地址类型
export type UserAddressType = {
id: string
user_id: string
receiver_name: string
receiver_phone: string
province: string
city: string
district: string
address_detail: string
postal_code: string | null
is_default: boolean
label: string | null
coordinates: string | null
delivery_instructions: string | null
business_hours: string | null
status: number
created_at: string
updated_at: string
}
// 商家类型
export type MerchantType = {
id: string
user_id: string
shop_name: string
shop_logo: string | null
shop_banner: string | null
shop_description: string | null
contact_name: string
contact_phone: string
shop_status: number
rating: number
total_sales: number
created_at: string
}
// 商品类型
export type ProductType = {
id: string
merchant_id: string
category_id: string
name: string
description: string | null
images: Array<string>
price: number
original_price: number | null
stock: number
sales: number
status: number
created_at: string
}
// 商品SKU类型
export type ProductSkuType = {
id: string
product_id: string
sku_code: string
specifications: UTSJSONObject | null
price: number
stock: number
image_url: string | null
status: number
}
// 购物车类型
export type CartItemType = {
id: string
user_id: string
product_id: string
sku_id: string
quantity: number
selected: boolean
product: ProductType | null
sku: ProductSkuType | null
}
// 订单类型
export type OrderType = {
id: string
order_no: string
user_id: string
merchant_id: string
status: number
total_amount: number
discount_amount: number
delivery_fee: number
actual_amount: number
payment_method: number | null
payment_status: number
delivery_address: UTSJSONObject
created_at: string
}
// 订单商品类型
export type OrderItemType = {
id: string
order_id: string
product_id: string
sku_id: string
product_name: string
sku_specifications: UTSJSONObject | null
price: number
quantity: number
total_amount: number
}
// 配送员类型
export type DeliveryDriverType = {
id: string
user_id: string
real_name: string
id_card: string
driver_license: string | null
vehicle_type: number
vehicle_number: string | null
work_status: number
current_location: UTSJSONObject | null
service_areas: Array<string>
rating: number
total_orders: number
auth_status: number
created_at: string
updated_at: string
}
// 配送任务类型
export type DeliveryTaskType = {
id: string
order_id: string
driver_id: string | null
pickup_address: UTSJSONObject
delivery_address: UTSJSONObject
distance: number | null
estimated_time: number | null
delivery_fee: number
status: number
pickup_time: string | null
delivered_time: string | null
delivery_code: string | null
remark: string | null
created_at: string
updated_at: string
}
// 优惠券模板类型
export type CouponTemplateType = {
id: string
name: string
description: string | null
coupon_type: number
discount_type: number
discount_value: number
min_order_amount: number
max_discount_amount: number | null
total_quantity: number | null
per_user_limit: number
usage_limit: number
merchant_id: string | null
category_ids: Array<string>
product_ids: Array<string>
user_type_limit: number | null
start_time: string
end_time: string
status: number
created_at: string
}
// 用户优惠券类型
export type UserCouponType = {
id: string
user_id: string
template_id: string
coupon_code: string
status: number
used_at: string | null
order_id: string | null
received_at: string
expire_at: string
}
// 分页数据类型
export type PageDataType<T> = {
data: Array<T>
total: number
page: number
pageSize: number
hasMore: boolean
}
// API响应类型
export type ApiResponseType<T> = {
success: boolean
data: T | null
message: string
code: number
}
// 订单状态枚举
export const ORDER_STATUS = {
PENDING_PAYMENT: 1,
PAID: 2,
SHIPPED: 3,
DELIVERED: 4,
COMPLETED: 5,
CANCELLED: 6,
REFUNDING: 7,
REFUNDED: 8
} as const
// 优惠券类型枚举
export const COUPON_TYPE = {
DISCOUNT_AMOUNT: 1, // 满减券
DISCOUNT_PERCENT: 2, // 折扣券
FREE_SHIPPING: 3, // 免运费券
NEWBIE: 4, // 新人券
MEMBER: 5, // 会员券
CATEGORY: 6, // 品类券
MERCHANT: 7, // 商家券
LIMITED_TIME: 8 // 限时券
} as const
// 支付方式枚举
export const PAYMENT_METHOD = {
WECHAT: 1,
ALIPAY: 2,
UNIONPAY: 3,
BALANCE: 4
} as const
// 配送状态枚举
export const DELIVERY_STATUS = {
PENDING: 1,
ASSIGNED: 2,
PICKED_UP: 3,
IN_TRANSIT: 4,
DELIVERED: 5,
FAILED: 6
} as const
// 用户类型枚举
export const MALL_USER_TYPE = {
CONSUMER: 1, // 消费者
MERCHANT: 2, // 商家
DELIVERY: 3, // 配送员
SERVICE: 4, // 客服
ADMIN: 5 // 管理员
} as const
// 用户状态枚举
export const USER_STATUS = {
NORMAL: 1, // 正常
FROZEN: 2, // 冻结
CANCELLED: 3, // 注销
PENDING: 4 // 待审核
} as const
// 认证状态枚举
export const VERIFICATION_STATUS = {
UNVERIFIED: 0, // 未认证
VERIFIED: 1, // 已认证
FAILED: 2 // 认证失败
} as const
// 地址标签枚举
export const ADDRESS_LABEL = {
HOME: 'home', // 家
OFFICE: 'office', // 公司
SCHOOL: 'school', // 学校
OTHER: 'other' // 其他
} as const
// 收藏类型枚举
export const FAVORITE_TYPE = {
PRODUCT: 'product', // 商品
SHOP: 'shop' // 店铺
} as const
// =========================
// 订阅相关类型与枚举
// =========================
// 订阅周期枚举
export const SUBSCRIPTION_PERIOD = {
MONTHLY: 'monthly',
YEARLY: 'yearly'
} as const
// 订阅状态枚举
export const SUBSCRIPTION_STATUS = {
TRIAL: 'trial',
ACTIVE: 'active',
PAST_DUE: 'past_due',
CANCELED: 'canceled',
EXPIRED: 'expired'
} as const
// 软件订阅方案类型
export type SubscriptionPlanType = {
id: string
plan_code: string
name: string
description: string | null
features: UTSJSONObject | null // { featureKey: description }
price: number // 单位:元(或分,取决于后端;前端以显示为准)
currency: string | null // 'CNY' | 'USD' ...
billing_period: keyof typeof SUBSCRIPTION_PERIOD | string // 'monthly' | 'yearly'
trial_days: number | null
is_active: boolean
sort_order?: number | null
created_at?: string
updated_at?: string
}
// 用户订阅记录类型
export type UserSubscriptionType = {
id: string
user_id: string
plan_id: string
status: keyof typeof SUBSCRIPTION_STATUS | string
start_date: string
end_date: string | null
next_billing_date: string | null
auto_renew: boolean
cancel_at_period_end?: boolean | null
metadata?: UTSJSONObject | null
created_at?: string
updated_at?: string
}

View File

@@ -363,3 +363,73 @@ export type UserSubscriptionType = {
created_at?: string created_at?: string
updated_at?: string updated_at?: string
} }
export interface Product {
id: string
name: string
description: string | null // 允许null
price: number
original_price: number | null // 允许null
images: string[]
// ... 其他属性
}
// types/mall-types.uts
export interface ProductType {
id: string
name: string
description: string
price: number
original_price: number
images: string[]
category_id: string
status: number
stock: number
sales: number
tags: string[]
created_at: string
updated_at: string
}
export interface CategoryType {
id: string
name: string
icon_url: string
parent_id: string | null
is_active: boolean
sort_order: number
}
export interface BannerType {
id: string
title: string
image_url: string
link_url: string
sort_order: number
status: number
}
export interface CouponTemplateType {
id: string
name: string
discount_value: number
min_order_amount: number
start_time: string
end_time: string
status: number
per_user_limit: number
}
export interface CartItemType {
id: string
user_id: string
product_id: string
quantity: number
created_at: string
}
// Mock 数据类型
export interface MockData {
banners: BannerType[]
categories: CategoryType[]
coupons: CouponTemplateType[]
products: ProductType[]
}

File diff suppressed because one or more lines are too long