页面提交

This commit is contained in:
FlowerWater
2025-11-29 17:20:17 +08:00
parent 95832a6288
commit 0eb8ac9181
50 changed files with 8471 additions and 63 deletions

94
src/api/address.ts Normal file
View File

@@ -0,0 +1,94 @@
import { mockAddressList } from '@/mock/address'
import type { Address } from '@/typings/mall'
/**
* 地址相关 API
*/
// 获取地址列表
export function getAddressList() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: mockAddressList,
})
}, 300)
})
}
// 添加地址
export function addAddress(data: Omit<Address, 'id'>) {
return new Promise((resolve) => {
setTimeout(() => {
const newAddress = {
...data,
id: `addr_${Date.now()}`,
}
mockAddressList.push(newAddress)
resolve({
code: 0,
data: newAddress,
message: '添加成功',
})
}, 300)
})
}
// 编辑地址
export function updateAddress(data: Address) {
return new Promise((resolve) => {
setTimeout(() => {
const index = mockAddressList.findIndex(item => item.id === data.id)
if (index > -1) {
mockAddressList[index] = data
resolve({
code: 0,
data,
message: '修改成功',
})
} else {
resolve({
code: 1,
message: '地址不存在',
})
}
}, 300)
})
}
// 删除地址
export function deleteAddress(id: string) {
return new Promise((resolve) => {
setTimeout(() => {
const index = mockAddressList.findIndex(item => item.id === id)
if (index > -1) {
mockAddressList.splice(index, 1)
resolve({
code: 0,
message: '删除成功',
})
} else {
resolve({
code: 1,
message: '地址不存在',
})
}
}, 300)
})
}
// 设置默认地址
export function setDefaultAddress(id: string) {
return new Promise((resolve) => {
setTimeout(() => {
mockAddressList.forEach(item => {
item.isDefault = item.id === id
})
resolve({
code: 0,
message: '设置成功',
})
}, 300)
})
}

57
src/api/auth.ts Normal file
View File

@@ -0,0 +1,57 @@
import { mockMember } from '@/mock/member'
import type { User } from '@/typings/mall'
/**
* 认证相关 API
*/
// 登录
export function login(data: { phone: string, code?: string, password?: string }) {
return new Promise((resolve) => {
setTimeout(() => {
// 模拟登录成功
const user: User = {
id: 'user_001',
username: data.phone,
nickname: `用户${data.phone.slice(-4)}`,
avatar: 'https://picsum.photos/200/200?random=avatar',
phone: data.phone,
creditLimits: [],
member: mockMember,
}
resolve({
code: 0,
data: {
token: 'mock_token_123456',
user,
},
message: '登录成功',
})
}, 500)
})
}
// 发送验证码
export function sendCode(phone: string) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: '验证码发送成功',
})
}, 300)
})
}
// 退出登录
export function logout() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: '退出成功',
})
}, 300)
})
}

17
src/api/banner.ts Normal file
View File

@@ -0,0 +1,17 @@
import { mockBannerList } from '@/mock/banner'
/**
* 轮播图相关 API
*/
// 获取轮播图列表
export function getBannerList() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: mockBannerList,
})
}, 300)
})
}

30
src/api/category.ts Normal file
View File

@@ -0,0 +1,30 @@
import { mockCategoryList } from '@/mock/category'
/**
* 分类相关 API
*/
// 获取分类列表
export function getCategoryList() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: mockCategoryList,
})
}, 300)
})
}
// 获取分类详情
export function getCategoryDetail(id: string) {
return new Promise((resolve) => {
setTimeout(() => {
const category = mockCategoryList.find(item => item.id === id)
resolve({
code: 0,
data: category || null,
})
}, 300)
})
}

125
src/api/finance.ts Normal file
View File

@@ -0,0 +1,125 @@
import { mockCreditLimitList, mockSettlementList, mockWriteOffList } from '@/mock/finance'
import { WriteOffStatus } from '@/typings/mall'
import type { SettlementStatus, WriteOff } from '@/typings/mall'
/**
* 金融相关 API
*/
// 获取信用额度
export function getCreditLimit() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: mockCreditLimitList,
})
}, 300)
})
}
// 获取应结账款列表
export function getSettlementList(params?: {
status?: SettlementStatus
merchantId?: string
}) {
return new Promise((resolve) => {
setTimeout(() => {
let list = [...mockSettlementList]
// 筛选
if (params?.status) {
// 如果查询未结,则包含逾期状态
if (params.status === 'unsettled') {
list = list.filter(item => item.status === 'unsettled' || item.status === 'overdue')
} else {
list = list.filter(item => item.status === params.status)
}
}
if (params?.merchantId) {
list = list.filter(item => item.merchantId === params.merchantId)
}
resolve({
code: 0,
data: list,
})
}, 300)
})
}
// 获取到期订单
export function getDueOrders() {
return new Promise((resolve) => {
setTimeout(() => {
const now = new Date()
// 7天后
const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
// 筛选7天内到期的未结账款包括已逾期
const list = mockSettlementList.filter((item) => {
// 只关注未结和逾期状态
if (item.status !== 'unsettled' && item.status !== 'overdue')
return false
const dueDate = new Date(item.dueDate)
// 只要到期时间在7天内包括过去的时间即已逾期都应该提醒
return dueDate <= sevenDaysLater
})
resolve({
code: 0,
data: list,
})
}, 300)
})
}
// 提交消账申请
export function submitWriteOff(data: {
settlementId: string
amount: number
proof: string[]
remark: string
}) {
return new Promise((resolve) => {
setTimeout(() => {
const newWriteOff: WriteOff = {
id: `writeoff_${Date.now()}`,
settlementId: data.settlementId,
amount: data.amount,
proof: data.proof,
remark: data.remark,
submitTime: new Date().toISOString(),
status: WriteOffStatus.PENDING,
}
// 模拟添加到列表
mockWriteOffList.push(newWriteOff)
resolve({
code: 0,
data: newWriteOff,
message: '提交成功,等待审核',
})
}, 500)
})
}
// 获取消账记录
export function getWriteOffList(settlementId?: string) {
return new Promise((resolve) => {
setTimeout(() => {
let list = [...mockWriteOffList]
if (settlementId) {
list = list.filter(item => item.settlementId === settlementId)
}
resolve({
code: 0,
data: list,
})
}, 300)
})
}

95
src/api/goods.ts Normal file
View File

@@ -0,0 +1,95 @@
import { mockGoodsList } from '@/mock/goods'
import type { Goods } from '@/typings/mall'
/**
* 商品相关 API
*/
// 获取商品列表(支持分页、筛选)
export function getGoodsList(params: {
page?: number
pageSize?: number
categoryId?: string
keyword?: string
sortBy?: 'sales' | 'price' | 'new'
sortOrder?: 'asc' | 'desc'
}) {
return new Promise((resolve) => {
setTimeout(() => {
let list = [...mockGoodsList]
// 筛选
if (params.categoryId) {
list = list.filter(item => item.categoryId === params.categoryId)
}
if (params.keyword) {
list = list.filter(item => item.name.includes(params.keyword))
}
// 排序
if (params.sortBy) {
list.sort((a, b) => {
let compareValue = 0
if (params.sortBy === 'sales') {
compareValue = a.sales - b.sales
}
else if (params.sortBy === 'price') {
compareValue = a.price - b.price
}
else if (params.sortBy === 'new') {
compareValue = a.id.localeCompare(b.id)
}
return params.sortOrder === 'desc' ? -compareValue : compareValue
})
}
// 分页
const page = params.page || 1
const pageSize = params.pageSize || 10
const start = (page - 1) * pageSize
const end = start + pageSize
resolve({
code: 0,
data: {
list: list.slice(start, end),
total: list.length,
page,
pageSize,
},
})
}, 300)
})
}
// 获取商品详情
export function getGoodsDetail(id: string) {
return new Promise<{ code: number, data: Goods | null }>((resolve) => {
setTimeout(() => {
const goods = mockGoodsList.find(item => item.id === id)
resolve({ code: 0, data: goods || null })
}, 300)
})
}
// 搜索商品
export function searchGoods(keyword: string) {
return getGoodsList({ keyword, pageSize: 20 })
}
// 获取推荐商品
export function getRecommendGoods(limit = 10) {
return new Promise((resolve) => {
setTimeout(() => {
// 按销量排序,取前 N 个
const list = [...mockGoodsList]
.sort((a, b) => b.sales - a.sales)
.slice(0, limit)
resolve({
code: 0,
data: list,
})
}, 300)
})
}

29
src/api/member.ts Normal file
View File

@@ -0,0 +1,29 @@
import { mockMember, memberLevelConfig } from '@/mock/member'
/**
* 会员相关 API
*/
// 获取会员信息
export function getMemberInfo() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: mockMember,
})
}, 300)
})
}
// 获取会员权益
export function getMemberBenefits() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: memberLevelConfig[mockMember.level].benefits,
})
}, 300)
})
}

112
src/api/order.ts Normal file
View File

@@ -0,0 +1,112 @@
import { OrderStatus } from '@/typings/mall'
import type { Order } from '@/typings/mall'
// 模拟订单列表
const mockOrderList: Order[] = []
/**
* 订单相关 API
*/
// 创建订单
export function createOrder(data: Omit<Order, 'id' | 'createTime' | 'status' | 'orderNo'>) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// 如果是信用支付
if (data.paymentMethod === 'credit') {
// 模拟检查额度(这里简单模拟,实际应调用金融服务)
// 假设额度总是足够的,或者在前端已经校验过了
// 创建应结账款记录(模拟)
console.log('创建信用支付订单,生成应结账款...')
}
const newOrder: Order = {
...data,
id: `order_${Date.now()}`,
orderNo: `ORD${Date.now()}`,
status: data.paymentMethod === 'credit' ? OrderStatus.PENDING_DELIVERY : OrderStatus.PENDING_PAYMENT, // 信用支付直接待发货
createTime: new Date().toISOString(),
isSettled: false,
payTime: data.paymentMethod === 'credit' ? new Date().toISOString() : undefined,
}
mockOrderList.unshift(newOrder)
resolve({
code: 0,
data: newOrder,
message: '订单创建成功',
})
}, 500)
})
}
// 获取订单列表
export function getOrderList(status?: OrderStatus) {
return new Promise((resolve) => {
setTimeout(() => {
let list = [...mockOrderList]
if (status) {
list = list.filter(item => item.status === status)
}
resolve({
code: 0,
data: list,
})
}, 300)
})
}
// 获取订单详情
export function getOrderDetail(id: string) {
return new Promise((resolve) => {
setTimeout(() => {
const order = mockOrderList.find(item => item.id === id)
resolve({
code: 0,
data: order || null,
})
}, 300)
})
}
// 取消订单
export function cancelOrder(id: string) {
return new Promise((resolve) => {
setTimeout(() => {
const order = mockOrderList.find(item => item.id === id)
if (order) {
order.status = 'cancelled' as OrderStatus
resolve({
code: 0,
message: '订单已取消',
})
} else {
resolve({
code: 1,
message: '订单不存在',
})
}
}, 300)
})
}
// 支付订单(模拟)
export function payOrder(id: string) {
return new Promise((resolve) => {
setTimeout(() => {
const order = mockOrderList.find(item => item.id === id)
if (order) {
order.status = 'pending_delivery' as OrderStatus // 支付后变为待发货
order.payTime = new Date().toISOString()
resolve({
code: 0,
message: '支付成功',
})
} else {
resolve({
code: 1,
message: '订单不存在',
})
}
}, 500)
})
}

View File

