feat(store): 添加门店管理功能和订单配送功能

- 在app.config.ts中添加门店相关路由配置
- 在config/app.ts中添加租户名称常量
- 在Header.tsx中实现门店选择功能,包括定位、距离计算和门店切换
- 更新ShopOrder模型,添加门店ID、门店名称、配送员ID和仓库ID字段
- 新增ShopStore相关API和服务,支持门店的增删改查
- 新增ShopStoreRider相关API和服务,支持配送员管理
- 新增ShopStoreUser相关API和服务,支持店员管理
- 新增ShopWarehouse相关API和服务,支持仓库管理
- 添加配送订单页面,支持订单状态管理和送达确认功能
- 优化经销商页面的样式布局
This commit is contained in:
2026-02-01 01:39:49 +08:00
parent f8e689e250
commit 3d82a0f194
27 changed files with 2027 additions and 65 deletions

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '门店中心'
})

0
src/store/index.scss Normal file
View File

281
src/store/index.tsx Normal file
View File

@@ -0,0 +1,281 @@
import React, {useCallback, useState} from 'react'
import {View, Text} from '@tarojs/components'
import {Avatar, Button, ConfigProvider, Grid} from '@nutui/nutui-react-taro'
import {Location, Scan, Shop, Shopping, User} from '@nutui/icons-react-taro'
import Taro, {useDidShow} from '@tarojs/taro'
import {useThemeStyles} from '@/hooks/useTheme'
import {useUser} from '@/hooks/useUser'
import {getSelectedStoreFromStorage} from '@/utils/storeSelection'
import {listShopStoreUser} from '@/api/shop/shopStoreUser'
import {getShopStore} from '@/api/shop/shopStore'
import type {ShopStore as ShopStoreModel} from '@/api/shop/shopStore/model'
const StoreIndex: React.FC = () => {
const themeStyles = useThemeStyles()
const {isLoggedIn, loading: userLoading, getAvatarUrl, getDisplayName, getRoleName, hasRole} = useUser()
const [boundStoreId, setBoundStoreId] = useState<number | undefined>(undefined)
const [selectedStore, setSelectedStore] = useState<ShopStoreModel | null>(getSelectedStoreFromStorage())
const [store, setStore] = useState<ShopStoreModel | null>(selectedStore)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const storeId = boundStoreId || selectedStore?.id
const parseStoreCoords = (s: ShopStoreModel): {lng: number; lat: number} | null => {
const raw = (s.lngAndLat || s.location || '').trim()
if (!raw) return null
const parts = raw.split(/[,\s]+/).filter(Boolean)
if (parts.length < 2) return null
const a = parseFloat(parts[0])
const b = parseFloat(parts[1])
if (Number.isNaN(a) || Number.isNaN(b)) return null
// 常见格式是 "lng,lat";这里做一个简单兜底
const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90
const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180
if (looksLikeLngLat) return {lng: a, lat: b}
if (looksLikeLatLng) return {lng: b, lat: a}
return null
}
const navigateToPage = (url: string) => {
if (!isLoggedIn) {
Taro.showToast({title: '请先登录', icon: 'none', duration: 1500})
return
}
Taro.navigateTo({url})
}
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const latestSelectedStore = getSelectedStoreFromStorage()
setSelectedStore(latestSelectedStore)
const userIdRaw = Number(Taro.getStorageSync('UserId'))
const userId = Number.isFinite(userIdRaw) && userIdRaw > 0 ? userIdRaw : undefined
let foundStoreId: number | undefined = undefined
if (userId) {
// 优先按“店员绑定关系”确定门店归属
try {
const list = await listShopStoreUser({userId})
const first = (list || []).find(i => i?.isDelete !== 1 && i?.storeId)
foundStoreId = first?.storeId
setBoundStoreId(foundStoreId)
} catch {
// fallback to SelectedStore
foundStoreId = undefined
setBoundStoreId(undefined)
}
} else {
foundStoreId = undefined
setBoundStoreId(undefined)
}
const nextStoreId = (foundStoreId || latestSelectedStore?.id)
if (!nextStoreId) {
setStore(latestSelectedStore)
return
}
// 获取门店详情(用于展示门店名称/地址/仓库等)
const full = await getShopStore(nextStoreId)
setStore(full || (latestSelectedStore?.id === nextStoreId ? latestSelectedStore : ({id: nextStoreId} as ShopStoreModel)))
} catch (e: any) {
const msg = e?.message || '获取门店信息失败'
setError(msg)
} finally {
setLoading(false)
}
}, [])
// 返回/切换到该页面时,同步最新的已选门店与绑定门店
useDidShow(() => {
refresh().catch(() => {})
})
const openStoreLocation = () => {
if (!store?.id) {
return Taro.showToast({title: '请先选择门店', icon: 'none'})
}
const coords = parseStoreCoords(store)
if (!coords) {
return Taro.showToast({title: '门店未配置定位', icon: 'none'})
}
Taro.openLocation({
latitude: coords.lat,
longitude: coords.lng,
name: store.name || '门店',
address: store.address || ''
})
}
if (!isLoggedIn && !userLoading) {
return (
<View className="bg-gray-100 min-h-screen p-4">
<View className="bg-white rounded-xl p-4">
<Text className="text-gray-700"></Text>
<View className="mt-3">
<Button type="primary" onClick={() => Taro.navigateTo({url: '/passport/login'})}>
</Button>
</View>
</View>
</View>
)
}
return (
<View className="bg-gray-100 min-h-screen">
{/* 头部信息 */}
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
<View
className="absolute w-32 h-32 rounded-full"
style={{backgroundColor: 'rgba(255, 255, 255, 0.1)', top: '-16px', right: '-16px'}}
></View>
<View
className="absolute w-24 h-24 rounded-full"
style={{backgroundColor: 'rgba(255, 255, 255, 0.08)', bottom: '-12px', left: '-12px'}}
></View>
<View
className="absolute w-16 h-16 rounded-full"
style={{backgroundColor: 'rgba(255, 255, 255, 0.05)', top: '60px', left: '120px'}}
></View>
<View className="flex items-center justify-between relative z-10">
<Avatar
size="50"
src={getAvatarUrl()}
icon={<User />}
className="mr-4"
style={{border: '2px solid rgba(255, 255, 255, 0.3)'}}
/>
<View className="flex-1 flex-col">
<View className="text-white text-lg font-bold mb-1">
{getDisplayName()}
</View>
<View className="text-sm" style={{color: 'rgba(255, 255, 255, 0.8)'}}>
{hasRole('store') ? '门店' : hasRole('rider') ? '配送员' : getRoleName()}
</View>
</View>
<Button
size="small"
style={{
background: 'rgba(255, 255, 255, 0.18)',
color: '#fff',
border: '1px solid rgba(255, 255, 255, 0.25)'
}}
loading={loading}
onClick={refresh}
>
</Button>
</View>
</View>
{/* 门店信息 */}
<View className="mx-4 -mt-6 rounded-xl p-4 relative z-10 bg-white">
<View className="flex items-center justify-between mb-2">
<Text className="font-semibold text-gray-800"></Text>
<View
className="text-gray-400 text-sm"
onClick={() => Taro.switchTab({url: '/pages/index/index'})}
>
</View>
</View>
{!storeId ? (
<View>
<Text className="text-sm text-gray-600">
</Text>
<View className="mt-3">
<Button type="primary" size="small" onClick={() => Taro.switchTab({url: '/pages/index/index'})}>
</Button>
</View>
</View>
) : (
<View>
<View className="text-base font-medium text-gray-900">
{store?.name || `门店ID: ${storeId}`}
</View>
{!!store?.address && (
<View className="text-sm text-gray-600 mt-1">
{store.address}
</View>
)}
{!!store?.warehouseName && (
<View className="text-sm text-gray-500 mt-1">
{store.warehouseName}
</View>
)}
{!!error && (
<View className="mt-2">
<Text className="text-sm text-red-600">{error}</Text>
</View>
)}
</View>
)}
</View>
{/* 功能入口 */}
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
<View className="font-semibold mb-4 text-gray-800"></View>
<ConfigProvider>
<Grid
columns={4}
className="no-border-grid"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text="门店订单" onClick={() => navigateToPage('/store/orders/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shopping color="#3b82f6" size="20" />
</View>
</View>
</Grid.Item>
<Grid.Item text="礼品卡核销" onClick={() => navigateToPage('/user/store/verification')}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Scan color="#10b981" size="20" />
</View>
</View>
</Grid.Item>
<Grid.Item text="门店导航" onClick={openStoreLocation}>
<View className="text-center">
<View className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Location color="#f59e0b" size="20" />
</View>
</View>
</Grid.Item>
<Grid.Item text="首页选店" onClick={() => Taro.switchTab({url: '/pages/index/index'})}>
<View className="text-center">
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shop color="#8b5cf6" size="20" />
</View>
</View>
</Grid.Item>
</Grid>
</ConfigProvider>
</View>
<View className="h-20"></View>
</View>
)
}
export default StoreIndex

View File

@@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '门店订单',
navigationBarTextStyle: 'black'
}

