Compare commits

..

3 Commits

Author SHA1 Message Date
xinadmin 4a5b7003ca 修复分类 2026-08-14 16:21:41 +08:00
xinadmin 8d308288bb 商品列表 2026-08-14 15:24:05 +08:00
xinadmin d1d8d7ab52 首页优化 2026-08-14 12:34:25 +08:00
17 changed files with 938 additions and 382 deletions
+1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '首页', navigationBarTitleText: '首页',
navigationStyle: 'custom',
}) })
+153 -19
View File
@@ -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,128 @@
&__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;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
&: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 +197,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 +312,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;
+139 -91
View File
@@ -1,52 +1,70 @@
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 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 statusBarHeight = useMemo(() => getStatusBarHeight(), [])
useDidShow(() => { useDidShow(() => {
loadCategories() loadHomeConfig()
if (loggedIn) { 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
} }
}, []) }, [])
/** 推荐商品(默认排序第一页) */ /** 推荐商品 */
const loadRecommend = useCallback(async () => { const loadRecommend = useCallback(async () => {
try { try {
const res = await getProductListApi({ page: 1, pageSize: 10 }) const res = await getProductListApi({ page: 1, pageSize: 10 })
@@ -56,17 +74,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 +102,7 @@ export default function IndexPage() {
/** 搜索框聚焦/提交 → 商品页搜索 */ /** 搜索框聚焦/提交 → 商品页搜索 */
const handleSearchFocus = useCallback(() => { const handleSearchFocus = useCallback(() => {
goProduct(undefined, keyword.trim()) goProduct(keyword.trim())
}, [goProduct, keyword]) }, [goProduct, keyword])
/** 快捷加购 */ /** 快捷加购 */
@@ -94,96 +121,117 @@ 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>
{!loggedIn ? ( { products.length === 0 ? (
<View className='home-recommend__empty'>
<Text className='home-recommend__empty-text'></Text>
<View
className='home-recommend__login-btn'
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
>
</View>
</View>
) : products.length === 0 ? (
<View className='home-recommend__empty'> <View className='home-recommend__empty'>
<Text className='home-recommend__empty-text'></Text> <Text className='home-recommend__empty-text'></Text>
</View> </View>
@@ -204,7 +252,7 @@ export default function IndexPage() {
{product.price !== null ? ( {product.price !== null ? (
<Text className='product-card__price'>{product.price}</Text> <Text className='product-card__price'>{product.price}</Text>
) : ( ) : (
<Text className='product-card__price product-card__price--none'></Text> <Text className='product-card__price product-card__price--none'></Text>
)} )}
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}> <View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
<Text className='product-card__add-icon'></Text> <Text className='product-card__add-icon'></Text>
+42 -27
View File
@@ -5,7 +5,6 @@
background: #f7f8fa; background: #f7f8fa;
.product-search { .product-search {
padding: 16rpx 24rpx;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -17,22 +16,29 @@
// ===== 左侧分类 ===== // ===== 左侧分类 =====
.product-categories { .product-categories {
width: 180rpx; width: 200rpx;
height: 100%; height: 100%;
background: #fff; background: #fff;
flex-shrink: 0; flex-shrink: 0;
border-right: 1rpx solid #ebedf0;
} }
.category-item { .category-item {
padding: 28rpx 16rpx; padding: 30rpx 16rpx 30rpx 28rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
position: relative; position: relative;
color: #323233;
&.expanded {
color: #ee0a24;
font-weight: 500;
}
&.active { &.active {
background: #f7f8fa; background: rgba(238, 10, 36, 0.06);
color: #ee0a24; color: #ee0a24;
font-weight: 500;
&::before { &::before {
content: ''; content: '';
@@ -47,10 +53,38 @@
} }
} }
&--child {
padding: 24rpx 16rpx 24rpx 48rpx;
color: #646566;
&.active::before {
height: 24rpx;
}
}
&__name { &__name {
flex: 1;
min-width: 0;
font-size: 26rpx; font-size: 26rpx;
line-height: 1.4; line-height: 1.4;
text-align: center; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--child &__name {
font-size: 24rpx;
}
&__arrow {
margin-left: 8rpx;
font-size: 20rpx;
color: #c8c9cc;
}
&.expanded &__arrow,
&.active &__arrow {
color: #ee0a24;
} }
} }
@@ -58,31 +92,11 @@
.product-main { .product-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 16rpx 20rpx 40rpx; padding: 20rpx 20rpx 40rpx;
overflow-y: auto; overflow-y: auto;
box-sizing: border-box; box-sizing: border-box;
} }
.child-scroll {
white-space: nowrap;
margin-bottom: 16rpx;
}
.child-chip {
display: inline-flex;
padding: 10rpx 24rpx;
margin-right: 12rpx;
border-radius: 999rpx;
background: #fff;
font-size: 24rpx;
color: #646566;
&.active {
background: #ee0a24;
color: #fff;
}
}
.product-empty { .product-empty {
padding-top: 120rpx; padding-top: 120rpx;
} }
@@ -93,6 +107,7 @@
border-radius: 16rpx; border-radius: 16rpx;
padding: 20rpx; padding: 20rpx;
margin-bottom: 16rpx; margin-bottom: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
&__image { &__image {
width: 160rpx; width: 160rpx;
+111 -75
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro' import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, Image, ScrollView } from '@tarojs/components' import { View, Text, Image, ScrollView } from '@tarojs/components'
import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui' import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui'
import useCartStore from '@/stores/cart/useCartStore' import useCartStore from '@/stores/cart/useCartStore'
import { getCategoriesApi, getProductListApi } from '@/services/product' import { getCategoriesApi, getProductListApi } from '@/services/product'
import type { ProductListParams } from '@/services/product'
import { getProductCover } from '@/types/product' import { getProductCover } from '@/types/product'
import type { Category, Product } from '@/types/product' import type { Category, Product } from '@/types/product'
import './index.less' import './index.less'
@@ -18,10 +19,10 @@ export default function ProductPage() {
/** 分类树 */ /** 分类树 */
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
/** 选中的顶级分类(null = 全部) */ /** 选中的分类IDnull = 全部;仅叶子分类可选:二级分类或无子分类的一级分类 */
const [activeTop, setActiveTop] = useState<number | null>(null) const [activeId, setActiveId] = useState<number | null>(null)
/** 选中的子分类(null = 顶级分类下全部 */ /** 展开的一级分类ID(纯 UI 状态:有子分类的一级分类不可选中,点击只展开/收起 */
const [activeChild, setActiveChild] = useState<number | null>(null) const [expandedTop, setExpandedTop] = useState<number | null>(null)
/** 已提交的搜索词(onSearch 才生效) */ /** 已提交的搜索词(onSearch 才生效) */
const [searchKey, setSearchKey] = useState('') const [searchKey, setSearchKey] = useState('')
/** 输入框内容 */ /** 输入框内容 */
@@ -32,7 +33,10 @@ export default function ProductPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false) const [finished, setFinished] = useState(false)
const listLoadingRef = useRef(false) /** 请求序号:并发时仅采用最后一次请求的结果,避免分类快速切换时旧响应覆盖新数据 */
const reqSeqRef = useRef(0)
/** 是否有请求进行中(仅用于避免"加载更多"并发) */
const loadingRef = useRef(false)
/** 加购弹层 */ /** 加购弹层 */
const [showPopup, setShowPopup] = useState(false) const [showPopup, setShowPopup] = useState(false)
@@ -40,31 +44,30 @@ export default function ProductPage() {
const [qty, setQty] = useState(1) const [qty, setQty] = useState(1)
const addingRef = useRef(false) const addingRef = useRef(false)
/** 选中的顶级分类的子分类 */ /** 当前选中二级分类所属的一级分类ID(用于父级高亮) */
const childCategories = useMemo(() => { const activeParentId = useMemo(() => {
const top = categories.find(c => c.id === activeTop) if (activeId == null) return null
return top?.children ?? [] return categories.find(c => (c.children ?? []).some(ch => ch.id === activeId))?.id ?? null
}, [categories, activeTop]) }, [categories, activeId])
/** 当前生效的分类ID(子分类优先) */ /** 当前生效的分类ID */
const effectiveCategoryId = useMemo( const effectiveCategoryId = activeId ?? undefined
() => activeChild ?? activeTop ?? undefined,
[activeChild, activeTop],
)
/** 拉取商品列表(keywordOverride 用于状态未更新时显式传本次搜索词) */ /** 拉取商品列表(keywordOverride 用于状态未更新时显式传本次搜索词) */
const fetchList = useCallback( const fetchList = useCallback(
async (pageNum: number, reset: boolean, keywordOverride?: string) => { async (pageNum: number, reset: boolean, keywordOverride?: string) => {
if (listLoadingRef.current) return if (!reset && loadingRef.current) return
listLoadingRef.current = true const seq = ++reqSeqRef.current
loadingRef.current = true
setLoading(true) setLoading(true)
try { try {
const res = await getProductListApi({ // undefined 会被序列化成字符串 "undefined" 拼进 URL,导致后端过滤为空,只传有值参数
category_id: effectiveCategoryId, const params: ProductListParams = { page: pageNum, pageSize: PAGE_SIZE }
keyword: keywordOverride ?? (searchKey || undefined), if (effectiveCategoryId != null) params.category_id = effectiveCategoryId
page: pageNum, const keyword = keywordOverride ?? (searchKey || undefined)
pageSize: PAGE_SIZE, if (keyword) params.keyword = keyword
}) const res = await getProductListApi(params)
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
const { data, total: totalCount } = res.data const { data, total: totalCount } = res.data
setProducts(prev => (reset ? data : [...prev, ...data])) setProducts(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum) setPage(pageNum)
@@ -72,13 +75,25 @@ export default function ProductPage() {
} catch { } catch {
// 错误已由 request 层 toast // 错误已由 request 层 toast
} finally { } finally {
listLoadingRef.current = false if (seq === reqSeqRef.current) {
setLoading(false) loadingRef.current = false
setLoading(false)
}
} }
}, },
[effectiveCategoryId, searchKey], [effectiveCategoryId, searchKey],
) )
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
const firstEffectRef = useRef(true)
useEffect(() => {
if (firstEffectRef.current) {
firstEffectRef.current = false
return
}
fetchList(1, true)
}, [fetchList])
/** 分类树(加载完成后处理首页跳转带入的分类) */ /** 分类树(加载完成后处理首页跳转带入的分类) */
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
try { try {
@@ -93,14 +108,21 @@ export default function ProductPage() {
// noop // noop
} }
if (pending) { if (pending) {
const parentOfChild = res.data.find(c => (c.children ?? []).some(ch => ch.id === pending))
const top = res.data.find(c => c.id === pending) const top = res.data.find(c => c.id === pending)
const topOfChild = res.data.find(c => c.children.some(ch => ch.id === pending)) if (parentOfChild) {
if (top) { // 带入的是二级分类:展开父级并选中
setActiveTop(pending) setExpandedTop(parentOfChild.id)
setActiveChild(null) setActiveId(pending)
} else if (topOfChild) { } else if (top) {
setActiveTop(topOfChild.id) const children = top.children ?? []
setActiveChild(pending) if (children.length > 0) {
// 有子分类的一级分类不可选中:展开并默认选中第一个子分类
setExpandedTop(top.id)
setActiveId(children[0].id)
} else {
setActiveId(top.id)
}
} }
} }
} catch { } catch {
@@ -126,20 +148,28 @@ export default function ProductPage() {
}) })
useReachBottom(() => { useReachBottom(() => {
if (!finished && !listLoadingRef.current) { if (!finished) {
fetchList(page + 1, false) fetchList(page + 1, false)
} }
}) })
/** 切换顶级分类 */ /** 点击一级分类:有子分类仅展开/收起(不可选中),无子分类则选中 */
const handleTopTap = useCallback((id: number | null) => { const handleTopTap = useCallback((cat: Category) => {
setActiveTop(id) if ((cat.children ?? []).length > 0) {
setActiveChild(null) setExpandedTop(prev => (prev === cat.id ? null : cat.id))
return
}
setActiveId(cat.id)
}, []) }, [])
/** 切换子分类 */ /** 选中二级分类 */
const handleChildTap = useCallback((id: number | null) => { const handleChildTap = useCallback((id: number) => {
setActiveChild(id) setActiveId(id)
}, [])
/** 选中"全部" */
const handleAllTap = useCallback(() => {
setActiveId(null)
}, []) }, [])
/** 提交搜索 */ /** 提交搜索 */
@@ -183,7 +213,6 @@ export default function ProductPage() {
value={inputKey} value={inputKey}
placeholder='搜索品名/规格' placeholder='搜索品名/规格'
shape='round' shape='round'
background='#f7f8fa'
onChange={e => setInputKey(String(e.detail))} onChange={e => setInputKey(String(e.detail))}
onSearch={handleSearch} onSearch={handleSearch}
onClear={handleClear} onClear={handleClear}
@@ -191,48 +220,55 @@ export default function ProductPage() {
</View> </View>
<View className='product-body'> <View className='product-body'>
{/* ========== 左侧分类 ========== */} {/* ========== 左侧分类(一级为分组,叶子分类可选) ========== */}
<ScrollView scrollY className='product-categories'> <ScrollView scrollY className='product-categories'>
<View <View
className={`category-item ${activeTop === null ? 'active' : ''}`} className={`category-item ${activeId === null ? 'active' : ''}`}
onClick={() => handleTopTap(null)} onClick={handleAllTap}
> >
<Text className='category-item__name'></Text> <Text className='category-item__name'></Text>
</View> </View>
{categories.map(cat => ( {categories.map(cat => {
<View const children = cat.children ?? []
key={cat.id} const hasChildren = children.length > 0
className={`category-item ${activeTop === cat.id ? 'active' : ''}`} return (
onClick={() => handleTopTap(cat.id)} <View key={cat.id}>
> <View
<Text className='category-item__name'>{cat.name}</Text> className={[
</View> 'category-item',
))} // 有子分类:子分类被选中时父级高亮;无子分类:自身可选中
hasChildren && activeParentId === cat.id && 'expanded',
!hasChildren && activeId === cat.id && 'active',
].filter(Boolean).join(' ')}
onClick={() => handleTopTap(cat)}
>
<Text className='category-item__name'>{cat.name}</Text>
{hasChildren && (
<Text className='category-item__arrow'>
{expandedTop === cat.id ? '▾' : '▸'}
</Text>
)}
</View>
{/* 二级分类 */}
{expandedTop === cat.id &&
children.map(child => (
<View
key={child.id}
className={`category-item category-item--child ${
activeId === child.id ? 'active' : ''
}`}
onClick={() => handleChildTap(child.id)}
>
<Text className='category-item__name'>{child.name}</Text>
</View>
))}
</View>
)
})}
</ScrollView> </ScrollView>
{/* ========== 右侧商品列表 ========== */} {/* ========== 右侧商品列表 ========== */}
<View className='product-main'> <View className='product-main'>
{/* 子分类 chips */}
{childCategories.length > 0 && (
<ScrollView scrollX className='child-scroll'>
<View
className={`child-chip ${activeChild === null ? 'active' : ''}`}
onClick={() => handleChildTap(null)}
>
<Text></Text>
</View>
{childCategories.map(child => (
<View
key={child.id}
className={`child-chip ${activeChild === child.id ? 'active' : ''}`}
onClick={() => handleChildTap(child.id)}
>
<Text>{child.name}</Text>
</View>
))}
</ScrollView>
)}
{/* 商品列表 */} {/* 商品列表 */}
{products.length === 0 && !loading ? ( {products.length === 0 && !loading ? (
<Empty description='暂无商品' className='product-empty' /> <Empty description='暂无商品' className='product-empty' />
+1 -1
View File
@@ -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: '账单详情',
})
+179
View File
@@ -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;
}
}
}
+178
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '账单', navigationBarTitleText: '账单',
}) })
+12 -50
View File
@@ -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;
}
}
} }
+20 -84
View File
@@ -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>
) )
} }
+44
View File
@@ -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', { skipToken: true })
}
+13 -13
View File
@@ -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')
}
+2 -2
View File
@@ -1,9 +1,9 @@
/** 商品分类节点(children 递归) */ /** 商品分类节点(children 递归;叶子分类无 children 字段 */
export interface Category { export interface Category {
id: number id: number
parent_id: number parent_id: number
name: string name: string
children: Category[] children?: Category[]
} }
/** 商品图片 */ /** 商品图片 */
+38 -19
View File
@@ -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
} }