@@ -0,0 +1,173 @@
<template>
<view class="cart-item">
<!-- 选中框 -->
<view class="checkbox" @click="handleToggle">
<text
class="icon"
:class="checked ? 'i-carbon-checkbox-checked' : 'i-carbon-checkbox'"
></text>
</view>
<!-- 商品图片 -->
<image class="cover" :src="item.cover" mode="aspectFill" @click="goToDetail" />
<!-- 商品信息 -->
<view class="info">
<view class="name" @click="goToDetail">{{ item.goodsName }}</view>
<view class="specs" v-if="specText">
<text>{{ specText }}</text>
</view>
<view class="bottom">
<view class="price">¥{{ item.price }}</view>
<CounterInput
:model-value="item.quantity"
:max="item.stock"
@update:model-value="handleQuantityChange"
/>
</view>
</view>
<!-- 删除按钮滑动或长按显示这里简化为右上角图标 -->
<view class="delete-btn" @click="handleDelete">
<text class="i-carbon-trash-can"></text>
</view>
</view>
</template>
<script setup lang="ts">
import type { CartItem } from '@/typings/mall'
import CounterInput from '../common/CounterInput.vue'
interface Props {
item: CartItem
checked?: boolean
}
const props = withDefaults(defineProps<Props>(), {
checked: false,
})
const emit = defineEmits<{
toggle: [id: string]
delete: [id: string]
updateQuantity: [id: string, quantity: number]
}>()
const specText = computed(() => {
return Object.values(props.item.selectedSpec).join('')
})
function handleToggle() {
emit('toggle', props.item.id)
}
function handleDelete() {
uni.showModal({
title: '提示',
content: '确定要删除该商品吗?',
success: (res) => {
if (res.confirm) {
emit('delete', props.item.id)
}
},
})
}
function handleQuantityChange(val: number) {
emit('updateQuantity', props.item.id, val)
}
function goToDetail() {
uni.navigateTo({
url: `/pages/goods/detail?id=${props.item.goodsId}`,
})
}
</script>
<style lang="scss" scoped>
.cart-item {
display: flex;
align-items: center;
padding: 24rpx 0;
// background: #fff; // 移除背景色,由父级控制
// border-radius: 16rpx;
border-bottom: 1rpx solid #f5f5f5;
margin-bottom: 0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
position: relative;
}
.checkbox {
padding: 20rpx;
margin-left: -20rpx;
.icon {
font-size: 40rpx;
color: #ccc;
&.i-carbon-checkbox-checked {
color: #ff4d4f;
}
}
}
.cover {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background: #f5f5f5;
margin-right: 20rpx;
}
.info {
flex: 1;
height: 160rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.name {
font-size: 28rpx;
color: #333;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.specs {
font-size: 24rpx;
color: #999;
background: #f5f5f5;
padding: 4rpx 12rpx;
border-radius: 8rpx;
align-self: flex-start;
}
.bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.price {
font-size: 32rpx;
color: #ff4d4f;
font-weight: 600;
}
.delete-btn {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 10rpx;
color: #999;
font-size: 32rpx;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<view class="cart-summary">
<view class="left">
<view class="checkbox" @click="handleToggleAll">
<text
class="icon"
:class="allChecked ? 'i-carbon-checkbox-checked' : 'i-carbon-checkbox'"
></text>
<text class="text">全选</text>
</view>
</view>
<view class="right">
<view class="total-info">
<text class="label">合计</text>
<text class="price">¥{{ totalPrice.toFixed(2) }}</text>
</view>
<view
class="checkout-btn"
:class="{ disabled: totalCount === 0 }"
@click="handleCheckout"
>
结算({{ totalCount }})
</view>
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
allChecked: boolean
totalPrice: number
totalCount: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
toggleAll: []
checkout: []
}>()
function handleToggleAll() {
emit('toggleAll')
}
function handleCheckout() {
if (props.totalCount === 0) return
emit('checkout')
}
</script>
<style lang="scss" scoped>
.cart-summary {
position: fixed;
bottom: 0; // 如果有 tabbar可能需要调整 bottom
/* #ifdef H5 */
bottom: 50px; // H5 tabbar 高度
/* #endif */
left: 0;
width: 100%;
height: 100rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: 99;
box-sizing: border-box;
}
.left {
.checkbox {
display: flex;
align-items: center;
gap: 8rpx;
.icon {
font-size: 40rpx;
color: #ccc;
&.i-carbon-checkbox-checked {
color: #ff4d4f;
}
}
.text {
font-size: 28rpx;
color: #333;
}
}
}
.right {
display: flex;
align-items: center;
gap: 24rpx;
.total-info {
display: flex;
align-items: baseline;
.label {
font-size: 28rpx;
color: #333;
}
.price {
font-size: 36rpx;
color: #ff4d4f;
font-weight: 600;
}
}
.checkout-btn {
width: 200rpx;
height: 72rpx;
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
color: #fff;
font-size: 28rpx;
font-weight: 600;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
&.disabled {
background: #ccc;
color: #fff;
}
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<view class="banner">
<swiper
class="swiper"
:indicator-dots="indicatorDots"
:autoplay="autoplay"
:interval="interval"
:duration="duration"
:circular="circular"
@change="onChange"
>
<swiper-item v-for="item in list" :key="item.id" @click="handleClick(item)">
<image class="banner-image" :src="item.image" mode="aspectFill" />
</swiper-item>
</swiper>
</view>
</template>
<script setup lang="ts">
import type { Banner } from '@/typings/mall'
interface Props {
list: Banner[] // 轮播图列表
indicatorDots?: boolean // 是否显示指示点
autoplay?: boolean // 是否自动播放
interval?: number // 自动切换时间间隔
duration?: number // 滑动动画时长
circular?: boolean // 是否循环播放
}
const props = withDefaults(defineProps<Props>(), {
indicatorDots: true,
autoplay: true,
interval: 3000,
duration: 500,
circular: true,
})
const emit = defineEmits<{
change: [index: number]
click: [item: Banner]
}>()
const currentIndex = ref(0)
function onChange(e: any) {
currentIndex.value = e.detail.current
emit('change', e.detail.current)
}
function handleClick(item: Banner) {
emit('click', item)
// 如果有关联商品,跳转到商品详情
if (item.goodsId) {
uni.navigateTo({
url: `/pages/goods/detail?id=${item.goodsId}`,
})
}
}
</script>
<style lang="scss" scoped>
.banner {
width: 100%;
height: 400rpx;
}
.swiper {
width: 100%;
height: 100%;
}
.banner-image {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<view class="category-grid">
<view
v-for="item in list"
:key="item.id"
class="category-item"
@click="handleClick(item)"
>
<view class="icon-wrapper">
<text class="icon" :class="item.icon"></text>
</view>
<text class="name">{{ item.name }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import type { Category } from '@/typings/mall'
interface Props {
list: Category[] // 分类列表
columns?: number // 列数
}
const props = withDefaults(defineProps<Props>(), {
columns: 4,
})
const emit = defineEmits<{
click: [item: Category]
}>()
function handleClick(item: Category) {
emit('click', item)
// 跳转到分类页面
uni.navigateTo({
url: `/pages/sort/index?categoryId=${item.id}`,
})
}
</script>
<style lang="scss" scoped>
.category-grid {
display: grid;
grid-template-columns: repeat(v-bind(columns), 1fr);
gap: 24rpx;
padding: 24rpx;
background: #fff;
}
.category-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.icon-wrapper {
width: 96rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
.icon {
font-size: 48rpx;
color: #fff;
}
}
.name {
font-size: 24rpx;
color: #333;
text-align: center;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<view class="counter-input">
<view
class="btn btn-minus"
:class="{ disabled: modelValue <= min }"
@click="decrease"
>
<text class="i-carbon-subtract"></text>
</view>
<input
v-model="displayValue"
class="input"
type="number"
:disabled="disabled"
@blur="handleBlur"
>
<view
class="btn btn-plus"
:class="{ disabled: modelValue >= max }"
@click="increase"
>
<text class="i-carbon-add"></text>
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
modelValue: number // 当前值
min?: number // 最小值
max?: number // 最大值
step?: number // 步长
disabled?: boolean // 是否禁用
}
const props = withDefaults(defineProps<Props>(), {
min: 1,
max: 999,
step: 1,
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const displayValue = ref(props.modelValue.toString())
// 监听 modelValue 变化
watch(() => props.modelValue, (val) => {
displayValue.value = val.toString()
})
// 减少
function decrease() {
if (props.modelValue <= props.min)
return
const newValue = Math.max(props.min, props.modelValue - props.step)
emit('update:modelValue', newValue)
}
// 增加
function increase() {
if (props.modelValue >= props.max)
return
const newValue = Math.min(props.max, props.modelValue + props.step)
emit('update:modelValue', newValue)
}
// 输入框失焦
function handleBlur() {
let value = Number.parseInt(displayValue.value, 10)
if (Number.isNaN(value) || value < props.min) {
value = props.min
}
else if (value > props.max) {
value = props.max
}
displayValue.value = value.toString()
emit('update:modelValue', value)
}
</script>
<style lang="scss" scoped>
.counter-input {
display: flex;
align-items: center;
background: #f7f8fa;
border-radius: 30rpx;
padding: 4rpx;
}
.btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
color: #333;
font-size: 32rpx;
border-radius: 28rpx;
transition: all 0.3s;
&:active:not(.disabled) {
background: rgba(0, 0, 0, 0.05);
}
&.disabled {
color: #ccc;
}
}
.input {
width: 80rpx;
height: 56rpx;
text-align: center;
font-size: 28rpx;
font-weight: 600;
color: #333;
border: none;
outline: none;
background: transparent;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<view class="price-tag">
<view v-if="showOriginal && originalPrice && originalPrice > price" class="original-price">
¥{{ formatPrice(originalPrice) }}
</view>
<view class="current-price" :class="{ large: size === 'large' }">
<text class="symbol">¥</text>
<text class="integer">{{ priceInteger }}</text>
<text class="decimal">.{{ priceDecimal }}</text>
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
price: number // 当前价格
originalPrice?: number // 原价
showOriginal?: boolean // 是否显示原价
size?: 'normal' | 'large' // 尺寸
}
const props = withDefaults(defineProps<Props>(), {
showOriginal: true,
size: 'normal',
})
// 格式化价格
function formatPrice(price: number) {
return price.toFixed(2)
}
// 价格整数部分
const priceInteger = computed(() => {
return Math.floor(props.price).toString()
})
// 价格小数部分
const priceDecimal = computed(() => {
const decimal = (props.price % 1).toFixed(2).slice(2)
return decimal
})
</script>
<style lang="scss" scoped>
.price-tag {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.original-price {
font-size: 24rpx;
color: #999;
text-decoration: line-through;
}
.current-price {
color: #ff4d4f;
font-weight: 600;
display: flex;
align-items: baseline;
&.large {
.symbol {
font-size: 28rpx;
}
.integer {
font-size: 40rpx;
}
.decimal {
font-size: 28rpx;
}
}
.symbol {
font-size: 24rpx;
}
.integer {
font-size: 32rpx;
}
.decimal {
font-size: 24rpx;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<view class="search-bar" :class="{ focused: isFocused }">
<view class="search-input-wrapper">
<text class="icon i-carbon-search"></text>
<input
v-model="searchValue"
class="search-input"
type="text"
:placeholder="placeholder"
:placeholder-style="placeholderStyle"
@focus="handleFocus"
@blur="handleBlur"
@confirm="handleSearch"
>
<text v-if="searchValue" class="icon i-carbon-close" @click="handleClear"></text>
</view>
<view v-if="showCancel && isFocused" class="cancel-btn" @click="handleCancel">
取消
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
modelValue?: string // 搜索值
placeholder?: string // 占位符
showCancel?: boolean // 是否显示取消按钮
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '搜索商品',
showCancel: true,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'search': [value: string]
'cancel': []
'clear': []
}>()
const searchValue = ref(props.modelValue)
const isFocused = ref(false)
const placeholderStyle = 'color: #999; font-size: 28rpx'
// 监听 modelValue 变化
watch(() => props.modelValue, (val) => {
searchValue.value = val
})
// 监听 searchValue 变化
watch(searchValue, (val) => {
emit('update:modelValue', val)
})
function handleFocus() {
isFocused.value = true
}
function handleBlur() {
setTimeout(() => {
isFocused.value = false
}, 200)
}
function handleSearch() {
emit('search', searchValue.value)
}
function handleClear() {
searchValue.value = ''
emit('clear')
}
function handleCancel() {
searchValue.value = ''
isFocused.value = false
emit('cancel')
}
</script>
<style lang="scss" scoped>
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background: #fff;
transition: all 0.3s;
&.focused {
.search-input-wrapper {
flex: 1;
}
}
}
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
height: 64rpx;
padding: 0 24rpx;
background: #f5f5f5;
border-radius: 32rpx;
transition: all 0.3s;
.icon {
font-size: 32rpx;
color: #999;
&.i-carbon-close {
margin-left: auto;
padding: 8rpx;
}
}
.search-input {
flex: 1;
height: 100%;
margin-left: 16rpx;
font-size: 28rpx;
border: none;
outline: none;
background: transparent;
}
}
.cancel-btn {
margin-left: 16rpx;
padding: 0 16rpx;
font-size: 28rpx;
color: #333;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<view class="credit-card">
<view class="header">
<text class="title">{{ title }}</text>
<text class="date">更新于 {{ updateTime }}</text>
</view>
<view class="content">
<view class="row">
<view class="item">
<text class="label">总额度</text>
<text class="value">¥{{ formatPrice(totalLimit) }}</text>
</view>
<view class="item">
<text class="label">可用额度</text>
<text class="value highlight">¥{{ formatPrice(availableLimit) }}</text>
</view>
</view>
<!-- 进度条 -->
<view class="progress-wrapper">
<view class="progress-bg">
<view class="progress-bar" :style="{ width: percent + '%' }"></view>
</view>
<view class="progress-text">
<text>已用 {{ percent }}%</text>
<text>¥{{ formatPrice(usedLimit) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
title: string
totalLimit: number
usedLimit: number
availableLimit: number
updateTime: string
}
const props = defineProps<Props>()
const percent = computed(() => {
if (props.totalLimit === 0) return 0
return Math.min(100, Math.round((props.usedLimit / props.totalLimit) * 100))
})
function formatPrice(price: number) {
return price.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
<style lang="scss" scoped>
.credit-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
margin-bottom: 24rpx;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.title {
font-size: 32rpx;
font-weight: 600;
color: #333;
position: relative;
padding-left: 20rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 32rpx;
background: #ff4d4f;
border-radius: 4rpx;
}
}
.date {
font-size: 24rpx;
color: #999;
}
}
.content {
.row {
display: flex;
margin-bottom: 30rpx;
.item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.label {
font-size: 26rpx;
color: #666;
}
.value {
font-size: 36rpx;
font-weight: 600;
color: #333;
&.highlight {
color: #52c41a;
}
}
}
}
.progress-wrapper {
.progress-bg {
height: 12rpx;
background: #f5f5f5;
border-radius: 6rpx;
overflow: hidden;
margin-bottom: 12rpx;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #ff9c6e 0%, #ff4d4f 100%);
border-radius: 6rpx;
transition: width 0.3s ease;
}
.progress-text {
display: flex;
justify-content: space-between;
font-size: 24rpx;
color: #999;
}
}
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<view class="settlement-item">
<view class="header">
<text class="order-no">订单号{{ item.orderNo }}</text>
<text class="status" :class="statusClass">{{ statusText }}</text>
</view>
<view class="content">
<view class="row">
<text class="label">商户名称</text>
<text class="value">{{ item.merchantName }}</text>
</view>
<view class="row">
<text class="label">应结金额</text>
<text class="value price">¥{{ item.amount.toFixed(2) }}</text>
</view>
<view class="row">
<text class="label">到期日期</text>
<text class="value">{{ item.dueDate }}</text>
</view>
<view class="row" v-if="item.settlementDate">
<text class="label">结算日期</text>
<text class="value">{{ item.settlementDate }}</text>
</view>
</view>
<view class="footer" v-if="showAction">
<view class="btn" @click="handleAction">申请消账</view>
</view>
</view>
</template>
<script setup lang="ts">
import { SettlementStatus } from '@/typings/mall'
import type { Settlement } from '@/typings/mall'
interface Props {
item: Settlement
showAction?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showAction: false,
})
const emit = defineEmits<{
action: [item: Settlement]
}>()
const statusText = computed(() => {
switch (props.item.status) {
case SettlementStatus.SETTLED:
return '已结'
case SettlementStatus.UNSETTLED:
return '未结'
case SettlementStatus.OVERDUE:
return '已逾期'
default:
return ''
}
})
const statusClass = computed(() => {
switch (props.item.status) {
case SettlementStatus.SETTLED:
return 'success'
case SettlementStatus.UNSETTLED:
return 'warning'
case SettlementStatus.OVERDUE:
return 'danger'
default:
return ''
}
})
function handleAction() {
emit('action', props.item)
}
</script>
<style lang="scss" scoped>
.settlement-item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
margin-bottom: 20rpx;
.order-no {
font-size: 26rpx;
color: #666;
}
.status {
font-size: 24rpx;
padding: 4rpx 12rpx;
border-radius: 4rpx;
&.success {
color: #52c41a;
background: #f6ffed;
border: 1rpx solid #b7eb8f;
}
&.warning {
color: #faad14;
background: #fffbe6;
border: 1rpx solid #ffe58f;
}
&.danger {
color: #ff4d4f;
background: #fff1f0;
border: 1rpx solid #ffa39e;
}
}
}
.content {
.row {
display: flex;
justify-content: space-between;
margin-bottom: 16rpx;
font-size: 28rpx;
.label {
color: #666;
}
.value {
color: #333;
&.price {
font-weight: 600;
color: #ff4d4f;
}
}
}
}
.footer {
display: flex;
justify-content: flex-end;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
margin-top: 20rpx;
.btn {
padding: 12rpx 32rpx;
background: #1890ff;
color: #fff;
font-size: 26rpx;
border-radius: 30rpx;
&:active {
opacity: 0.8;
}
}
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<view class="write-off-popup" :class="{ show: visible }">
<view class="mask" @click="handleClose"></view>
<view class="content">
<view class="header">
<text class="title">提交消账申请</text>
<text class="close-btn i-carbon-close" @click="handleClose"></text>
</view>
<scroll-view scroll-y class="body">
<view class="form-item">
<view class="label">消账金额</view>
<input
v-model="formData.amount"
class="input"
type="digit"
placeholder="请输入金额"
/>
</view>
<view class="form-item">
<view class="label">备注说明</view>
<textarea
v-model="formData.remark"
class="textarea"
placeholder="请输入备注说明"
/>
</view>
<view class="form-item">
<view class="label">上传凭证</view>
<view class="upload-box" @click="handleUpload">
<text class="i-carbon-add icon"></text>
<text class="text">上传图片</text>
</view>
<view class="preview-list" v-if="formData.proof.length">
<view
v-for="(img, index) in formData.proof"
:key="index"
class="preview-item"
>
<image :src="img" mode="aspectFill" />
<view class="del-btn" @click="handleRemoveImg(index)">
<text class="i-carbon-close"></text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="footer">
<view class="btn submit-btn" @click="handleSubmit">提交申请</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
visible: boolean
defaultAmount?: number
}
const props = withDefaults(defineProps<Props>(), {
defaultAmount: 0,
})
const emit = defineEmits<{
'update:visible': [visible: boolean]
'submit': [data: { amount: number, remark: string, proof: string[] }]
}>()
const formData = reactive({
amount: '',
remark: '',
proof: [] as string[],
})
watch(() => props.visible, (val) => {
if (val) {
formData.amount = props.defaultAmount.toString()
formData.remark = ''
formData.proof = []
}
})
function handleClose() {
emit('update:visible', false)
}
function handleUpload() {
// 模拟上传
uni.chooseImage({
count: 1,
success: (res) => {
// 实际开发中需要上传到服务器,这里直接使用本地路径模拟
formData.proof.push(res.tempFilePaths[0])
},
})
}
function handleRemoveImg(index: number) {
formData.proof.splice(index, 1)
}
function handleSubmit() {
const amount = Number.parseFloat(formData.amount)
if (!amount || amount <= 0) {
uni.showToast({ title: '请输入有效金额', icon: 'none' })
return
}
if (!formData.remark) {
uni.showToast({ title: '请输入备注', icon: 'none' })
return
}
emit('submit', {
amount,
remark: formData.remark,
proof: [...formData.proof],
})
handleClose()
}
</script>
<style lang="scss" scoped>
.write-off-popup {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
visibility: hidden;
transition: visibility 0.3s;
&.show {
visibility: visible;
.mask {
opacity: 1;
}
.content {
transform: translateY(0);
}
}
}
.mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.content {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
transform: translateY(100%);
transition: transform 0.3s;
display: flex;
flex-direction: column;
max-height: 80vh;
}
.header {
padding: 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f5f5f5;
.title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.close-btn {
font-size: 40rpx;
color: #999;
}
}
.body {
padding: 30rpx;
max-height: 600rpx;
}
.form-item {
margin-bottom: 30rpx;
.label {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
}
.input {
height: 80rpx;
background: #f5f5f5;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.textarea {
width: 100%;
height: 160rpx;
background: #f5f5f5;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
box-sizing: border-box;
}
}
.upload-box {
width: 160rpx;
height: 160rpx;
background: #f5f5f5;
border-radius: 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
.icon {
font-size: 48rpx;
margin-bottom: 8rpx;
}
.text {
font-size: 24rpx;
}
}
.preview-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-top: 20rpx;
.preview-item {
width: 160rpx;
height: 160rpx;
position: relative;
image {
width: 100%;
height: 100%;
border-radius: 8rpx;
}
.del-btn {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24rpx;
}
}
}
.footer {
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f5f5f5;
.submit-btn {
height: 88rpx;
background: #1890ff;
color: #fff;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.9;
}
}
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<view class="goods-card" @click="handleClick">
<image class="cover" :src="goods.cover" mode="aspectFill" />
<view class="info">
<view class="shop-name" v-if="goods.shopName">
<text class="i-carbon-store icon"></text>
<text>{{ goods.shopName }}</text>
</view>
<view class="name">{{ goods.name }}</view>
<view class="tags" v-if="goods.tags && goods.tags.length">
<text v-for="tag in goods.tags" :key="tag" class="tag">{{ tag }}</text>
</view>
<view class="bottom">
<PriceTag :price="goods.price" :original-price="goods.originalPrice" size="normal" />
<view class="sales">已售{{ formatSales(goods.sales) }}</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { Goods } from '@/typings/mall'
import PriceTag from '../common/PriceTag.vue'
interface Props {
goods: Goods
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [goods: Goods]
}>()
function handleClick() {
emit('click', props.goods)
uni.navigateTo({
url: `/pages/goods/detail?id=${props.goods.id}`,
})
}
function formatSales(sales: number) {
if (sales >= 10000) {
return `${(sales / 10000).toFixed(1)}`
}
return sales.toString()
}
</script>
<style lang="scss" scoped>
.goods-card {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s;
&:active {
transform: scale(0.98);
}
}
.cover {
width: 100%;
height: 340rpx;
background: #f5f5f5;
}
.info {
padding: 16rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.shop-name {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: #666;
.icon {
font-size: 28rpx;
}
}
.name {
font-size: 28rpx;
color: #333;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
min-height: 78rpx;
}
.tags {
display: flex;
gap: 8rpx;
flex-wrap: wrap;
}
.tag {
padding: 4rpx 12rpx;
font-size: 20rpx;
color: #ff4d4f;
background: #fff1f0;
border-radius: 4rpx;
}
.bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-top: 8rpx;
flex-wrap: wrap; // 允许换行
gap: 8rpx; // 增加间距
}
.sales {
font-size: 24rpx;
color: #999;
white-space: nowrap; // 防止文字竖排
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<view class="spec-selector-popup" :class="{ show: visible }">
<view class="mask" @click="handleClose"></view>
<view class="content">
<view class="header">
<image class="goods-img" :src="goods.cover" mode="aspectFill" />
<view class="info">
<view class="price-wrapper">
<text class="currency">¥</text>
<text class="price">{{ goods.price }}</text>
</view>
<view class="stock">库存 {{ goods.stock }} </view>
<view class="selected-text">
{{ selectedText }}
</view>
</view>
<view class="close-btn" @click="handleClose">
<text class="i-carbon-close"></text>
</view>
</view>
<scroll-view scroll-y class="body">
<view class="body-content">
<view v-for="(spec, index) in goods.specs" :key="index" class="spec-group">
<view class="spec-title">{{ spec.name }}</view>
<view class="spec-values">
<view
v-for="value in spec.values"
:key="value"
class="spec-item"
:class="{ active: selectedSpecs[spec.name] === value }"
@click="handleSelectSpec(spec.name, value)"
>
{{ value }}
</view>
</view>
</view>
<view class="quantity-group">
<view class="label">购买数量</view>
<CounterInput v-model="quantity" :max="goods.stock" />
</view>
</view>
</scroll-view>
<view class="footer">
<view class="btn cart-btn" @click="handleConfirm('cart')">加入购物车</view>
<view class="btn buy-btn" @click="handleConfirm('buy')">立即购买</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { Goods } from '@/typings/mall'
import CounterInput from '../common/CounterInput.vue'
interface Props {
visible: boolean
goods: Goods
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [visible: boolean]
'confirm': [data: { quantity: number, specs: Record<string, string>, type: 'cart' | 'buy' }]
}>()
const quantity = ref(1)
const selectedSpecs = ref<Record<string, string>>({})
// 初始化选中规格
watch(() => props.goods, (newGoods) => {
if (newGoods && newGoods.specs) {
const specs: Record<string, string> = {}
newGoods.specs.forEach(spec => {
if (spec.values.length > 0) {
specs[spec.name] = spec.values[0]
}
})
selectedSpecs.value = specs
}
}, { immediate: true })
// 计算已选文案
const selectedText = computed(() => {
const specs = Object.values(selectedSpecs.value).join('')
return specs ? `已选:${specs}` : '请选择规格'
})
function handleClose() {
emit('update:visible', false)
}
function handleSelectSpec(name: string, value: string) {
selectedSpecs.value[name] = value
}
function handleConfirm(type: 'cart' | 'buy') {
emit('confirm', {
quantity: quantity.value,
specs: { ...selectedSpecs.value },
type,
})
handleClose()
}
</script>
<style lang="scss" scoped>
.spec-selector-popup {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
visibility: hidden;
transition: visibility 0.3s;
&.show {
visibility: visible;
.mask {
opacity: 1;
}
.content {
transform: translateY(0);
}
}
}
.mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.content {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
transform: translateY(100%);
transition: transform 0.3s;
display: flex;
flex-direction: column;
max-height: 80vh;
}
.header {
padding: 24rpx;
display: flex;
gap: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
position: relative;
.goods-img {
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
background: #f5f5f5;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8rpx;
}
.price-wrapper {
color: #ff4d4f;
font-weight: 600;
.currency {
font-size: 24rpx;
}
.price {
font-size: 40rpx;
}
}
.stock {
font-size: 24rpx;
color: #999;
}
.selected-text {
font-size: 26rpx;
color: #333;
}
.close-btn {
position: absolute;
top: 24rpx;
right: 24rpx;
padding: 10rpx;
color: #999;
font-size: 32rpx;
}
}
.body {
flex: 1;
max-height: 600rpx;
}
.body-content {
padding: 24rpx;
}
.spec-group {
margin-bottom: 32rpx;
.spec-title {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
}
.spec-values {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.spec-item {
padding: 10rpx 30rpx;
background: #f5f5f5;
border-radius: 30rpx;
font-size: 26rpx;
color: #333;
border: 1rpx solid transparent;
&.active {
background: #fff1f0;
color: #ff4d4f;
border-color: #ff4d4f;
}
}
}
.quantity-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 40rpx;
margin-bottom: 40rpx;
.label {
font-size: 28rpx;
color: #333;
}
}
.footer {
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
gap: 20rpx;
border-top: 1rpx solid #f5f5f5;
.btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
color: #fff;
&.cart-btn {
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
}
&.buy-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
}
}
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<view class="benefits-grid">
<view
v-for="(item, index) in list"
:key="index"
class="benefit-item"
>
<view class="icon-wrapper">
<text class="i-carbon-star icon"></text>
</view>
<text class="name">{{ item }}</text>
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
list: string[]
}
defineProps<Props>()
</script>
<style lang="scss" scoped>
.benefits-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24rpx;
padding: 24rpx;
background: #fff;
border-radius: 16rpx;
}
.benefit-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.icon-wrapper {
width: 88rpx;
height: 88rpx;
background: #fff7e6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.icon {
font-size: 40rpx;
color: #fa8c16;
}
}
.name {
font-size: 24rpx;
color: #333;
text-align: center;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<view class="member-card" :style="{ background: config?.color || '#333' }">
<view class="header">
<view class="info">
<view class="level-name">{{ config?.name || '普通会员' }}</view>
<view class="expire" v-if="member">有效期至 {{ member.expireDate }}</view>
</view>
<view class="icon-wrapper">
<text class="i-carbon-crown icon"></text>
</view>
</view>
<view class="footer">
<view class="points">
<text class="label">当前积分</text>
<text class="value">{{ member?.points || 0 }}</text>
</view>
<view class="btn">会员中心</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { Member } from '@/typings/mall'
interface Props {
member: Member | null
config: any
}
defineProps<Props>()
</script>
<style lang="scss" scoped>
.member-card {
border-radius: 20rpx;
padding: 40rpx;
color: #fff;
position: relative;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
&::after {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 400rpx;
height: 400rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 60rpx;
.level-name {
font-size: 40rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.expire {
font-size: 24rpx;
opacity: 0.8;
}
.icon-wrapper {
width: 80rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.icon {
font-size: 40rpx;
}
}
}
.footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
.points {
display: flex;
flex-direction: column;
.label {
font-size: 24rpx;
opacity: 0.8;
margin-bottom: 8rpx;
}
.value {
font-size: 48rpx;
font-weight: 600;
}
}
.btn {
padding: 12rpx 32rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 30rpx;
font-size: 24rpx;
backdrop-filter: blur(10px);
}
}
</style>

37
src/mock/address.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Address } from '@/typings/mall'
/**
* 地址模拟数据
*/
export const mockAddressList: Address[] = [
{
id: 'addr_001',
name: '张三',
phone: '13800138000',
province: '广东省',
city: '深圳市',
district: '南山区',
detail: '科技园南区深南大道10000号',
isDefault: true,
},
{
id: 'addr_002',
name: '李四',
phone: '13900139000',
province: '广东省',
city: '广州市',
district: '天河区',
detail: '珠江新城花城大道88号',
isDefault: false,
},
{
id: 'addr_003',
name: '王五',
phone: '13700137000',
province: '北京市',
city: '北京市',
district: '朝阳区',
detail: '建国路99号',
isDefault: false,
},
]

31
src/mock/banner.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { Banner } from '@/typings/mall'
/**
* 轮播图模拟数据
*/
export const mockBannerList: Banner[] = [
{
id: 'banner_001',
image: 'https://picsum.photos/750/400?random=banner1',
title: '春季新品上市',
goodsId: 'goods_001',
},
{
id: 'banner_002',
image: 'https://picsum.photos/750/400?random=banner2',
title: '数码产品大促',
goodsId: 'goods_004',
},
{
id: 'banner_003',
image: 'https://picsum.photos/750/400?random=banner3',
title: '美妆护肤专场',
goodsId: 'goods_010',
},
{
id: 'banner_004',
image: 'https://picsum.photos/750/400?random=banner4',
title: '家居好物推荐',
goodsId: 'goods_008',
},
]

55
src/mock/category.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { Category } from '@/typings/mall'
/**
* 分类模拟数据
*/
export const mockCategoryList: Category[] = [
{
id: 'cat_001',
name: '服装',
icon: 'i-carbon-clothing',
cover: 'https://picsum.photos/200/200?random=cat1',
},
{
id: 'cat_002',
name: '数码',
icon: 'i-carbon-phone',
cover: 'https://picsum.photos/200/200?random=cat2',
},
{
id: 'cat_003',
name: '食品',
icon: 'i-carbon-restaurant',
cover: 'https://picsum.photos/200/200?random=cat3',
},
{
id: 'cat_004',
name: '家居',
icon: 'i-carbon-home',
cover: 'https://picsum.photos/200/200?random=cat4',
},
{
id: 'cat_005',
name: '美妆',
icon: 'i-carbon-face-satisfied',
cover: 'https://picsum.photos/200/200?random=cat5',
},
{
id: 'cat_006',
name: '运动',
icon: 'i-carbon-basketball',
cover: 'https://picsum.photos/200/200?random=cat6',
},
{
id: 'cat_007',
name: '图书',
icon: 'i-carbon-book',
cover: 'https://picsum.photos/200/200?random=cat7',
},
{
id: 'cat_008',
name: '母婴',
icon: 'i-carbon-baby',
cover: 'https://picsum.photos/200/200?random=cat8',
},
]

168
src/mock/finance.ts Normal file
View File

@@ -0,0 +1,168 @@
import { SettlementStatus, WriteOffStatus } from '@/typings/mall'
import type { CreditLimit, Settlement, WriteOff } from '@/typings/mall'
/**
* 信用额度模拟数据
*/
export const mockCreditLimitList: CreditLimit[] = [
{
merchantId: 'merchant_a',
merchantName: '商户A',
totalLimit: 100000,
usedLimit: 99900,
availableLimit: 100,
updateTime: '2025-11-28 10:00:00',
},
{
merchantId: 'merchant_b',
merchantName: '商户B',
totalLimit: 50000,
usedLimit: 12000,
availableLimit: 38000,
updateTime: '2025-11-28 10:00:00',
},
]
/**
* 应结账款模拟数据
*/
export const mockSettlementList: Settlement[] = [
// 未结账款
{
id: 'settlement_001',
orderNo: 'ORD20251128001',
merchantId: 'merchant_a',
merchantName: '商户A',
amount: 5000,
status: SettlementStatus.UNSETTLED,
dueDate: '2025-12-15',
relatedOrders: ['ORD20251128001', 'ORD20251128002'],
},
{
id: 'settlement_002',
orderNo: 'ORD20251125001',
merchantId: 'merchant_a',
merchantName: '商户A',
amount: 8000,
status: SettlementStatus.UNSETTLED,
dueDate: '2025-12-10',
relatedOrders: ['ORD20251125001'],
},
{
id: 'settlement_003',
orderNo: 'ORD20251120001',
merchantId: 'merchant_b',
merchantName: '商户B',
amount: 3000,
status: SettlementStatus.UNSETTLED,
dueDate: '2025-12-05',
relatedOrders: ['ORD20251120001', 'ORD20251120002', 'ORD20251120003'],
},
{
id: 'settlement_004',
orderNo: 'ORD20251115001',
merchantId: 'merchant_b',
merchantName: '商户B',
amount: 4500,
status: SettlementStatus.OVERDUE,
dueDate: '2025-11-30',
relatedOrders: ['ORD20251115001'],
},
// 新增:昨天到期(逾期)
{
id: 'settlement_new_001',
orderNo: 'ORD20251128999',
merchantId: 'merchant_a',
merchantName: '商户A',
amount: 1200,
status: SettlementStatus.OVERDUE,
dueDate: '2025-11-28',
relatedOrders: ['ORD20251128999'],
},
// 新增:明天到期
{
id: 'settlement_new_002',
orderNo: 'ORD20251129001',
merchantId: 'merchant_b',
merchantName: '商户B',
amount: 2800,
status: SettlementStatus.UNSETTLED,
dueDate: '2025-11-30',
relatedOrders: ['ORD20251129001'],
},
// 已结账款
{
id: 'settlement_005',
orderNo: 'ORD20251110001',
merchantId: 'merchant_a',
merchantName: '商户A',
amount: 6000,
status: SettlementStatus.SETTLED,
dueDate: '2025-11-25',
settlementDate: '2025-11-22',
relatedOrders: ['ORD20251110001'],
},
{
id: 'settlement_006',
orderNo: 'ORD20251105001',
merchantId: 'merchant_a',
merchantName: '商户A',
amount: 7500,
status: SettlementStatus.SETTLED,
dueDate: '2025-11-20',
settlementDate: '2025-11-18',
relatedOrders: ['ORD20251105001', 'ORD20251105002'],
},
{
id: 'settlement_007',
orderNo: 'ORD20251101001',
merchantId: 'merchant_b',
merchantName: '商户B',
amount: 2500,
status: SettlementStatus.SETTLED,
dueDate: '2025-11-15',
settlementDate: '2025-11-12',
relatedOrders: ['ORD20251101001'],
},
]
/**
* 消账记录模拟数据
*/
export const mockWriteOffList: WriteOff[] = [
{
id: 'writeoff_001',
settlementId: 'settlement_005',
amount: 6000,
proof: [
'https://picsum.photos/400/300?random=proof1',
'https://picsum.photos/400/300?random=proof2',
],
remark: '已完成付款,请查收',
submitTime: '2025-11-22 14:30:00',
status: WriteOffStatus.APPROVED,
},
{
id: 'writeoff_002',
settlementId: 'settlement_006',
amount: 7500,
proof: [
'https://picsum.photos/400/300?random=proof3',
],
remark: '转账凭证',
submitTime: '2025-11-18 10:15:00',
status: WriteOffStatus.APPROVED,
},
{
id: 'writeoff_003',
settlementId: 'settlement_001',
amount: 5000,
proof: [
'https://picsum.photos/400/300?random=proof4',
],
remark: '部分付款',
submitTime: '2025-11-28 09:00:00',
status: WriteOffStatus.PENDING,
},
]

238
src/mock/goods.ts Normal file
View File

@@ -0,0 +1,238 @@
import type { Goods } from '@/typings/mall'
/**
* 商品模拟数据
*/
export const mockGoodsList: Goods[] = [
// 服装类商品
{
id: 'goods_001',
shopId: 'merchant_a',
shopName: '商户A',
name: '2024春季新款连衣裙',
cover: 'https://picsum.photos/400/400?random=1',
images: [
'https://picsum.photos/800/800?random=1',
'https://picsum.photos/800/800?random=2',
'https://picsum.photos/800/800?random=3',
],
price: 299,
originalPrice: 599,
stock: 100,
sales: 1234,
description: '优质面料,舒适透气,修身显瘦,适合春夏季节穿着。',
specs: [
{ name: '颜色', values: ['黑色', '白色', '红色'] },
{ name: '尺码', values: ['S', 'M', 'L', 'XL'] },
],
tags: ['新品', '热销'],
categoryId: 'cat_001',
categoryName: '服装',
},
{
id: 'goods_002',
shopId: 'merchant_a',
shopName: '商户A',
name: '男士休闲T恤',
cover: 'https://picsum.photos/400/400?random=4',
images: [
'https://picsum.photos/800/800?random=4',
'https://picsum.photos/800/800?random=5',
],
price: 89,
originalPrice: 159,
stock: 200,
sales: 856,
description: '纯棉面料,柔软舒适,经典百搭款式。',
specs: [
{ name: '颜色', values: ['白色', '灰色', '黑色', '蓝色'] },
{ name: '尺码', values: ['M', 'L', 'XL', 'XXL'] },
],
tags: ['热销'],
categoryId: 'cat_001',
categoryName: '服装',
},
{
id: 'goods_003',
shopId: 'merchant_a',
shopName: '商户A',
name: '女士牛仔裤',
cover: 'https://picsum.photos/400/400?random=6',
images: [
'https://picsum.photos/800/800?random=6',
'https://picsum.photos/800/800?random=7',
],
price: 199,
originalPrice: 399,
stock: 150,
sales: 678,
description: '高腰设计,显瘦修身,弹力面料,穿着舒适。',
specs: [
{ name: '颜色', values: ['浅蓝', '深蓝', '黑色'] },
{ name: '尺码', values: ['25', '26', '27', '28', '29'] },
],
tags: ['推荐'],
categoryId: 'cat_001',
categoryName: '服装',
},
// 数码类商品
{
id: 'goods_004',
shopId: 'merchant_b',
shopName: '商户B',
name: '无线蓝牙耳机',
cover: 'https://picsum.photos/400/400?random=8',
images: [
'https://picsum.photos/800/800?random=8',
'https://picsum.photos/800/800?random=9',
],
price: 299,
originalPrice: 499,
stock: 80,
sales: 2345,
description: '主动降噪,长续航,高音质,支持快充。',
specs: [
{ name: '颜色', values: ['白色', '黑色'] },
],
tags: ['新品', '热销'],
categoryId: 'cat_002',
categoryName: '数码',
},
{
id: 'goods_005',
shopId: 'merchant_b',
shopName: '商户B',
name: '智能手表',
cover: 'https://picsum.photos/400/400?random=10',
images: [
'https://picsum.photos/800/800?random=10',
'https://picsum.photos/800/800?random=11',
],
price: 899,
originalPrice: 1299,
stock: 50,
sales: 567,
description: '健康监测,运动追踪,消息提醒,长续航。',
specs: [
{ name: '颜色', values: ['黑色', '银色', '金色'] },
{ name: '表带', values: ['硅胶', '皮革', '金属'] },
],
tags: ['新品'],
categoryId: 'cat_002',
categoryName: '数码',
},
// 食品类商品
{
id: 'goods_006',
shopId: 'merchant_a',
shopName: '商户A',
name: '进口零食大礼包',
cover: 'https://picsum.photos/400/400?random=12',
images: [
'https://picsum.photos/800/800?random=12',
'https://picsum.photos/800/800?random=13',
],
price: 128,
originalPrice: 198,
stock: 300,
sales: 1890,
description: '多种口味,营养健康,适合全家分享。',
specs: [],
tags: ['热销', '推荐'],
categoryId: 'cat_003',
categoryName: '食品',
},
{
id: 'goods_007',
shopId: 'merchant_a',
shopName: '商户A',
name: '有机坚果礼盒',
cover: 'https://picsum.photos/400/400?random=14',
images: [
'https://picsum.photos/800/800?random=14',
'https://picsum.photos/800/800?random=15',
],
price: 168,
originalPrice: 268,
stock: 120,
sales: 456,
description: '精选优质坚果,营养丰富,送礼佳品。',
specs: [
{ name: '规格', values: ['500g', '1000g'] },
],
tags: ['推荐'],
categoryId: 'cat_003',
categoryName: '食品',
},
// 家居类商品
{
id: 'goods_008',
shopId: 'merchant_b',
shopName: '商户B',
name: '北欧风格台灯',
cover: 'https://picsum.photos/400/400?random=16',
images: [
'https://picsum.photos/800/800?random=16',
'https://picsum.photos/800/800?random=17',
],
price: 159,
originalPrice: 299,
stock: 90,
sales: 234,
description: '简约设计,护眼光源,适合卧室书房。',
specs: [
{ name: '颜色', values: ['白色', '木色'] },
],
tags: ['新品'],
categoryId: 'cat_004',
categoryName: '家居',
},
{
id: 'goods_009',
shopId: 'merchant_b',
shopName: '商户B',
name: '四件套床上用品',
cover: 'https://picsum.photos/400/400?random=18',
images: [
'https://picsum.photos/800/800?random=18',
'https://picsum.photos/800/800?random=19',
],
price: 299,
originalPrice: 599,
stock: 150,
sales: 789,
description: '纯棉面料,柔软亲肤,多种花色可选。',
specs: [
{ name: '尺寸', values: ['1.5m床', '1.8m床', '2.0m床'] },
{ name: '颜色', values: ['浅灰', '深灰', '米白', '粉色'] },
],
tags: ['热销'],
categoryId: 'cat_004',
categoryName: '家居',
},
// 美妆类商品
{
id: 'goods_010',
shopId: 'merchant_a',
shopName: '商户A',
name: '保湿面霜套装',
cover: 'https://picsum.photos/400/400?random=20',
images: [
'https://picsum.photos/800/800?random=20',
'https://picsum.photos/800/800?random=21',
],
price: 399,
originalPrice: 699,
stock: 200,
sales: 1567,
description: '深层补水,改善肌肤,温和不刺激。',
specs: [],
tags: ['热销', '推荐'],
categoryId: 'cat_005',
categoryName: '美妆',
},
]

10
src/mock/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Mock 数据统一导出
*/
export * from './goods'
export * from './category'
export * from './finance'
export * from './member'
export * from './banner'
export * from './address'

40
src/mock/member.ts Normal file
View File

@@ -0,0 +1,40 @@
import { MemberLevel } from '@/typings/mall'
import type { Member } from '@/typings/mall'
/**
* 会员等级配置
*/
export const memberLevelConfig = {
[MemberLevel.NORMAL]: {
name: '普通会员',
benefits: ['积分累积', '生日优惠'],
color: '#999999',
},
[MemberLevel.SILVER]: {
name: '银卡会员',
benefits: ['积分累积', '生日优惠', '专属客服', '9.5折优惠'],
color: '#C0C0C0',
},
[MemberLevel.GOLD]: {
name: '金卡会员',
benefits: ['积分累积', '生日优惠', '专属客服', '9折优惠', '免运费'],
color: '#FFD700',
},
[MemberLevel.PLATINUM]: {
name: '白金会员',
benefits: ['积分累积', '生日优惠', '专属客服', '8.5折优惠', '免运费', '优先发货'],
color: '#E5E4E2',
},
}
/**
* 会员模拟数据
*/
export const mockMember: Member = {
id: 'member_001',
userId: 'user_001',
level: MemberLevel.GOLD,
points: 3580,
expireDate: '2026-11-28',
benefits: memberLevelConfig[MemberLevel.GOLD].benefits,
}

View File

@@ -0,0 +1,123 @@
<script lang="ts" setup>
import { useFinanceStore } from '@/store/finance'
import CreditCard from '@/components/finance/CreditCard.vue'
definePage({
style: {
navigationBarTitleText: '信用额度',
enablePullDownRefresh: true,
},
})
const financeStore = useFinanceStore()
const loading = ref(true)
onShow(() => {
loadData()
})
async function loadData() {
loading.value = true
await financeStore.fetchCreditLimit()
loading.value = false
uni.stopPullDownRefresh()
}
onPullDownRefresh(() => {
loadData()
})
</script>
<template>
<view class="credit-page">
<!-- 顶部总览 -->
<view class="overview-card">
<view class="label">总可用额度 ()</view>
<view class="amount">{{ financeStore.totalAvailableLimit.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</view>
<view class="sub-info">
<text>总额度 ¥{{ financeStore.creditLimits.reduce((sum, item) => sum + item.totalLimit, 0).toLocaleString() }}</text>
<text class="divider">|</text>
<text>已用 ¥{{ financeStore.totalUsedLimit.toLocaleString() }}</text>
</view>
</view>
<!-- 商户额度列表 -->
<view class="list-container">
<view class="section-title">商户额度详情</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-else>
<CreditCard
v-for="item in financeStore.creditLimits"
:key="item.merchantId"
:title="item.merchantName"
:total-limit="item.totalLimit"
:used-limit="item.usedLimit"
:available-limit="item.availableLimit"
:update-time="item.updateTime"
/>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.credit-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 40rpx;
}
.overview-card {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
padding: 60rpx 40rpx;
color: #fff;
.label {
font-size: 28rpx;
opacity: 0.8;
margin-bottom: 16rpx;
}
.amount {
font-size: 64rpx;
font-weight: 600;
margin-bottom: 30rpx;
}
.sub-info {
display: flex;
align-items: center;
font-size: 26rpx;
opacity: 0.9;
.divider {
margin: 0 20rpx;
opacity: 0.5;
}
}
}
.list-container {
padding: 30rpx;
margin-top: -40rpx;
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
padding-left: 10rpx;
}
}
.loading {
padding: 40rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
</style>

View File

@@ -0,0 +1,744 @@
<script lang="ts" setup>
import type { Settlement } from '@/typings/mall'
import SettlementItem from '@/components/finance/SettlementItem.vue'
import WriteOffForm from '@/components/finance/WriteOffForm.vue'
import { useFinanceStore } from '@/store/finance'
import { SettlementStatus } from '@/typings/mall'
// 手动导入 wot-design-uni 组件
import WdTabs from 'wot-design-uni/components/wd-tabs/wd-tabs.vue'
import WdTab from 'wot-design-uni/components/wd-tab/wd-tab.vue'
import WdCard from 'wot-design-uni/components/wd-card/wd-card.vue'
import WdTag from 'wot-design-uni/components/wd-tag/wd-tag.vue'
import WdCellGroup from 'wot-design-uni/components/wd-cell-group/wd-cell-group.vue'
import WdCell from 'wot-design-uni/components/wd-cell/wd-cell.vue'
import WdButton from 'wot-design-uni/components/wd-button/wd-button.vue'
import WdMessageBox from 'wot-design-uni/components/wd-message-box/wd-message-box.vue'
import WdStatusTip from 'wot-design-uni/components/wd-status-tip/wd-status-tip.vue'
definePage({
style: {
navigationBarTitleText: '应结账款',
enablePullDownRefresh: true,
},
})
const financeStore = useFinanceStore()
// 状态
const currentTab = ref(0)
const tabs = [
{ name: '未结', status: SettlementStatus.UNSETTLED },
{ name: '已结', status: SettlementStatus.SETTLED },
]
const writeOffVisible = ref(false)
const currentSettlement = ref<Settlement | null>(null)
const currentMerchantSettlements = ref<Settlement[]>([]) // 批量消账时的商户所有账款
const isBatchMode = ref(false) // 是否批量消账模式
// 按商户分组
const groupedByMerchant = computed(() => {
const groups: Record<string, {
merchantId: string
merchantName: string
settlements: Settlement[]
totalAmount: number
hasOverdue: boolean
}> = {}
financeStore.settlementList.forEach((settlement) => {
// 验证数据完整性
if (!settlement || !settlement.merchantId || !settlement.status) {
console.warn('Invalid settlement data:', settlement)
return
}
if (!groups[settlement.merchantId]) {
groups[settlement.merchantId] = {
merchantId: settlement.merchantId,
merchantName: settlement.merchantName,
settlements: [],
totalAmount: 0,
hasOverdue: false,
}
}
groups[settlement.merchantId].settlements.push(settlement)
groups[settlement.merchantId].totalAmount += settlement.amount
if (settlement.status === SettlementStatus.OVERDUE) {
groups[settlement.merchantId].hasOverdue = true
}
})
return Object.values(groups)
})
// 页面显示
onShow(() => {
loadData()
})
// 加载数据
async function loadData() {
// 安全检查
if (currentTab.value < 0 || currentTab.value >= tabs.length) {
console.error('Invalid tab index:', currentTab.value)
currentTab.value = 0
}
const status = tabs[currentTab.value].status
await Promise.all([
financeStore.fetchSettlementList({ status }),
financeStore.fetchDueOrders(),
])
uni.stopPullDownRefresh()
}
// 切换 Tab
function handleTabChange(event: any) {
// 兼容处理wot-ui 的 change 事件可能传递 { index: 1, ... } 或者直接是 1
const index = typeof event === 'object' ? event.index : event
console.log('Tab changed to:', index)
if (typeof index === 'number' && index >= 0 && index < tabs.length) {
currentTab.value = index
loadData()
} else {
console.error('Invalid tab index received:', event)
}
}
// 下拉刷新
onPullDownRefresh(() => {
loadData()
})
// 打开单个订单消账弹窗
function handleOpenWriteOff(item: Settlement) {
currentSettlement.value = item
currentMerchantSettlements.value = []
isBatchMode.value = false
writeOffVisible.value = true
}
// 打开商户批量消账弹窗
function handleOpenBatchWriteOff(merchantId: string) {
const group = groupedByMerchant.value.find(g => g.merchantId === merchantId)
if (!group)
return
currentSettlement.value = null
currentMerchantSettlements.value = group.settlements
isBatchMode.value = true
writeOffVisible.value = true
}
// 计算当前消账金额
const currentWriteOffAmount = computed(() => {
if (isBatchMode.value) {
return currentMerchantSettlements.value.reduce((sum, s) => sum + s.amount, 0)
}
return currentSettlement.value?.amount || 0
})
// 提交消账
async function handleSubmitWriteOff(data: { amount: number, remark: string, proof: string[] }) {
uni.showLoading({ title: '提交中...' })
try {
if (isBatchMode.value) {
// 批量消账
for (const settlement of currentMerchantSettlements.value) {
await financeStore.submitWriteOff({
settlementId: settlement.id,
amount: settlement.amount,
...data,
})
}
}
else if (currentSettlement.value) {
// 单个消账
await financeStore.submitWriteOff({
settlementId: currentSettlement.value.id,
...data,
})
}
uni.showToast({ title: '提交成功', icon: 'success' })
writeOffVisible.value = false
loadData() // 刷新列表
}
catch (error) {
uni.showToast({ title: '提交失败', icon: 'none' })
}
finally {
uni.hideLoading()
}
}
// 状态文本
function getStatusText(status: SettlementStatus) {
if (!status) return '未知'
switch (status) {
case SettlementStatus.SETTLED:
return '已结'
case SettlementStatus.UNSETTLED:
return '未结'
case SettlementStatus.OVERDUE:
return '已逾期'
default:
return ''
}
}
// 状态样式类
function getStatusClass(status: SettlementStatus) {
if (!status) return ''
switch (status) {
case SettlementStatus.SETTLED:
return 'success'
case SettlementStatus.UNSETTLED:
return 'warning'
case SettlementStatus.OVERDUE:
return 'danger'
default:
return ''
}
}
// 状态类型 (用于 wot-ui tag)
function getStatusType(status: SettlementStatus): 'success' | 'warning' | 'danger' | 'primary' {
if (!status) return 'primary'
switch (status) {
case SettlementStatus.SETTLED:
return 'success'
case SettlementStatus.UNSETTLED:
return 'warning'
case SettlementStatus.OVERDUE:
return 'danger'
default:
return 'primary'
}
}
// 判断是否紧急3天内到期或已逾期
function isUrgent(dateStr: string) {
const now = new Date()
const dueDate = new Date(dateStr)
const diffTime = dueDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays <= 3
}
// 获取距离到期天数文本
function getDaysUntilDue(dateStr: string) {
const now = new Date()
const dueDate = new Date(dateStr)
const diffTime = dueDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays < 0) return `逾期 ${Math.abs(diffDays)}`
if (diffDays === 0) return '今天到期'
return `${diffDays}`
}
</script>
<template>
<view class="settlement-page">
<!-- 到期提醒 -->
<view v-if="financeStore.dueOrders.length > 0" class="due-alert">
<view class="alert-box">
<view class="alert-header">
<text class="i-carbon-warning-filled alert-icon" />
<text class="alert-title">近期到期提醒</text>
</view>
<scroll-view scroll-x class="due-list" show-scrollbar="false">
<view
v-for="item in financeStore.dueOrders"
:key="item.id"
class="due-item"
>
<view class="due-header">
<text class="merchant-name">{{ item.merchantName }}</text>
<text class="due-days" :class="{ urgent: isUrgent(item.dueDate) }">
{{ getDaysUntilDue(item.dueDate) }}
</text>
</view>
<view class="due-info">
<text class="due-date">{{ item.dueDate }}</text>
</view>
<view class="due-amount">
¥{{ item.amount.toFixed(2) }}
</view>
</view>
</scroll-view>
</view>
</view>
<!-- Tabs -->
<wd-tabs v-model="currentTab" @change="handleTabChange">
<wd-tab v-for="(tab, index) in tabs" :key="index" :title="tab.name" />
</wd-tabs>
<!-- 列表 -->
<view class="list-content">
<view v-if="groupedByMerchant.length > 0" class="merchant-list">
<!-- 商户卡片 -->
<view v-for="group in groupedByMerchant" :key="group.merchantId" class="merchant-card">
<!-- 商户头部 -->
<view class="merchant-header">
<view class="merchant-info">
<view class="merchant-icon">
<text class="i-carbon-store" />
</view>
<view class="merchant-details">
<view class="merchant-name-row">
<text class="merchant-name">{{ group.merchantName }}</text>
<view v-if="group.hasOverdue" class="overdue-badge">
<text class="i-carbon-warning-alt" />
<text>逾期</text>
</view>
</view>
<view class="merchant-amount-row">
<text class="amount-label">应结总额</text>
<text class="amount-value">¥{{ group.totalAmount.toFixed(2) }}</text>
</view>
</view>
</view>
<!-- 头部批量操作 -->
<view v-if="currentTab === 0" class="header-action">
<view class="batch-btn-small" @click.stop="handleOpenBatchWriteOff(group.merchantId)">
<text class="i-carbon-checkmark-outline" />
<text>批量消账</text>
</view>
</view>
</view>
<!-- 订单列表 -->
<view class="order-list">
<view v-for="item in group.settlements" :key="item.id" class="order-item">
<!-- 订单头部 -->
<view class="order-header">
<view class="order-no-wrapper">
<text class="order-label">订单号</text>
<text class="order-no">{{ item.orderNo }}</text>
</view>
<view class="status-badge" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</view>
</view>
<!-- 订单内容 -->
<view class="order-body">
<view class="info-row">
<text class="info-label">到期日期</text>
<text class="info-value">{{ item.dueDate }}</text>
</view>
<view v-if="item.settlementDate" class="info-row">
<text class="info-label">结算日期</text>
<text class="info-value">{{ item.settlementDate }}</text>
</view>
<view class="info-row">
<text class="info-label">应结金额</text>
<text class="info-value amount">¥{{ item.amount.toFixed(2) }}</text>
</view>
</view>
<!-- 订单操作 -->
<view
v-if="item.status === SettlementStatus.UNSETTLED || item.status === SettlementStatus.OVERDUE"
class="order-footer"
>
<view class="action-btn" @click="handleOpenWriteOff(item)">
<text class="i-carbon-document-tasks" />
<text>申请消账</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<wd-status-tip v-else image="search" tip="暂无账款记录" />
</view>
<!-- 消账弹窗 -->
<WriteOffForm
v-model:visible="writeOffVisible"
:default-amount="currentWriteOffAmount"
@submit="handleSubmitWriteOff"
/>
</view>
</template>
<style lang="scss" scoped>
// 设计令牌
$primary: #4d80f0;
$danger: #fa4350;
$warning: #ff8f0d;
$success: #00c05a;
$text-1: #262626;
$text-2: #909399;
$text-3: #c0c4cc;
$bg-page: #f4f4f4;
$bg-card: #ffffff;
// 页面容器
.settlement-page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 40rpx;
}
// 到期提醒区域
.due-alert {
margin: 20rpx 20rpx 0;
.alert-box {
background: #fffbf0;
border: 2rpx solid rgba($warning, 0.2);
border-radius: 16rpx;
padding: 20rpx;
}
.alert-header {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 16rpx;
.alert-icon {
color: $warning;
font-size: 32rpx;
}
.alert-title {
font-size: 28rpx;
color: $text-1;
font-weight: 600;
}
}
.due-list {
white-space: nowrap;
.due-item {
display: inline-block;
background: #fff;
padding: 16rpx 24rpx;
border-radius: 12rpx;
margin-right: 16rpx;
border: 2rpx solid rgba($warning, 0.1);
box-shadow: 0 4rpx 12rpx rgba($warning, 0.05);
min-width: 220rpx;
.due-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
gap: 12rpx;
.merchant-name {
font-size: 24rpx;
color: $text-1;
font-weight: 600;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.due-days {
font-size: 20rpx;
color: $warning;
background: rgba($warning, 0.1);
padding: 2rpx 8rpx;
border-radius: 6rpx;
flex-shrink: 0;
&.urgent {
color: $danger;
background: rgba($danger, 0.1);
}
}
}
.due-info {
margin-bottom: 8rpx;
.due-date {
font-size: 22rpx;
color: $text-2;
}
}
.due-amount {
font-size: 32rpx;
color: $danger;
font-weight: 700;
font-family: 'DIN Alternate', sans-serif;
}
}
}
}
// 列表内容
.list-content {
padding: 20rpx;
}
// 商户列表
.merchant-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
// 商户卡片
.merchant-card {
background: $bg-card;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
// 商户头部
.merchant-header {
padding: 24rpx 30rpx;
background: linear-gradient(to right, #f8f9ff, #ffffff);
border-bottom: 2rpx solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
.merchant-info {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
.merchant-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, $primary, #6c9ef5);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba($primary, 0.2);
.i-carbon-store {
font-size: 36rpx;
color: white;
}
}
.merchant-details {
flex: 1;
}
.merchant-name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
.merchant-name {
font-size: 30rpx;
font-weight: 700;
color: $text-1;
}
.overdue-badge {
display: flex;
align-items: center;
gap: 4rpx;
padding: 2rpx 10rpx;
background: rgba($danger, 0.1);
border-radius: 6rpx;
font-size: 20rpx;
color: $danger;
font-weight: 600;
.i-carbon-warning-alt {
font-size: 22rpx;
}
}
}
.merchant-amount-row {
display: flex;
align-items: baseline;
gap: 12rpx;
.amount-label {
font-size: 22rpx;
color: $text-2;
}
.amount-value {
font-size: 34rpx;
font-weight: 800;
color: $danger;
font-family: 'DIN Alternate', sans-serif;
}
}
}
.header-action {
margin-left: 20rpx;
.batch-btn-small {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 20rpx;
background: rgba($primary, 0.1);
border-radius: 30rpx;
color: $primary;
font-size: 24rpx;
font-weight: 600;
transition: all 0.2s;
&:active {
background: rgba($primary, 0.2);
transform: scale(0.96);
}
.i-carbon-checkmark-outline {
font-size: 28rpx;
}
}
}
}
// 订单列表
.order-list {
padding: 0 24rpx;
}
// 订单项
.order-item {
padding: 24rpx 0;
border-bottom: 2rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
// 订单头部
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
.order-no-wrapper {
display: flex;
align-items: center;
gap: 12rpx;
.order-label {
font-size: 20rpx;
color: white;
background: $text-3;
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
.order-no {
font-size: 26rpx;
font-weight: 600;
color: $text-1;
font-family: monospace;
}
}
.status-badge {
font-size: 22rpx;
padding: 2rpx 12rpx;
border-radius: 20rpx;
font-weight: 500;
&.success {
color: $success;
background: rgba($success, 0.1);
}
&.warning {
color: $warning;
background: rgba($warning, 0.1);
}
&.danger {
color: $danger;
background: rgba($danger, 0.1);
}
}
}
// 订单内容
.order-body {
background: #f9f9f9;
padding: 16rpx 20rpx;
border-radius: 12rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
.info-label {
font-size: 24rpx;
color: $text-2;
}
.info-value {
font-size: 24rpx;
color: $text-1;
font-weight: 500;
&.amount {
font-size: 28rpx;
font-weight: 700;
color: $text-1;
}
}
}
}
// 订单操作
.order-footer {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 8rpx 20rpx;
background: rgba($primary, 0.05);
border: 1rpx solid rgba($primary, 0.2);
border-radius: 24rpx;
color: $primary;
font-size: 24rpx;
font-weight: 500;
transition: all 0.2s;
&:active {
background: rgba($primary, 0.1);
transform: scale(0.98);
}
.i-carbon-document-tasks {
font-size: 26rpx;
}
}
}
}
</style>

