feat(M08-C): 补管理员订单处置与代下单

This commit is contained in:
Codex
2026-08-10 10:20:32 +08:00
parent 8b2156cdd5
commit 566e61b5d9
20 changed files with 701 additions and 96 deletions
+136 -22
View File
@@ -20,6 +20,17 @@ const orderStatusLabels = {
REFUNDED: '已退款',
CLOSED: '已关闭',
}
const orderActionsByStatus = {
DRAFT: [{ action: 'SUBMIT', label: '提交订单' }, { action: 'CANCEL', label: '取消订单' }, { action: 'CLOSE', label: '关闭订单' }],
PENDING_PAYMENT: [{ action: 'CANCEL', label: '取消订单' }, { action: 'CLOSE', label: '关闭订单' }],
PAID: [{ action: 'RESERVE', label: '确认预留' }, { action: 'START', label: '开始使用' }, { action: 'CANCEL', label: '取消订单' }, { action: 'BEGIN_REFUND', label: '发起退款' }],
RESERVED: [{ action: 'START', label: '开始使用' }, { action: 'CANCEL', label: '取消订单' }, { action: 'BEGIN_REFUND', label: '发起退款' }],
IN_PROGRESS: [{ action: 'FINISH', label: '结束使用' }, { action: 'BEGIN_REFUND', label: '发起退款' }],
FINISHED: [{ action: 'BEGIN_REFUND', label: '发起退款' }, { action: 'CLOSE', label: '关闭订单' }],
CANCELLED: [{ action: 'BEGIN_REFUND', label: '发起退款' }, { action: 'CLOSE', label: '关闭订单' }],
REFUNDING: [{ action: 'COMPLETE_REFUND', label: '确认退款完成' }],
REFUNDED: [{ action: 'CLOSE', label: '关闭订单' }],
}
Page({
data: {
@@ -32,7 +43,17 @@ Page({
rooms: [],
orders: [],
visibleOrders: [],
orderFilters: [
{ value: '', label: '全部' },
{ value: 'PENDING_PAYMENT', label: '待支付' },
{ value: 'RESERVED', label: '待开始' },
{ value: 'IN_PROGRESS', label: '进行中' },
{ value: 'FINISHED', label: '已结束' },
{ value: 'REFUNDING', label: '退款中' },
],
selectedOrderStatus: '',
busyRoomId: '',
busyOrderId: '',
summary: {
roomTotal: 0,
available: 0,
@@ -46,6 +67,10 @@ Page({
await this.loadDashboard()
},
async onShow() {
if (this.data.selectedStoreId) await this.loadStoreData(this.data.selectedStoreId)
},
async onPullDownRefresh() {
await this.loadDashboard(this.data.selectedStoreId)
wx.stopPullDownRefresh()
@@ -60,10 +85,7 @@ Page({
if (!access.roles.some((role) => managerRoles.includes(role))) {
throw new Error('当前账号没有门店运营权限')
}
const [storesResponse, ordersResponse] = await Promise.all([
request('/management/stores'),
request('/orders?page=1&pageSize=50'),
])
const storesResponse = await request('/management/stores')
const stores = storesResponse.data || []
const selectedStoreId = stores.some((store) => store.id === preferredStoreId)
? preferredStoreId
@@ -73,11 +95,10 @@ Page({
|| access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN'),
stores,
orders: (ordersResponse.data?.items || []).map((item) => this.presentOrder(item)),
selectedStoreId,
selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '',
})
await this.loadRooms(selectedStoreId)
await this.loadStoreData(selectedStoreId)
} catch (error) {
this.setData({ errorMessage: error.message || '门店运营数据加载失败' })
} finally {
@@ -89,33 +110,54 @@ Page({
const storeId = event.currentTarget.dataset.storeId
if (!storeId || storeId === this.data.selectedStoreId) return
const store = this.data.stores.find((item) => item.id === storeId)
this.setData({ selectedStoreId: storeId, selectedStoreName: store?.name || '' })
await this.loadRooms(storeId)
this.setData({
selectedStoreId: storeId,
selectedStoreName: store?.name || '',
selectedOrderStatus: '',
})
await this.loadStoreData(storeId)
},
async loadRooms(storeId) {
async loadStoreData(storeId) {
if (!storeId) {
this.setData({ rooms: [], visibleOrders: [] })
this.setData({ rooms: [], orders: [], visibleOrders: [] })
this.refreshSummary()
return
}
try {
const response = await request(`/management/stores/${encodeURIComponent(storeId)}/rooms`)
const rooms = (response.data || []).map((item) => ({
const encodedStoreId = encodeURIComponent(storeId)
const [roomsResponse, ordersResponse] = await Promise.all([
request(`/management/stores/${encodedStoreId}/rooms`),
request(`/orders?page=1&pageSize=50&storeId=${encodedStoreId}`),
])
const rooms = (roomsResponse.data || []).map((item) => ({
...item,
statusText: roomStatusLabels[item.operationalStatus] || item.operationalStatus,
priceText: cents(item.basePriceCents),
}))
this.setData({
rooms,
visibleOrders: this.data.orders.filter((item) => item.storeId === storeId),
})
const orders = (ordersResponse.data?.items || []).map((item) => this.presentOrder(item))
this.setData({ rooms, orders })
this.applyOrderFilter()
this.refreshSummary()
} catch (error) {
this.setData({ errorMessage: error.message || '房态加载失败' })
this.setData({ errorMessage: error.message || '门店运营数据加载失败' })
}
},
selectOrderFilter(event) {
this.setData({ selectedOrderStatus: event.currentTarget.dataset.status || '' })
this.applyOrderFilter()
},
applyOrderFilter() {
const status = this.data.selectedOrderStatus
this.setData({
visibleOrders: status
? this.data.orders.filter((item) => item.status === status)
: this.data.orders,
})
},
refreshSummary() {
const rooms = this.data.rooms
const activeStatuses = ['PAID', 'RESERVED', 'IN_PROGRESS']
@@ -125,7 +167,7 @@ Page({
available: rooms.filter((item) => item.configurationStatus === 'ENABLED' && item.operationalStatus === 'AVAILABLE').length,
inUse: rooms.filter((item) => ['RESERVED', 'IN_USE'].includes(item.operationalStatus)).length,
attention: rooms.filter((item) => item.configurationStatus === 'DISABLED' || ['MAINTENANCE', 'CLEANING_REQUIRED'].includes(item.operationalStatus)).length,
activeOrders: this.data.visibleOrders.filter((item) => activeStatuses.includes(item.status)).length,
activeOrders: this.data.orders.filter((item) => activeStatuses.includes(item.status)).length,
},
})
},
@@ -158,7 +200,7 @@ Page({
method: 'PATCH',
data: { storeId: this.data.selectedStoreId, ...status, reason },
})
await this.loadRooms(this.data.selectedStoreId)
await this.loadStoreData(this.data.selectedStoreId)
wx.showToast({ title: '房态已更新', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: error.message || '房态更新失败' })
@@ -167,10 +209,82 @@ Page({
}
},
openOrder(event) {
createOrder() {
if (!this.data.canWrite || !this.data.selectedStoreId) return
wx.navigateTo({
url: `/pages/manager/order-create?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`,
})
},
manageOrder(event) {
if (!this.data.canWrite) return
const orderId = event.currentTarget.dataset.orderId
if (!orderId) return
wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` })
const status = event.currentTarget.dataset.status
const actions = orderActionsByStatus[status] || []
if (!actions.length) {
wx.showToast({ title: '当前状态无可用动作', icon: 'none' })
return
}
wx.showActionSheet({
itemList: actions.map((item) => item.label),
success: ({ tapIndex }) => {
const selected = actions[tapIndex]
wx.showModal({
title: selected.label,
content: `确认对订单执行“${selected.label}”吗?`,
success: ({ confirm }) => {
if (confirm) this.executeOrderAction(orderId, selected)
},
})
},
})
},
addOrderNote(event) {
if (!this.data.canWrite) return
const orderId = event.currentTarget.dataset.orderId
wx.showModal({
title: '添加订单备注',
editable: true,
placeholderText: '请输入备注内容',
success: ({ confirm, content }) => {
const note = String(content || '').trim()
if (confirm && note) this.saveOrderNote(orderId, note)
},
})
},
async executeOrderAction(orderId, selected) {
if (!orderId || this.data.busyOrderId) return
this.setData({ busyOrderId: orderId, errorMessage: '' })
try {
await request(`/management/orders/${encodeURIComponent(orderId)}/actions`, {
method: 'POST',
data: { action: selected.action, reason: `管理员小程序:${selected.label}` },
})
await this.loadStoreData(this.data.selectedStoreId)
wx.showToast({ title: '订单已更新', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: error.message || '订单操作失败' })
} finally {
this.setData({ busyOrderId: '' })
}
},
async saveOrderNote(orderId, note) {
if (!orderId || this.data.busyOrderId) return
this.setData({ busyOrderId: orderId, errorMessage: '' })
try {
await request(`/management/orders/${encodeURIComponent(orderId)}/note`, {
method: 'POST',
data: { note },
})
wx.showToast({ title: '备注已保存', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: error.message || '订单备注保存失败' })
} finally {
this.setData({ busyOrderId: '' })
}
},
presentOrder(item) {
+21 -2
View File
@@ -48,9 +48,24 @@
</view>
</view>
<view class="section-title">近期订单</view>
<view class="section-heading">
<view class="section-title">近期订单</view>
<button wx:if="{{canWrite}}" size="mini" type="primary" bindtap="createOrder">代下单</button>
</view>
<scroll-view class="order-filters" scroll-x enhanced show-scrollbar="false">
<view class="order-filters-inner">
<button
wx:for="{{orderFilters}}"
wx:key="value"
size="mini"
class="{{item.value === selectedOrderStatus ? 'order-filter active' : 'order-filter'}}"
data-status="{{item.value}}"
bindtap="selectOrderFilter"
>{{item.label}}</button>
</view>
</scroll-view>
<view wx:if="{{!loading && visibleOrders.length === 0}}" class="empty">当前门店暂无订单</view>
<view wx:for="{{visibleOrders}}" wx:key="id" class="order-card" data-order-id="{{item.id}}" bindtap="openOrder">
<view wx:for="{{visibleOrders}}" wx:key="id" class="order-card">
<view class="card-main">
<view>
<view class="card-title">{{item.roomName}} · {{item.roomNo}}</view>
@@ -59,6 +74,10 @@
<view class="status-pill">{{item.statusText}}</view>
</view>
<view class="order-line"><text>{{item.timeText}}</text><strong>{{item.amountText}}</strong></view>
<view wx:if="{{canWrite}}" class="card-actions">
<button size="mini" loading="{{busyOrderId === item.id}}" data-order-id="{{item.id}}" data-status="{{item.status}}" bindtap="manageOrder">订单处置</button>
<button size="mini" data-order-id="{{item.id}}" bindtap="addOrderNote">添加备注</button>
</view>
</view>
</view>
</scroll-view>
+34
View File
@@ -81,6 +81,40 @@
margin: 28rpx 0 16rpx;
}
.section-heading {
align-items: center;
display: flex;
justify-content: space-between;
}
.section-heading .section-title {
margin-right: 20rpx;
}
.section-heading button {
margin: 20rpx 0 8rpx;
}
.order-filters {
margin-bottom: 16rpx;
white-space: nowrap;
width: 100%;
}
.order-filters-inner {
display: inline-flex;
gap: 12rpx;
}
.order-filter {
margin: 0;
}
.order-filter.active {
background: #0f172a;
color: #fff;
}
.room-card,
.order-card {
background: #fff;
+124
View File
@@ -0,0 +1,124 @@
const { request, ensureLogin, cents } = require('../../utils/api.js')
Page({
data: {
loading: false,
submitting: false,
errorMessage: '',
storeId: '',
storeName: '',
rooms: [],
roomIndex: 0,
userId: '',
startDate: '',
startTime: '',
endDate: '',
endTime: '',
pricingModes: [
{ value: 'HOURLY', label: '小时' },
{ value: 'OVERNIGHT', label: '过夜' },
{ value: 'FULL_DAY', label: '全天' },
],
pricingModeIndex: 0,
},
async onLoad(options) {
const start = new Date(Date.now() + 60 * 60 * 1000)
start.setMinutes(0, 0, 0)
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000)
this.setData({
storeId: options.storeId || '',
storeName: options.storeName ? decodeURIComponent(options.storeName) : '',
startDate: this.datePart(start),
startTime: this.timePart(start),
endDate: this.datePart(end),
endTime: this.timePart(end),
})
await this.loadRooms()
},
async loadRooms() {
if (!this.data.storeId) {
this.setData({ errorMessage: '缺少授权门店信息' })
return
}
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const response = await request(`/management/stores/${encodeURIComponent(this.data.storeId)}/rooms`)
const rooms = (response.data || [])
.filter((room) => room.configurationStatus === 'ENABLED')
.map((room) => ({
...room,
displayName: `${room.name} · ${room.roomNo} · ${cents(room.basePriceCents)}`,
}))
this.setData({ rooms, roomIndex: 0 })
} catch (error) {
this.setData({ errorMessage: error.message || '房间加载失败' })
} finally {
this.setData({ loading: false })
}
},
inputUserId(event) { this.setData({ userId: String(event.detail.value || '').trim() }) },
selectRoom(event) { this.setData({ roomIndex: Number(event.detail.value) }) },
selectPricingMode(event) { this.setData({ pricingModeIndex: Number(event.detail.value) }) },
changeStartDate(event) { this.setData({ startDate: event.detail.value }) },
changeStartTime(event) { this.setData({ startTime: event.detail.value }) },
changeEndDate(event) { this.setData({ endDate: event.detail.value }) },
changeEndTime(event) { this.setData({ endTime: event.detail.value }) },
async submit() {
if (this.data.submitting) return
const room = this.data.rooms[this.data.roomIndex]
const pricingMode = this.data.pricingModes[this.data.pricingModeIndex]?.value
const userId = this.data.userId
if (!/^[1-9]\d{0,19}$/.test(userId)) {
this.setData({ errorMessage: '请输入有效的会员用户 ID' })
return
}
if (!room) {
this.setData({ errorMessage: '请选择可用房间' })
return
}
const startAt = new Date(`${this.data.startDate}T${this.data.startTime}:00`)
const endAt = new Date(`${this.data.endDate}T${this.data.endTime}:00`)
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || endAt <= startAt) {
this.setData({ errorMessage: '结束时间必须晚于开始时间' })
return
}
this.setData({ submitting: true, errorMessage: '' })
try {
const response = await request('/management/orders/reserve-on-behalf', {
method: 'POST',
data: {
userId,
roomId: room.id,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
pricingMode,
},
})
wx.showModal({
title: '代下单成功',
content: `订单 ${response.data?.orderNo || response.data?.orderId || ''} 已创建,价格由服务端计算。`,
showCancel: false,
success: () => wx.navigateBack(),
})
} catch (error) {
this.setData({ errorMessage: error.message || '代下单失败' })
} finally {
this.setData({ submitting: false })
}
},
datePart(value) {
const pad = (part) => String(part).padStart(2, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
},
timePart(value) {
const pad = (part) => String(part).padStart(2, '0')
return `${pad(value.getHours())}:${pad(value.getMinutes())}`
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "管理员代下单",
"enablePullDownRefresh": false
}
+41
View File
@@ -0,0 +1,41 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="container order-create">
<view class="title">管理员代下单</view>
<view class="subtitle">{{storeName}}</view>
<view class="notice">订单金额由服务端按房间、时段和计价方式重新计算;仅可选择当前账号授权门店的房间。</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<view wx:if="{{loading}}" class="loading">正在加载房间...</view>
<view class="field">
<text class="label">会员用户 ID</text>
<input type="number" value="{{userId}}" placeholder="输入会员用户 ID" bindinput="inputUserId" />
</view>
<view class="field">
<text class="label">房间</text>
<picker range="{{rooms}}" range-key="displayName" value="{{roomIndex}}" bindchange="selectRoom">
<view class="picker-value">{{rooms.length ? rooms[roomIndex].displayName : '暂无可用房间'}}</view>
</picker>
</view>
<view class="field">
<text class="label">计价方式</text>
<picker range="{{pricingModes}}" range-key="label" value="{{pricingModeIndex}}" bindchange="selectPricingMode">
<view class="picker-value">{{pricingModes[pricingModeIndex].label}}</view>
</picker>
</view>
<view class="field">
<text class="label">开始时间</text>
<view class="datetime-row">
<picker mode="date" value="{{startDate}}" bindchange="changeStartDate"><view class="picker-value">{{startDate}}</view></picker>
<picker mode="time" value="{{startTime}}" bindchange="changeStartTime"><view class="picker-value">{{startTime}}</view></picker>
</view>
</view>
<view class="field">
<text class="label">结束时间</text>
<view class="datetime-row">
<picker mode="date" value="{{endDate}}" bindchange="changeEndDate"><view class="picker-value">{{endDate}}</view></picker>
<picker mode="time" value="{{endTime}}" bindchange="changeEndTime"><view class="picker-value">{{endTime}}</view></picker>
</view>
</view>
<button type="primary" loading="{{submitting}}" disabled="{{loading || submitting || !rooms.length}}" bindtap="submit">确认代下单</button>
</view>
</scroll-view>
+51
View File
@@ -0,0 +1,51 @@
.order-create {
padding-bottom: 48rpx;
}
.subtitle {
color: #64748b;
margin: 8rpx 0 24rpx;
}
.notice {
background: #eff6ff;
border: 1rpx solid #bfdbfe;
border-radius: 16rpx;
color: #1e40af;
font-size: 24rpx;
line-height: 1.6;
margin-bottom: 24rpx;
padding: 20rpx;
}
.field {
margin-bottom: 24rpx;
}
.label {
color: #334155;
display: block;
font-size: 25rpx;
margin-bottom: 10rpx;
}
.field input,
.picker-value {
background: #fff;
border: 1rpx solid #cbd5e1;
border-radius: 14rpx;
min-height: 48rpx;
padding: 18rpx;
}
.datetime-row {
display: grid;
gap: 14rpx;
grid-template-columns: 1fr 1fr;
}
.loading {
color: #64748b;
padding: 20rpx 0;
text-align: center;
}