feat(M08-C): 建立管理员房态运营入口
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
const { request, ensureLogin, cents } = require('../../utils/api.js')
|
||||
|
||||
const managerRoles = ['STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN']
|
||||
const roomStatusLabels = {
|
||||
AVAILABLE: '空闲',
|
||||
MAINTENANCE: '维护中',
|
||||
RESERVED: '已预订',
|
||||
IN_USE: '使用中',
|
||||
CLEANING_REQUIRED: '待清洁',
|
||||
}
|
||||
const orderStatusLabels = {
|
||||
DRAFT: '草稿',
|
||||
PENDING_PAYMENT: '待支付',
|
||||
PAID: '已支付',
|
||||
RESERVED: '待开始',
|
||||
IN_PROGRESS: '进行中',
|
||||
FINISHED: '已结束',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
CLOSED: '已关闭',
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
canWrite: false,
|
||||
stores: [],
|
||||
selectedStoreId: '',
|
||||
selectedStoreName: '',
|
||||
rooms: [],
|
||||
orders: [],
|
||||
visibleOrders: [],
|
||||
busyRoomId: '',
|
||||
summary: {
|
||||
roomTotal: 0,
|
||||
available: 0,
|
||||
inUse: 0,
|
||||
attention: 0,
|
||||
activeOrders: 0,
|
||||
},
|
||||
},
|
||||
|
||||
async onLoad() {
|
||||
await this.loadDashboard()
|
||||
},
|
||||
|
||||
async onPullDownRefresh() {
|
||||
await this.loadDashboard(this.data.selectedStoreId)
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
|
||||
async loadDashboard(preferredStoreId = '') {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const me = await request('/auth/me')
|
||||
const access = me.data?.access || { roles: [], capabilities: [], storeIds: [] }
|
||||
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 stores = storesResponse.data || []
|
||||
const selectedStoreId = stores.some((store) => store.id === preferredStoreId)
|
||||
? preferredStoreId
|
||||
: (stores[0]?.id || '')
|
||||
this.setData({
|
||||
canWrite: access.capabilities.includes('store.operation.write')
|
||||
|| 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)
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '门店运营数据加载失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
async selectStore(event) {
|
||||
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)
|
||||
},
|
||||
|
||||
async loadRooms(storeId) {
|
||||
if (!storeId) {
|
||||
this.setData({ rooms: [], visibleOrders: [] })
|
||||
this.refreshSummary()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await request(`/management/stores/${encodeURIComponent(storeId)}/rooms`)
|
||||
const rooms = (response.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),
|
||||
})
|
||||
this.refreshSummary()
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '房态加载失败' })
|
||||
}
|
||||
},
|
||||
|
||||
refreshSummary() {
|
||||
const rooms = this.data.rooms
|
||||
const activeStatuses = ['PAID', 'RESERVED', 'IN_PROGRESS']
|
||||
this.setData({
|
||||
summary: {
|
||||
roomTotal: rooms.length,
|
||||
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,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
changeRoomStatus(event) {
|
||||
if (!this.data.canWrite) return
|
||||
const roomId = event.currentTarget.dataset.roomId
|
||||
wx.showActionSheet({
|
||||
itemList: ['设为空闲', '设为维护中', '设为待清洁'],
|
||||
success: ({ tapIndex }) => {
|
||||
const statuses = ['AVAILABLE', 'MAINTENANCE', 'CLEANING_REQUIRED']
|
||||
this.updateRoomStatus(roomId, { operationalStatus: statuses[tapIndex] }, '管理员小程序调整房态')
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
toggleRoomConfiguration(event) {
|
||||
if (!this.data.canWrite) return
|
||||
const roomId = event.currentTarget.dataset.roomId
|
||||
const current = event.currentTarget.dataset.status
|
||||
const configurationStatus = current === 'ENABLED' ? 'DISABLED' : 'ENABLED'
|
||||
this.updateRoomStatus(roomId, { configurationStatus }, configurationStatus === 'ENABLED' ? '管理员小程序启用房间' : '管理员小程序停用房间')
|
||||
},
|
||||
|
||||
async updateRoomStatus(roomId, status, reason) {
|
||||
if (!roomId || this.data.busyRoomId) return
|
||||
this.setData({ busyRoomId: roomId, errorMessage: '' })
|
||||
try {
|
||||
await request(`/management/rooms/${encodeURIComponent(roomId)}/status`, {
|
||||
method: 'PATCH',
|
||||
data: { storeId: this.data.selectedStoreId, ...status, reason },
|
||||
})
|
||||
await this.loadRooms(this.data.selectedStoreId)
|
||||
wx.showToast({ title: '房态已更新', icon: 'success' })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '房态更新失败' })
|
||||
} finally {
|
||||
this.setData({ busyRoomId: '' })
|
||||
}
|
||||
},
|
||||
|
||||
openOrder(event) {
|
||||
const orderId = event.currentTarget.dataset.orderId
|
||||
if (!orderId) return
|
||||
wx.navigateTo({ url: `/pages/orders/detail?orderId=${encodeURIComponent(orderId)}` })
|
||||
},
|
||||
|
||||
presentOrder(item) {
|
||||
return {
|
||||
...item,
|
||||
statusText: orderStatusLabels[item.status] || item.status,
|
||||
amountText: cents(item.paidAmountCents || item.totalAmountCents),
|
||||
timeText: `${this.formatTime(item.startAt)} - ${this.formatTime(item.endAt)}`,
|
||||
}
|
||||
},
|
||||
|
||||
formatTime(value) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value || '')
|
||||
const pad = (part) => String(part).padStart(2, '0')
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "门店运营",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container manager-dashboard">
|
||||
<view class="manager-header">
|
||||
<view>
|
||||
<view class="title">门店运营</view>
|
||||
<view class="subtitle">{{selectedStoreName || '请选择授权门店'}}</view>
|
||||
</view>
|
||||
<view class="permission-tag">{{canWrite ? '可操作' : '只读'}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
<view wx:if="{{loading}}" class="loading">正在加载运营数据...</view>
|
||||
|
||||
<scroll-view wx:if="{{stores.length}}" class="store-tabs" scroll-x enhanced show-scrollbar="false">
|
||||
<view class="store-tabs-inner">
|
||||
<button
|
||||
wx:for="{{stores}}"
|
||||
wx:key="id"
|
||||
size="mini"
|
||||
class="{{item.id === selectedStoreId ? 'store-tab active' : 'store-tab'}}"
|
||||
data-store-id="{{item.id}}"
|
||||
bindtap="selectStore"
|
||||
>{{item.name}}</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="summary-grid">
|
||||
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
|
||||
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
|
||||
<view class="summary-card primary"><strong>{{summary.inUse}}</strong><text>占用</text></view>
|
||||
<view class="summary-card warning"><strong>{{summary.attention}}</strong><text>待处理</text></view>
|
||||
<view class="summary-card"><strong>{{summary.activeOrders}}</strong><text>活跃订单</text></view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">实时房态</view>
|
||||
<view wx:if="{{!loading && rooms.length === 0}}" class="empty">当前门店暂无房间</view>
|
||||
<view wx:for="{{rooms}}" wx:key="id" class="room-card {{item.configurationStatus === 'DISABLED' ? 'disabled' : ''}}">
|
||||
<view class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.name}} · {{item.roomNo}}</view>
|
||||
<view class="card-meta">{{item.categoryName}} / {{item.capacity}} 人 / {{item.priceText}} 起</view>
|
||||
</view>
|
||||
<view class="status-pill status-{{item.operationalStatus}}">{{item.configurationStatus === 'DISABLED' ? '已停用' : item.statusText}}</view>
|
||||
</view>
|
||||
<view wx:if="{{canWrite}}" class="card-actions">
|
||||
<button size="mini" loading="{{busyRoomId === item.id}}" data-room-id="{{item.id}}" bindtap="changeRoomStatus">调整房态</button>
|
||||
<button size="mini" data-room-id="{{item.id}}" data-status="{{item.configurationStatus}}" bindtap="toggleRoomConfiguration">{{item.configurationStatus === 'ENABLED' ? '停用' : '启用'}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">近期订单</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 class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.roomName}} · {{item.roomNo}}</view>
|
||||
<view class="card-meta">{{item.orderNo}}</view>
|
||||
</view>
|
||||
<view class="status-pill">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="order-line"><text>{{item.timeText}}</text><strong>{{item.amountText}}</strong></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,134 @@
|
||||
.manager-dashboard {
|
||||
padding-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.manager-header,
|
||||
.card-main,
|
||||
.order-line {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.card-meta {
|
||||
color: #6b7280;
|
||||
font-size: 24rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.permission-tag,
|
||||
.status-pill {
|
||||
background: #eef2ff;
|
||||
border-radius: 999rpx;
|
||||
color: #3730a3;
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.store-tabs {
|
||||
margin: 24rpx 0;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-tabs-inner {
|
||||
display: inline-flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.store-tab {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-tab.active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18rpx;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.summary-card text {
|
||||
color: #64748b;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.summary-card.success strong { color: #15803d; }
|
||||
.summary-card.primary strong { color: #2563eb; }
|
||||
.summary-card.warning strong { color: #c2410c; }
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin: 28rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.room-card,
|
||||
.order-card {
|
||||
background: #fff;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 18rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.room-card.disabled {
|
||||
opacity: .64;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-AVAILABLE { background: #dcfce7; color: #166534; }
|
||||
.status-MAINTENANCE { background: #ffedd5; color: #9a3412; }
|
||||
.status-RESERVED,
|
||||
.status-IN_USE { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-CLEANING_REQUIRED { background: #fef3c7; color: #92400e; }
|
||||
|
||||
.order-line {
|
||||
color: #64748b;
|
||||
font-size: 23rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.order-line strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: #64748b;
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
Reference in New Issue
Block a user