View File

@@ -1,13 +1,214 @@
<script lang="ts" setup>
import { useCartStore } from '@/store/cart'
import CartItem from '@/components/cart/CartItem.vue'
import CartSummary from '@/components/cart/CartSummary.vue'
definePage({
style: {
navigationBarTitleText: '购物车',
},
})
const cartStore = useCartStore()
import { useFinanceStore } from '@/store/finance'
const financeStore = useFinanceStore()
onShow(() => {
financeStore.fetchCreditLimit()
cartStore.initCheck()
})
// 按店铺分组
const shopGroups = computed(() => {
const groups: Record<string, {
shopId: string,
shopName: string,
items: any[],
availableLimit: number
}> = {}
cartStore.items.forEach(item => {
if (!groups[item.shopId]) {
const creditLimit = financeStore.creditLimits.find(l => l.merchantId === item.shopId)
groups[item.shopId] = {
shopId: item.shopId,
shopName: item.shopName,
items: [],
availableLimit: creditLimit ? creditLimit.availableLimit : 0
}
}
groups[item.shopId].items.push(item)
})
return Object.values(groups)
})
// 计算预计实付金额(扣除信用额度)
const estimatedRealPay = computed(() => {
let totalRealPay = 0
shopGroups.value.forEach(group => {
// 计算该店铺选中商品的总价
const shopCheckedTotal = group.items
.filter(item => item.checked)
.reduce((sum, item) => sum + item.price * item.quantity, 0)
if (shopCheckedTotal > 0) {
// 扣除可用额度
const realPay = Math.max(0, shopCheckedTotal - group.availableLimit)
totalRealPay += realPay
}
})
return totalRealPay
})
function handleToggle(id: string) {
cartStore.toggleChecked(id)
}
function handleUpdateQuantity(id: string, quantity: number) {
cartStore.updateQuantity(id, quantity)
}
function handleDelete(id: string) {
cartStore.removeItem(id)
}
function handleToggleAll() {
cartStore.toggleAllChecked()
}
function handleCheckout() {
if (cartStore.checkedCount === 0) {
uni.showToast({
title: '请选择商品',
icon: 'none',
})
return
}
uni.navigateTo({
url: '/pages/order/confirm',
})
}
function goToHome() {
uni.switchTab({
url: '/pages/index/index',
})
}
</script>
<template>
<view class="mt-10 text-center text-green-500">
购物车
<view class="cart-page">
<view v-if="cartStore.items.length > 0" class="cart-list">
<view v-for="group in shopGroups" :key="group.shopId" class="shop-group">
<view class="shop-header">
<text class="i-carbon-store icon"></text>
<text class="name">{{ group.shopName }}</text>
<text class="limit" v-if="group.availableLimit > 0">可用额度:¥{{ group.availableLimit }}</text>
</view>
<CartItem
v-for="item in group.items"
:key="item.id"
:item="item"
:checked="item.checked"
@toggle="handleToggle"
@update-quantity="handleUpdateQuantity"
@delete="handleDelete"
/>
</view>
</view>
<view v-else class="empty-state">
<text class="i-carbon-shopping-cart text-6xl text-gray-300 mb-4"></text>
<text class="text">购物车是空的</text>
<view class="btn" @click="goToHome">去逛逛</view>
</view>
<CartSummary
v-if="cartStore.items.length > 0"
:all-checked="cartStore.isAllChecked"
:total-price="estimatedRealPay"
:total-count="cartStore.checkedCount"
@toggle-all="handleToggleAll"
@checkout="handleCheckout"
/>
</view>
</template>
<style lang="scss" scoped>
.cart-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 200rpx; // 留出底部结算栏和 tabbar 的空间
}
.cart-list {
padding: 24rpx;
.shop-group {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
padding: 20rpx;
.shop-header {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
.icon {
font-size: 32rpx;
color: #333;
margin-right: 12rpx;
}
.name {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.limit {
font-size: 22rpx;
color: #999;
margin-left: 12rpx;
background: #f5f5f5;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
}
}
}
.empty-state {
height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.text {
font-size: 28rpx;
color: #999;
margin-bottom: 32rpx;
}
.btn {
width: 200rpx;
height: 72rpx;
border: 1rpx solid #ff4d4f;
color: #ff4d4f;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
}
</style>

386
src/pages/goods/detail.vue Normal file
View File

@@ -0,0 +1,386 @@
<script lang="ts" setup>
import { getGoodsDetail } from '@/api/goods'
import type { Goods } from '@/typings/mall'
import SpecSelector from '@/components/goods/SpecSelector.vue'
import PriceTag from '@/components/common/PriceTag.vue'
import { useCartStore } from '@/store/cart'
definePage({
style: {
navigationBarTitleText: '商品详情',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
},
})
const cartStore = useCartStore()
// 状态
const goods = ref<Goods | null>(null)
const loading = ref(true)
const specVisible = ref(false)
const currentImageIndex = ref(0)
// 页面加载
onLoad((options) => {
if (options?.id) {
loadGoodsDetail(options.id)
}
})
// 加载详情
async function loadGoodsDetail(id: string) {
loading.value = true
try {
const res = await getGoodsDetail(id)
goods.value = res.data
} catch (error) {
console.error('加载商品详情失败', error)
uni.showToast({
title: '加载失败',
icon: 'none',
})
} finally {
loading.value = false
}
}
// 轮播图切换
function handleSwiperChange(e: any) {
currentImageIndex.value = e.detail.current
}
// 打开规格选择
function openSpec() {
specVisible.value = true
}
// 确认选择
function handleSpecConfirm(data: { quantity: number, specs: Record<string, string>, type: 'cart' | 'buy' }) {
if (!goods.value) return
if (data.type === 'cart') {
cartStore.addItem({
goodsId: goods.value.id,
shopId: goods.value.shopId,
shopName: goods.value.shopName,
goodsName: goods.value.name,
cover: goods.value.cover,
price: goods.value.price,
selectedSpec: data.specs,
quantity: data.quantity,
stock: goods.value.stock,
})
uni.showToast({
title: '已加入购物车',
icon: 'success',
})
} else {
// 立即购买,跳转到订单确认页(暂未实现,先提示)
uni.showToast({
title: '跳转订单确认页',
icon: 'none',
})
// uni.navigateTo({
// url: `/pages/order/confirm?goodsId=${goods.value.id}&quantity=${data.quantity}...`
// })
}
}
// 跳转购物车
function goToCart() {
uni.switchTab({
url: '/pages/goods/cart',
})
}
</script>
<template>
<view v-if="goods" class="goods-detail">
<!-- 商品图片轮播 -->
<view class="swiper-wrapper">
<swiper
class="swiper"
circular
autoplay
:interval="3000"
:duration="500"
@change="handleSwiperChange"
>
<swiper-item v-for="(img, index) in goods.images" :key="index">
<image class="swiper-img" :src="img" mode="aspectFill" />
</swiper-item>
</swiper>
<view class="indicator">
{{ currentImageIndex + 1 }} / {{ goods.images.length }}
</view>
</view>
<!-- 商品信息 -->
<view class="info-card">
<view class="price-row">
<PriceTag :price="goods.price" :original-price="goods.originalPrice" size="large" />
<view class="sales">销量 {{ goods.sales }}</view>
</view>
<view class="name">{{ goods.name }}</view>
<view class="desc">{{ goods.description }}</view>
</view>
<!-- 规格选择入口 -->
<view class="cell-group" @click="openSpec">
<view class="cell">
<text class="label">选择</text>
<text class="value">规格 / 数量</text>
<text class="i-carbon-chevron-right icon"></text>
</view>
</view>
<!-- 商品详情内容模拟 -->
<view class="detail-content">
<view class="section-title">商品详情</view>
<view class="content-body">
<image
v-for="(img, index) in goods.images"
:key="index"
:src="img"
mode="widthFix"
class="detail-img"
/>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-bar">
<view class="icons">
<view class="icon-btn" @click="() => {}">
<text class="i-carbon-store icon"></text>
<text class="text">店铺</text>
</view>
<view class="icon-btn" @click="goToCart">
<view class="badge" v-if="cartStore.totalCount > 0">{{ cartStore.totalCount }}</view>
<text class="i-carbon-shopping-cart icon"></text>
<text class="text">购物车</text>
</view>
</view>
<view class="btns">
<view class="btn cart-btn" @click="openSpec">加入购物车</view>
<view class="btn buy-btn" @click="openSpec">立即购买</view>
</view>
</view>
<!-- 规格选择弹窗 -->
<SpecSelector
v-model:visible="specVisible"
:goods="goods"
@confirm="handleSpecConfirm"
/>
</view>
<view v-else-if="loading" class="loading-page">
<text>加载中...</text>
</view>
</template>
<style lang="scss" scoped>
.goods-detail {
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
background: #f5f5f5;
min-height: 100vh;
}
.swiper-wrapper {
position: relative;
width: 100%;
height: 750rpx;
.swiper {
width: 100%;
height: 100%;
}
.swiper-img {
width: 100%;
height: 100%;
}
.indicator {
position: absolute;
bottom: 24rpx;
right: 24rpx;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
}
.info-card {
background: #fff;
padding: 24rpx;
margin-bottom: 20rpx;
.price-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 16rpx;
.sales {
font-size: 24rpx;
color: #999;
}
}
.name {
font-size: 32rpx;
font-weight: 600;
color: #333;
line-height: 1.4;
margin-bottom: 12rpx;
}
.desc {
font-size: 26rpx;
color: #666;
line-height: 1.4;
}
}
.cell-group {
background: #fff;
margin-bottom: 20rpx;
.cell {
display: flex;
align-items: center;
padding: 24rpx;
font-size: 28rpx;
.label {
color: #999;
width: 80rpx;
}
.value {
flex: 1;
color: #333;
}
.icon {
color: #ccc;
}
}
}
.detail-content {
background: #fff;
padding: 24rpx;
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
padding-left: 16rpx;
border-left: 6rpx solid #ff4d4f;
}
.content-body {
.detail-img {
width: 100%;
display: block;
}
}
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
min-height: 100rpx;
background: #fff;
display: flex;
align-items: center;
padding: 14rpx 24rpx;
padding-bottom: calc(14rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(14rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: 100;
.icons {
display: flex;
margin-right: 30rpx;
.icon-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100rpx;
position: relative;
.icon {
font-size: 40rpx;
color: #666;
margin-bottom: 4rpx;
}
.text {
font-size: 20rpx;
color: #666;
}
.badge {
position: absolute;
top: -4rpx;
right: 10rpx;
background: #ff4d4f;
color: #fff;
font-size: 20rpx;
padding: 0 8rpx;
border-radius: 16rpx;
min-width: 28rpx;
text-align: center;
}
}
}
.btns {
flex: 1;
display: flex;
height: 72rpx;
border-radius: 36rpx;
overflow: hidden;
.btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
color: #fff;
&.cart-btn {
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
}
&.buy-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
}
}
}
}
.loading-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 28rpx;
}
</style>