View File

@@ -0,0 +1,83 @@
import {useEffect, useMemo, useState} from 'react'
import Taro from '@tarojs/taro'
import {Button} from '@nutui/nutui-react-taro'
import {View, Text} from '@tarojs/components'
import OrderList from '@/user/order/components/OrderList'
import {getSelectedStoreFromStorage} from '@/utils/storeSelection'
import {listShopStoreUser} from '@/api/shop/shopStoreUser'
export default function StoreOrders() {
const [boundStoreId, setBoundStoreId] = useState<number | undefined>(undefined)
const isLoggedIn = useMemo(() => {
return !!Taro.getStorageSync('access_token') && !!Taro.getStorageSync('UserId')
}, [])
const selectedStore = useMemo(() => getSelectedStoreFromStorage(), [])
const storeId = boundStoreId || selectedStore?.id
useEffect(() => {
}, [])
useEffect(() => {
// 优先按“店员绑定关系”确定门店归属:门店看到的是自己的订单
const userId = Number(Taro.getStorageSync('UserId'))
if (!Number.isFinite(userId) || userId <= 0) return
listShopStoreUser({userId}).then(list => {
const first = (list || []).find(i => i?.isDelete !== 1 && i?.storeId)
if (first?.storeId) setBoundStoreId(first.storeId)
}).catch(() => {
// fallback to SelectedStore
})
}, [])
if (!isLoggedIn) {
return (
<View className="bg-gray-50 min-h-screen p-4">
<View className="bg-white rounded-lg p-4">
<Text className="text-sm text-gray-700"></Text>
<View className="mt-3">
<Button type="primary" size="small" onClick={() => Taro.navigateTo({url: '/passport/login'})}>
</Button>
</View>
</View>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
<View className="px-3">
<View className="bg-white rounded-lg p-3 mb-3">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-base font-medium">
{boundStoreId
? (selectedStore?.id === boundStoreId ? (selectedStore?.name || `门店ID: ${boundStoreId}`) : `门店ID: ${boundStoreId}`)
: (selectedStore?.name || '未选择门店')}
</Text>
</View>
{!storeId ? (
<View className="bg-white rounded-lg p-4">
<Text className="text-sm text-gray-600">
</Text>
<View className="mt-3">
<Button
type="primary"
size="small"
onClick={() => Taro.switchTab({url: '/pages/index/index'})}
>
</Button>
</View>
</View>
) : (
<OrderList mode="store" baseParams={{storeId}} />
)}
</View>
</View>
)
}