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

@@ -2,6 +2,8 @@ import { API_BASE_URL } from './env'
// 租户ID - 请根据实际情况修改 // 租户ID - 请根据实际情况修改
export const TenantId = '10584'; export const TenantId = '10584';
// 租户名称
export const TenantName = '桂乐淘';
// 接口地址 - 请根据实际情况修改 // 接口地址 - 请根据实际情况修改
export const BaseUrl = API_BASE_URL; export const BaseUrl = API_BASE_URL;
// 当前版本 // 当前版本

View File

@@ -27,6 +27,14 @@ export interface ShopOrder {
merchantName?: string; merchantName?: string;
// 商户编号 // 商户编号
merchantCode?: string; merchantCode?: string;
// 归属门店IDshop_store.id
storeId?: number;
// 归属门店名称
storeName?: string;
// 配送员用户ID优先级派单
riderId?: number;
// 发货仓库ID
warehouseId?: number;
// 使用的优惠券id // 使用的优惠券id
couponId?: number; couponId?: number;
// 使用的会员卡id // 使用的会员卡id
@@ -61,6 +69,8 @@ export interface ShopOrder {
sendStartTime?: string; sendStartTime?: string;
// 配送结束时间 // 配送结束时间
sendEndTime?: string; sendEndTime?: string;
// 配送员送达拍照(选填)
sendEndImg?: string;
// 发货店铺id // 发货店铺id
expressMerchantId?: number; expressMerchantId?: number;
// 发货店铺 // 发货店铺
@@ -165,6 +175,14 @@ export interface OrderGoodsItem {
export interface OrderCreateRequest { export interface OrderCreateRequest {
// 商品信息列表 // 商品信息列表
goodsItems: OrderGoodsItem[]; goodsItems: OrderGoodsItem[];
// 归属门店IDshop_store.id
storeId?: number;
// 归属门店名称(可选)
storeName?: string;
// 配送员用户ID优先级派单
riderId?: number;
// 发货仓库ID
warehouseId?: number;
// 收货地址ID // 收货地址ID
addressId?: number; addressId?: number;
// 支付方式 // 支付方式
@@ -197,6 +215,14 @@ export interface OrderGoodsItem {
export interface OrderCreateRequest { export interface OrderCreateRequest {
// 商品信息列表 // 商品信息列表
goodsItems: OrderGoodsItem[]; goodsItems: OrderGoodsItem[];
// 归属门店IDshop_store.id
storeId?: number;
// 归属门店名称(可选)
storeName?: string;
// 配送员用户ID优先级派单
riderId?: number;
// 发货仓库ID
warehouseId?: number;
// 收货地址ID // 收货地址ID
addressId?: number; addressId?: number;
// 支付方式 // 支付方式
@@ -223,6 +249,12 @@ export interface ShopOrderParam extends PageParam {
payType?: number; payType?: number;
isInvoice?: boolean; isInvoice?: boolean;
userId?: number; userId?: number;
// 归属门店IDshop_store.id
storeId?: number;
// 配送员用户ID
riderId?: number;
// 发货仓库ID
warehouseId?: number;
keywords?: string; keywords?: string;
deliveryStatus?: number; deliveryStatus?: number;
statusFilter?: number; statusFilter?: number;

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopStore, ShopStoreParam } from './model';
/**
* 分页查询门店
*/
export async function pageShopStore(params: ShopStoreParam) {
const res = await request.get<ApiResult<PageResult<ShopStore>>>(
'/shop/shop-store/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询门店列表
*/
export async function listShopStore(params?: ShopStoreParam) {
const res = await request.get<ApiResult<ShopStore[]>>(
'/shop/shop-store',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加门店
*/
export async function addShopStore(data: ShopStore) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改门店
*/
export async function updateShopStore(data: ShopStore) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除门店
*/
export async function removeShopStore(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除门店
*/
export async function removeBatchShopStore(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询门店
*/
export async function getShopStore(id: number) {
const res = await request.get<ApiResult<ShopStore>>(
'/shop/shop-store/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,63 @@
import type { PageParam } from '@/api';
/**
* 门店
*/
export interface ShopStore {
// 自增ID
id?: number;
// 店铺名称
name?: string;
// 门店地址
address?: string;
// 手机号码
phone?: string;
// 邮箱
email?: string;
// 门店经理
managerName?: string;
// 门店banner
shopBanner?: string;
// 所在省份
province?: string;
// 所在城市
city?: string;
// 所在辖区
region?: string;
// 经度和纬度
lngAndLat?: string;
// 位置
location?:string;
// 区域
district?: string;
// 轮廓
points?: string;
// 用户ID
userId?: number;
// 默认仓库IDshop_warehouse.id
warehouseId?: number;
// 默认仓库名称(可选)
warehouseName?: string;
// 状态
status?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 门店搜索条件
*/
export interface ShopStoreParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopStoreRider, ShopStoreRiderParam } from './model';
/**
* 分页查询配送员
*/
export async function pageShopStoreRider(params: ShopStoreRiderParam) {
const res = await request.get<ApiResult<PageResult<ShopStoreRider>>>(
'/shop/shop-store-rider/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询配送员列表
*/
export async function listShopStoreRider(params?: ShopStoreRiderParam) {
const res = await request.get<ApiResult<ShopStoreRider[]>>(
'/shop/shop-store-rider',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加配送员
*/
export async function addShopStoreRider(data: ShopStoreRider) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store-rider',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改配送员
*/
export async function updateShopStoreRider(data: ShopStoreRider) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store-rider',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除配送员
*/
export async function removeShopStoreRider(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-rider/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除配送员
*/
export async function removeBatchShopStoreRider(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-rider/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询配送员
*/
export async function getShopStoreRider(id: number) {
const res = await request.get<ApiResult<ShopStoreRider>>(
'/shop/shop-store-rider/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,67 @@
import type { PageParam } from '@/api';
/**
* 配送员
*/
export interface ShopStoreRider {
// 主键ID
id?: string;
// 配送点IDshop_dealer.id
dealerId?: number;
// 骑手编号(可选)
riderNo?: string;
// 姓名
realName?: string;
// 手机号
mobile?: string;
// 头像
avatar?: string;
// 身份证号(可选)
idCardNo?: string;
// 状态1启用0禁用
status?: number;
// 接单状态0休息/下线1在线2忙碌
workStatus?: number;
// 是否开启自动派单1是0否
autoDispatchEnabled?: number;
// 派单优先级(同小区多骑手时可用,值越大越优先)
dispatchPriority?: number;
// 最大同时配送单数0表示不限制
maxOnhandOrders?: number;
// 是否计算工资(提成)1计算0不计算如三方配送点可设0
commissionCalcEnabled?: number;
// 水每桶提成金额(元/桶)
waterBucketUnitFee?: string;
// 其他商品提成方式1按订单固定金额2按订单金额比例3按商品规则(另表)
otherGoodsCommissionType?: number;
// 其他商品提成值:固定金额(元)或比例(%)
otherGoodsCommissionValue?: string;
// 用户ID
userId?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 配送员搜索条件
*/
export interface ShopStoreRiderParam extends PageParam {
id?: number;
keywords?: string;
// 配送点/门店ID后端可能用 dealerId 或 storeId
dealerId?: number;
storeId?: number;
status?: number;
workStatus?: number;
autoDispatchEnabled?: number;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopStoreUser, ShopStoreUserParam } from './model';
/**
* 分页查询店员
*/
export async function pageShopStoreUser(params: ShopStoreUserParam) {
const res = await request.get<ApiResult<PageResult<ShopStoreUser>>>(
'/shop/shop-store-user/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询店员列表
*/
export async function listShopStoreUser(params?: ShopStoreUserParam) {
const res = await request.get<ApiResult<ShopStoreUser[]>>(
'/shop/shop-store-user',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加店员
*/
export async function addShopStoreUser(data: ShopStoreUser) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store-user',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改店员
*/
export async function updateShopStoreUser(data: ShopStoreUser) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store-user',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除店员
*/
export async function removeShopStoreUser(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-user/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除店员
*/
export async function removeBatchShopStoreUser(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-user/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询店员
*/
export async function getShopStoreUser(id: number) {
const res = await request.get<ApiResult<ShopStoreUser>>(
'/shop/shop-store-user/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,36 @@
import type { PageParam } from '@/api';
/**
* 店员
*/
export interface ShopStoreUser {
// 主键ID
id?: number;
// 配送点IDshop_dealer.id
storeId?: number;
// 用户ID
userId?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 店员搜索条件
*/
export interface ShopStoreUserParam extends PageParam {
id?: number;
keywords?: string;
storeId?: number;
userId?: number;
isDelete?: number;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopWarehouse, ShopWarehouseParam } from './model';
/**
* 分页查询仓库
*/
export async function pageShopWarehouse(params: ShopWarehouseParam) {
const res = await request.get<ApiResult<PageResult<ShopWarehouse>>>(
'/shop/shop-warehouse/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询仓库列表
*/
export async function listShopWarehouse(params?: ShopWarehouseParam) {
const res = await request.get<ApiResult<ShopWarehouse[]>>(
'/shop/shop-warehouse',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加仓库
*/
export async function addShopWarehouse(data: ShopWarehouse) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-warehouse',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改仓库
*/
export async function updateShopWarehouse(data: ShopWarehouse) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-warehouse',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除仓库
*/
export async function removeShopWarehouse(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-warehouse/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除仓库
*/
export async function removeBatchShopWarehouse(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-warehouse/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询仓库
*/
export async function getShopWarehouse(id: number) {
const res = await request.get<ApiResult<ShopWarehouse>>(
'/shop/shop-warehouse/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,53 @@
import type { PageParam } from '@/api';
/**
* 仓库
*/
export interface ShopWarehouse {
// 自增ID
id?: number;
// 仓库名称
name?: string;
// 唯一标识
code?: string;
// 类型 中心仓,区域仓,门店仓
type?: string;
// 仓库地址
address?: string;
// 真实姓名
realName?: string;
// 联系电话
phone?: string;
// 省份
province?: string;
// 城市
city: undefined,
// 区域
region: undefined,
// 经纬度
lngAndLat?: string;
// 用户ID
userId?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 状态
status?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 仓库搜索条件
*/
export interface ShopWarehouseParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -58,6 +58,7 @@ export default {
"gift/detail", "gift/detail",
"gift/add", "gift/add",
"store/verification", "store/verification",
"store/orders/index",
"theme/index", "theme/index",
"poster/poster", "poster/poster",
"chat/conversation/index", "chat/conversation/index",
@@ -91,10 +92,18 @@ export default {
'comments/index', 'comments/index',
'search/index'] 'search/index']
}, },
{
"root": "store",
"pages": [
"index",
"orders/index"
]
},
{ {
"root": "rider", "root": "rider",
"pages": [ "pages": [
"index" "index",
"orders/index"
] ]
}, },
{ {

View File

@@ -132,7 +132,7 @@ const DealerIndex: React.FC = () => {
<Text className="font-semibold text-gray-800"></Text> <Text className="font-semibold text-gray-800"></Text>
</View> </View>
<View className="grid grid-cols-3 gap-3"> <View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.available background: businessGradients.money.available
}}> }}>
<Text className="text-lg font-bold mb-1 text-white"> <Text className="text-lg font-bold mb-1 text-white">
@@ -140,7 +140,7 @@ const DealerIndex: React.FC = () => {
</Text> </Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text> <Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View> </View>
<View className="text-center p-3 rounded-lg" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.frozen background: businessGradients.money.frozen
}}> }}>
<Text className="text-lg font-bold mb-1 text-white"> <Text className="text-lg font-bold mb-1 text-white">
@@ -148,7 +148,7 @@ const DealerIndex: React.FC = () => {
</Text> </Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text> <Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View> </View>
<View className="text-center p-3 rounded-lg" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total background: businessGradients.money.total
}}> }}>
<Text className="text-lg font-bold mb-1 text-white"> <Text className="text-lg font-bold mb-1 text-white">

View File

@@ -1,10 +1,10 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import {Button, Space, Sticky} from '@nutui/nutui-react-taro' import {Button, Sticky, Popup, Cell, CellGroup} from '@nutui/nutui-react-taro'
import {TriangleDown} from '@nutui/icons-react-taro' import {TriangleDown} from '@nutui/icons-react-taro'
import {Avatar, NavBar} from '@nutui/nutui-react-taro' import {Avatar, NavBar} from '@nutui/nutui-react-taro'
import {getUserInfo, getWxOpenId} from "@/api/layout"; import {getUserInfo, getWxOpenId} from "@/api/layout";
import {TenantId} from "@/config/app"; import {TenantId, TenantName} from "@/config/app";
import {getOrganization} from "@/api/system/organization"; import {getOrganization} from "@/api/system/organization";
import {myUserVerify} from "@/api/system/userVerify"; import {myUserVerify} from "@/api/system/userVerify";
import { useShopInfo } from '@/hooks/useShopInfo'; import { useShopInfo } from '@/hooks/useShopInfo';
@@ -13,6 +13,9 @@ import {View,Text} from '@tarojs/components'
import MySearch from "./MySearch"; import MySearch from "./MySearch";
import './Header.scss'; import './Header.scss';
import {User} from "@/api/system/user/model"; import {User} from "@/api/system/user/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
const Header = (_: any) => { const Header = (_: any) => {
// 使用新的useShopInfo Hook // 使用新的useShopInfo Hook
@@ -25,8 +28,124 @@ const Header = (_: any) => {
const [stickyStatus, setStickyStatus] = useState<boolean>(false) const [stickyStatus, setStickyStatus] = useState<boolean>(false)
const [userInfo] = useState<User>() const [userInfo] = useState<User>()
// 门店选择:用于首页展示“最近门店”,并在下单时写入订单 storeId
const [storePopupVisible, setStorePopupVisible] = useState(false)
const [stores, setStores] = useState<ShopStore[]>([])
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
const [userLocation, setUserLocation] = useState<{lng: number; lat: number} | null>(null)
const getTenantName = () => { const getTenantName = () => {
return userInfo?.tenantName || '商城名称' return userInfo?.tenantName || TenantName
}
const parseStoreCoords = (s: ShopStore): {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 distanceMeters = (a: {lng: number; lat: number}, b: {lng: number; lat: number}) => {
const toRad = (x: number) => (x * Math.PI) / 180
const R = 6371000 // meters
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const sin1 = Math.sin(dLat / 2)
const sin2 = Math.sin(dLng / 2)
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)))
}
const formatDistance = (meters?: number) => {
if (meters === undefined || Number.isNaN(meters)) return ''
if (meters < 1000) return `${Math.round(meters)}m`
return `${(meters / 1000).toFixed(1)}km`
}
const getStoreDistance = (s: ShopStore) => {
if (!userLocation) return undefined
const coords = parseStoreCoords(s)
if (!coords) return undefined
return distanceMeters(userLocation, coords)
}
const initStoreSelection = async () => {
// 先读取本地已选门店,避免页面首屏抖动
const stored = getSelectedStoreFromStorage()
if (stored?.id) {
setSelectedStore(stored)
}
// 拉取门店列表(失败时允许用户手动重试/继续使用本地门店)
let list: ShopStore[] = []
try {
list = await listShopStore()
} catch (e) {
console.error('获取门店列表失败:', e)
list = []
}
const usable = (list || []).filter(s => s?.isDelete !== 1)
setStores(usable)
// 尝试获取定位,用于计算最近门店
let loc: {lng: number; lat: number} | null = null
try {
const r = await Taro.getLocation({type: 'gcj02'})
loc = {lng: r.longitude, lat: r.latitude}
} catch (e) {
// 不强制定位授权;无定位时仍允许用户手动选择
console.warn('获取定位失败,将不显示最近门店距离:', e)
}
setUserLocation(loc)
const ensureStoreDetail = async (s: ShopStore) => {
if (!s?.id) return s
// 如果后端已经返回默认仓库等字段,就不额外请求
if (s.warehouseId) return s
try {
const full = await getShopStore(s.id)
return full || s
} catch (_e) {
return s
}
}
// 若用户没有选过门店,则自动选择最近门店(或第一个)
const alreadySelected = stored?.id
if (alreadySelected || usable.length === 0) return
let autoPick: ShopStore | undefined
if (loc) {
autoPick = [...usable]
.map(s => {
const coords = parseStoreCoords(s)
const d = coords ? distanceMeters(loc, coords) : undefined
return {s, d}
})
.sort((x, y) => (x.d ?? Number.POSITIVE_INFINITY) - (y.d ?? Number.POSITIVE_INFINITY))[0]?.s
} else {
autoPick = usable[0]
}
if (autoPick?.id) {
const full = await ensureStoreDetail(autoPick)
setSelectedStore(full)
saveSelectedStoreToStorage(full)
}
} }
const reload = async () => { const reload = async () => {
@@ -187,6 +306,7 @@ const Header = (_: any) => {
useEffect(() => { useEffect(() => {
reload().then() reload().then()
initStoreSelection().then()
}, []) }, [])
return ( return (
@@ -216,31 +336,95 @@ const Header = (_: any) => {
onBackClick={() => { onBackClick={() => {
}} }}
left={ left={
<View
style={{display: 'flex', alignItems: 'center', gap: '8px'}}
onClick={() => setStorePopupVisible(true)}
>
<Avatar
size="22"
src={getWebsiteLogo()}
/>
<Text className={'text-white'}>
{selectedStore?.name || '请选择门店'}
</Text>
<TriangleDown className={'text-white'} size={9}/>
</View>
}
right={
!IsLogin ? ( !IsLogin ? (
<View style={{display: 'flex', alignItems: 'center'}}> <Button
<Button style={{color: '#ffffff'}} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}> size="small"
<Space> fill="none"
<Avatar style={{color: '#ffffff'}}
size="22" open-type="getPhoneNumber"
src={getWebsiteLogo()} onGetPhoneNumber={handleGetPhoneNumber}
/> >
<Text style={{color: '#ffffff'}}>{getTenantName()}</Text>
<TriangleDown size={9} className={'text-white'}/> </Button>
</Space> ) : null
</Button> }
</View> >
) : (
<View style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
<Avatar
size="22"
src={getWebsiteLogo()}
/>
<Text className={'text-white'}>{getTenantName()}</Text>
<TriangleDown className={'text-white'} size={9}/>
</View>
)}>
<Text className={'text-white'}>{getTenantName()}</Text> <Text className={'text-white'}>{getTenantName()}</Text>
</NavBar> </NavBar>
<Popup
visible={storePopupVisible}
position="bottom"
style={{height: '70vh'}}
onClose={() => setStorePopupVisible(false)}
>
<View className="p-4">
<View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setStorePopupVisible(false)}
>
</Text>
</View>
<View className="text-xs text-gray-500 mb-2">
{userLocation ? '已获取定位,按距离排序' : '未获取定位,可手动选择门店'}
</View>
<CellGroup>
{[...stores]
.sort((a, b) => (getStoreDistance(a) ?? Number.POSITIVE_INFINITY) - (getStoreDistance(b) ?? Number.POSITIVE_INFINITY))
.map((s) => {
const d = getStoreDistance(s)
const isActive = !!selectedStore?.id && selectedStore.id === s.id
return (
<Cell
key={s.id}
title={
<View className="flex items-center justify-between">
<Text className={isActive ? 'text-green-600' : ''}>{s.name || `门店${s.id}`}</Text>
{d !== undefined && <Text className="text-xs text-gray-500">{formatDistance(d)}</Text>}
</View>
}
description={s.address || ''}
onClick={async () => {
let storeToSave = s
if (s?.id) {
try {
const full = await getShopStore(s.id)
if (full) storeToSave = full
} catch (_e) {
// keep base item
}
}
setSelectedStore(storeToSave)
saveSelectedStoreToStorage(storeToSave)
setStorePopupVisible(false)
Taro.showToast({title: '门店已切换', icon: 'success'})
}}
/>
)
})}
</CellGroup>
</View>
</Popup>
</Sticky> </Sticky>
</> </>
) )

View File

@@ -11,13 +11,14 @@ import {
People, People,
// AfterSaleService, // AfterSaleService,
Logout, Logout,
ShoppingAdd, Shop,
Jdl,
Service Service
} from '@nutui/icons-react-taro' } from '@nutui/icons-react-taro'
import {useUser} from "@/hooks/useUser"; import {useUser} from "@/hooks/useUser";
const UserCell = () => { const UserCell = () => {
const {logoutUser} = useUser(); const {logoutUser, hasRole} = useUser();
const onLogout = () => { const onLogout = () => {
Taro.showModal({ Taro.showModal({
@@ -49,10 +50,49 @@ const UserCell = () => {
border: 'none' border: 'none'
} as React.CSSProperties} } as React.CSSProperties}
> >
<Grid.Item text="配送订单" onClick={() => navTo('/rider/index', true)}>
{hasRole('store') && (
<Grid.Item text="门店中心" onClick={() => navTo('/store/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shop color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
)}
{hasRole('rider') && (
<Grid.Item text="配送中心" onClick={() => navTo('/rider/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Jdl color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
)}
{(hasRole('staff') || hasRole('admin')) && (
<Grid.Item text="门店订单" onClick={() => navTo('/user/store/orders/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shop color="#f59e0b" size="20"/>
</View>
</View>
</Grid.Item>
)}
<Grid.Item text="收货地址" onClick={() => navTo('/user/address/index', true)}>
<View className="text-center"> <View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2"> <View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<ShoppingAdd color="#3b82f6" size="20"/> <Location color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Ask className={'text-cyan-500'} size="20"/>
</View> </View>
</View> </View>
</Grid.Item> </Grid.Item>
@@ -71,14 +111,6 @@ const UserCell = () => {
</Button> </Button>
</Grid.Item> </Grid.Item>
<Grid.Item text="收货地址" onClick={() => navTo('/user/address/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Location color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'实名认证'} onClick={() => navTo('/user/userVerify/index', true)}> <Grid.Item text={'实名认证'} onClick={() => navTo('/user/userVerify/index', true)}>
<View className="text-center"> <View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2"> <View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
@@ -111,13 +143,6 @@ const UserCell = () => {
{/* </View>*/} {/* </View>*/}
{/*</Grid.Item>*/} {/*</Grid.Item>*/}
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Ask className={'text-cyan-500'} size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'关于我们'} onClick={() => navTo('/user/about/index')}> <Grid.Item text={'关于我们'} onClick={() => navTo('/user/about/index')}>
<View className="text-center"> <View className="text-center">
@@ -189,4 +214,3 @@ const UserCell = () => {
) )
} }
export default UserCell export default UserCell

View File

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

378
src/rider/orders/index.tsx Normal file
View File

@@ -0,0 +1,378 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import Taro from '@tarojs/taro'
import { Tabs, TabPane, Cell, Space, Button, Dialog, Image, Empty, InfiniteLoading} from '@nutui/nutui-react-taro'
import {View, Text} from '@tarojs/components'
import dayjs from 'dayjs'
import {pageShopOrder, updateShopOrder} from '@/api/shop/shopOrder'
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
import {uploadFile} from '@/api/system/file'
export default function RiderOrders() {
const riderId = useMemo(() => {
const v = Number(Taro.getStorageSync('UserId'))
return Number.isFinite(v) && v > 0 ? v : undefined
}, [])
const pageRef = useRef(1)
const [tabIndex, setTabIndex] = useState(0)
const [list, setList] = useState<ShopOrder[]>([])
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deliverDialogVisible, setDeliverDialogVisible] = useState(false)
const [deliverSubmitting, setDeliverSubmitting] = useState(false)
const [deliverOrder, setDeliverOrder] = useState<ShopOrder | null>(null)
const [deliverImg, setDeliverImg] = useState<string | undefined>(undefined)
// 前端展示用:后台可配置实际自动确认收货时长
const AUTO_CONFIRM_RECEIVE_HOURS_FALLBACK = 24
const riderTabs = useMemo(
() => [
{index: 0, title: '全部', statusFilter: -1},
{index: 1, title: '配送中', statusFilter: 3}, // 后端deliveryStatus=20
{index: 2, title: '待客户确认', statusFilter: 3}, // 同上,前端再按 sendEndTime 细分
{index: 3, title: '已完成', statusFilter: 5}, // 后端orderStatus=1
],
[]
)
const isAbnormalOrder = (order: ShopOrder) => {
const s = order.orderStatus
return s === 2 || s === 3 || s === 4 || s === 5 || s === 6 || s === 7
}
const getOrderStatusText = (order: ShopOrder) => {
if (order.orderStatus === 2) return '已取消'
if (order.orderStatus === 3) return '取消中'
if (order.orderStatus === 4) return '退款申请中'
if (order.orderStatus === 5) return '退款被拒绝'
if (order.orderStatus === 6) return '退款成功'
if (order.orderStatus === 7) return '客户申请退款'
if (!order.payStatus) return '未付款'
if (order.orderStatus === 1) return '已完成'
// 配送员页:用 sendEndTime 表示“已送达收货点”
if (order.deliveryStatus === 20) {
if (order.sendEndTime) return '待客户确认收货'
return '配送中'
}
if (order.deliveryStatus === 10) return '待发货'
if (order.deliveryStatus === 30) return '部分发货'
return '处理中'
}
const getOrderStatusColor = (order: ShopOrder) => {
if (isAbnormalOrder(order)) return 'text-orange-500'
if (order.orderStatus === 1) return 'text-green-600'
if (order.sendEndTime) return 'text-purple-600'
return 'text-blue-600'
}
const canConfirmDelivered = (order: ShopOrder) => {
if (!order.payStatus) return false
if (order.orderStatus === 1) return false
if (isAbnormalOrder(order)) return false
// 只允许在“配送中”阶段确认送达
if (order.deliveryStatus !== 20) return false
return !order.sendEndTime
}
const filterByTab = useCallback(
(orders: ShopOrder[]) => {
if (tabIndex === 1) {
// 配送中:未确认送达
return orders.filter(o => o.deliveryStatus === 20 && !o.sendEndTime && !isAbnormalOrder(o) && o.orderStatus !== 1)
}
if (tabIndex === 2) {
// 待客户确认:已确认送达
return orders.filter(o => o.deliveryStatus === 20 && !!o.sendEndTime && !isAbnormalOrder(o) && o.orderStatus !== 1)
}
if (tabIndex === 3) {
return orders.filter(o => o.orderStatus === 1)
}
return orders
},
[tabIndex]
)
const reload = useCallback(
async (resetPage = false) => {
if (!riderId) return
setLoading(true)
setError(null)
const currentPage = resetPage ? 1 : pageRef.current
const currentTab = riderTabs.find(t => t.index === tabIndex) || riderTabs[0]
const params: ShopOrderParam = {
page: currentPage,
riderId,
statusFilter: currentTab.statusFilter,
}
try {
const res = await pageShopOrder(params)
const incoming = (res?.list || []) as ShopOrder[]
setList(prev => (resetPage ? incoming : prev.concat(incoming)))
setHasMore(incoming.length >= 10)
pageRef.current = currentPage
} catch (e) {
console.error('加载配送订单失败:', e)
setError('加载失败,请重试')
setHasMore(false)
} finally {
setLoading(false)
}
},
[riderId, riderTabs, tabIndex]
)
const reloadMore = useCallback(async () => {
if (loading || !hasMore) return
pageRef.current += 1
await reload(false)
}, [hasMore, loading, reload])
const openDeliverDialog = (order: ShopOrder) => {
setDeliverOrder(order)
setDeliverImg(order.sendEndImg)
setDeliverDialogVisible(true)
}
const handleChooseDeliverImg = async () => {
try {
const file = await uploadFile()
setDeliverImg(file?.url)
} catch (e) {
console.error('上传送达照片失败:', e)
Taro.showToast({title: '上传失败,请重试', icon: 'none'})
}
}
const handleConfirmDelivered = async () => {
if (!deliverOrder?.orderId) return
if (deliverSubmitting) return
setDeliverSubmitting(true)
try {
await updateShopOrder({
orderId: deliverOrder.orderId,
// 用于前端/后端识别“配送员已送达收货点”
sendEndTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
sendEndImg: deliverImg,
})
Taro.showToast({title: '已确认送达', icon: 'success'})
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
pageRef.current = 1
await reload(true)
} catch (e) {
console.error('确认送达失败:', e)
Taro.showToast({title: '确认送达失败', icon: 'none'})
} finally {
setDeliverSubmitting(false)
}
}
useEffect(() => {
}, [])
useEffect(() => {
pageRef.current = 1
void reload(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabIndex, riderId])
if (!riderId) {
return (
<View className="bg-gray-50 min-h-screen p-4">
<Text></Text>
</View>
)
}
const displayList = filterByTab(list)
return (
<View className="bg-gray-50 min-h-screen">
<View>
<Tabs
align="left"
className="fixed left-0"
style={{zIndex: 998, borderBottom: '1px solid #e5e5e5'}}
tabStyle={{backgroundColor: '#ffffff'}}
value={tabIndex}
onChange={(paneKey) => setTabIndex(Number(paneKey))}
>
{riderTabs.map(t => (
<TabPane key={t.index} title={loading && tabIndex === t.index ? `${t.title}...` : t.title}></TabPane>
))}
</Tabs>
<View style={{height: '84vh', width: '100%', padding: '0', overflowY: 'auto', overflowX: 'hidden'}} id="rider-order-scroll">
{error ? (
<View className="flex flex-col items-center justify-center h-64">
<Text className="text-gray-500 mb-4">{error}</Text>
<Button size="small" type="primary" onClick={() => reload(true)}>
</Button>
</View>
) : (
<InfiniteLoading
target="rider-order-scroll"
hasMore={hasMore}
onLoadMore={reloadMore}
loadingText={<></>}
loadMoreText={
displayList.length === 0 ? (
<Empty style={{backgroundColor: 'transparent'}} description="暂无配送订单"/>
) : (
<View className="h-24"></View>
)
}
>
{displayList.map((o, idx) => {
const phoneToCall = o.phone || o.mobile
const flow1Done = !!o.riderId
const flow2Done = o.deliveryStatus === 20 || o.deliveryStatus === 30
const flow3Done = !!o.sendEndTime
const flow4Done = o.orderStatus === 1
const autoConfirmAt = o.sendEndTime
? dayjs(o.sendEndTime).add(AUTO_CONFIRM_RECEIVE_HOURS_FALLBACK, 'hour')
: null
const autoConfirmLeftMin = autoConfirmAt ? autoConfirmAt.diff(dayjs(), 'minute') : null
return (
<Cell
key={`${o.orderId || idx}`}
style={{padding: '16px'}}
onClick={() => o.orderId && Taro.navigateTo({url: `/shop/orderDetail/index?orderId=${o.orderId}`})}
>
<View className="w-full">
<View className="flex justify-between items-center">
<Text className="text-gray-800 font-bold text-sm">{o.orderNo || `订单#${o.orderId}`}</Text>
<Text className={`${getOrderStatusColor(o)} text-sm font-medium`}>{getOrderStatusText(o)}</Text>
</View>
<View className="text-gray-400 text-xs mt-1">
{o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '-'}
</View>
<View className="mt-3 bg-white rounded-lg">
<View className="text-sm text-gray-700">
<Text className="text-gray-500"></Text>
<Text>{o.selfTakeMerchantName || o.address || '-'}</Text>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{o.realName || '-'} {o.phone ? `(${o.phone})` : ''}</Text>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{o.payPrice || o.totalPrice || '-'}</Text>
<Text className="text-gray-500 ml-3"></Text>
<Text>{o.totalNum ?? '-'}</Text>
</View>
{o.sendEndTime && (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.sendEndTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
)}
</View>
{/* 配送流程 */}
<View className="mt-3 bg-gray-50 rounded-lg p-2 text-xs">
<Text className="text-gray-600"></Text>
<Text className={flow1Done ? 'text-green-600 font-medium' : 'text-gray-400'}>1 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow2Done ? (flow3Done ? 'text-green-600 font-medium' : 'text-blue-600 font-medium') : 'text-gray-400'}>2 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow3Done ? (flow4Done ? 'text-green-600 font-medium' : 'text-purple-600 font-medium') : 'text-gray-400'}>3 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow4Done ? 'text-green-600 font-medium' : 'text-gray-400'}>4 </Text>
{o.sendEndTime && o.orderStatus !== 1 && autoConfirmAt && (
<View className="mt-1 text-gray-500">
{autoConfirmAt.format('YYYY-MM-DD HH:mm')}
{typeof autoConfirmLeftMin === 'number' && autoConfirmLeftMin > 0 ? `,约剩余 ${Math.ceil(autoConfirmLeftMin / 60)} 小时` : ''}
</View>
)}
</View>
<View className="mt-3 flex justify-end">
<Space>
{!!phoneToCall && (
<Button
size="small"
onClick={(e) => {
e.stopPropagation()
Taro.makePhoneCall({phoneNumber: phoneToCall})
}}
>
</Button>
)}
{canConfirmDelivered(o) && (
<Button
size="small"
type="primary"
onClick={(e) => {
e.stopPropagation()
openDeliverDialog(o)
}}
>
</Button>
)}
</Space>
</View>
</View>
</Cell>
)
})}
</InfiniteLoading>
)}
</View>
</View>
<Dialog
title="确认送达"
visible={deliverDialogVisible}
confirmText={deliverSubmitting ? '提交中...' : '确认送达'}
cancelText="取消"
onConfirm={handleConfirmDelivered}
onCancel={() => {
if (deliverSubmitting) return
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
}}
>
<View className="text-sm text-gray-700">
<View></View>
<View className="mt-3">
<Button size="small" onClick={handleChooseDeliverImg}>
{deliverImg ? '重新拍照/上传' : '拍照/上传(选填)'}
</Button>
</View>
{deliverImg && (
<View className="mt-3">
<Image src={deliverImg} width="100%" height="120" />
<View className="mt-2 flex justify-end">
<Button size="small" onClick={() => setDeliverImg(undefined)}>
</Button>
</View>
</View>
)}
</View>
</Dialog>
</View>
)
}

View File

@@ -1,5 +1,5 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {Cell, CellGroup, Image, Space, Button} from '@nutui/nutui-react-taro' import {Cell, CellGroup, Image, Space, Button, Dialog} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import {View} from '@tarojs/components' import {View} from '@tarojs/components'
import {ShopOrder} from "@/api/shop/shopOrder/model"; import {ShopOrder} from "@/api/shop/shopOrder/model";
@@ -13,6 +13,7 @@ import './index.scss'
const OrderDetail = () => { const OrderDetail = () => {
const [order, setOrder] = useState<ShopOrder | null>(null); const [order, setOrder] = useState<ShopOrder | null>(null);
const [orderGoodsList, setOrderGoodsList] = useState<ShopOrderGoods[]>([]); const [orderGoodsList, setOrderGoodsList] = useState<ShopOrderGoods[]>([]);
const [confirmReceiveDialogVisible, setConfirmReceiveDialogVisible] = useState(false)
const router = Taro.getCurrentInstance().router; const router = Taro.getCurrentInstance().router;
const orderId = router?.params?.orderId; const orderId = router?.params?.orderId;
@@ -67,6 +68,25 @@ const OrderDetail = () => {
} }
}; };
// 确认收货(客户)
const handleConfirmReceive = async () => {
if (!order?.orderId) return
try {
setConfirmReceiveDialogVisible(false)
await updateShopOrder({
orderId: order.orderId,
deliveryStatus: order.deliveryStatus, // 10未发货 20已发货 30部分发货
orderStatus: 1 // 已完成
})
Taro.showToast({title: '确认收货成功', icon: 'success'})
setOrder(prev => (prev ? {...prev, orderStatus: 1} : prev))
} catch (e) {
console.error('确认收货失败:', e)
Taro.showToast({title: '确认收货失败', icon: 'none'})
setConfirmReceiveDialogVisible(true)
}
}
const getOrderStatusText = (order: ShopOrder) => { const getOrderStatusText = (order: ShopOrder) => {
// 优先检查订单状态 // 优先检查订单状态
if (order.orderStatus === 2) return '已取消'; if (order.orderStatus === 2) return '已取消';
@@ -81,8 +101,15 @@ const OrderDetail = () => {
// 已付款后检查发货状态 // 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货'; if (order.deliveryStatus === 10) return '待发货';
if (order.deliveryStatus === 20) return '待收货'; if (order.deliveryStatus === 20) {
if (order.deliveryStatus === 30) return '已收货'; // 若订单有配送员,则以配送员送达时间作为“可确认收货”的依据
if (order.riderId) {
if (order.sendEndTime && order.orderStatus !== 1) return '待确认收货';
return '配送中';
}
return '待收货';
}
if (order.deliveryStatus === 30) return '部分发货';
// 最后检查订单完成状态 // 最后检查订单完成状态
if (order.orderStatus === 1) return '已完成'; if (order.orderStatus === 1) return '已完成';
@@ -133,6 +160,15 @@ const OrderDetail = () => {
return <div>...</div>; return <div>...</div>;
} }
const currentUserId = Number(Taro.getStorageSync('UserId'))
const isOwner = !!currentUserId && currentUserId === order.userId
const canConfirmReceive =
isOwner &&
order.payStatus &&
order.orderStatus !== 1 &&
order.deliveryStatus === 20 &&
(!order.riderId || !!order.sendEndTime)
return ( return (
<div className={'order-detail-page'}> <div className={'order-detail-page'}>
{/* 支付倒计时显示 - 详情页实时更新 */} {/* 支付倒计时显示 - 详情页实时更新 */}
@@ -190,11 +226,25 @@ const OrderDetail = () => {
{!order.payStatus && <Button onClick={() => console.log('取消订单')}></Button>} {!order.payStatus && <Button onClick={() => console.log('取消订单')}></Button>}
{!order.payStatus && <Button type="primary" onClick={() => console.log('立即支付')}></Button>} {!order.payStatus && <Button type="primary" onClick={() => console.log('立即支付')}></Button>}
{order.orderStatus === 1 && <Button onClick={handleApplyRefund}>退</Button>} {order.orderStatus === 1 && <Button onClick={handleApplyRefund}>退</Button>}
{order.deliveryStatus === 20 && {canConfirmReceive && (
<Button type="primary" onClick={() => console.log('确认收货')}></Button>} <Button type="primary" onClick={() => setConfirmReceiveDialogVisible(true)}>
</Button>
)}
</Space> </Space>
</View> </View>
</View> </View>
<Dialog
title="确认收货"
visible={confirmReceiveDialogVisible}
confirmText="确认收货"
cancelText="我再想想"
onConfirm={handleConfirmReceive}
onCancel={() => setConfirmReceiveDialogVisible(false)}
>
</Dialog>
</div> </div>
); );
}; };

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>
)
}

View File

@@ -89,6 +89,12 @@ interface OrderListProps {
searchParams?: ShopOrderParam; searchParams?: ShopOrderParam;
showSearch?: boolean; showSearch?: boolean;
onSearchParamsChange?: (params: ShopOrderParam) => void; // 新增:通知父组件参数变化 onSearchParamsChange?: (params: ShopOrderParam) => void; // 新增:通知父组件参数变化
// 订单视图模式:用户/门店/骑手
mode?: 'user' | 'store' | 'rider';
// 固定过滤条件(例如 storeId / riderId会合并到每次请求里
baseParams?: ShopOrderParam;
// 只读模式:隐藏“支付/取消/确认收货/退款”等用户操作按钮
readOnly?: boolean;
} }
function OrderList(props: OrderListProps) { function OrderList(props: OrderListProps) {
@@ -115,6 +121,7 @@ function OrderList(props: OrderListProps) {
const [orderToCancel, setOrderToCancel] = useState<ShopOrder | null>(null) const [orderToCancel, setOrderToCancel] = useState<ShopOrder | null>(null)
const [confirmReceiveDialogVisible, setConfirmReceiveDialogVisible] = useState(false) const [confirmReceiveDialogVisible, setConfirmReceiveDialogVisible] = useState(false)
const [orderToConfirmReceive, setOrderToConfirmReceive] = useState<ShopOrder | null>(null) const [orderToConfirmReceive, setOrderToConfirmReceive] = useState<ShopOrder | null>(null)
const isReadOnly = props.readOnly || props.mode === 'store' || props.mode === 'rider'
// 获取订单状态文本 // 获取订单状态文本
const getOrderStatusText = (order: ShopOrder) => { const getOrderStatusText = (order: ShopOrder) => {
@@ -131,8 +138,14 @@ function OrderList(props: OrderListProps) {
// 已付款后检查发货状态 // 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货'; if (order.deliveryStatus === 10) return '待发货';
if (order.deliveryStatus === 20) return '待收货'; if (order.deliveryStatus === 20) {
if (order.deliveryStatus === 30) return '已完成'; // 若订单没有配送员,沿用原“待收货”语义
if (!order.riderId) return '待收货';
// 配送员确认送达后sendEndTime有值才进入“待确认收货”
if (order.sendEndTime && order.orderStatus !== 1) return '待确认收货';
return '配送中';
}
if (order.deliveryStatus === 30) return '部分发货';
// 最后检查订单完成状态 // 最后检查订单完成状态
if (order.orderStatus === 1) return '已完成'; if (order.orderStatus === 1) return '已完成';
@@ -155,8 +168,12 @@ function OrderList(props: OrderListProps) {
// 已付款后检查发货状态 // 已付款后检查发货状态
if (order.deliveryStatus === 10) return 'text-blue-500'; // 待发货 if (order.deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (order.deliveryStatus === 20) return 'text-purple-500'; // 待收货 if (order.deliveryStatus === 20) {
if (order.deliveryStatus === 30) return 'text-green-500'; // 收货 if (!order.riderId) return 'text-purple-500'; // 收货
if (order.sendEndTime && order.orderStatus !== 1) return 'text-purple-500'; // 待确认收货
return 'text-blue-500'; // 配送中
}
if (order.deliveryStatus === 30) return 'text-blue-500'; // 部分发货
// 最后检查订单完成状态 // 最后检查订单完成状态
if (order.orderStatus === 1) return 'text-green-600'; // 已完成 if (order.orderStatus === 1) return 'text-green-600'; // 已完成
@@ -167,9 +184,13 @@ function OrderList(props: OrderListProps) {
// 使用后端统一的 statusFilter 进行筛选 // 使用后端统一的 statusFilter 进行筛选
const getOrderStatusParams = (index: string | number) => { const getOrderStatusParams = (index: string | number) => {
let params: ShopOrderParam = {}; let params: ShopOrderParam = {
// 添加用户ID过滤 ...(props.baseParams || {})
params.userId = Taro.getStorageSync('UserId'); };
// 默认是用户视图:添加 userId 过滤;门店/骑手视图由 baseParams 控制
if (!props.mode || props.mode === 'user') {
params.userId = Taro.getStorageSync('UserId');
}
// 获取当前tab的statusFilter配置 // 获取当前tab的statusFilter配置
const currentTab = tabs.find(tab => tab.index === Number(index)); const currentTab = tabs.find(tab => tab.index === Number(index));
@@ -190,7 +211,7 @@ function OrderList(props: OrderListProps) {
// 合并搜索条件tab的statusFilter优先级更高 // 合并搜索条件tab的statusFilter优先级更高
const searchConditions: any = { const searchConditions: any = {
page: currentPage, page: currentPage,
userId: statusParams.userId, // 用户ID ...statusParams,
...props.searchParams, // 搜索关键词等其他条件 ...props.searchParams, // 搜索关键词等其他条件
}; };
@@ -285,7 +306,7 @@ function OrderList(props: OrderListProps) {
await updateShopOrder({ await updateShopOrder({
...orderToConfirmReceive, ...orderToConfirmReceive,
deliveryStatus: 20, // 已收货 deliveryStatus: orderToConfirmReceive.deliveryStatus, // 10未发货 20已发货 30部分发货收货由orderStatus控制
orderStatus: 1 // 已完成 orderStatus: 1 // 已完成
}); });
@@ -764,6 +785,7 @@ function OrderList(props: OrderListProps) {
<Text className={'w-full text-right'}>{item.payPrice}</Text> <Text className={'w-full text-right'}>{item.payPrice}</Text>
{/* 操作按钮 */} {/* 操作按钮 */}
{!isReadOnly && (
<Space className={'btn flex justify-end'}> <Space className={'btn flex justify-end'}>
{/* 待付款状态:显示取消订单和立即支付 */} {/* 待付款状态:显示取消订单和立即支付 */}
{(!item.payStatus) && item.orderStatus !== 2 && ( {(!item.payStatus) && item.orderStatus !== 2 && (
@@ -788,7 +810,7 @@ function OrderList(props: OrderListProps) {
)} )}
{/* 待收货状态:显示查看物流和确认收货 */} {/* 待收货状态:显示查看物流和确认收货 */}
{item.deliveryStatus === 20 && item.orderStatus !== 2 && ( {item.deliveryStatus === 20 && (!item.riderId || !!item.sendEndTime) && item.orderStatus !== 2 && (
<Space> <Space>
<Button size={'small'} onClick={(e) => { <Button size={'small'} onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -837,6 +859,7 @@ function OrderList(props: OrderListProps) {
}}></Button> }}></Button>
)} )}
</Space> </Space>
)}
</Space> </Space>
</Cell> </Cell>
) )

View File

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

View File

@@ -0,0 +1,73 @@
import {useEffect, useMemo, useState} from 'react'
import Taro from '@tarojs/taro'
import {NavBar, Button} from '@nutui/nutui-react-taro'
import {ArrowLeft} from '@nutui/icons-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 [statusBarHeight, setStatusBarHeight] = useState<number>(0)
const [boundStoreId, setBoundStoreId] = useState<number | undefined>(undefined)
const store = useMemo(() => getSelectedStoreFromStorage(), [])
const storeId = boundStoreId || store?.id
useEffect(() => {
Taro.getSystemInfo({
success: (res) => setStatusBarHeight(res.statusBarHeight ?? 0)
})
}, [])
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
})
}, [])
return (
<View className="bg-gray-50 min-h-screen">
<View style={{height: `${statusBarHeight || 0}px`, backgroundColor: '#ffffff'}}></View>
<NavBar
fixed
style={{marginTop: `${statusBarHeight || 0}px`, backgroundColor: '#ffffff'}}
left={<ArrowLeft onClick={() => Taro.navigateBack()}/>}
>
<span></span>
</NavBar>
<View className="pt-14 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">{store?.name || (boundStoreId ? `门店ID: ${boundStoreId}` : '未选择门店')}</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}} readOnly />
)}
</View>
</View>
)
}

View File

@@ -1,6 +1,10 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { createOrder, WxPayResult } from '@/api/shop/shopOrder'; import { createOrder, WxPayResult } from '@/api/shop/shopOrder';
import { OrderCreateRequest } from '@/api/shop/shopOrder/model'; import { OrderCreateRequest } from '@/api/shop/shopOrder/model';
import { getSelectedStoreFromStorage, getSelectedStoreIdFromStorage } from '@/utils/storeSelection';
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model';
import type { ShopWarehouse } from '@/api/shop/shopWarehouse/model';
import request from '@/utils/request';
/** /**
* 支付类型枚举 * 支付类型枚举
@@ -24,6 +28,9 @@ export interface PaymentCallback {
* 统一支付处理类 * 统一支付处理类
*/ */
export class PaymentHandler { export class PaymentHandler {
// 简单缓存,避免频繁请求(小程序单次运行生命周期内有效)
private static storeRidersCache = new Map<number, ShopStoreRider[]>();
private static warehousesCache: ShopWarehouse[] | null = null;
/** /**
* 执行支付 * 执行支付
@@ -39,6 +46,36 @@ export class PaymentHandler {
Taro.showLoading({ title: '支付中...' }); Taro.showLoading({ title: '支付中...' });
try { try {
// 若调用方未指定门店,则自动注入“已选门店”,用于订单门店归属/统计。
if (orderData.storeId === undefined || orderData.storeId === null) {
const storeId = getSelectedStoreIdFromStorage();
if (storeId) {
orderData.storeId = storeId;
}
}
if (!orderData.storeName) {
const store = getSelectedStoreFromStorage();
if (store?.name) {
orderData.storeName = store.name;
}
}
// 自动派单按门店骑手优先级dispatchPriority选择 riderId不覆盖手动指定
if ((orderData.riderId === undefined || orderData.riderId === null) && orderData.storeId) {
const riderUserId = await this.pickRiderUserIdForStore(orderData.storeId);
if (riderUserId) {
orderData.riderId = riderUserId;
}
}
// 仓库选择:若未指定 warehouseId则按“离门店最近”兜底选择一个不覆盖手动指定
if ((orderData.warehouseId === undefined || orderData.warehouseId === null) && orderData.storeId) {
const warehouseId = await this.pickWarehouseIdForStore(orderData.storeId);
if (warehouseId) {
orderData.warehouseId = warehouseId;
}
}
// 设置支付类型 // 设置支付类型
orderData.payType = paymentType; orderData.payType = paymentType;
@@ -119,6 +156,127 @@ export class PaymentHandler {
} }
} }
private static parseLngLat(raw: string | undefined): { lng: number; lat: number } | null {
const text = (raw || '').trim();
if (!text) return null;
const parts = text.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;
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;
}
private static distanceMeters(a: { lng: number; lat: number }, b: { lng: number; lat: number }) {
const toRad = (x: number) => (x * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const sin1 = Math.sin(dLat / 2);
const sin2 = Math.sin(dLng / 2);
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
private static async getRidersForStore(storeId: number): Promise<ShopStoreRider[]> {
const cached = this.storeRidersCache.get(storeId);
if (cached) return cached;
// 后端字段可能叫 dealerId 或 storeId这里都带上服务端忽略未知字段即可。
// 这里做一次路径兼容camel vs kebab避免接口路径不一致导致整单失败。
const list = await this.listByCompatEndpoint<ShopStoreRider>(
['/shop/shopStoreRider', '/shop/shop-store-rider'],
{
dealerId: storeId,
storeId: storeId,
status: 1
}
);
const usable = (list || []).filter(r => r?.isDelete !== 1 && (r.status === undefined || r.status === 1));
this.storeRidersCache.set(storeId, usable);
return usable;
}
private static async pickRiderUserIdForStore(storeId: number): Promise<number | undefined> {
const riders = await this.getRidersForStore(storeId);
if (!riders.length) return undefined;
// 优先:启用 + 在线 + 自动派单,再按 dispatchPriority 由高到低
const score = (r: ShopStoreRider) => {
const enabled = (r.status === undefined || r.status === 1) ? 1 : 0;
const online = r.workStatus === 1 ? 1 : 0;
const auto = r.autoDispatchEnabled === 1 ? 1 : 0;
const p = typeof r.dispatchPriority === 'number' ? r.dispatchPriority : 0;
return enabled * 1000 + online * 100 + auto * 10 + p;
};
const sorted = [...riders].sort((a, b) => score(b) - score(a));
return sorted[0]?.userId;
}
private static async getWarehouses(): Promise<ShopWarehouse[]> {
if (this.warehousesCache) return this.warehousesCache;
const list = await this.listByCompatEndpoint<ShopWarehouse>(
['/shop/shopWarehouse', '/shop/shop-warehouse'],
{}
);
const usable = (list || []).filter(w => w?.isDelete !== 1 && (w.status === undefined || w.status === 1));
this.warehousesCache = usable;
return usable;
}
private static async pickWarehouseIdForStore(storeId: number): Promise<number | undefined> {
const store = getSelectedStoreFromStorage();
if (!store?.id || store.id !== storeId) return undefined;
// 一门店一默认仓库:优先使用门店自带的 warehouseId
if (store.warehouseId) return store.warehouseId;
const storeCoords = this.parseLngLat(store.lngAndLat || store.location);
if (!storeCoords) return undefined;
const warehouses = await this.getWarehouses();
if (!warehouses.length) return undefined;
// 优先选择“门店仓”,否则选最近的任意仓库
const candidates = warehouses.filter(w => w.type?.includes('门店') || w.type?.includes('门店仓'));
const list = candidates.length ? candidates : warehouses;
const withDistance = list
.map(w => {
const coords = this.parseLngLat(w.lngAndLat);
if (!coords) return { w, d: Number.POSITIVE_INFINITY };
return { w, d: this.distanceMeters(storeCoords, coords) };
})
.sort((a, b) => a.d - b.d);
return withDistance[0]?.w?.id;
}
private static async listByCompatEndpoint<T>(
urls: string[],
params: Record<string, any>
): Promise<T[]> {
for (const url of urls) {
try {
const res: any = await (request as any).get(url, params, { showError: false });
if (res?.code === 0 && Array.isArray(res?.data)) {
return res.data as T[];
}
} catch (_e) {
// try next
}
}
return [];
}
/** /**
* 处理微信支付 * 处理微信支付
*/ */

View File

@@ -0,0 +1,27 @@
import Taro from '@tarojs/taro';
import type { ShopStore } from '@/api/shop/shopStore/model';
export const SELECTED_STORE_STORAGE_KEY = 'SelectedStore';
export function getSelectedStoreFromStorage(): ShopStore | null {
try {
const raw = Taro.getStorageSync(SELECTED_STORE_STORAGE_KEY);
if (!raw) return null;
return (typeof raw === 'string' ? JSON.parse(raw) : raw) as ShopStore;
} catch (_e) {
return null;
}
}
export function saveSelectedStoreToStorage(store: ShopStore | null) {
if (!store) {
Taro.removeStorageSync(SELECTED_STORE_STORAGE_KEY);
return;
}
Taro.setStorageSync(SELECTED_STORE_STORAGE_KEY, store);
}
export function getSelectedStoreIdFromStorage(): number | undefined {
return getSelectedStoreFromStorage()?.id;
}