页面提交

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

View File

@@ -0,0 +1,344 @@
# Design Document
## Overview
本设计文档描述了结算页面应结账款UI优化的技术实现方案。优化目标是提升视觉层次、改善信息密度、增强交互反馈从而提供更好的用户体验。
当前页面基于 Vue 3 + TypeScript + uni-app 框架开发,使用 SCSS 进行样式管理。优化将保持现有架构不变,主要通过调整样式、优化组件结构和改进视觉设计来实现。
## Architecture
### 组件层次结构
```
settlement.vue (主页面)
├── DueAlert (到期提醒区域)
│ └── DueItem[] (到期项列表)
├── TabNavigation (标签导航)
│ └── TabItem[] (标签项)
├── MerchantGroup[] (商户分组)
│ ├── MerchantHeader (商户头部)
│ ├── BatchActionButton (批量操作按钮)
│ └── OrderList (订单列表)
│ └── OrderItem[] (订单项)
│ ├── OrderHeader (订单头部)
│ ├── OrderContent (订单内容)
│ └── OrderFooter (订单操作)
└── EmptyState (空状态)
```
### 样式架构
采用 BEM 命名规范和模块化 SCSS
- 使用语义化的颜色变量
- 统一的间距系统(基于 4rpx 倍数)
- 响应式字体大小
- 可复用的混入mixin
## Components and Interfaces
### 1. DueAlert Component
**职责**: 显示即将到期的账款提醒
**Props**: 无(从 store 获取数据)
**数据源**: `financeStore.dueOrders`
**视觉特性**:
- 琥珀色背景 (#fffbe6)
- 警告图标 + 标题
- 横向滚动列表
- 卡片式到期项
### 2. TabNavigation Component
**职责**: 提供未结/已结状态切换
**Props**: 无
**状态**: `currentTab` (0: 未结, 1: 已结)
**视觉特性**:
- 固定在顶部
- 活动标签蓝色下划线
- 平滑过渡动画
### 3. MerchantGroup Component
**职责**: 按商户聚合显示账款信息
**数据结构**:
```typescript
interface MerchantGroup {
merchantId: string
merchantName: string
settlements: Settlement[]
totalAmount: number
hasOverdue: boolean
}
```
**视觉特性**:
- 圆角卡片 (16rpx)
- 渐变头部背景
- 逾期徽章
- 突出显示总金额
### 4. OrderItem Component
**职责**: 显示单个订单的结算信息
**数据结构**:
```typescript
interface Settlement {
id: string
orderNo: string
merchantId: string
merchantName: string
amount: number
dueDate: string
settlementDate?: string
status: SettlementStatus
}
```
**视觉特性**:
- 清晰的信息层次
- 状态徽章(颜色编码)
- 金额突出显示
- 操作按钮(未结状态)
## Data Models
### SettlementStatus Enum
```typescript
enum SettlementStatus {
UNSETTLED = 'unsettled', // 未结
SETTLED = 'settled', // 已结
OVERDUE = 'overdue' // 已逾期
}
```
### Settlement Interface
```typescript
interface Settlement {
id: string
orderNo: string
merchantId: string
merchantName: string
amount: number
dueDate: string
settlementDate?: string
status: SettlementStatus
}
```
### DueOrder Interface
```typescript
interface DueOrder {
id: string
dueDate: string
amount: number
}
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Due Alert Visibility Consistency
*For any* state of the due orders list, when the list is empty, the Due Alert component should not be rendered in the DOM
**Validates: Requirements 1.4**
### Property 2: Merchant Grouping Completeness
*For any* settlement list, all settlements should be grouped by merchantId with no settlements left ungrouped or duplicated across groups
**Validates: Requirements 2.1**
### Property 3: Status Badge Color Mapping
*For any* settlement status value, the rendered status badge should use the correct color scheme (green for settled, amber for unsettled, red for overdue)
**Validates: Requirements 3.3**
### Property 4: Amount Display Formatting
*For any* numeric amount value, when displayed in the UI, it should be formatted with exactly 2 decimal places and prefixed with the ¥ symbol
**Validates: Requirements 3.4**
### Property 5: Tab State Synchronization
*For any* tab selection change, the active tab indicator should update and the corresponding settlement data should be loaded matching the selected status
**Validates: Requirements 5.4**
### Property 6: Button Interaction Feedback
*For any* button press event, the button should apply 0.8 opacity during the active state to provide visual feedback
**Validates: Requirements 4.3**
### Property 7: Overdue Badge Display Logic
*For any* merchant group, the overdue badge should be displayed if and only if at least one settlement in that group has status OVERDUE
**Validates: Requirements 2.3**
### Property 8: Empty State Rendering
*For any* state where the grouped merchant list is empty, the empty state component should be displayed with centered alignment and appropriate messaging
**Validates: Requirements 6.1, 6.2, 6.3, 6.4**
### Property 9: Spacing Consistency
*For any* rendered card or container element, the padding and margin values should follow the 4rpx-based spacing system (20rpx, 24rpx, etc.)
**Validates: Requirements 7.1, 7.2, 7.3**
### Property 10: Color Semantic Consistency
*For any* UI element representing a specific semantic meaning (primary action, amount, success, warning, danger), the color used should match the defined color palette
**Validates: Requirements 8.1, 8.2, 8.3, 8.4, 8.5, 8.6**
## Error Handling
### 数据加载错误
- 使用 uni.showToast 显示错误提示
- 保持当前状态,允许用户重试
- 下拉刷新作为恢复机制
### 消账提交错误
- 显示具体错误信息
- 保持弹窗打开,允许用户修改后重试
- 使用 loading 状态防止重复提交
### 空数据处理
- 显示友好的空状态页面
- 提供明确的提示信息
- 不显示错误,因为空数据是正常状态
## Testing Strategy
### Unit Testing
本项目将使用 **Vitest** 作为单元测试框架,配合 **@vue/test-utils** 进行 Vue 组件测试。
**测试范围**:
1. **计算属性测试**
- `groupedByMerchant`: 验证商户分组逻辑正确性
- `currentWriteOffAmount`: 验证单个/批量消账金额计算
2. **方法测试**
- `getStatusText()`: 验证状态文本映射
- `getStatusClass()`: 验证状态样式类映射
- `handleTabChange()`: 验证标签切换逻辑
3. **边界情况**
- 空数据列表
- 单个商户多个订单
- 全部逾期订单
### Property-Based Testing
本项目将使用 **fast-check** 作为属性测试库,验证通用正确性属性。
**配置要求**:
- 每个属性测试至少运行 100 次迭代
- 使用明确的注释标记关联的设计文档属性
**测试属性**:
每个属性测试必须使用以下格式的注释标记:
```typescript
// **Feature: settlement-ui-optimization, Property {number}: {property_text}**
```
**属性测试列表**:
1. **Property 1**: Due Alert Visibility Consistency
2. **Property 2**: Merchant Grouping Completeness
3. **Property 3**: Status Badge Color Mapping
4. **Property 4**: Amount Display Formatting
5. **Property 5**: Tab State Synchronization
6. **Property 6**: Button Interaction Feedback
7. **Property 7**: Overdue Badge Display Logic
8. **Property 8**: Empty State Rendering
9. **Property 9**: Spacing Consistency
10. **Property 10**: Color Semantic Consistency
### 测试工具和依赖
```json
{
"devDependencies": {
"vitest": "^1.0.0",
"@vue/test-utils": "^2.4.0",
"fast-check": "^3.15.0",
"@vitest/ui": "^1.0.0"
}
}
```
### 测试命令
```bash
# 运行所有测试
pnpm test
# 运行单元测试
pnpm test:unit
# 运行属性测试
pnpm test:property
# 测试覆盖率
pnpm test:coverage
```
## Implementation Notes
### 样式优化重点
1. **颜色系统**
- 主色:#1890ff (蓝色)
- 金额:#ff4d4f (红色)
- 成功:#52c41a (绿色)
- 警告:#faad14 (琥珀色)
- 文本层次:#333 / #666 / #999
2. **间距系统**
- 基础单位4rpx
- 常用值20rpx, 24rpx, 32rpx
- 卡片间距20rpx
- 内容内边距24rpx
3. **圆角规范**
- 卡片16rpx
- 按钮8rpx
- 徽章4rpx
4. **阴影效果**
- 卡片0 2rpx 8rpx rgba(0, 0, 0, 0.05)
- 固定导航0 2rpx 8rpx rgba(0, 0, 0, 0.05)
### 性能考虑
- 使用 `v-if` 而非 `v-show` 处理大列表的条件渲染
- 商户分组减少渲染节点数量
- 横向滚动使用 `scroll-view` 组件优化性能
### 可访问性
- 保持足够的颜色对比度WCAG AA 标准)
- 使用语义化的图标
- 提供清晰的状态反馈
### 响应式设计
- 使用 rpx 单位适配不同屏幕
- 固定导航栏考虑 H5 环境的顶部偏移
- 横向滚动适配小屏幕设备

View File

@@ -0,0 +1,107 @@
# Requirements Document
## Introduction
本文档定义了结算页面应结账款UI优化的需求。当前页面存在视觉层次不清晰、信息密度过高、交互反馈不足等问题。本次优化旨在提升用户体验使页面更加清晰、易用、美观。
## Glossary
- **Settlement System**: 结算系统,管理商户应收账款的系统
- **Merchant Group**: 商户分组按商户ID聚合的账款集合
- **Settlement Item**: 结算项,单个订单的应结账款记录
- **Write-off**: 消账,申请结算账款的操作
- **Due Alert**: 到期提醒,显示即将到期账款的警告组件
- **Tab Navigation**: 标签导航,用于切换未结/已结状态的导航组件
## Requirements
### Requirement 1
**User Story:** 作为用户,我希望到期提醒区域更加醒目且易于理解,以便快速识别需要关注的账款
#### Acceptance Criteria
1. WHEN the Due Alert component is displayed THEN the Settlement System SHALL use a distinct warning color scheme with amber background and red accent
2. WHEN the Due Alert contains multiple items THEN the Settlement System SHALL display them in a horizontally scrollable list with clear visual separation
3. WHEN a due item is rendered THEN the Settlement System SHALL display the due date and amount with appropriate font sizes and color contrast
4. WHEN the Due Alert has no items THEN the Settlement System SHALL hide the component completely
### Requirement 2
**User Story:** 作为用户,我希望商户分组卡片具有更好的视觉层次,以便快速区分不同商户的账款信息
#### Acceptance Criteria
1. WHEN a Merchant Group card is rendered THEN the Settlement System SHALL apply rounded corners with 16rpx radius and subtle shadow
2. WHEN the merchant header is displayed THEN the Settlement System SHALL use a gradient background to distinguish it from the content area
3. WHEN a merchant has overdue settlements THEN the Settlement System SHALL display a prominent red badge next to the merchant name
4. WHEN the merchant total amount is shown THEN the Settlement System SHALL use large bold red text with minimum 32rpx font size
### Requirement 3
**User Story:** 作为用户,我希望订单列表项具有清晰的视觉分隔和信息层次,以便快速浏览和理解账款详情
#### Acceptance Criteria
1. WHEN Settlement Items are displayed in a list THEN the Settlement System SHALL separate each item with a 1rpx border or 20rpx spacing
2. WHEN an order number is shown THEN the Settlement System SHALL display it in gray color with 26rpx font size
3. WHEN status badges are rendered THEN the Settlement System SHALL use color-coded backgrounds (green for settled, amber for unsettled, red for overdue)
4. WHEN amount values are displayed THEN the Settlement System SHALL use red color and bold weight for emphasis
5. WHEN date information is shown THEN the Settlement System SHALL use consistent formatting and gray color for labels
### Requirement 4
**User Story:** 作为用户,我希望操作按钮具有明确的视觉反馈和层次,以便清楚地知道可以执行哪些操作
#### Acceptance Criteria
1. WHEN the batch write-off button is displayed THEN the Settlement System SHALL use a full-width blue gradient button with 16rpx vertical padding
2. WHEN the single write-off button is displayed THEN the Settlement System SHALL use an outlined style with blue border and text
3. WHEN a button is pressed THEN the Settlement System SHALL apply 0.8 opacity for visual feedback
4. WHEN buttons are rendered THEN the Settlement System SHALL use rounded corners with minimum 8rpx radius
### Requirement 5
**User Story:** 作为用户,我希望标签导航清晰且响应迅速,以便在未结和已结账款之间快速切换
#### Acceptance Criteria
1. WHEN the Tab Navigation is displayed THEN the Settlement System SHALL fix it at the top of the viewport with white background
2. WHEN a tab is active THEN the Settlement System SHALL display a blue underline indicator with 4rpx height
3. WHEN tab text is rendered THEN the Settlement System SHALL use 28rpx font size with gray color for inactive and blue for active
4. WHEN a tab is clicked THEN the Settlement System SHALL update the active state and load corresponding data
### Requirement 6
**User Story:** 作为用户,我希望空状态页面友好且信息明确,以便了解当前没有数据的原因
#### Acceptance Criteria
1. WHEN no settlement data exists THEN the Settlement System SHALL display a centered empty state component
2. WHEN the empty state is shown THEN the Settlement System SHALL include an icon with 4xl size and gray color
3. WHEN the empty message is displayed THEN the Settlement System SHALL use 28rpx font size with gray color
4. WHEN the empty state is rendered THEN the Settlement System SHALL position it with 200rpx top padding
### Requirement 7
**User Story:** 作为用户,我希望整体页面具有一致的间距和对齐,以便获得专业和舒适的视觉体验
#### Acceptance Criteria
1. WHEN page content is rendered THEN the Settlement System SHALL use consistent 20rpx padding for main containers
2. WHEN cards are displayed THEN the Settlement System SHALL apply 20rpx bottom margin for vertical spacing
3. WHEN internal card content is shown THEN the Settlement System SHALL use 24rpx horizontal padding
4. WHEN text elements are aligned THEN the Settlement System SHALL maintain consistent baseline alignment across rows
### Requirement 8
**User Story:** 作为用户,我希望颜色使用符合语义且具有良好的可读性,以便快速理解信息的重要性和状态
#### Acceptance Criteria
1. WHEN primary actions are displayed THEN the Settlement System SHALL use blue (#1890ff) as the primary color
2. WHEN amounts are shown THEN the Settlement System SHALL use red (#ff4d4f) to indicate financial values
3. WHEN success states are indicated THEN the Settlement System SHALL use green (#52c41a) with light green background
4. WHEN warning states are indicated THEN the Settlement System SHALL use amber (#faad14) with light yellow background
5. WHEN danger states are indicated THEN the Settlement System SHALL use red (#ff4d4f) with light red background
6. WHEN text hierarchy is established THEN the Settlement System SHALL use #333 for primary text, #666 for secondary, and #999 for tertiary

View File

@@ -0,0 +1,111 @@
# Implementation Plan
- 创建 SCSS 变量文件定义颜色系统、间距系统、圆角规范
- 定义可复用的 mixin卡片样式、按钮样式、徽章样式
- 确保所有设计令牌符合设计文档规范
- _Requirements: 7.1, 7.2, 7.3, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_
- [ ] 2. 优化到期提醒区域DueAlert
- 实现琥珀色背景和警告图标的视觉设计
- 创建横向滚动的到期项列表布局
- 优化到期日期和金额的字体大小和颜色对比
- 实现空状态时隐藏组件的逻辑
- _Requirements: 1.1, 1.2, 1.3, 1.4_
- [ ]* 2.1 编写 Property 1 的属性测试
- **Property 1: Due Alert Visibility Consistency**
- **Validates: Requirements 1.4**
- [ ] 3. 优化标签导航TabNavigation
- 实现固定在顶部的导航栏样式
- 添加活动标签的蓝色下划线指示器
- 优化标签文字的字体大小和颜色状态
- 实现标签切换的平滑过渡动画
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- [ ]* 3.1 编写 Property 5 的属性测试
- **Property 5: Tab State Synchronization**
- **Validates: Requirements 5.4**
- [ ] 4. 优化商户分组卡片MerchantGroup
- 实现圆角卡片样式16rpx和阴影效果
- 创建渐变背景的商户头部设计
- 实现逾期徽章的显示逻辑和样式
- 优化商户总金额的字体大小和颜色(大号粗体红色)
- _Requirements: 2.1, 2.2, 2.3, 2.4_
- [ ]* 4.1 编写 Property 2 的属性测试
- **Property 2: Merchant Grouping Completeness**
- **Validates: Requirements 2.1**
- [ ]* 4.2 编写 Property 7 的属性测试
- **Property 7: Overdue Badge Display Logic**
- **Validates: Requirements 2.3**
- [ ] 5. 优化订单列表项OrderItem
- 实现订单项之间的视觉分隔(边框或间距)
- 优化订单号的字体大小和颜色
- 实现状态徽章的颜色编码系统(绿色/琥珀色/红色)
- 优化金额显示的字体粗细和颜色
- 统一日期信息的格式和标签颜色
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ]* 5.1 编写 Property 3 的属性测试
- **Property 3: Status Badge Color Mapping**
- **Validates: Requirements 3.3**
- [ ]* 5.2 编写 Property 4 的属性测试
- **Property 4: Amount Display Formatting**
- **Validates: Requirements 3.4**
- [ ] 6. 优化操作按钮样式
- 实现批量消账按钮的全宽蓝色渐变样式
- 实现单个消账按钮的描边样式
- 添加按钮按下时的透明度反馈0.8
- 统一按钮圆角规范(最小 8rpx
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ]* 6.1 编写 Property 6 的属性测试
- **Property 6: Button Interaction Feedback**
- **Validates: Requirements 4.3**
- [ ] 7. 优化空状态页面
- 实现居中对齐的空状态组件布局
- 添加大尺寸灰色图标4xl
- 优化空状态消息的字体大小和颜色
- 设置适当的顶部内边距200rpx
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [ ]* 7.1 编写 Property 8 的属性测试
- **Property 8: Empty State Rendering**
- **Validates: Requirements 6.1, 6.2, 6.3, 6.4**
- [ ] 8. 全局样式调整和一致性检查
- 验证所有容器使用一致的 20rpx 内边距
- 验证所有卡片使用 20rpx 底部外边距
- 验证卡片内容使用 24rpx 水平内边距
- 检查文本元素的基线对齐一致性
- _Requirements: 7.1, 7.2, 7.3, 7.4_
- [ ]* 8.1 编写 Property 9 的属性测试
- **Property 9: Spacing Consistency**
- **Validates: Requirements 7.1, 7.2, 7.3**
- [ ]* 8.2 编写 Property 10 的属性测试
- **Property 10: Color Semantic Consistency**
- **Validates: Requirements 8.1, 8.2, 8.3, 8.4, 8.5, 8.6**
- [ ] 9. 响应式和性能优化
- 验证 rpx 单位在不同屏幕尺寸下的适配效果
- 优化固定导航栏在 H5 环境的顶部偏移
- 确保横向滚动在小屏幕设备上流畅运行
- 验证大列表使用 v-if 的条件渲染性能
- _Requirements: 所有需求的性能和响应式方面_
- [ ] 10. 最终检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请询问用户

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')
}
/**
* 获取用户信息
*/
const fetchUserInfo = async () => {
const res = await getUserInfo()
setUserInfo(res)
return res
}
return {
userInfo,
clearUserInfo,
fetchUserInfo,
setUserInfo,
setUserAvatar,
}
actions: {
// 登录(模拟)
login(data: any) {
this.isLogin = true
// ...
},
{
persist: true,
// 退出登录
logout() {
this.isLogin = false
this.userInfo = null
},
)
},
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
}