首页优化
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
# 小程序首页接口文档
|
||||||
|
|
||||||
|
> 适用端:微信小程序(h5 仓库,Taro)
|
||||||
|
> 后台配置入口:PC 后台「客户端配置」菜单(首页轮播图 / 宫格导航 / 促销推荐卡片)
|
||||||
|
> 更新日期:2026-08-14
|
||||||
|
|
||||||
|
## 通用约定
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 根地址 | `BASE_URL`(见 `src/utils/request.ts`,如 `http://localhost:8000/index.php`) |
|
||||||
|
| 认证 | 请求头 `Authorization: Bearer {token}`,token 来自登录接口,本地存储 key `auth_token` |
|
||||||
|
| 响应包络 | `{ success: boolean, data: T, msg?: string, showType?: number }` |
|
||||||
|
| 失败处理 | `success=false` 时 `msg` 为中文错误信息;HTTP 401 表示登录过期,需重新登录 |
|
||||||
|
|
||||||
|
## GET /mini/home
|
||||||
|
|
||||||
|
首页配置聚合接口:一次返回轮播图、宫格导航、促销推荐卡片三组数据,均为**启用状态(status=1)**且按 `sort` 升序(越小越靠前)。
|
||||||
|
|
||||||
|
- **权限**:需登录(Bearer token)
|
||||||
|
- **请求参数**:无
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"banners": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "新鲜直采",
|
||||||
|
"image_id": 123,
|
||||||
|
"link": "/pages/goods/detail?id=1",
|
||||||
|
"sort": 0,
|
||||||
|
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/xxx.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"navs": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "蔬菜专区",
|
||||||
|
"image_id": 124,
|
||||||
|
"link": "/pages/category/index?id=1",
|
||||||
|
"sort": 0,
|
||||||
|
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/yyy.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"promos": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "限时特惠",
|
||||||
|
"sub_title": "全场 8 折起",
|
||||||
|
"image_id": 125,
|
||||||
|
"link": "/pages/promo/detail?id=1",
|
||||||
|
"sort": 0,
|
||||||
|
"image_url": "http://localhost:8000/storage/uploads/2026/08/14/zzz.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
**banners(轮播图)**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | number | 轮播图 ID |
|
||||||
|
| title | string | 标题(后台维护,可用于无障碍/占位) |
|
||||||
|
| image_id | number | 图片文件 ID(sys_file),一般无需使用 |
|
||||||
|
| **image_url** | string \| null | 轮播图片完整 URL,直接用于 `<Image src>`;未传图时为 null |
|
||||||
|
| link | string | 小程序页面跳转路径,**空字符串表示点击不跳转** |
|
||||||
|
| sort | number | 排序值(已按此升序返回,前端无需再排) |
|
||||||
|
|
||||||
|
**navs(宫格导航)**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | number | 导航 ID |
|
||||||
|
| name | string | 导航名称(宫格文字) |
|
||||||
|
| **image_url** | string \| null | 导航图标完整 URL |
|
||||||
|
| link | string | 小程序页面跳转路径,空字符串不跳转 |
|
||||||
|
| sort | number | 排序值 |
|
||||||
|
|
||||||
|
**promos(促销推荐卡片)**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | number | 卡片 ID |
|
||||||
|
| title | string | 卡片标题 |
|
||||||
|
| sub_title | string | 副标题/促销文案,可能为空字符串 |
|
||||||
|
| **image_url** | string \| null | 卡片图片完整 URL |
|
||||||
|
| link | string | 小程序页面跳转路径,空字符串不跳转 |
|
||||||
|
| sort | number | 排序值 |
|
||||||
|
|
||||||
|
### 前端接入示例
|
||||||
|
|
||||||
|
`src/services/home.ts`(新增):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { get } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 首页轮播图项 */
|
||||||
|
export interface HomeBanner {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
image_id: number
|
||||||
|
image_url: string | null
|
||||||
|
link: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页宫格导航项 */
|
||||||
|
export interface HomeNav {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image_id: number
|
||||||
|
image_url: string | null
|
||||||
|
link: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页促销推荐卡片 */
|
||||||
|
export interface HomePromo {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
sub_title: string
|
||||||
|
image_id: number
|
||||||
|
image_url: string | null
|
||||||
|
link: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页配置聚合数据 */
|
||||||
|
export interface HomeConfig {
|
||||||
|
banners: HomeBanner[]
|
||||||
|
navs: HomeNav[]
|
||||||
|
promos: HomePromo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||||
|
export function getHomeConfigApi() {
|
||||||
|
return get<HomeConfig>('/mini/home')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
页面中使用(跳转需兼容空链接):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHomeConfigApi().then(res => setConfig(res.data))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 统一跳转:link 为空不跳转 */
|
||||||
|
function handleLink(link: string) {
|
||||||
|
if (!link) return
|
||||||
|
Taro.navigateTo({ url: link })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
1. **三组数据均可能为空数组**(后台未配置或全部停用),页面需做空态处理。
|
||||||
|
2. `image_url` 可能为 `null`(后台未上传图片),渲染前判空。
|
||||||
|
3. `link` 为小程序内部页面路径(以 `/` 开头),用 `Taro.navigateTo` 跳转;若目标为 tabBar 页面需改用 `Taro.switchTab`(建议后台配置时避免填 tabBar 路径)。
|
||||||
|
4. 数据实时生效:后台修改后,小程序下次进入首页请求即为最新内容,无缓存。
|
||||||
|
5. 接口需登录后调用;未登录(401)会由 request 封装自动跳登录页。
|
||||||
@@ -7,6 +7,7 @@ export default defineAppConfig({
|
|||||||
'pages/profile/index',
|
'pages/profile/index',
|
||||||
'pages/order-list/index',
|
'pages/order-list/index',
|
||||||
'pages/statement/index',
|
'pages/statement/index',
|
||||||
|
'pages/statement-detail/index',
|
||||||
'pages/settings/index',
|
'pages/settings/index',
|
||||||
'pages/login/index',
|
'pages/login/index',
|
||||||
'pages/register/index',
|
'pages/register/index',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '首页',
|
navigationBarTitleText: '首页',
|
||||||
|
navigationStyle: 'custom',
|
||||||
})
|
})
|
||||||
|
|||||||
+152
-19
@@ -4,17 +4,62 @@
|
|||||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
// ===== 顶部搜索 =====
|
// ===== 自定义顶部导航栏 =====
|
||||||
.home-search {
|
.home-header {
|
||||||
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
|
background: linear-gradient(160deg, #d60410 0%, #ee0a24 55%, #ff4d4f 100%);
|
||||||
padding: 16rpx 24rpx;
|
// 底部留白供轮播图上移叠放
|
||||||
|
padding-bottom: 88rpx;
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16rpx 32rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slogan {
|
||||||
|
margin-left: 16rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 22rpx;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__notice {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
padding: 8rpx 16rpx 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 轮播图 =====
|
// ===== 轮播图 =====
|
||||||
.home-banner {
|
.home-banner {
|
||||||
margin: 20rpx 24rpx 0;
|
// 上移叠在红色导航栏上,形成视觉连贯
|
||||||
|
margin: -72rpx 24rpx 0;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(238, 10, 36, 0.15);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
&__swiper {
|
&__swiper {
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
@@ -22,52 +67,127 @@
|
|||||||
|
|
||||||
&__image {
|
&__image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 320rpx;
|
height: 300rpx;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__placeholder {
|
&__placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 320rpx;
|
height: 300rpx;
|
||||||
background: linear-gradient(135deg, #ff9a9e, #ff4d4f);
|
background: linear-gradient(135deg, #ff8a5c, #ff4d4f);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__placeholder-text {
|
&__placeholder-title {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 40rpx;
|
font-size: 44rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: 8rpx;
|
letter-spacing: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__placeholder-sub {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 24rpx;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 导航菜单 =====
|
// ===== 宫格导航 =====
|
||||||
.home-menu {
|
.home-menu {
|
||||||
margin: 20rpx 24rpx 0;
|
margin: 20rpx 24rpx 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 20rpx 0 8rpx;
|
padding: 28rpx 0 16rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
width: 84rpx;
|
width: 88rpx;
|
||||||
height: 84rpx;
|
height: 88rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 28rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 36rpx;
|
font-size: 38rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 促销推荐卡片 =====
|
||||||
|
.home-promo {
|
||||||
|
margin: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promo-card {
|
||||||
|
position: relative;
|
||||||
|
height: 180rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, rgba(160, 4, 16, 0.82) 0%, rgba(238, 10, 36, 0.45) 55%, rgba(238, 10, 36, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
position: absolute;
|
||||||
|
left: 32rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 24rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 推荐商品 =====
|
// ===== 推荐商品 =====
|
||||||
.home-recommend {
|
.home-recommend {
|
||||||
margin: 20rpx 24rpx 0;
|
margin: 28rpx 24rpx 0;
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -76,6 +196,19 @@
|
|||||||
padding: 8rpx 8rpx 20rpx;
|
padding: 8rpx 8rpx 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-bar {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: linear-gradient(180deg, #ee0a24, #ff6034);
|
||||||
|
margin-right: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -178,7 +311,7 @@
|
|||||||
width: 52rpx;
|
width: 52rpx;
|
||||||
height: 52rpx;
|
height: 52rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ee0a24;
|
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
+135
-73
@@ -1,46 +1,68 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { View, Text, Image } from '@tarojs/components'
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
import { Grid, GridItem, Search, Swiper, SwiperItem } from '@antmjs/vantui'
|
import { Grid, GridItem, Icon, Search, Swiper, SwiperItem } from '@antmjs/vantui'
|
||||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
import useCartStore from '@/stores/cart/useCartStore'
|
import useCartStore from '@/stores/cart/useCartStore'
|
||||||
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
import { getHomeConfigApi } from '@/services/home'
|
||||||
|
import type { HomeConfig } from '@/services/home'
|
||||||
|
import { getProductListApi } from '@/services/product'
|
||||||
import { getProductCover } from '@/types/product'
|
import { getProductCover } from '@/types/product'
|
||||||
import type { Category, Product } from '@/types/product'
|
import type { Product } from '@/types/product'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
/** 首页 → 商品页的本地存储传参 key(switchTab 无法带参) */
|
/** 首页 → 商品页的本地存储传参 key(switchTab 无法带参) */
|
||||||
const PENDING_CATEGORY_KEY = 'product_category_id'
|
const PENDING_CATEGORY_KEY = 'product_category_id'
|
||||||
const PENDING_KEYWORD_KEY = 'product_keyword'
|
const PENDING_KEYWORD_KEY = 'product_keyword'
|
||||||
|
|
||||||
/** 分类菜单色块配色 */
|
/** tabBar 页面路径(link 跳转需改用 switchTab) */
|
||||||
const MENU_COLORS = ['#ee0a24', '#ff9f43', '#07c160', '#1989fa', '#8a5cf6', '#00b8d9']
|
const TAB_PATHS = [
|
||||||
|
'pages/index/index',
|
||||||
|
'pages/product/index',
|
||||||
|
'pages/cart/index',
|
||||||
|
'pages/message/index',
|
||||||
|
'pages/profile/index',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 宫格导航无图时的兜底色块配色(生鲜红橙系) */
|
||||||
|
const NAV_COLORS = ['#ee0a24', '#ff7a1a', '#07c160', '#1989fa', '#8a5cf6', '#ff976a', '#00b8d9', '#f56c6c']
|
||||||
|
|
||||||
|
/** 获取状态栏高度(H5 端为 0) */
|
||||||
|
function getStatusBarHeight(): number {
|
||||||
|
try {
|
||||||
|
const info = typeof Taro.getWindowInfo === 'function' ? Taro.getWindowInfo() : Taro.getSystemInfoSync()
|
||||||
|
return info.statusBarHeight || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const token = useAuthStore(s => s.token)
|
const token = useAuthStore(s => s.token)
|
||||||
const addItem = useCartStore(s => s.addItem)
|
const addItem = useCartStore(s => s.addItem)
|
||||||
|
|
||||||
/** 顶级分类(导航菜单前 6 个) */
|
/** 首页配置(轮播图 / 宫格导航 / 促销卡片) */
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||||
/** 推荐商品 */
|
/** 推荐商品 */
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
/** 搜索框输入 */
|
/** 搜索框输入 */
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
|
|
||||||
const loggedIn = !!token
|
const loggedIn = !!token
|
||||||
|
const statusBarHeight = useMemo(() => getStatusBarHeight(), [])
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
loadCategories()
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
|
loadHomeConfig()
|
||||||
loadRecommend()
|
loadRecommend()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 商品分类 */
|
/** 首页配置聚合数据(需登录) */
|
||||||
const loadCategories = useCallback(async () => {
|
const loadHomeConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getCategoriesApi()
|
const res = await getHomeConfigApi()
|
||||||
setCategories(res.data.filter(c => c.parent_id === 0))
|
setConfig(res.data)
|
||||||
} catch {
|
} catch {
|
||||||
// 错误已由 request 层 toast
|
// 错误已由 request 层 toast
|
||||||
}
|
}
|
||||||
@@ -56,17 +78,26 @@ export default function IndexPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** 轮播数据:取有图商品的前 4 张图 */
|
/**
|
||||||
const bannerImages = useMemo(
|
* 后台配置的 link 统一跳转:
|
||||||
() => products.filter(p => getProductCover(p)).slice(0, 4),
|
* - 空字符串不跳转
|
||||||
[products],
|
* - tabBar 页面用 switchTab,其余用 navigateTo
|
||||||
)
|
*/
|
||||||
|
const handleLink = useCallback((link: string) => {
|
||||||
|
if (!link) return
|
||||||
|
const path = link.split('?')[0].replace(/^\//, '')
|
||||||
|
if (TAB_PATHS.includes(path)) {
|
||||||
|
Taro.switchTab({ url: `/${path}` })
|
||||||
|
} else {
|
||||||
|
Taro.navigateTo({ url: link })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
/** 跳转商品页并带上分类/关键词 */
|
/** 跳转商品页并带上关键词 */
|
||||||
const goProduct = useCallback((categoryId?: number, kw?: string) => {
|
const goProduct = useCallback((kw?: string) => {
|
||||||
try {
|
try {
|
||||||
if (categoryId !== undefined) Taro.setStorageSync(PENDING_CATEGORY_KEY, categoryId)
|
|
||||||
if (kw !== undefined) Taro.setStorageSync(PENDING_KEYWORD_KEY, kw)
|
if (kw !== undefined) Taro.setStorageSync(PENDING_KEYWORD_KEY, kw)
|
||||||
|
Taro.removeStorageSync(PENDING_CATEGORY_KEY)
|
||||||
} catch {
|
} catch {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
@@ -75,7 +106,7 @@ export default function IndexPage() {
|
|||||||
|
|
||||||
/** 搜索框聚焦/提交 → 商品页搜索 */
|
/** 搜索框聚焦/提交 → 商品页搜索 */
|
||||||
const handleSearchFocus = useCallback(() => {
|
const handleSearchFocus = useCallback(() => {
|
||||||
goProduct(undefined, keyword.trim())
|
goProduct(keyword.trim())
|
||||||
}, [goProduct, keyword])
|
}, [goProduct, keyword])
|
||||||
|
|
||||||
/** 快捷加购 */
|
/** 快捷加购 */
|
||||||
@@ -94,82 +125,113 @@ export default function IndexPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='home-page'>
|
<View className='home-page'>
|
||||||
{/* ========== 顶部搜索 ========== */}
|
{/* ========== 自定义顶部导航栏 ========== */}
|
||||||
<View className='home-search'>
|
<View className='home-header' style={{ paddingTop: `${statusBarHeight}px` }}>
|
||||||
<Search
|
<View className='home-header__bar'>
|
||||||
value={keyword}
|
<View className='home-header__brand'>
|
||||||
placeholder='搜索商品名称/规格'
|
<Text className='home-header__title'>鲜采直供</Text>
|
||||||
shape='round'
|
<Text className='home-header__slogan'>产地直采 · 新鲜直达</Text>
|
||||||
background='transparent'
|
</View>
|
||||||
onChange={e => setKeyword(String(e.detail))}
|
<View className='home-header__notice' onClick={() => Taro.switchTab({ url: '/pages/message/index' })}>
|
||||||
onFocus={handleSearchFocus}
|
<Icon name='bell' size='44rpx' color='#ffffff' />
|
||||||
onSearch={handleSearchFocus}
|
</View>
|
||||||
/>
|
</View>
|
||||||
|
<View className='home-header__search'>
|
||||||
|
<Search
|
||||||
|
value={keyword}
|
||||||
|
placeholder='搜索商品名称/规格'
|
||||||
|
shape='round'
|
||||||
|
background='transparent'
|
||||||
|
onChange={e => setKeyword(String(e.detail))}
|
||||||
|
onFocus={handleSearchFocus}
|
||||||
|
onSearch={handleSearchFocus}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ========== 轮播图 ========== */}
|
{/* ========== 轮播图 ========== */}
|
||||||
<View className='home-banner'>
|
<View className='home-banner'>
|
||||||
<Swiper
|
<Swiper
|
||||||
className='home-banner__swiper'
|
className='home-banner__swiper'
|
||||||
height='320rpx'
|
height='300rpx'
|
||||||
autoPlay={3000}
|
autoPlay={3000}
|
||||||
loop
|
loop
|
||||||
paginationVisible
|
paginationVisible
|
||||||
paginationColor='#ffffff'
|
paginationColor='#ffffff'
|
||||||
>
|
>
|
||||||
{bannerImages.length > 0 ? (
|
{config.banners.length > 0 ? (
|
||||||
bannerImages.map((product, idx) => (
|
config.banners.map(banner =>
|
||||||
<SwiperItem key={product.id}>
|
banner.image_url ? (
|
||||||
<Image
|
<SwiperItem key={banner.id}>
|
||||||
className='home-banner__image'
|
<Image
|
||||||
src={getProductCover(product)}
|
className='home-banner__image'
|
||||||
mode='aspectFill'
|
src={banner.image_url}
|
||||||
onClick={() => goProduct()}
|
mode='aspectFill'
|
||||||
/>
|
onClick={() => handleLink(banner.link)}
|
||||||
</SwiperItem>
|
/>
|
||||||
))
|
</SwiperItem>
|
||||||
|
) : null,
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<SwiperItem>
|
<SwiperItem>
|
||||||
<View className='home-banner__placeholder' onClick={() => goProduct()}>
|
<View className='home-banner__placeholder'>
|
||||||
<Text className='home-banner__placeholder-text'>订货采购</Text>
|
<Text className='home-banner__placeholder-title'>新鲜直采</Text>
|
||||||
|
<Text className='home-banner__placeholder-sub'>每日凌晨采货 · 天亮前到店</Text>
|
||||||
</View>
|
</View>
|
||||||
</SwiperItem>
|
</SwiperItem>
|
||||||
)}
|
)}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ========== 导航菜单 ========== */}
|
{/* ========== 宫格导航(一行四个) ========== */}
|
||||||
<View className='home-menu'>
|
{config.navs.length > 0 && (
|
||||||
<Grid columnNum={4} border={false} iconSize={52}>
|
<View className='home-menu'>
|
||||||
{categories.slice(0, 6).map((cat, idx) => (
|
<Grid columnNum={4} border={false} iconSize={88}>
|
||||||
<GridItem
|
{config.navs.map((nav, idx) => (
|
||||||
key={cat.id}
|
<GridItem
|
||||||
text={cat.name}
|
key={nav.id}
|
||||||
onClick={() => goProduct(cat.id)}
|
text={nav.name}
|
||||||
renderIcon={
|
onClick={() => handleLink(nav.link)}
|
||||||
<View className='menu-icon' style={{ background: MENU_COLORS[idx % MENU_COLORS.length] }}>
|
renderIcon={
|
||||||
<Text className='menu-icon__text'>{cat.name.slice(0, 1)}</Text>
|
nav.image_url ? (
|
||||||
</View>
|
<Image className='menu-icon__image' src={nav.image_url} mode='aspectFit' />
|
||||||
}
|
) : (
|
||||||
/>
|
<View className='menu-icon' style={{ background: NAV_COLORS[idx % NAV_COLORS.length] }}>
|
||||||
|
<Text className='menu-icon__text'>{nav.name.slice(0, 1)}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 促销推荐卡片 ========== */}
|
||||||
|
{config.promos.length > 0 && (
|
||||||
|
<View className='home-promo'>
|
||||||
|
{config.promos.map(promo => (
|
||||||
|
<View key={promo.id} className='promo-card' onClick={() => handleLink(promo.link)}>
|
||||||
|
{promo.image_url && (
|
||||||
|
<Image className='promo-card__bg' src={promo.image_url} mode='aspectFill' lazyLoad />
|
||||||
|
)}
|
||||||
|
{promo.title && <View className='promo-card__mask' />}
|
||||||
|
<View className='promo-card__content'>
|
||||||
|
<Text className='promo-card__title'>{promo.title}</Text>
|
||||||
|
{promo.sub_title && <Text className='promo-card__sub'>{promo.sub_title}</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
<GridItem
|
</View>
|
||||||
icon='apps-o'
|
)}
|
||||||
text='全部商品'
|
|
||||||
onClick={() => goProduct()}
|
|
||||||
/>
|
|
||||||
<GridItem
|
|
||||||
icon='orders-o'
|
|
||||||
text='我的订单'
|
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/order-list/index?status=all' })}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* ========== 推荐商品 ========== */}
|
{/* ========== 推荐商品 ========== */}
|
||||||
<View className='home-recommend'>
|
<View className='home-recommend'>
|
||||||
<View className='home-recommend__header'>
|
<View className='home-recommend__header'>
|
||||||
<Text className='home-recommend__title'>推荐商品</Text>
|
<View className='home-recommend__title-wrap'>
|
||||||
|
<View className='home-recommend__title-bar' />
|
||||||
|
<Text className='home-recommend__title'>推荐商品</Text>
|
||||||
|
</View>
|
||||||
<Text className='home-recommend__more' onClick={() => goProduct()}>查看更多 ›</Text>
|
<Text className='home-recommend__more' onClick={() => goProduct()}>查看更多 ›</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const MENU_ITEMS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'statement',
|
key: 'statement',
|
||||||
label: '对账单',
|
label: '账单',
|
||||||
icon: 'balance-list-o',
|
icon: 'balance-list-o',
|
||||||
onClick: () => Taro.navigateTo({ url: '/pages/statement/index' }),
|
onClick: () => Taro.navigateTo({ url: '/pages/statement/index' }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '账单详情',
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
.bill-detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
padding: 20rpx 24rpx 120rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.bill-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
|
||||||
|
&--unpaid {
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--paid {
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10rpx 0;
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #ebedf0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #323233;
|
||||||
|
|
||||||
|
&--credit {
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bill-section {
|
||||||
|
&__title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #323233;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #c8c9cc;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bill-goods {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spec {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #c8c9cc;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__side {
|
||||||
|
text-align: right;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__qty {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bill-order {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #323233;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
margin: 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bill-pay {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { Button, Empty } from '@antmjs/vantui'
|
||||||
|
import { getStatementDetailApi, payStatementApi } from '@/services/statement'
|
||||||
|
import { STATEMENT_STATUS_MAP } from '@/types/statement'
|
||||||
|
import type { StatementDetail } from '@/types/statement'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账单详情
|
||||||
|
* 商品汇总(金额直接合并,单价明细见订单记录)+ 回筐计费 + 关联订单 + 付款确认
|
||||||
|
*/
|
||||||
|
export default function StatementDetailPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const id = Number(router.params.id ?? 0)
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<StatementDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [paying, setPaying] = useState(false)
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async () => {
|
||||||
|
if (!id) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getStatementDetailApi(id)
|
||||||
|
setDetail(res.data)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetail()
|
||||||
|
}, [loadDetail])
|
||||||
|
|
||||||
|
/** 付款确认 */
|
||||||
|
const handlePay = useCallback(async () => {
|
||||||
|
if (!detail || paying) return
|
||||||
|
const confirm = await Taro.showModal({
|
||||||
|
title: '付款确认',
|
||||||
|
content: `确认已支付账单 ${detail.statement_no} 共 ¥${detail.total_amount}?`,
|
||||||
|
confirmText: '确认付款',
|
||||||
|
})
|
||||||
|
if (!confirm.confirm) return
|
||||||
|
setPaying(true)
|
||||||
|
try {
|
||||||
|
await payStatementApi(detail.id)
|
||||||
|
Taro.showToast({ title: '付款确认成功', icon: 'success' })
|
||||||
|
loadDetail()
|
||||||
|
} catch {
|
||||||
|
// 错误(重复付款等)已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
setPaying(false)
|
||||||
|
}
|
||||||
|
}, [detail, paying, loadDetail])
|
||||||
|
|
||||||
|
if (loading && !detail) {
|
||||||
|
return <View className='bill-detail'><Empty description='加载中...' /></View>
|
||||||
|
}
|
||||||
|
if (!detail) {
|
||||||
|
return <View className='bill-detail'><Empty description='账单不存在' /></View>
|
||||||
|
}
|
||||||
|
|
||||||
|
const crateNegative = Number(detail.crate_amount) < 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='bill-detail'>
|
||||||
|
{/* ===== 账单信息 ===== */}
|
||||||
|
<View className='bill-card'>
|
||||||
|
<View className='bill-card__header'>
|
||||||
|
<Text className='bill-card__no'>{detail.statement_no}</Text>
|
||||||
|
<Text
|
||||||
|
className={detail.status === 0 ? 'bill-card__status bill-card__status--unpaid' : 'bill-card__status bill-card__status--paid'}
|
||||||
|
>
|
||||||
|
{STATEMENT_STATUS_MAP[detail.status]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>账单周期</Text>
|
||||||
|
<Text className='bill-card__value'>{detail.period_start} ~ {detail.period_end}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>应结算日期</Text>
|
||||||
|
<Text className='bill-card__value'>{detail.settlement_date ?? '-'}</Text>
|
||||||
|
</View>
|
||||||
|
{detail.status === 1 && detail.settled_at && (
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>付款时间</Text>
|
||||||
|
<Text className='bill-card__value'>{detail.settled_at}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>商品金额</Text>
|
||||||
|
<Text className='bill-card__value'>¥{detail.goods_amount}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>回筐金额</Text>
|
||||||
|
<Text className={crateNegative ? 'bill-card__value bill-card__value--credit' : 'bill-card__value'}>
|
||||||
|
{crateNegative ? `-¥${Math.abs(Number(detail.crate_amount)).toFixed(2)}` : `¥${detail.crate_amount}`}
|
||||||
|
{crateNegative ? '(抵扣)' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bill-card__row bill-card__row--total'>
|
||||||
|
<Text className='bill-card__label'>账单总额</Text>
|
||||||
|
<Text className='bill-card__total'>¥{detail.total_amount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ===== 商品汇总 ===== */}
|
||||||
|
<View className='bill-card'>
|
||||||
|
<Text className='bill-section__title'>商品汇总({detail.goods?.length ?? 0})</Text>
|
||||||
|
<Text className='bill-section__desc'>同一商品多次订单金额直接合并;单价明细请查看订单记录</Text>
|
||||||
|
{(detail.goods ?? []).map((item, index) => (
|
||||||
|
<View key={index} className='bill-goods'>
|
||||||
|
<View className='bill-goods__main'>
|
||||||
|
<Text className='bill-goods__name'>{item.product_name}</Text>
|
||||||
|
<Text className='bill-goods__spec'>{item.product_spec || ''}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bill-goods__side'>
|
||||||
|
<Text className='bill-goods__qty'>{item.quantity} {item.unit}</Text>
|
||||||
|
<Text className='bill-goods__amount'>¥{item.amount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{(detail.goods ?? []).length === 0 && <Empty description='暂无商品记录' />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ===== 回筐计费 ===== */}
|
||||||
|
<View className='bill-card'>
|
||||||
|
<Text className='bill-section__title'>回筐计费</Text>
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>周转框</Text>
|
||||||
|
<Text className='bill-card__value'>{detail.box_num} 个{detail.box_num < 0 ? '(抵扣)' : ''}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bill-card__row'>
|
||||||
|
<Text className='bill-card__label'>托盘</Text>
|
||||||
|
<Text className='bill-card__value'>{detail.tray_num} 个{detail.tray_num < 0 ? '(抵扣)' : ''}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ===== 关联订单 ===== */}
|
||||||
|
<View className='bill-card'>
|
||||||
|
<Text className='bill-section__title'>关联订单({detail.orders?.length ?? 0})</Text>
|
||||||
|
{(detail.orders ?? []).map(order => (
|
||||||
|
<View
|
||||||
|
key={order.id}
|
||||||
|
className='bill-order'
|
||||||
|
onClick={() => Taro.navigateTo({ url: `/pages/order-list/index` })}
|
||||||
|
>
|
||||||
|
<Text className='bill-order__no'>{order.order_no}</Text>
|
||||||
|
<Text className='bill-order__date'>{order.order_date}</Text>
|
||||||
|
<Text className='bill-order__amount'>¥{order.total_amount}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{(detail.orders ?? []).length === 0 && <Empty description='暂无订单记录' />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ===== 付款按钮 ===== */}
|
||||||
|
{detail.status === 0 && (
|
||||||
|
<View className='bill-pay'>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
block
|
||||||
|
round
|
||||||
|
loading={paying}
|
||||||
|
className='bill-pay__btn'
|
||||||
|
onClick={handlePay}
|
||||||
|
>
|
||||||
|
付款(¥{detail.total_amount})
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '对账单',
|
navigationBarTitleText: '账单',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,12 +16,9 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__tip {
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
color: #fff;
|
color: #969799;
|
||||||
background: #ee0a24;
|
|
||||||
padding: 12rpx 28rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +42,7 @@
|
|||||||
color: #c8c9cc;
|
color: #c8c9cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 对账单项 =====
|
// ===== 账单单项 =====
|
||||||
.statement-item {
|
.statement-item {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
@@ -66,7 +63,14 @@
|
|||||||
|
|
||||||
&__status {
|
&__status {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #ee0a24;
|
|
||||||
|
&--unpaid {
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--paid {
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
@@ -94,46 +98,4 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 生成弹层 =====
|
|
||||||
.gen-popup {
|
|
||||||
padding: 32rpx 32rpx 24rpx;
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__desc {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #969799;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #323233;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__value {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #646566;
|
|
||||||
padding: 12rpx 24rpx;
|
|
||||||
background: #f7f8fa;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__submit {
|
|
||||||
margin-top: 40rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||||
import { View, Text, Picker } from '@tarojs/components'
|
import { View, Text } from '@tarojs/components'
|
||||||
import { Button, Empty, Popup } from '@antmjs/vantui'
|
import { Empty } from '@antmjs/vantui'
|
||||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
import { generateStatementApi, getStatementListApi } from '@/services/statement'
|
import { getStatementListApi } from '@/services/statement'
|
||||||
import { STATEMENT_STATUS_MAP } from '@/types/statement'
|
import { STATEMENT_STATUS_MAP } from '@/types/statement'
|
||||||
import type { Statement } from '@/types/statement'
|
import type { Statement } from '@/types/statement'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
/** 日期 → Y-m-d */
|
|
||||||
function toYMD(d: Date): string {
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对账单页(框架)
|
* 账单页
|
||||||
* 门店自助:对账单列表 + 按日期区间生成
|
* 账单按门店回款周期自动生成:列表 + 查看详情 + 付款
|
||||||
*/
|
*/
|
||||||
export default function StatementPage() {
|
export default function StatementPage() {
|
||||||
const token = useAuthStore(s => s.token)
|
const token = useAuthStore(s => s.token)
|
||||||
@@ -29,15 +23,9 @@ export default function StatementPage() {
|
|||||||
const [finished, setFinished] = useState(false)
|
const [finished, setFinished] = useState(false)
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
|
|
||||||
/** 生成弹层 */
|
|
||||||
const [showGen, setShowGen] = useState(false)
|
|
||||||
const [genStart, setGenStart] = useState(() => toYMD(new Date(Date.now() - 30 * 864e5)))
|
|
||||||
const [genEnd, setGenEnd] = useState(() => toYMD(new Date()))
|
|
||||||
const [genLoading, setGenLoading] = useState(false)
|
|
||||||
|
|
||||||
const loggedIn = !!token
|
const loggedIn = !!token
|
||||||
|
|
||||||
/** 拉取对账单列表 */
|
/** 拉取账单列表 */
|
||||||
const loadList = useCallback(
|
const loadList = useCallback(
|
||||||
async (pageNum: number, reset: boolean) => {
|
async (pageNum: number, reset: boolean) => {
|
||||||
if (!loggedIn || loadingRef.current) return
|
if (!loggedIn || loadingRef.current) return
|
||||||
@@ -69,57 +57,43 @@ export default function StatementPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 生成对账单 */
|
|
||||||
const handleGenerate = useCallback(async () => {
|
|
||||||
if (genStart > genEnd) {
|
|
||||||
Taro.showToast({ title: '结束日期不能早于开始日期', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (genLoading) return
|
|
||||||
setGenLoading(true)
|
|
||||||
try {
|
|
||||||
await generateStatementApi({ period_start: genStart, period_end: genEnd })
|
|
||||||
Taro.showToast({ title: '对账单已生成', icon: 'success' })
|
|
||||||
setShowGen(false)
|
|
||||||
loadList(1, true)
|
|
||||||
} catch {
|
|
||||||
// 错误(周期内无订单等)已由 request 层 toast
|
|
||||||
} finally {
|
|
||||||
setGenLoading(false)
|
|
||||||
}
|
|
||||||
}, [genStart, genEnd, genLoading, loadList])
|
|
||||||
|
|
||||||
const goLogin = useCallback(() => {
|
const goLogin = useCallback(() => {
|
||||||
Taro.navigateTo({ url: '/pages/login/index' })
|
Taro.navigateTo({ url: '/pages/login/index' })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const goDetail = useCallback((id: number) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/statement-detail/index?id=${id}` })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='statement-page'>
|
<View className='statement-page'>
|
||||||
{/* ========== 头部 ========== */}
|
{/* ========== 头部 ========== */}
|
||||||
<View className='statement-header'>
|
<View className='statement-header'>
|
||||||
<Text className='statement-header__title'>对账单</Text>
|
<Text className='statement-header__title'>账单</Text>
|
||||||
<View className='statement-header__btn' onClick={() => setShowGen(true)}>
|
<Text className='statement-header__tip'>按回款周期自动生成</Text>
|
||||||
<Text>生成对账单</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ========== 列表 ========== */}
|
{/* ========== 列表 ========== */}
|
||||||
{!loggedIn ? (
|
{!loggedIn ? (
|
||||||
<Empty description='登录后查看对账单' className='statement-empty'>
|
<Empty description='登录后查看账单' className='statement-empty'>
|
||||||
<View className='statement-empty__btn' onClick={goLogin}>去登录</View>
|
<View className='statement-empty__btn' onClick={goLogin}>去登录</View>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : statements.length === 0 ? (
|
) : statements.length === 0 ? (
|
||||||
loading ? (
|
loading ? (
|
||||||
<View className='statement-loading'><Text>加载中...</Text></View>
|
<View className='statement-loading'><Text>加载中...</Text></View>
|
||||||
) : (
|
) : (
|
||||||
<Empty description='暂无对账单' className='statement-empty' />
|
<Empty description='暂无账单' className='statement-empty' />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
statements.map(st => (
|
statements.map(st => (
|
||||||
<View key={st.id} className='statement-item'>
|
<View key={st.id} className='statement-item' onClick={() => goDetail(st.id)}>
|
||||||
<View className='statement-item__header'>
|
<View className='statement-item__header'>
|
||||||
<Text className='statement-item__no'>{st.statement_no}</Text>
|
<Text className='statement-item__no'>{st.statement_no}</Text>
|
||||||
<Text className='statement-item__status'>{STATEMENT_STATUS_MAP[st.status]}</Text>
|
<Text
|
||||||
|
className={st.status === 0 ? 'statement-item__status statement-item__status--unpaid' : 'statement-item__status statement-item__status--paid'}
|
||||||
|
>
|
||||||
|
{STATEMENT_STATUS_MAP[st.status]}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='statement-item__body'>
|
<View className='statement-item__body'>
|
||||||
<Text className='statement-item__period'>
|
<Text className='statement-item__period'>
|
||||||
@@ -137,44 +111,6 @@ export default function StatementPage() {
|
|||||||
{loggedIn && finished && statements.length > 0 && (
|
{loggedIn && finished && statements.length > 0 && (
|
||||||
<View className='statement-loading'><Text>没有更多了</Text></View>
|
<View className='statement-loading'><Text>没有更多了</Text></View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ========== 生成弹层 ========== */}
|
|
||||||
<Popup
|
|
||||||
show={showGen}
|
|
||||||
position='bottom'
|
|
||||||
round
|
|
||||||
closeable
|
|
||||||
closeOnClickOverlay
|
|
||||||
safeAreaInsetBottom
|
|
||||||
onClose={() => setShowGen(false)}
|
|
||||||
>
|
|
||||||
<View className='gen-popup'>
|
|
||||||
<Text className='gen-popup__title'>生成对账单</Text>
|
|
||||||
<Text className='gen-popup__desc'>按门店客户等级价汇总周期内订单明细</Text>
|
|
||||||
<View className='gen-popup__row'>
|
|
||||||
<Text className='gen-popup__label'>开始日期</Text>
|
|
||||||
<Picker mode='date' value={genStart} end={genEnd} onChange={e => setGenStart(e.detail.value)}>
|
|
||||||
<View className='gen-popup__value'>{genStart}</View>
|
|
||||||
</Picker>
|
|
||||||
</View>
|
|
||||||
<View className='gen-popup__row'>
|
|
||||||
<Text className='gen-popup__label'>结束日期</Text>
|
|
||||||
<Picker mode='date' value={genEnd} start={genStart} onChange={e => setGenEnd(e.detail.value)}>
|
|
||||||
<View className='gen-popup__value'>{genEnd}</View>
|
|
||||||
</Picker>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
type='danger'
|
|
||||||
block
|
|
||||||
round
|
|
||||||
loading={genLoading}
|
|
||||||
className='gen-popup__submit'
|
|
||||||
onClick={handleGenerate}
|
|
||||||
>
|
|
||||||
生成
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Popup>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { get } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 首页轮播图项 */
|
||||||
|
export interface HomeBanner {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
image_id: number
|
||||||
|
image_url: string | null
|
||||||
|
link: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页宫格导航项 */
|
||||||
|
export interface HomeNav {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
image_id: number
|
||||||
|
image_url: string | null
|
||||||
|
link: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页促销推荐卡片 */
|
||||||
|
export interface HomePromo {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
sub_title: string
|
||||||
|
image_id: number
|
||||||
|
image_url: string | null
|
||||||
|
link: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页配置聚合数据 */
|
||||||
|
export interface HomeConfig {
|
||||||
|
banners: HomeBanner[]
|
||||||
|
navs: HomeNav[]
|
||||||
|
promos: HomePromo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||||
|
export function getHomeConfigApi() {
|
||||||
|
return get<HomeConfig>('/mini/home')
|
||||||
|
}
|
||||||
+13
-13
@@ -1,23 +1,23 @@
|
|||||||
import { get, post } from '@/utils/request'
|
import { get, post } from '@/utils/request'
|
||||||
import type { PaginatedData } from '@/types/api'
|
import type { PaginatedData } from '@/types/api'
|
||||||
import type { Statement, StatementDetail } from '@/types/statement'
|
import type { PendingContainers, Statement, StatementDetail } from '@/types/statement'
|
||||||
|
|
||||||
/** 对账单列表(仅本店):GET /mini/statement */
|
/** 账单列表(仅本店,按回款周期自动生成):GET /mini/statement */
|
||||||
export function getStatementListApi(params: { page?: number; pageSize?: number } = {}) {
|
export function getStatementListApi(params: { page?: number; pageSize?: number } = {}) {
|
||||||
return get<PaginatedData<Statement>>('/mini/statement', { data: params })
|
return get<PaginatedData<Statement>>('/mini/statement', { data: params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成对账单:POST /mini/statement/generate */
|
/** 账单详情(校验归属,含商品汇总/回筐计费/关联订单):GET /mini/statement/{id} */
|
||||||
export function generateStatementApi(params: { period_start: string; period_end: string }) {
|
|
||||||
return post<{
|
|
||||||
id: number
|
|
||||||
statement_no: string
|
|
||||||
total_amount: string
|
|
||||||
settlement_date: string | null
|
|
||||||
}>('/mini/statement/generate', params)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 对账单详情(校验归属):GET /mini/statement/{id} */
|
|
||||||
export function getStatementDetailApi(id: number) {
|
export function getStatementDetailApi(id: number) {
|
||||||
return get<StatementDetail>(`/mini/statement/${id}`)
|
return get<StatementDetail>(`/mini/statement/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 付款确认:POST /mini/statement/{id}/pay */
|
||||||
|
export function payStatementApi(id: number) {
|
||||||
|
return post<{ id: number; total_amount: string }>(`/mini/statement/${id}/pay`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前门店待回筐/托盘台账:GET /mini/statement/pending-containers */
|
||||||
|
export function getPendingContainersApi() {
|
||||||
|
return get<PendingContainers>('/mini/statement/pending-containers')
|
||||||
|
}
|
||||||
|
|||||||
+38
-19
@@ -1,45 +1,64 @@
|
|||||||
/** 对账单状态:0 待对账 / 1 已对账 / 2 已结算 */
|
/** 账单状态:0 待付款 / 1 已付款 */
|
||||||
export type StatementStatus = 0 | 1 | 2
|
export type StatementStatus = 0 | 1
|
||||||
|
|
||||||
export const STATEMENT_STATUS_MAP: Record<StatementStatus, string> = {
|
export const STATEMENT_STATUS_MAP: Record<StatementStatus, string> = {
|
||||||
0: '待对账',
|
0: '待付款',
|
||||||
1: '已对账',
|
1: '已付款',
|
||||||
2: '已结算',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对账单 */
|
/** 账单 */
|
||||||
export interface Statement {
|
export interface Statement {
|
||||||
id: number
|
id: number
|
||||||
statement_no: string
|
statement_no: string
|
||||||
period_start: string
|
period_start: string
|
||||||
period_end: string
|
period_end: string
|
||||||
|
/** 商品金额合计 */
|
||||||
|
goods_amount: string
|
||||||
|
/** 账单计费周转框数(可为负=抵扣) */
|
||||||
|
box_num: number
|
||||||
|
/** 账单计费托盘数(可为负=抵扣) */
|
||||||
|
tray_num: number
|
||||||
|
/** 回筐金额合计(可为负=抵扣) */
|
||||||
|
crate_amount: string
|
||||||
|
/** 账单总金额 = 商品金额 + 回筐金额 */
|
||||||
total_amount: string
|
total_amount: string
|
||||||
/** 生成时快照的回款周期 */
|
/** 生成时快照的回款周期 */
|
||||||
payment_cycle_days: number
|
payment_cycle_days: number
|
||||||
/** 应结算日期 = 周期结束 + 回款周期天 */
|
/** 应结算日期 = 周期结束 + 回款周期天 */
|
||||||
settlement_date: string | null
|
settlement_date: string | null
|
||||||
status: StatementStatus
|
status: StatementStatus
|
||||||
reconciled_at: string | null
|
|
||||||
settled_at: string | null
|
settled_at: string | null
|
||||||
remark: string
|
remark: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对账单明细行 */
|
/** 账单商品汇总记录(按商品聚合,不记单价) */
|
||||||
export interface StatementItem {
|
export interface StatementGoods {
|
||||||
order_id: number
|
|
||||||
order_item_id: number
|
|
||||||
product_id: number
|
|
||||||
product_name: string
|
product_name: string
|
||||||
price: string
|
product_spec: string
|
||||||
|
unit: string
|
||||||
quantity: string
|
quantity: string
|
||||||
weight: string
|
weight: string
|
||||||
amount: string
|
amount: string
|
||||||
/** 0 未对账 / 1 已对账 */
|
|
||||||
is_reconciled: number
|
|
||||||
store_remark: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对账单详情 */
|
/** 账单关联订单(下单单价明细在订单记录中查看) */
|
||||||
export interface StatementDetail extends Statement {
|
export interface StatementOrder {
|
||||||
items: StatementItem[]
|
id: number
|
||||||
|
order_no: string
|
||||||
|
order_date: string
|
||||||
|
total_amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单详情 */
|
||||||
|
export interface StatementDetail extends Statement {
|
||||||
|
goods: StatementGoods[]
|
||||||
|
orders: StatementOrder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店待回筐台账 */
|
||||||
|
export interface PendingContainers {
|
||||||
|
pending_box_num: number
|
||||||
|
pending_tray_num: number
|
||||||
|
box_price: string
|
||||||
|
tray_price: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user