View File

@@ -1,13 +1,184 @@
<script lang="ts" setup>
import { getBannerList } from '@/api/banner'
import { getCategoryList } from '@/api/category'
import { getRecommendGoods } from '@/api/goods'
import type { Banner, Category, Goods } from '@/typings/mall'
import SearchBar from '@/components/common/SearchBar.vue'
import BannerComponent from '@/components/common/Banner.vue'
import CategoryGrid from '@/components/common/CategoryGrid.vue'
import GoodsCard from '@/components/goods/GoodsCard.vue'
import { useCartStore } from '@/store/cart'
definePage({
style: {
navigationBarTitleText: '首页',
},
})
const cartStore = useCartStore()
// 数据
const bannerList = ref<Banner[]>([])
const categoryList = ref<Category[]>([])
const goodsList = ref<Goods[]>([])
const searchKeyword = ref('')
// 加载状态
const loading = ref(false)
onMounted(() => {
loadData()
})
// 加载数据
async function loadData() {
loading.value = true
try {
// 并行加载数据
const [bannerRes, categoryRes, goodsRes] = await Promise.all([
getBannerList(),
getCategoryList(),
getRecommendGoods(10),
])
bannerList.value = (bannerRes as any).data || []
categoryList.value = (categoryRes as any).data || []
goodsList.value = (goodsRes as any).data || []
}
catch (error) {
console.error('加载数据失败', error)
uni.showToast({
title: '加载失败',
icon: 'none',
})
}
finally {
loading.value = false
}
}
// 搜索
function handleSearch(keyword: string) {
if (!keyword.trim()) {
uni.showToast({
title: '请输入搜索关键词',
icon: 'none',
})
return
}
uni.navigateTo({
url: `/pages/goods/list?keyword=${encodeURIComponent(keyword)}`,
})
}
// 下拉刷新
function onPullDownRefresh() {
loadData().finally(() => {
uni.stopPullDownRefresh()
})
}
defineExpose({
onPullDownRefresh,
})
</script>
<template>
<view class="mt-10 text-center text-green-500">
首页
<view class="index-page">
<!-- 搜索框 -->
<SearchBar
v-model="searchKeyword"
:show-cancel="false"
@search="handleSearch"
/>
<!-- 轮播图 -->
<BannerComponent v-if="bannerList.length" :list="bannerList" />
<!-- 分类 -->
<view class="section">
<view class="section-title">商品分类</view>
<CategoryGrid :list="categoryList" :columns="4" />
</view>
<!-- 推荐商品 -->
<view class="section">
<view class="section-title">
<text>为你推荐</text>
<text class="more" @click="() => uni.switchTab({ url: '/pages/sort/index' })">
更多
<text class="i-carbon-chevron-right"></text>
</text>
</view>
<view class="goods-list">
<GoodsCard
v-for="item in goodsList"
:key="item.id"
:goods="item"
/>
</view>
</view>
<!-- 购物车角标 -->
<view v-if="cartStore.totalCount > 0" class="cart-badge">
{{ cartStore.totalCount > 99 ? '99+' : cartStore.totalCount }}
</view>
</view>
</template>
<style lang="scss" scoped>
.index-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.section {
margin-top: 24rpx;
background: #fff;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
.more {
font-size: 24rpx;
font-weight: 400;
color: #999;
display: flex;
align-items: center;
gap: 4rpx;
}
}
.goods-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
padding: 0 16rpx 16rpx;
}
.cart-badge {
position: fixed;
bottom: 120rpx;
right: 40rpx;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
color: #fff;
font-size: 24rpx;
font-weight: 600;
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(255, 77, 79, 0.4);
z-index: 100;
}
</style>

