页面提交
This commit is contained in:
344
.kiro/specs/settlement-ui-optimization/design.md
Normal file
344
.kiro/specs/settlement-ui-optimization/design.md
Normal 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 环境的顶部偏移
|
||||||
|
- 横向滚动适配小屏幕设备
|
||||||
107
.kiro/specs/settlement-ui-optimization/requirements.md
Normal file
107
.kiro/specs/settlement-ui-optimization/requirements.md
Normal 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
|
||||||
111
.kiro/specs/settlement-ui-optimization/tasks.md
Normal file
111
.kiro/specs/settlement-ui-optimization/tasks.md
Normal 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
94
src/api/address.ts
Normal 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
57
src/api/auth.ts
Normal 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
17
src/api/banner.ts
Normal 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
30
src/api/category.ts
Normal 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
125
src/api/finance.ts
Normal 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
95
src/api/goods.ts
Normal 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
29
src/api/member.ts
Normal 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
112
src/api/order.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
173
src/components/cart/CartItem.vue
Normal file
173
src/components/cart/CartItem.vue
Normal 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>
|
||||||
133
src/components/cart/CartSummary.vue
Normal file
133
src/components/cart/CartSummary.vue
Normal 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>
|
||||||
77
src/components/common/Banner.vue
Normal file
77
src/components/common/Banner.vue
Normal 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>
|
||||||
79
src/components/common/CategoryGrid.vue
Normal file
79
src/components/common/CategoryGrid.vue
Normal 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>
|
||||||
124
src/components/common/CounterInput.vue
Normal file
124
src/components/common/CounterInput.vue
Normal 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>
|
||||||
89
src/components/common/PriceTag.vue
Normal file
89
src/components/common/PriceTag.vue
Normal 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>
|
||||||
137
src/components/common/SearchBar.vue
Normal file
137
src/components/common/SearchBar.vue
Normal 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>
|
||||||
148
src/components/finance/CreditCard.vue
Normal file
148
src/components/finance/CreditCard.vue
Normal 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>
|
||||||
169
src/components/finance/SettlementItem.vue
Normal file
169
src/components/finance/SettlementItem.vue
Normal 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>
|
||||||
303
src/components/finance/WriteOffForm.vue
Normal file
303
src/components/finance/WriteOffForm.vue
Normal 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>
|
||||||
130
src/components/goods/GoodsCard.vue
Normal file
130
src/components/goods/GoodsCard.vue
Normal 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>
|
||||||
294
src/components/goods/SpecSelector.vue
Normal file
294
src/components/goods/SpecSelector.vue
Normal 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>
|
||||||
61
src/components/member/MemberBenefits.vue
Normal file
61
src/components/member/MemberBenefits.vue
Normal 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>
|
||||||
116
src/components/member/MemberCard.vue
Normal file
116
src/components/member/MemberCard.vue
Normal 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
37
src/mock/address.ts
Normal 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
31
src/mock/banner.ts
Normal 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
55
src/mock/category.ts
Normal 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
168
src/mock/finance.ts
Normal 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
238
src/mock/goods.ts
Normal 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
10
src/mock/index.ts
Normal 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
40
src/mock/member.ts
Normal 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,
|
||||||
|
}
|
||||||
123
src/pages/finance/credit.vue
Normal file
123
src/pages/finance/credit.vue
Normal 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>
|
||||||
744
src/pages/finance/settlement.vue
Normal file
744
src/pages/finance/settlement.vue
Normal 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>
|
||||||
|
|
||||||
@@ -1,13 +1,214 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useCartStore } from '@/store/cart'
|
||||||
|
import CartItem from '@/components/cart/CartItem.vue'
|
||||||
|
import CartSummary from '@/components/cart/CartSummary.vue'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '购物车',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</view>
|
||||||
</template>
|
</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
386
src/pages/goods/detail.vue
Normal 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>
|
||||||
@@ -1,13 +1,184 @@
|
|||||||
<script lang="ts" setup>
|
<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({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '首页',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</view>
|
||||||
</template>
|
</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
236
src/pages/login/index.vue
Normal 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>
|
||||||
@@ -1,13 +1,565 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '我的',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</view>
|
||||||
</template>
|
</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
147
src/pages/member/index.vue
Normal 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
693
src/pages/order/confirm.vue
Normal 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
384
src/pages/order/detail.vue
Normal 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
329
src/pages/order/list.vue
Normal 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>
|
||||||
@@ -1,13 +1,218 @@
|
|||||||
<script lang="ts" setup>
|
<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({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '分类',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</view>
|
||||||
</template>
|
</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
126
src/store/cart.ts
Normal 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
79
src/store/finance.ts
Normal 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
40
src/store/member.ts
Normal 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
74
src/store/order.ts
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,61 +1,40 @@
|
|||||||
import type { IUserInfoRes } from '@/api/types/login'
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import type { User } from '@/typings/mall'
|
||||||
import {
|
import { mockMember } from '@/mock/member'
|
||||||
getUserInfo,
|
|
||||||
} from '@/api/login'
|
|
||||||
|
|
||||||
// 初始化状态
|
export const useUserStore = defineStore('user', {
|
||||||
const userInfoState: IUserInfoRes = {
|
state: () => ({
|
||||||
userId: -1,
|
userInfo: {
|
||||||
username: '',
|
id: 'user_001',
|
||||||
nickname: '',
|
username: 'admin',
|
||||||
avatar: '/static/images/default-avatar.png',
|
nickname: '测试用户',
|
||||||
}
|
avatar: 'https://picsum.photos/200/200?random=avatar',
|
||||||
|
phone: '13800138000',
|
||||||
|
creditLimits: [], // 实际应从 financeStore 获取或关联
|
||||||
|
member: mockMember,
|
||||||
|
} as User | null,
|
||||||
|
isLogin: true, // 默认已登录
|
||||||
|
}),
|
||||||
|
|
||||||
export const useUserStore = defineStore(
|
actions: {
|
||||||
'user',
|
// 登录(模拟)
|
||||||
() => {
|
login(data: any) {
|
||||||
// 定义用户信息
|
this.isLogin = true
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// 退出登录
|
||||||
* 获取用户信息
|
logout() {
|
||||||
*/
|
this.isLogin = false
|
||||||
const fetchUserInfo = async () => {
|
this.userInfo = null
|
||||||
const res = await getUserInfo()
|
},
|
||||||
setUserInfo(res)
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userInfo,
|
|
||||||
clearUserInfo,
|
|
||||||
fetchUserInfo,
|
|
||||||
setUserInfo,
|
|
||||||
setUserAvatar,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
persist: true,
|
persist: {
|
||||||
|
key: 'shop-toy-user',
|
||||||
|
storage: {
|
||||||
|
getItem: key => uni.getStorageSync(key),
|
||||||
|
setItem: (key, value) => uni.setStorageSync(key, value),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
|
|||||||
206
src/typings/mall.ts
Normal file
206
src/typings/mall.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user