236
src/pages/login/index.vue Normal file
View File

@@ -0,0 +1,236 @@
<script lang="ts" setup>
import { useUserStore } from '@/store/user'
import { login, sendCode } from '@/api/auth'
definePage({
style: {
navigationBarTitleText: '登录',
navigationBarBackgroundColor: '#ffffff',
},
})
const userStore = useUserStore()
// 状态
const phone = ref('13800138000')
const code = ref('')
const loading = ref(false)
const countdown = ref(0)
const timer = ref<any>(null)
// 发送验证码
async function handleSendCode() {
if (!phone.value) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (countdown.value > 0) return
try {
await sendCode(phone.value)
uni.showToast({ title: '验证码已发送', icon: 'none' })
// 倒计时
countdown.value = 60
timer.value = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer.value)
}
}, 1000)
} catch (error) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
// 登录
async function handleLogin() {
if (!phone.value) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (!code.value) {
// 为了演示方便,这里允许空验证码直接登录(模拟)
// uni.showToast({ title: '请输入验证码', icon: 'none' })
// return
}
loading.value = true
try {
const res: any = await login({ phone: phone.value, code: code.value })
// 更新用户信息
userStore.userInfo = res.data.user
userStore.isLogin = true
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
uni.showToast({ title: '登录失败', icon: 'none' })
} finally {
loading.value = false
}
}
onUnload(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
</script>
<template>
<view class="login-page">
<view class="logo-wrapper">
<view class="logo">
<text class="i-carbon-store icon"></text>
</view>
<text class="app-name">商城+金融</text>
</view>
<view class="form">
<view class="input-group">
<text class="i-carbon-phone icon"></text>
<input
v-model="phone"
class="input"
type="number"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="input-group">
<text class="i-carbon-security icon"></text>
<input
v-model="code"
class="input"
type="number"
placeholder="请输入验证码"
maxlength="6"
/>
<view
class="code-btn"
:class="{ disabled: countdown > 0 }"
@click="handleSendCode"
>
{{ countdown > 0 ? `${countdown}s后重发` : '获取验证码' }}
</view>
</view>
<view class="submit-btn" @click="handleLogin">
<text v-if="!loading">登录</text>
<text v-else>登录中...</text>
</view>
<view class="tips">
<text>未注册手机号验证后自动创建账号</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: #fff;
padding: 60rpx;
display: flex;
flex-direction: column;
}
.logo-wrapper {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 100rpx;
margin-bottom: 100rpx;
.logo {
width: 160rpx;
height: 160rpx;
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 77, 79, 0.3);
.icon {
font-size: 80rpx;
color: #fff;
}
}
.app-name {
font-size: 40rpx;
font-weight: 600;
color: #333;
}
}
.form {
.input-group {
display: flex;
align-items: center;
height: 100rpx;
background: #f5f5f5;
border-radius: 50rpx;
padding: 0 40rpx;
margin-bottom: 30rpx;
.icon {
font-size: 40rpx;
color: #999;
margin-right: 20rpx;
}
.input {
flex: 1;
height: 100%;
font-size: 28rpx;
}
.code-btn {
font-size: 26rpx;
color: #ff4d4f;
padding-left: 20rpx;
border-left: 1rpx solid #ddd;
line-height: 1;
&.disabled {
color: #999;
}
}
}
.submit-btn {
height: 100rpx;
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
border-radius: 50rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 32rpx;
font-weight: 600;
margin-top: 60rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 77, 79, 0.3);
&:active {
opacity: 0.9;
}
}
.tips {
text-align: center;
margin-top: 30rpx;
font-size: 24rpx;
color: #999;
}
}
</style>

View File

@@ -1,13 +1,565 @@
<script lang="ts" setup>
import { useUserStore } from '@/store/user'
definePage({
style: {
navigationBarTitleText: '我的',
navigationBarBackgroundColor: '#fff',
navigationBarTextStyle: 'black',
},
})
const userStore = useUserStore()
// 页面显示
onShow(() => {
if (!userStore.isLogin) {
uni.navigateTo({
url: '/pages/login/index',
})
}
})
// 跳转订单列表
function goToOrder(status: string) {
// 找到对应的 tab index
let index = 0
if (status === 'pending_payment') index = 1
else if (status === 'pending_delivery') index = 2
else if (status === 'pending_receive') index = 3
else if (status === 'completed') index = 4
// 这里简单跳转到订单列表页,实际可能需要传递参数控制 tab
uni.navigateTo({
url: '/pages/order/list',
})
}
// 跳转页面
function navigateTo(url: string) {
uni.navigateTo({ url })
}
// 退出登录
function handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
userStore.logout()
uni.reLaunch({
url: '/pages/login/index',
})
}
},
})
}
</script>
<template>
<view class="mt-10 text-center text-green-500">
我的页面
<view class="me-page">
<!-- 用户信息 -->
<view class="user-card" v-if="userStore.userInfo">
<image class="avatar" :src="userStore.userInfo.avatar" mode="aspectFill" />
<view class="info">
<view class="nickname">{{ userStore.userInfo.nickname }}</view>
<view class="phone">{{ userStore.userInfo.phone }}</view>
</view>
<view class="member-tag" @click="navigateTo('/pages/member/index')">
<text class="i-carbon-crown icon"></text>
<text>金卡会员</text>
<text class="i-carbon-chevron-right arrow"></text>
</view>
</view>
<view class="user-card unlogin" v-else @click="navigateTo('/pages/login/index')">
<view class="avatar placeholder">
<text class="i-carbon-user"></text>
</view>
<view class="info">
<view class="nickname">点击登录/注册</view>
</view>
</view>
<!-- 订单入口 -->
<view class="section-card">
<view class="header" @click="goToOrder('')">
<text class="title">我的订单</text>
<view class="more">
<text>全部订单</text>
<text class="i-carbon-chevron-right icon"></text>
</view>
</view>
<view class="grid-nav">
<view class="nav-item" @click="goToOrder('pending_payment')">
<text class="i-carbon-wallet icon"></text>
<text class="text">待付款</text>
</view>
<view class="nav-item" @click="goToOrder('pending_delivery')">
<text class="i-carbon-delivery icon"></text>
<text class="text">待发货</text>
</view>
<view class="nav-item" @click="goToOrder('pending_receive')">
<text class="i-carbon-package icon"></text>
<text class="text">待收货</text>
</view>
<view class="nav-item" @click="goToOrder('completed')">
<text class="i-carbon-checkbox-checked icon"></text>
<text class="text">已完成</text>
</view>
</view>
</view>
<!-- 金融服务 -->
<view class="section-card">
<view class="header">
<text class="title">金融服务</text>
</view>
<view class="grid-nav col-2">
<view class="nav-item row" @click="navigateTo('/pages/finance/credit')">
<view class="icon-wrapper blue">
<text class="i-carbon-chart-line icon"></text>
</view>
<view class="info">
<text class="name">信用额度</text>
<text class="desc">查看可用额度</text>
</view>
</view>
<view class="nav-item row" @click="navigateTo('/pages/finance/settlement')">
<view class="icon-wrapper orange">
<text class="i-carbon-document icon"></text>
</view>
<view class="info">
<text class="name">应结账款</text>
<text class="desc">消账与结算</text>
</view>
</view>
</view>
<!-- 贷款产品推广 -->
<view class="loan-products">
<!-- 信用贷 -->
<view class="loan-card credit">
<view class="card-header">
<view class="title-row">
<text class="i-carbon-favorite icon"></text>
<text class="title">信用贷</text>
</view>
<view class="tags">
<text class="tag">额度高</text>
<text class="tag">审批快</text>
</view>
</view>
<view class="card-body">
<view class="data-col">
<text class="value">300</text>
<text class="label">最高</text>
</view>
<view class="data-col right">
<text class="value small">3</text>
<text class="label">最长</text>
</view>
</view>
<view class="card-footer">
<view class="apply-btn">立即申请</view>
</view>
</view>
<!-- 抵押贷 -->
<view class="loan-card mortgage">
<view class="card-header">
<view class="title-row">
<text class="i-carbon-favorite icon"></text>
<text class="title">抵押贷</text>
</view>
<view class="tags">
<text class="tag">额度高</text>
<text class="tag">利率低</text>
</view>
</view>
<view class="card-body">
<view class="data-col">
<text class="value">1000</text>
<text class="label">最高</text>
</view>
<view class="data-col right">
<text class="value small">10</text>
<text class="label">最长</text>
</view>
</view>
<view class="card-footer">
<view class="apply-btn">立即申请</view>
</view>
</view>
</view>
</view>
<!-- 常用功能 -->
<view class="section-card">
<view class="cell-group">
<view class="cell" @click="navigateTo('/pages/member/index')">
<text class="i-carbon-user-favorite icon"></text>
<text class="label">会员中心</text>
<text class="i-carbon-chevron-right arrow"></text>
</view>
<view class="cell">
<text class="i-carbon-location icon"></text>
<text class="label">地址管理</text>
<text class="i-carbon-chevron-right arrow"></text>
</view>
<view class="cell" @click="handleLogout">
<text class="i-carbon-logout icon"></text>
<text class="label">退出登录</text>
<text class="i-carbon-chevron-right arrow"></text>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.me-page {
min-height: 100vh;
background: #f5f5f5;
padding: 20rpx;
padding-bottom: 40rpx;
}
.user-card {
background: #fff;
border-radius: 16rpx;
padding: 40rpx 30rpx;
display: flex;
align-items: center;
margin-bottom: 20rpx;
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 24rpx;
background: #f5f5f5;
&.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
font-size: 60rpx;
}
}
.info {
flex: 1;
.nickname {
font-size: 36rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.phone {
font-size: 26rpx;
color: #999;
}
}
.member-tag {
background: linear-gradient(135deg, #333 0%, #000 100%);
color: #ffd700;
padding: 10rpx 20rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
font-size: 24rpx;
gap: 8rpx;
.icon {
font-size: 28rpx;
}
.arrow {
font-size: 20rpx;
opacity: 0.8;
}
}
}
.section-card {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.more {
display: flex;
align-items: center;
font-size: 24rpx;
color: #999;
.icon {
margin-left: 4rpx;
}
}
}
}
.grid-nav {
display: flex;
padding: 30rpx 0;
&.col-2 {
padding: 0;
.nav-item {
flex: 1;
flex-direction: row;
padding: 30rpx;
border-right: 1rpx solid #f5f5f5;
align-items: center;
gap: 20rpx;
&:last-child {
border-right: none;
}
.icon-wrapper {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.blue {
background: #e6f7ff;
color: #1890ff;
}
&.orange {
background: #fff7e6;
color: #fa8c16;
}
.icon {
font-size: 40rpx;
}
}
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
.name {
font-size: 28rpx;
color: #333;
font-weight: 600;
margin-bottom: 4rpx;
}
.desc {
font-size: 22rpx;
color: #999;
}
}
}
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
.icon {
font-size: 48rpx;
color: #666;
}
.text {
font-size: 24rpx;
color: #333;
}
}
}
.loan-products {
display: flex;
gap: 20rpx;
padding: 0 30rpx 30rpx;
.loan-card {
flex: 1;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 240rpx;
position: relative;
overflow: hidden;
&.credit {
background: linear-gradient(180deg, #e6f7ff 0%, #fff 100%);
border: 1rpx solid rgba(24, 144, 255, 0.1);
.icon {
color: #1890ff;
}
.tags .tag {
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
}
.apply-btn {
background: #1890ff;
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.2);
}
}
&.mortgage {
background: linear-gradient(180deg, #fff7e6 0%, #fff 100%);
border: 1rpx solid rgba(250, 140, 22, 0.1);
.icon {
color: #fa8c16;
}
.tags .tag {
background: rgba(250, 140, 22, 0.1);
color: #fa8c16;
}
.apply-btn {
background: #fa8c16;
box-shadow: 0 4rpx 12rpx rgba(250, 140, 22, 0.2);
}
}
.card-header {
margin-bottom: 24rpx;
.title-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 12rpx;
.icon {
font-size: 32rpx;
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
}
.tags {
display: flex;
gap: 8rpx;
.tag {
font-size: 20rpx;
padding: 2rpx 8rpx;
border-radius: 4rpx;
}
}
}
.card-body {
display: flex;
justify-content: space-between;
margin-bottom: 24rpx;
.data-col {
display: flex;
flex-direction: column;
gap: 4rpx;
&.right {
align-items: flex-end;
}
.value {
font-size: 36rpx;
font-weight: 700;
color: #333;
font-family: 'DIN Alternate', sans-serif;
&.small {
font-size: 32rpx;
}
}
.label {
font-size: 22rpx;
color: #999;
}
}
}
.card-footer {
display: flex;
justify-content: center;
.apply-btn {
color: #fff;
font-size: 24rpx;
padding: 8rpx 32rpx;
border-radius: 24rpx;
font-weight: 500;
}
}
}
}
.cell-group {
.cell {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.icon {
font-size: 36rpx;
color: #666;
margin-right: 20rpx;
}
.label {
flex: 1;
font-size: 28rpx;
color: #333;
}
.arrow {
font-size: 32rpx;
color: #ccc;
}
}
}
</style>

147
src/pages/member/index.vue Normal file
View File

@@ -0,0 +1,147 @@
<script lang="ts" setup>
import { useMemberStore } from '@/store/member'
import MemberCard from '@/components/member/MemberCard.vue'
import MemberBenefits from '@/components/member/MemberBenefits.vue'
definePage({
style: {
navigationBarTitleText: '会员中心',
navigationBarBackgroundColor: '#1a1a1a',
navigationBarTextStyle: 'white',
},
})
const memberStore = useMemberStore()
const loading = ref(true)
onShow(() => {
loadData()
})
async function loadData() {
loading.value = true
await Promise.all([
memberStore.fetchMemberInfo(),
memberStore.fetchBenefits(),
])
loading.value = false
}
</script>
<template>
<view class="member-page">
<view class="header-bg"></view>
<view class="content">
<!-- 会员卡片 -->
<MemberCard
:member="memberStore.memberInfo"
:config="memberStore.currentLevelConfig"
/>
<!-- 会员权益 -->
<view class="section">
<view class="section-title">会员权益</view>
<MemberBenefits :list="memberStore.benefits" />
</view>
<!-- 积分记录模拟 -->
<view class="section">
<view class="section-title">积分记录</view>
<view class="points-list">
<view class="points-item">
<view class="left">
<view class="title">购物赠送</view>
<view class="time">2025-11-28 10:00:00</view>
</view>
<view class="right plus">+100</view>
</view>
<view class="points-item">
<view class="left">
<view class="title">签到奖励</view>
<view class="time">2025-11-27 09:00:00</view>
</view>
<view class="right plus">+10</view>
</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.member-page {
min-height: 100vh;
background: #f5f5f5;
position: relative;
}
.header-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 300rpx;
background: #1a1a1a;
z-index: 0;
}
.content {
position: relative;
z-index: 1;
padding: 30rpx;
}
.section {
margin-top: 40rpx;
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
padding-left: 10rpx;
border-left: 6rpx solid #d4b106; // 金色
}
}
.points-list {
background: #fff;
border-radius: 16rpx;
padding: 0 24rpx;
.points-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.left {
.title {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.time {
font-size: 24rpx;
color: #999;
}
}
.right {
font-size: 32rpx;
font-weight: 600;
&.plus {
color: #ff4d4f;
}
}
}
}
</style>

693
src/pages/order/confirm.vue Normal file
View File

@@ -0,0 +1,693 @@
<script lang="ts" setup>
import { useCartStore } from '@/store/cart'
import { useOrderStore } from '@/store/order'
import { useFinanceStore } from '@/store/finance'
import { mockAddressList } from '@/mock/address'
import type { Address } from '@/typings/mall'
definePage({
style: {
navigationBarTitleText: '确认订单',
},
})
const cartStore = useCartStore()
const orderStore = useOrderStore()
const financeStore = useFinanceStore()
// 状态
const address = ref<Address | null>(mockAddressList[0] || null) // 默认使用第一个地址
const loading = ref(false)
const useCredit = ref(false) // 是否开启信用支付开关
// 按店铺分组商品
const shopGroups = computed(() => {
const groups: Record<string, {
shopName: string,
items: any[],
total: number,
paymentMethod: 'online' | 'credit' | 'mixed',
creditAmount: number,
onlineAmount: number,
creditError?: string,
availableLimit: number
}> = {}
cartStore.checkedItems.forEach(item => {
if (!groups[item.shopId]) {
groups[item.shopId] = {
shopName: item.shopName,
items: [],
total: 0,
paymentMethod: 'online', // 默认在线支付
creditAmount: 0,
onlineAmount: 0,
availableLimit: 0
}
}
groups[item.shopId].items.push(item)
groups[item.shopId].total += item.price * item.quantity
})
// 计算每个店铺的支付方式
for (const shopId in groups) {
const shop = groups[shopId]
const creditLimit = financeStore.creditLimits.find(l => l.merchantId === shopId)
const availableLimit = creditLimit ? creditLimit.availableLimit : 0
shop.availableLimit = availableLimit
if (useCredit.value) {
if (availableLimit >= shop.total) {
// 额度充足,全额信用支付
shop.paymentMethod = 'credit'
shop.creditAmount = shop.total
shop.onlineAmount = 0
} else if (availableLimit > 0) {
// 额度不足但有余额,混合支付
shop.paymentMethod = 'mixed'
shop.creditAmount = availableLimit
shop.onlineAmount = shop.total - availableLimit
shop.creditError = `额度不足(可用¥${availableLimit.toFixed(2)}),将使用在线支付` // 提示文案稍后调整
} else {
// 无额度,全额在线支付
shop.paymentMethod = 'online'
shop.creditAmount = 0
shop.onlineAmount = shop.total
shop.creditError = creditLimit ? '额度不足(可用¥0.00)' : '暂无信用额度'
}
} else {
shop.paymentMethod = 'online'
shop.creditAmount = 0
shop.onlineAmount = shop.total
}
}
return groups
})
// 计算支付金额
const paymentSummary = computed(() => {
let onlineTotal = 0
let creditTotal = 0
for (const shopId in shopGroups.value) {
const shop = shopGroups.value[shopId]
onlineTotal += shop.onlineAmount
creditTotal += shop.creditAmount
}
return {
onlineTotal,
creditTotal
}
})
// 检查是否有商品
onLoad(() => {
if (cartStore.checkedCount === 0) {
uni.showToast({
title: '请先选择商品',
icon: 'none',
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
// 获取信用额度
financeStore.fetchCreditLimit()
// 检查购物车数据完整性
cartStore.initCheck()
})
// 提交订单
async function handleSubmit() {
if (!address.value) {
uni.showToast({
title: '请选择收货地址',
icon: 'none',
})
return
}
loading.value = true
try {
const orderData = {
items: cartStore.checkedItems.map(item => ({
goodsId: item.goodsId,
shopId: item.shopId,
shopName: item.shopName,
goodsName: item.goodsName,
cover: item.cover,
price: item.price,
quantity: item.quantity,
selectedSpec: item.selectedSpec,
})),
totalAmount: cartStore.totalPrice,
actualAmount: paymentSummary.value.onlineTotal,
address: address.value,
paymentMethod: useCredit.value ? 'mixed' : 'online',
paymentDetails: Object.values(shopGroups.value).map(group => ({
shopId: group.items[0].shopId,
creditAmount: group.creditAmount,
onlineAmount: group.onlineAmount
}))
}
const order = await orderStore.createOrder(orderData)
// 清空购物车已选商品
cartStore.clearChecked()
// 跳转到订单详情(或支付页)
uni.redirectTo({
url: `/pages/order/detail?id=${order.id}`,
})
} catch (error) {
uni.showToast({
title: '创建订单失败',
icon: 'none',
})
} finally {
loading.value = false
}
}
</script>
<template>
<view class="confirm-page">
<!-- 地址栏 -->
<view class="address-card">
<view v-if="address" class="address-info">
<view class="user-row">
<text class="name">{{ address.name }}</text>
<text class="phone">{{ address.phone }}</text>
<text v-if="address.isDefault" class="tag">默认</text>
</view>
<view class="address-text">
{{ address.province }}{{ address.city }}{{ address.district }}{{ address.detail }}
</view>
</view>
<view v-else class="no-address">
<text>请选择收货地址</text>
</view>
<text class="i-carbon-chevron-right icon"></text>
</view>
<!-- 商品列表 -->
<view class="goods-list">
<view v-for="(group, shopId) in shopGroups" :key="shopId" class="shop-group">
<view class="shop-header">
<view class="shop-info">
<text class="i-carbon-store icon"></text>
<text class="name">{{ group.shopName }}</text>
<text class="limit" v-if="group.availableLimit > 0">可用额度:¥{{ group.availableLimit }}</text>
</view>
<view class="payment-tag" :class="group.paymentMethod">
<text v-if="group.paymentMethod === 'credit'">信用支付</text>
<text v-else-if="group.paymentMethod === 'mixed'">混合支付</text>
<text v-else>在线支付</text>
</view>
</view>
<!-- 混合支付提示 -->
<view v-if="group.paymentMethod === 'mixed'" class="credit-tip mixed">
<text class="i-carbon-information icon"></text>
<text>可用额度¥{{ group.creditAmount.toFixed(2) }}抵扣剩余¥{{ group.onlineAmount.toFixed(2) }}使用在线支付</text>
</view>
<!-- 纯在线支付提示额度不足或无额度 -->
<view v-else-if="group.creditError && useCredit" class="credit-tip error">
<text class="i-carbon-warning icon"></text>
<text>{{ group.creditError }}将使用在线支付</text>
</view>
<view v-for="item in group.items" :key="item.id" class="goods-item">
<image class="cover" :src="item.cover" mode="aspectFill" />
<view class="info">
<view class="name">{{ item.goodsName }}</view>
<view class="spec">
{{ Object.values(item.selectedSpec).join('') }}
</view>
<view class="bottom">
<text class="price">¥{{ item.price }}</text>
<text class="quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
<view class="shop-total">
<text>小计</text>
<text class="price">¥{{ group.total.toFixed(2) }}</text>
</view>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-card">
<view class="title">支付方式</view>
<view class="payment-item">
<view class="left">
<text class="i-carbon-wallet icon credit"></text>
<view class="info">
<text>信用支付</text>
<text class="sub">开启后优先使用信用额度支付</text>
</view>
</view>
<view class="right">
<switch :checked="useCredit" @change="useCredit = !useCredit" color="#ff4d4f" style="transform:scale(0.8)" />
</view>
</view>
</view>
<!-- 金额明细 -->
<view class="price-card">
<view class="row">
<text class="label">商品总额</text>
<text class="value">¥{{ cartStore.totalPrice.toFixed(2) }}</text>
</view>
<view class="row">
<text class="label">运费</text>
<text class="value">¥0.00</text>
</view>
<view class="row total-row">
<text class="label">实付款</text>
<text class="value price">¥{{ cartStore.totalPrice.toFixed(2) }}</text>
</view>
</view>
<!-- 底部提交栏 -->
<view class="bottom-bar">
<view class="total-info">
<view class="total-row">
<text>实付款</text>
<text class="price">¥{{ paymentSummary.onlineTotal.toFixed(2) }}</text>
</view>
<view class="credit-row" v-if="paymentSummary.creditTotal > 0">
<text>信用扣除¥{{ paymentSummary.creditTotal.toFixed(2) }}</text>
</view>
</view>
<view class="submit-btn" @click="handleSubmit">
提交订单
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.confirm-page {
min-height: 100vh;
background: #f5f5f5;
padding: 20rpx;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
.address-card {
background: #fff;
padding: 30rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.address-info {
flex: 1;
}
.user-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 12rpx;
.name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.phone {
font-size: 28rpx;
color: #666;
}
.tag {
font-size: 20rpx;
color: #ff4d4f;
border: 1rpx solid #ff4d4f;
padding: 0 8rpx;
border-radius: 4rpx;
}
}
.address-text {
font-size: 26rpx;
color: #333;
line-height: 1.4;
}
.icon {
color: #ccc;
font-size: 32rpx;
}
}
.goods-list {
margin-bottom: 20rpx;
.shop-group {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
}
.shop-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.shop-info {
display: flex;
align-items: center;
gap: 12rpx;
.icon {
font-size: 32rpx;
color: #333;
}
.name {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.limit {
font-size: 22rpx;
color: #999;
margin-left: 8rpx;
}
}
.payment-tag {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
&.credit {
color: #ff4d4f;
background: #fff1f0;
border: 1rpx solid #ffccc7;
}
&.online {
color: #09bb07;
background: #f6ffed;
border: 1rpx solid #b7eb8f;
}
&.mixed {
color: #fa8c16;
background: #fff7e6;
border: 1rpx solid #ffd591;
}
}
}
.credit-tip {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
border-radius: 8rpx;
margin-bottom: 20rpx;
font-size: 24rpx;
.icon {
font-size: 28rpx;
}
&.mixed {
background: #e6f7ff;
border: 1rpx solid #91d5ff;
color: #1890ff;
}
&.error {
background: #fffbe6;
border: 1rpx solid #ffe58f;
color: #faad14;
}
}
.shop-total {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 20rpx;
font-size: 26rpx;
color: #666;
.price {
color: #ff4d4f;
font-size: 30rpx;
font-weight: 600;
margin-left: 8rpx;
}
}
.goods-item {
display: flex;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
.cover {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background: #f5f5f5;
margin-right: 20rpx;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.name {
font-size: 28rpx;
color: #333;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.spec {
font-size: 24rpx;
color: #999;
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
.price {
font-size: 30rpx;
color: #333;
font-weight: 600;
}
.quantity {
font-size: 26rpx;
color: #999;
}
}
}
}
.payment-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
}
.payment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&.disabled {
opacity: 0.6;
}
.left {
display: flex;
align-items: center;
gap: 20rpx;
font-size: 28rpx;
color: #333;
.icon {
font-size: 40rpx;
&.wechat {
color: #09bb07;
}
&.credit {
color: #ff4d4f;
}
}
.info {
display: flex;
flex-direction: column;
.sub {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
&.error {
color: #ff4d4f;
}
}
}
}
.right {
.check {
color: #ff4d4f;
font-size: 36rpx;
}
.unchecked {
color: #ccc;
font-size: 36rpx;
}
}
}
}
.price-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
.row {
display: flex;
justify-content: space-between;
margin-bottom: 16rpx;
font-size: 28rpx;
color: #666;
&:last-child {
margin-bottom: 0;
}
&.total-row {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f5f5f5;
font-weight: 600;
color: #333;
.price {
color: #ff4d4f;
font-size: 32rpx;
}
}
}
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
min-height: 100rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 14rpx 24rpx;
padding-bottom: calc(14rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(14rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: 100;
.total-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: 24rpx;
.total-row {
display: flex;
align-items: baseline;
font-size: 28rpx;
color: #333;
.price {
color: #ff4d4f;
font-size: 36rpx;
font-weight: 600;
}
}
.credit-row {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
}
.submit-btn {
width: 200rpx;
height: 72rpx;
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
color: #fff;
font-size: 28rpx;
font-weight: 600;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

384
src/pages/order/detail.vue Normal file
View File

@@ -0,0 +1,384 @@
<script lang="ts" setup>
import { useOrderStore } from '@/store/order'
import type { Order } from '@/typings/mall'
definePage({
style: {
navigationBarTitleText: '订单详情',
},
})
const orderStore = useOrderStore()
const order = ref<Order | null>(null)
const loading = ref(true)
// 计算信用支付金额
const creditPaid = computed(() => {
if (!order.value) return 0
// 优先从 paymentDetails 汇总
if (order.value.paymentDetails && order.value.paymentDetails.length > 0) {
return order.value.paymentDetails.reduce((sum, detail) => sum + detail.creditAmount, 0)
}
// 兜底:总额 - 实付
return order.value.totalAmount - order.value.actualAmount
})
onLoad((options) => {
if (options?.id) {
loadDetail(options.id)
}
})
async function loadDetail(id: string) {
loading.value = true
try {
const res = await orderStore.fetchOrderDetail(id)
order.value = res
} catch (error) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
// 支付
async function handlePay() {
if (!order.value) return
uni.showLoading({ title: '支付中...' })
try {
await orderStore.payOrder(order.value.id)
uni.showToast({ title: '支付成功', icon: 'success' })
loadDetail(order.value.id) // 刷新详情
} catch (error) {
uni.showToast({ title: '支付失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 取消
function handleCancel() {
if (!order.value) return
uni.showModal({
title: '提示',
content: '确定要取消该订单吗?',
success: async (res) => {
if (res.confirm) {
try {
await orderStore.cancelOrder(order.value!.id)
uni.showToast({ title: '订单已取消', icon: 'none' })
loadDetail(order.value!.id) // 刷新详情
} catch (error) {
uni.showToast({ title: '取消失败', icon: 'none' })
}
}
},
})
}
</script>
<template>
<view v-if="order" class="order-detail-page">
<!-- 状态栏 -->
<view class="status-card">
<view class="status-text">
{{ order.status === 'pending_payment' ? '等待买家付款' :
order.status === 'pending_delivery' ? '等待卖家发货' :
order.status === 'pending_receive' ? '等待买家收货' :
order.status === 'completed' ? '交易完成' : '交易关闭' }}
</view>
<view class="status-desc" v-if="order.status === 'pending_payment'">
请在 30 分钟内完成支付
</view>
</view>
<!-- 地址信息 -->
<view class="address-card">
<view class="icon-wrapper">
<text class="i-carbon-location icon"></text>
</view>
<view class="info">
<view class="user">
<text class="name">{{ order.address.name }}</text>
<text class="phone">{{ order.address.phone }}</text>
</view>
<view class="address">
{{ order.address.province }}{{ order.address.city }}{{ order.address.district }}{{ order.address.detail }}
</view>
</view>
</view>
<!-- 商品列表 -->
<view class="goods-card">
<view v-for="item in order.items" :key="item.goodsId" class="goods-item">
<image class="cover" :src="item.cover" mode="aspectFill" />
<view class="info">
<view class="name">{{ item.goodsName }}</view>
<view class="spec">{{ Object.values(item.selectedSpec).join('') }}</view>
<view class="bottom">
<text class="price">¥{{ item.price }}</text>
<text class="quantity">x{{ item.quantity }}</text>
</view>
</view>
</view>
</view>
<!-- 订单信息 -->
<view class="info-card">
<view class="row">
<text class="label">订单编号</text>
<text class="value">{{ order.orderNo }}</text>
</view>
<view class="row">
<text class="label">创建时间</text>
<text class="value">{{ order.createTime }}</text>
</view>
<view class="row" v-if="order.payTime">
<text class="label">支付时间</text>
<text class="value">{{ order.payTime }}</text>
</view>
</view>
<!-- 金额信息 -->
<view class="price-card">
<view class="row">
<text class="label">商品总额</text>
<text class="value">¥{{ order.totalAmount }}</text>
</view>
<view class="row">
<text class="label">运费</text>
<text class="value">¥0.00</text>
</view>
<view class="row" v-if="creditPaid > 0">
<text class="label">信用扣除</text>
<text class="value credit">-¥{{ creditPaid.toFixed(2) }}</text>
</view>
<view class="row total">
<text class="label">实付款</text>
<text class="value price">¥{{ order.actualAmount }}</text>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-bar" v-if="order.status === 'pending_payment'">
<view class="btn cancel-btn" @click="handleCancel">取消订单</view>
<view class="btn pay-btn" @click="handlePay">立即支付</view>
</view>
</view>
<view v-else-if="loading" class="loading-page">
<text>加载中...</text>
</view>
</template>
<style lang="scss" scoped>
.order-detail-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
.status-card {
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
padding: 40rpx 30rpx;
color: #fff;
.status-text {
font-size: 36rpx;
font-weight: 600;
margin-bottom: 8rpx;
}
.status-desc {
font-size: 26rpx;
opacity: 0.9;
}
}
.address-card {
background: #fff;
padding: 30rpx;
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
.icon-wrapper {
width: 60rpx;
height: 60rpx;
background: #f5f5f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.icon {
font-size: 32rpx;
color: #666;
}
}
.info {
flex: 1;
.user {
margin-bottom: 8rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
.phone {
margin-left: 20rpx;
font-weight: 400;
font-size: 28rpx;
}
}
.address {
font-size: 26rpx;
color: #666;
line-height: 1.4;
}
}
}
.goods-card {
background: #fff;
padding: 20rpx;
margin-bottom: 20rpx;
.goods-item {
display: flex;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
.cover {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background: #f5f5f5;
margin-right: 20rpx;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.name {
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.spec {
font-size: 24rpx;
color: #999;
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
.price {
font-size: 30rpx;
color: #333;
}
.quantity {
font-size: 26rpx;
color: #999;
}
}
}
}
}
.info-card, .price-card {
background: #fff;
padding: 24rpx;
margin-bottom: 20rpx;
.row {
display: flex;
justify-content: space-between;
margin-bottom: 16rpx;
font-size: 26rpx;
color: #666;
&:last-child {
margin-bottom: 0;
}
.value {
color: #333;
&.credit {
color: #ff4d4f;
}
}
&.total {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
font-size: 28rpx;
font-weight: 600;
.price {
color: #ff4d4f;
font-size: 32rpx;
}
}
}
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 100rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 24rpx;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
gap: 20rpx;
.btn {
padding: 16rpx 40rpx;
border-radius: 40rpx;
font-size: 28rpx;
&.cancel-btn {
border: 1rpx solid #ddd;
color: #666;
}
&.pay-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
color: #fff;
border: none;
}
}
}
.loading-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 28rpx;
}
</style>

329
src/pages/order/list.vue Normal file
View File

@@ -0,0 +1,329 @@
<script lang="ts" setup>
import { useOrderStore } from '@/store/order'
import type { Order, OrderStatus } from '@/typings/mall'
definePage({
style: {
navigationBarTitleText: '我的订单',
enablePullDownRefresh: true,
},
})
const orderStore = useOrderStore()
// 状态
const currentTab = ref(0)
const tabs = [
{ name: '全部', status: '' },
{ name: '待付款', status: 'pending_payment' },
{ name: '待发货', status: 'pending_delivery' },
{ name: '待收货', status: 'pending_receive' },
{ name: '已完成', status: 'completed' },
]
// 页面显示
onShow(() => {
loadData()
})
// 加载数据
async function loadData() {
const status = tabs[currentTab.value].status as OrderStatus | undefined
await orderStore.fetchOrderList(status)
uni.stopPullDownRefresh()
}
// 切换 Tab
function handleTabChange(index: number) {
currentTab.value = index
loadData()
}
// 下拉刷新
onPullDownRefresh(() => {
loadData()
})
// 跳转详情
function goToDetail(id: string) {
uni.navigateTo({
url: `/pages/order/detail?id=${id}`,
})
}
// 支付
async function handlePay(id: string) {
uni.showLoading({ title: '支付中...' })
try {
await orderStore.payOrder(id)
uni.showToast({ title: '支付成功', icon: 'success' })
} catch (error) {
uni.showToast({ title: '支付失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
// 取消订单
function handleCancel(id: string) {
uni.showModal({
title: '提示',
content: '确定要取消该订单吗?',
success: async (res) => {
if (res.confirm) {
try {
await orderStore.cancelOrder(id)
uni.showToast({ title: '订单已取消', icon: 'none' })
} catch (error) {
uni.showToast({ title: '取消失败', icon: 'none' })
}
}
},
})
}
</script>
<template>
<view class="order-list-page">
<!-- Tabs -->
<view class="tabs">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTab === index }"
@click="handleTabChange(index)"
>
{{ tab.name }}
<view class="line" v-if="currentTab === index"></view>
</view>
</view>
<!-- 列表 -->
<view class="list-content">
<view v-if="orderStore.orderList.length > 0">
<view
v-for="order in orderStore.orderList"
:key="order.id"
class="order-card"
@click="goToDetail(order.id)"
>
<view class="header">
<text class="order-no">订单号{{ order.orderNo }}</text>
<text class="status">{{ order.status === 'pending_payment' ? '待付款' :
order.status === 'pending_delivery' ? '待发货' :
order.status === 'pending_receive' ? '待收货' :
order.status === 'completed' ? '已完成' : '已取消' }}</text>
</view>
<view class="goods-list">
<view v-for="item in order.items" :key="item.goodsId" class="goods-item">
<image class="cover" :src="item.cover" mode="aspectFill" />
<view class="info">
<view class="name">{{ item.goodsName }}</view>
<view class="spec">{{ Object.values(item.selectedSpec).join('') }}</view>
</view>
<view class="right">
<view class="price">¥{{ item.price }}</view>
<view class="quantity">x{{ item.quantity }}</view>
</view>
</view>
</view>
<view class="footer">
<view class="total">
{{ order.items.length }} 件商品实付 <text class="price">¥{{ order.actualAmount }}</text>
</view>
<view class="actions">
<view
v-if="order.status === 'pending_payment'"
class="btn cancel-btn"
@click.stop="handleCancel(order.id)"
>
取消订单
</view>
<view
v-if="order.status === 'pending_payment'"
class="btn pay-btn"
@click.stop="handlePay(order.id)"
>
立即支付
</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="i-carbon-document text-4xl text-gray-300 mb-2"></text>
<text>暂无订单</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.order-list-page {
min-height: 100vh;
background: #f5f5f5;
padding-top: 88rpx; // tab高度
}
.tabs {
position: fixed;
top: 0;
/* #ifdef H5 */
top: 44px; // 导航栏高度
/* #endif */
left: 0;
width: 100%;
height: 88rpx;
background: #fff;
display: flex;
z-index: 10;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: #ff4d4f;
font-weight: 600;
}
.line {
position: absolute;
bottom: 0;
width: 40rpx;
height: 4rpx;
background: #ff4d4f;
border-radius: 2rpx;
}
}
}
.list-content {
padding: 20rpx;
}
.order-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.header {
display: flex;
justify-content: space-between;
font-size: 26rpx;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
.order-no {
color: #666;
}
.status {
color: #ff4d4f;
}
}
.goods-item {
display: flex;
margin-bottom: 20rpx;
.cover {
width: 140rpx;
height: 140rpx;
border-radius: 8rpx;
background: #f5f5f5;
margin-right: 20rpx;
}
.info {
flex: 1;
.name {
font-size: 28rpx;
color: #333;
line-height: 1.4;
margin-bottom: 8rpx;
}
.spec {
font-size: 24rpx;
color: #999;
}
}
.right {
text-align: right;
.price {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.quantity {
font-size: 24rpx;
color: #999;
}
}
}
.footer {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
.total {
font-size: 26rpx;
color: #666;
.price {
color: #333;
font-size: 32rpx;
font-weight: 600;
}
}
.actions {
display: flex;
gap: 20rpx;
.btn {
padding: 12rpx 32rpx;
border-radius: 30rpx;
font-size: 26rpx;
border: 1rpx solid #ddd;
color: #666;
&.pay-btn {
border-color: #ff4d4f;
color: #ff4d4f;
}
}
}
}
}
.empty-state {
padding-top: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
color: #999;
font-size: 28rpx;
}
</style>

View File

@@ -1,13 +1,218 @@
<script lang="ts" setup>
import { getCategoryList } from '@/api/category'
import { getGoodsList } from '@/api/goods'
import type { Category, Goods } from '@/typings/mall'
import SearchBar from '@/components/common/SearchBar.vue'
import GoodsCard from '@/components/goods/GoodsCard.vue'
definePage({
style: {
navigationBarTitleText: '分类',
disableScroll: true, // 禁止页面滚动,使用 scroll-view
},
})
// 状态
const categoryList = ref<Category[]>([])
const goodsList = ref<Goods[]>([])
const currentCategoryId = ref('')
const searchKeyword = ref('')
const loading = ref(false)
// 页面加载
onLoad((options) => {
if (options?.categoryId) {
currentCategoryId.value = options.categoryId
}
loadCategories()
})
// 加载分类
async function loadCategories() {
try {
const res = await getCategoryList()
categoryList.value = (res as any).data || []
// 默认选中第一个
if (!currentCategoryId.value && categoryList.value.length > 0) {
currentCategoryId.value = categoryList.value[0].id
}
// 加载商品
if (currentCategoryId.value) {
loadGoods()
}
} catch (error) {
console.error('加载分类失败', error)
}
}
// 加载商品
async function loadGoods() {
if (!currentCategoryId.value) return
loading.value = true
try {
const res = await getGoodsList({
categoryId: currentCategoryId.value,
keyword: searchKeyword.value,
pageSize: 100, // 简单起见,一次加载较多
})
goodsList.value = (res as any).data.list || []
} catch (error) {
console.error('加载商品失败', error)
} finally {
loading.value = false
}
}
// 切换分类
function handleCategoryClick(id: string) {
if (currentCategoryId.value === id) return
currentCategoryId.value = id
searchKeyword.value = '' // 切换分类时清空搜索
loadGoods()
}
// 搜索
function handleSearch(keyword: string) {
searchKeyword.value = keyword
loadGoods()
}
// 跳转详情
function goToDetail(goods: Goods) {
uni.navigateTo({
url: `/pages/goods/detail?id=${goods.id}`,
})
}
</script>
<template>
<view class="mt-10 text-center text-green-500">
分类页面
<view class="category-page">
<!-- 顶部搜索 -->
<view class="header">
<SearchBar
v-model="searchKeyword"
:show-cancel="false"
placeholder="搜索分类下的商品"
@search="handleSearch"
@clear="handleSearch('')"
/>
</view>
<view class="content">
<!-- 左侧分类导航 -->
<scroll-view scroll-y class="nav-side">
<view
v-for="item in categoryList"
:key="item.id"
class="nav-item"
:class="{ active: currentCategoryId === item.id }"
@click="handleCategoryClick(item.id)"
>
<view class="nav-text">{{ item.name }}</view>
</view>
</scroll-view>
<!-- 右侧商品列表 -->
<scroll-view scroll-y class="goods-side">
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view v-else-if="goodsList.length === 0" class="empty-state">
<text class="i-carbon-box text-4xl text-gray-300 mb-2"></text>
<text>暂无商品</text>
</view>
<view v-else class="goods-grid">
<GoodsCard
v-for="item in goodsList"
:key="item.id"
:goods="item"
@click="goToDetail(item)"
/>
</view>
</scroll-view>
</view>
</view>
</template>
<style lang="scss" scoped>
.category-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #fff;
}
.header {
padding-bottom: 10rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.content {
flex: 1;
display: flex;
overflow: hidden;
}
.nav-side {
width: 180rpx;
height: 100%;
background: #f7f8fa;
.nav-item {
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
background: #fff;
color: #333;
font-weight: 600;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 32rpx;
background: #ff4d4f;
border-radius: 0 4rpx 4rpx 0;
}
}
}
}
.goods-side {
flex: 1;
height: 100%;
background: #fff;
padding: 20rpx;
box-sizing: border-box;
}
.goods-grid {
display: grid;
grid-template-columns: repeat(2, 1fr); // 两列布局
gap: 20rpx;
}
.loading-state, .empty-state {
height: 400rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
font-size: 28rpx;
}
</style>

126
src/store/cart.ts Normal file
View File

@@ -0,0 +1,126 @@
import { defineStore } from 'pinia'
import type { CartItem } from '@/typings/mall'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
}),
getters: {
// 购物车商品数量
totalCount(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0)
},
// 选中的商品
checkedItems(): CartItem[] {
return this.items.filter(item => item.checked)
},
// 选中商品数量
checkedCount(): number {
return this.checkedItems.reduce((sum, item) => sum + item.quantity, 0)
},
// 总价
totalPrice(): number {
return this.checkedItems.reduce((sum, item) => {
return sum + item.price * item.quantity
}, 0)
},
// 是否全选
isAllChecked(): boolean {
return this.items.length > 0 && this.items.every(item => item.checked)
},
},
actions: {
// 初始化检查(迁移旧数据)
initCheck() {
this.items.forEach(item => {
if (!item.shopId) {
item.shopId = 'merchant_a'
item.shopName = '商户A'
}
})
},
// 添加商品
addItem(item: Omit<CartItem, 'id' | 'checked'>) {
const existItem = this.items.find(
i =>
i.goodsId === item.goodsId
&& JSON.stringify(i.selectedSpec) === JSON.stringify(item.selectedSpec),
)
if (existItem) {
// 已存在,增加数量
existItem.quantity += item.quantity
}
else {
// 不存在,添加新商品
this.items.push({
...item,
id: `cart_${Date.now()}`,
checked: true,
shopId: item.shopId || 'merchant_a', // 兼容旧数据默认商户A
shopName: item.shopName || '商户A', // 兼容旧数据默认商户A
})
}
},
// 删除商品
removeItem(id: string) {
const index = this.items.findIndex(item => item.id === id)
if (index > -1) {
this.items.splice(index, 1)
}
},
// 更新数量
updateQuantity(id: string, quantity: number) {
const item = this.items.find(item => item.id === id)
if (item) {
item.quantity = Math.max(1, Math.min(quantity, item.stock))
}
},
// 切换选中状态
toggleChecked(id: string) {
const item = this.items.find(item => item.id === id)
if (item) {
item.checked = !item.checked
}
},
// 全选/取消全选
toggleAllChecked() {
const checked = !this.isAllChecked
this.items.forEach((item) => {
item.checked = checked
})
},
// 清空购物车
clear() {
this.items = []
},
// 清空已选中的商品
clearChecked() {
this.items = this.items.filter(item => !item.checked)
},
},
// 持久化配置
persist: {
key: 'shop-toy-cart',
storage: {
getItem: key => uni.getStorageSync(key),
setItem: (key, value) => uni.setStorageSync(key, value),
},
},
})

79
src/store/finance.ts Normal file
View File

@@ -0,0 +1,79 @@
import { defineStore } from 'pinia'
import { getCreditLimit, getSettlementList, getDueOrders, submitWriteOff, getWriteOffList } from '@/api/finance'
import type { CreditLimit, Settlement, SettlementStatus, WriteOff } from '@/typings/mall'
export const useFinanceStore = defineStore('finance', {
state: () => ({
creditLimits: [] as CreditLimit[],
settlementList: [] as Settlement[],
dueOrders: [] as Settlement[],
writeOffList: [] as WriteOff[],
}),
getters: {
// 总可用额度
totalAvailableLimit(state): number {
return state.creditLimits.reduce((sum, item) => sum + item.availableLimit, 0)
},
// 总已用额度
totalUsedLimit(state): number {
return state.creditLimits.reduce((sum, item) => sum + item.usedLimit, 0)
},
},
actions: {
// 获取信用额度
async fetchCreditLimit() {
try {
const res: any = await getCreditLimit()
this.creditLimits = res.data
} catch (error) {
console.error('获取信用额度失败', error)
}
},
// 获取应结账款列表
async fetchSettlementList(params?: { status?: SettlementStatus, merchantId?: string }) {
try {
const res: any = await getSettlementList(params)
this.settlementList = res.data
} catch (error) {
console.error('获取应结账款失败', error)
}
},
// 获取到期订单
async fetchDueOrders() {
try {
const res: any = await getDueOrders()
this.dueOrders = res.data
} catch (error) {
console.error('获取到期订单失败', error)
}
},
// 提交消账
async submitWriteOff(data: { settlementId: string, amount: number, proof: string[], remark: string }) {
try {
await submitWriteOff(data)
// 刷新列表
this.fetchSettlementList()
this.fetchWriteOffList(data.settlementId)
} catch (error) {
console.error('提交消账失败', error)
throw error
}
},
// 获取消账记录
async fetchWriteOffList(settlementId?: string) {
try {
const res: any = await getWriteOffList(settlementId)
this.writeOffList = res.data
} catch (error) {
console.error('获取消账记录失败', error)
}
},
},
})

40
src/store/member.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { getMemberInfo, getMemberBenefits } from '@/api/member'
import type { Member } from '@/typings/mall'
import { memberLevelConfig } from '@/mock/member'
export const useMemberStore = defineStore('member', {
state: () => ({
memberInfo: null as Member | null,
benefits: [] as string[],
}),
getters: {
currentLevelConfig(state) {
if (!state.memberInfo) return null
return memberLevelConfig[state.memberInfo.level]
},
},
actions: {
// 获取会员信息
async fetchMemberInfo() {
try {
const res: any = await getMemberInfo()
this.memberInfo = res.data
} catch (error) {
console.error('获取会员信息失败', error)
}
},
// 获取会员权益
async fetchBenefits() {
try {
const res: any = await getMemberBenefits()
this.benefits = res.data
} catch (error) {
console.error('获取会员权益失败', error)
}
},
},
})

74
src/store/order.ts Normal file
View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { createOrder, getOrderList, getOrderDetail, cancelOrder, payOrder } from '@/api/order'
import type { Order, OrderStatus } from '@/typings/mall'
export const useOrderStore = defineStore('order', {
state: () => ({
orderList: [] as Order[],
currentOrder: null as Order | null,
}),
actions: {
// 获取订单列表
async fetchOrderList(status?: OrderStatus) {
try {
const res: any = await getOrderList(status)
this.orderList = res.data
} catch (error) {
console.error('获取订单列表失败', error)
}
},
// 获取订单详情
async fetchOrderDetail(id: string) {
try {
const res: any = await getOrderDetail(id)
this.currentOrder = res.data
return res.data
} catch (error) {
console.error('获取订单详情失败', error)
return null
}
},
// 创建订单
async createOrder(data: any) {
try {
const res: any = await createOrder(data)
return res.data
} catch (error) {
console.error('创建订单失败', error)
throw error
}
},
// 支付订单
async payOrder(id: string) {
try {
await payOrder(id)
// 更新列表或详情
if (this.currentOrder && this.currentOrder.id === id) {
this.fetchOrderDetail(id)
}
this.fetchOrderList()
} catch (error) {
console.error('支付订单失败', error)
throw error
}
},
// 取消订单
async cancelOrder(id: string) {
try {
await cancelOrder(id)
if (this.currentOrder && this.currentOrder.id === id) {
this.fetchOrderDetail(id)
}
this.fetchOrderList()
} catch (error) {
console.error('取消订单失败', error)
throw error
}
},
},
})

View File

@@ -1,61 +1,40 @@
import type { IUserInfoRes } from '@/api/types/login'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
getUserInfo,
} from '@/api/login'
import type { User } from '@/typings/mall'
import { mockMember } from '@/mock/member'
// 初始化状态
const userInfoState: IUserInfoRes = {
userId: -1,
username: '',
nickname: '',
avatar: '/static/images/default-avatar.png',
}
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: {
id: 'user_001',
username: 'admin',
nickname: '测试用户',
avatar: 'https://picsum.photos/200/200?random=avatar',
phone: '13800138000',
creditLimits: [], // 实际应从 financeStore 获取或关联
member: mockMember,
} as User | null,
isLogin: true, // 默认已登录
}),
export const useUserStore = defineStore(
'user',
() => {
// 定义用户信息
const userInfo = ref<IUserInfoRes>({ ...userInfoState })
// 设置用户信息
const setUserInfo = (val: IUserInfoRes) => {
console.log('设置用户信息', val)
// 若头像为空 则使用默认头像
if (!val.avatar) {
val.avatar = userInfoState.avatar
}
userInfo.value = val
}
const setUserAvatar = (avatar: string) => {
userInfo.value.avatar = avatar
console.log('设置用户头像', avatar)
console.log('userInfo', userInfo.value)
}
// 删除用户信息
const clearUserInfo = () => {
userInfo.value = { ...userInfoState }
uni.removeStorageSync('user')
}
actions: {
// 登录(模拟)
login(data: any) {
this.isLogin = true
// ...
},
/**
* 获取用户信息
*/
const fetchUserInfo = async () => {
const res = await getUserInfo()
setUserInfo(res)
return res
}
return {
userInfo,
clearUserInfo,
fetchUserInfo,
setUserInfo,
setUserAvatar,
}
// 退出登录
logout() {
this.isLogin = false
this.userInfo = null
},
},
{
persist: true,
persist: {
key: 'shop-toy-user',
storage: {
getItem: key => uni.getStorageSync(key),
setItem: (key, value) => uni.setStorageSync(key, value),
},
},
)
})

206
src/typings/mall.ts Normal file
View File

@@ -0,0 +1,206 @@
/**
* 商品相关类型定义
*/
// 商品规格
export interface GoodsSpec {
name: string // 规格名(如:颜色、尺寸)
values: string[] // 规格值(如:红色、蓝色)
}
// 商品信息
export interface Goods {
id: string
shopId: string // 店铺ID
shopName: string // 店铺名称
name: string // 商品名称
cover: string // 封面图
images: string[] // 商品图片
price: number // 价格
originalPrice: number // 原价
stock: number // 库存
sales: number // 销量
description: string // 商品描述
specs: GoodsSpec[] // 规格
tags: string[] // 标签
categoryId: string // 分类ID
categoryName: string // 分类名称
}
/**
* 分类相关类型定义
*/
export interface Category {
id: string
name: string // 分类名称
icon: string // 分类图标
cover: string // 分类封面
parentId?: string // 父分类ID
children?: Category[] // 子分类
}
/**
* 购物车相关类型定义
*/
export interface CartItem {
id: string
shopId: string
shopName: string
goodsId: string
goodsName: string
cover: string
price: number
selectedSpec: Record<string, string> // 选中的规格
quantity: number
stock: number
checked: boolean // 是否选中
}
/**
* 订单相关类型定义
*/
export interface OrderItem {
goodsId: string
shopId: string
shopName: string
goodsName: string
cover: string
price: number
quantity: number
selectedSpec: Record<string, string>
}
export enum OrderStatus {
PENDING_PAYMENT = 'pending_payment', // 待支付
PENDING_DELIVERY = 'pending_delivery', // 待发货
PENDING_RECEIVE = 'pending_receive', // 待收货
COMPLETED = 'completed', // 已完成
CANCELLED = 'cancelled', // 已取消
}
export interface Order {
id: string
orderNo: string // 订单号
items: OrderItem[]
totalAmount: number // 总金额
actualAmount: number // 实付金额
status: OrderStatus // 订单状态
paymentMethod?: 'online' | 'credit' | 'mixed' // 支付方式
paymentDetails?: { shopId: string, creditAmount: number, onlineAmount: number }[] // 混合支付详情
address: Address // 收货地址
createTime: string
payTime?: string
// 金融相关
merchantId?: string // 关联商户ID
isSettled: boolean // 是否已结
settlementTime?: string // 结算时间
dueDate?: string // 到期日期
}
export interface Address {
id: string
name: string // 收货人
phone: string // 手机号
province: string // 省
city: string // 市
district: string // 区
detail: string // 详细地址
isDefault: boolean // 是否默认
}
/**
* 金融相关类型定义
*/
// 信用额度
export interface CreditLimit {
merchantId: string // 商户ID
merchantName: string // 商户名称商户A、商户B
totalLimit: number // 总额度
usedLimit: number // 已用额度
availableLimit: number // 可用额度
updateTime: string // 更新时间
}
// 应结账款状态
export enum SettlementStatus {
SETTLED = 'settled', // 已结
UNSETTLED = 'unsettled', // 未结
OVERDUE = 'overdue', // 逾期
}
// 应结账款
export interface Settlement {
id: string
orderNo: string // 订单号
merchantId: string // 商户ID
merchantName: string // 商户名称
amount: number // 金额
status: SettlementStatus // 状态(已结/未结)
dueDate: string // 到期日期
settlementDate?: string // 结算日期
relatedOrders: string[] // 关联订单号列表
}
// 消账状态
export enum WriteOffStatus {
PENDING = 'pending', // 待审核
APPROVED = 'approved', // 已通过
REJECTED = 'rejected', // 已拒绝
}
// 提交消账
export interface WriteOff {
id: string
settlementId: string // 应结账款ID
amount: number // 消账金额
proof: string[] // 凭证图片
remark: string // 备注
submitTime: string // 提交时间
status: WriteOffStatus // 状态
}
/**
* 会员相关类型定义
*/
export enum MemberLevel {
NORMAL = 'normal', // 普通会员
SILVER = 'silver', // 银卡会员
GOLD = 'gold', // 金卡会员
PLATINUM = 'platinum', // 白金会员
}
export interface Member {
id: string
userId: string
level: MemberLevel // 会员等级
points: number // 积分
expireDate: string // 到期日期
benefits: string[] // 会员权益
}
/**
* 用户相关类型定义
*/
export interface User {
id: string
username: string
nickname: string
avatar: string
phone: string
// 金融相关
creditLimits: CreditLimit[] // 信用额度列表
// 会员相关
member?: Member // 会员信息
}
/**
* 轮播图相关类型定义
*/
export interface Banner {
id: string
image: string // 图片地址
title: string // 标题
link?: string // 跳转链接
goodsId?: string // 关联商品ID
}