feat(M08-C): 补保洁与设备运营
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"pages/recharge/index",
|
||||
"pages/cleaner/tasks",
|
||||
"pages/manager/dashboard",
|
||||
"pages/manager/operations",
|
||||
"pages/manager/people",
|
||||
"pages/manager/order-create",
|
||||
"pages/logs/logs"
|
||||
|
||||
@@ -38,6 +38,7 @@ Page({
|
||||
errorMessage: '',
|
||||
canWrite: false,
|
||||
canReadUsers: false,
|
||||
canReadOperations: false,
|
||||
stores: [],
|
||||
selectedStoreId: '',
|
||||
selectedStoreName: '',
|
||||
@@ -98,6 +99,9 @@ Page({
|
||||
canReadUsers: access.capabilities.includes('user.read')
|
||||
|| access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN'),
|
||||
canReadOperations: access.capabilities.some((code) => [
|
||||
'cleaning.task.read', 'device.read', 'device.write', 'tenant.manage',
|
||||
].includes(code)) || access.roles.includes('PLATFORM_ADMIN'),
|
||||
stores,
|
||||
selectedStoreId,
|
||||
selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '',
|
||||
@@ -225,6 +229,13 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/manager/people' })
|
||||
},
|
||||
|
||||
openOperations() {
|
||||
if (!this.data.canReadOperations || !this.data.selectedStoreId) return
|
||||
wx.navigateTo({
|
||||
url: `/pages/manager/operations?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`,
|
||||
})
|
||||
},
|
||||
|
||||
manageOrder(event) {
|
||||
if (!this.data.canWrite) return
|
||||
const orderId = event.currentTarget.dataset.orderId
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
<button size="mini" bindtap="openPeople">进入</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{canReadOperations && selectedStoreId}}" class="management-tools">
|
||||
<view>
|
||||
<view class="card-title">保洁与设备</view>
|
||||
<view class="card-meta">任务验收、设备状态与临时开门/电控</view>
|
||||
</view>
|
||||
<button size="mini" bindtap="openOperations">进入</button>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
const { request, cents, ensureLogin } = require('../../utils/api.js')
|
||||
|
||||
const cleaningStatusLabels = {
|
||||
WAITING: '待接单',
|
||||
CLAIMED: '已接单',
|
||||
STARTED: '清洁中',
|
||||
SUBMITTED: '待验收',
|
||||
COMPLETED: '已完成',
|
||||
REJECTED: '已驳回',
|
||||
EXEMPT: '免清洁',
|
||||
SETTLED: '已结算',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
const deviceStatusLabels = {
|
||||
ONLINE: '在线',
|
||||
OFFLINE: '离线',
|
||||
FAULT: '故障',
|
||||
}
|
||||
|
||||
const deviceTypeLabels = {
|
||||
CONTROL_BOX: '控制箱',
|
||||
SUB_LOCK: '子锁',
|
||||
SMART_SOCKET: '智能插座',
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
storeId: '',
|
||||
storeName: '',
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
canReadCleaning: false,
|
||||
canWriteCleaning: false,
|
||||
canReadDevices: false,
|
||||
canWriteDevices: false,
|
||||
cleaningSummary: {
|
||||
taskTotal: 0,
|
||||
pendingReview: 0,
|
||||
active: 0,
|
||||
rejected: 0,
|
||||
completed: 0,
|
||||
},
|
||||
cleaningTasks: [],
|
||||
devices: [],
|
||||
openAlerts: [],
|
||||
maintenance: [],
|
||||
busyTaskId: '',
|
||||
busyDeviceKey: '',
|
||||
},
|
||||
|
||||
async onLoad(options) {
|
||||
this.setData({
|
||||
storeId: options.storeId || '',
|
||||
storeName: options.storeName || '',
|
||||
})
|
||||
await this.loadOperations()
|
||||
},
|
||||
|
||||
async onPullDownRefresh() {
|
||||
await this.loadOperations()
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
|
||||
async loadOperations() {
|
||||
if (!this.data.storeId) {
|
||||
this.setData({ errorMessage: '缺少门店信息' })
|
||||
return
|
||||
}
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const me = await request('/auth/me')
|
||||
const access = me.data?.access || { roles: [], capabilities: [] }
|
||||
const isPlatform = access.roles.includes('PLATFORM_ADMIN')
|
||||
const canManageTenant = access.capabilities.includes('tenant.manage') || isPlatform
|
||||
const canReadCleaning = access.capabilities.includes('cleaning.task.read') || canManageTenant
|
||||
const canWriteCleaning = access.capabilities.includes('cleaning.task.write') || canManageTenant
|
||||
const canReadDevices = access.capabilities.some((code) => ['device.read', 'device.write'].includes(code)) || canManageTenant
|
||||
const canWriteDevices = access.capabilities.includes('device.write') || canManageTenant
|
||||
this.setData({ canReadCleaning, canWriteCleaning, canReadDevices, canWriteDevices })
|
||||
await Promise.all([
|
||||
canReadCleaning ? this.loadCleaning() : Promise.resolve(),
|
||||
canReadDevices ? this.loadDevices() : Promise.resolve(),
|
||||
])
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '运营数据加载失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
async loadCleaning() {
|
||||
const storeId = encodeURIComponent(this.data.storeId)
|
||||
const [tasksResponse, statsResponse] = await Promise.all([
|
||||
request(`/management/cleaning/tasks?page=1&pageSize=50&storeId=${storeId}`),
|
||||
request(`/management/cleaning/statistics?storeId=${storeId}`),
|
||||
])
|
||||
const cleaningTasks = (tasksResponse.data?.items || []).map((item) => ({
|
||||
...item,
|
||||
statusText: cleaningStatusLabels[item.status] || item.status,
|
||||
rewardText: cents(item.rewardCents),
|
||||
roomText: item.roomName ? `${item.roomName} · ${item.roomNo || ''}` : `房间 ${item.roomId}`,
|
||||
canReview: item.status === 'SUBMITTED',
|
||||
canExempt: ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(item.status),
|
||||
}))
|
||||
this.setData({
|
||||
cleaningTasks,
|
||||
cleaningSummary: statsResponse.data?.summary || this.data.cleaningSummary,
|
||||
})
|
||||
},
|
||||
|
||||
async loadDevices() {
|
||||
const storeId = encodeURIComponent(this.data.storeId)
|
||||
const response = await request(`/management/device-topology?storeId=${storeId}`)
|
||||
const topology = response.data || {}
|
||||
const devices = (topology.assets || []).map((item) => ({
|
||||
...item,
|
||||
typeText: deviceTypeLabels[item.deviceType] || item.deviceType,
|
||||
statusText: deviceStatusLabels[item.status] || item.status,
|
||||
roomText: item.roomId ? `房间 ${item.roomId}` : '门店公共设备',
|
||||
lastSeenText: this.formatTime(item.lastSeenAt || item.lastHeartbeatAt),
|
||||
}))
|
||||
this.setData({
|
||||
devices,
|
||||
openAlerts: topology.openAlerts || [],
|
||||
maintenance: (topology.maintenance || []).filter((item) => item.status === 'OPEN'),
|
||||
})
|
||||
},
|
||||
|
||||
reviewTask(event) {
|
||||
if (!this.data.canWriteCleaning) return
|
||||
const taskId = event.currentTarget.dataset.taskId
|
||||
const action = event.currentTarget.dataset.action
|
||||
if (action === 'reject') {
|
||||
wx.showModal({
|
||||
title: '驳回保洁任务',
|
||||
editable: true,
|
||||
placeholderText: '请输入补做原因',
|
||||
success: ({ confirm, content }) => {
|
||||
const reason = String(content || '').trim()
|
||||
if (confirm && reason) this.submitCleaningAction(taskId, 'reject', { reason }, '任务已驳回')
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
const labels = action === 'complete'
|
||||
? { title: '验收通过', content: '确认照片与现场清洁结果已达标吗?', toast: '验收已完成' }
|
||||
: { title: '设为免清洁', content: '确认该房间本次无需清洁吗?', toast: '已设为免清洁' }
|
||||
wx.showModal({
|
||||
title: labels.title,
|
||||
content: labels.content,
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) this.submitCleaningAction(taskId, action, { note: `管理员小程序:${labels.title}` }, labels.toast)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async submitCleaningAction(taskId, action, data, toast) {
|
||||
if (!taskId || this.data.busyTaskId) return
|
||||
this.setData({ busyTaskId: taskId, errorMessage: '' })
|
||||
try {
|
||||
await request(`/management/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, {
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
await this.loadCleaning()
|
||||
wx.showToast({ title: toast, icon: 'success' })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '保洁任务处理失败' })
|
||||
} finally {
|
||||
this.setData({ busyTaskId: '' })
|
||||
}
|
||||
},
|
||||
|
||||
controlDevice(event) {
|
||||
if (!this.data.canWriteDevices) return
|
||||
const { roomId, action } = event.currentTarget.dataset
|
||||
if (!roomId || !action) return
|
||||
const actions = {
|
||||
'door-open': {
|
||||
title: '临时开门',
|
||||
endpoint: '/management/device-control/door',
|
||||
data: { order: 'open', holdopen: 0, delayTime: 4 },
|
||||
},
|
||||
'power-on': {
|
||||
title: '全屋通电',
|
||||
endpoint: '/management/device-control/power',
|
||||
data: { slotall: 'on' },
|
||||
},
|
||||
'power-off': {
|
||||
title: '全屋断电',
|
||||
endpoint: '/management/device-control/power',
|
||||
data: { slotall: 'off' },
|
||||
},
|
||||
'socket-on': {
|
||||
title: '插座通电',
|
||||
endpoint: '/management/device-control/socket/switch',
|
||||
data: { on: true, slotNum: 1 },
|
||||
},
|
||||
'socket-off': {
|
||||
title: '插座断电',
|
||||
endpoint: '/management/device-control/socket/switch',
|
||||
data: { on: false, slotNum: 1 },
|
||||
},
|
||||
}
|
||||
const selected = actions[action]
|
||||
if (!selected) return
|
||||
wx.showModal({
|
||||
title: selected.title,
|
||||
content: `确认对房间 ${roomId} 执行“${selected.title}”吗?`,
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) this.submitDeviceControl(roomId, action, selected)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async submitDeviceControl(roomId, action, selected) {
|
||||
const busyDeviceKey = `${roomId}:${action}`
|
||||
if (this.data.busyDeviceKey) return
|
||||
this.setData({ busyDeviceKey, errorMessage: '' })
|
||||
try {
|
||||
await request(selected.endpoint, {
|
||||
method: 'POST',
|
||||
data: { storeId: this.data.storeId, roomId, ...selected.data },
|
||||
})
|
||||
wx.showToast({ title: '指令已发送', icon: 'success' })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '设备控制失败' })
|
||||
} finally {
|
||||
this.setData({ busyDeviceKey: '' })
|
||||
}
|
||||
},
|
||||
|
||||
formatTime(value) {
|
||||
if (!value) return '暂无心跳'
|
||||
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,71 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container operations-page">
|
||||
<view class="page-heading">
|
||||
<view>
|
||||
<view class="title">保洁与设备</view>
|
||||
<view class="subtitle">{{storeName || '当前门店'}}</view>
|
||||
</view>
|
||||
<view class="permission-tag">{{canWriteCleaning || canWriteDevices ? '可操作' : '只读'}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
<view wx:if="{{loading}}" class="loading">正在加载运营数据...</view>
|
||||
|
||||
<block wx:if="{{canReadCleaning}}">
|
||||
<view class="section-title">保洁任务</view>
|
||||
<view class="summary-grid">
|
||||
<view class="summary-card"><strong>{{cleaningSummary.taskTotal}}</strong><text>任务</text></view>
|
||||
<view class="summary-card warning"><strong>{{cleaningSummary.pendingReview}}</strong><text>待验收</text></view>
|
||||
<view class="summary-card primary"><strong>{{cleaningSummary.active}}</strong><text>处理中</text></view>
|
||||
<view class="summary-card danger"><strong>{{cleaningSummary.rejected}}</strong><text>已驳回</text></view>
|
||||
<view class="summary-card success"><strong>{{cleaningSummary.completed}}</strong><text>已完成</text></view>
|
||||
</view>
|
||||
<view wx:if="{{!loading && cleaningTasks.length === 0}}" class="empty">当前门店暂无保洁任务</view>
|
||||
<view wx:for="{{cleaningTasks}}" wx:key="id" class="operation-card">
|
||||
<view class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.roomText}}</view>
|
||||
<view class="card-meta">{{item.taskNo}} · {{item.rewardText}}</view>
|
||||
</view>
|
||||
<view class="status-pill status-{{item.status}}">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view wx:if="{{item.requirement}}" class="detail-line">要求:{{item.requirement}}</view>
|
||||
<view wx:if="{{item.rejectReason}}" class="reject-reason">驳回:{{item.rejectReason}}</view>
|
||||
<view wx:if="{{canWriteCleaning && (item.canReview || item.canExempt)}}" class="card-actions">
|
||||
<button wx:if="{{item.canReview}}" size="mini" type="primary" loading="{{busyTaskId === item.id}}" data-task-id="{{item.id}}" data-action="complete" bindtap="reviewTask">验收通过</button>
|
||||
<button wx:if="{{item.canReview}}" size="mini" data-task-id="{{item.id}}" data-action="reject" bindtap="reviewTask">驳回补做</button>
|
||||
<button wx:if="{{item.canExempt}}" size="mini" data-task-id="{{item.id}}" data-action="exempt" bindtap="reviewTask">免清洁</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:if="{{canReadDevices}}">
|
||||
<view class="section-heading">
|
||||
<view class="section-title">设备状态</view>
|
||||
<view class="section-count">告警 {{openAlerts.length}} · 维护 {{maintenance.length}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!loading && devices.length === 0}}" class="empty">当前门店暂无设备</view>
|
||||
<view wx:for="{{devices}}" wx:key="id" class="operation-card">
|
||||
<view class="card-main">
|
||||
<view>
|
||||
<view class="card-title">{{item.typeText}} · {{item.deviceId}}</view>
|
||||
<view class="card-meta">{{item.roomText}} / {{item.model}}</view>
|
||||
</view>
|
||||
<view class="status-pill device-{{item.status}}">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="detail-line">最近心跳:{{item.lastSeenText}}</view>
|
||||
<view wx:if="{{canWriteDevices && item.roomId && item.deviceType === 'CONTROL_BOX'}}" class="card-actions controls">
|
||||
<button size="mini" loading="{{busyDeviceKey === item.roomId + ':door-open'}}" data-room-id="{{item.roomId}}" data-action="door-open" bindtap="controlDevice">临时开门</button>
|
||||
<button size="mini" data-room-id="{{item.roomId}}" data-action="power-on" bindtap="controlDevice">全屋通电</button>
|
||||
<button size="mini" type="warn" data-room-id="{{item.roomId}}" data-action="power-off" bindtap="controlDevice">全屋断电</button>
|
||||
</view>
|
||||
<view wx:if="{{canWriteDevices && item.roomId && item.deviceType === 'SMART_SOCKET'}}" class="card-actions controls">
|
||||
<button size="mini" data-room-id="{{item.roomId}}" data-action="socket-on" bindtap="controlDevice">插座通电</button>
|
||||
<button size="mini" type="warn" data-room-id="{{item.roomId}}" data-action="socket-off" bindtap="controlDevice">插座断电</button>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view wx:if="{{!loading && !canReadCleaning && !canReadDevices}}" class="empty">当前账号没有保洁或设备查看权限</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,121 @@
|
||||
.operations-page {
|
||||
padding-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.card-main,
|
||||
.section-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.card-meta,
|
||||
.detail-line,
|
||||
.section-count {
|
||||
color: #64748b;
|
||||
font-size: 23rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.permission-tag,
|
||||
.status-pill {
|
||||
background: #eef2ff;
|
||||
border-radius: 999rpx;
|
||||
color: #3730a3;
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin: 30rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 14rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 14rpx 10rpx;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.summary-card text {
|
||||
color: #64748b;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.summary-card.warning strong { color: #c2410c; }
|
||||
.summary-card.primary strong { color: #2563eb; }
|
||||
.summary-card.danger strong { color: #b91c1c; }
|
||||
.summary-card.success strong { color: #15803d; }
|
||||
|
||||
.operation-card {
|
||||
background: #fff;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 18rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-line,
|
||||
.reject-reason {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.reject-reason {
|
||||
color: #b91c1c;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-SUBMITTED { background: #fef3c7; color: #92400e; }
|
||||
.status-COMPLETED,
|
||||
.status-SETTLED,
|
||||
.device-ONLINE { background: #dcfce7; color: #166534; }
|
||||
.status-REJECTED,
|
||||
.device-FAULT { background: #fee2e2; color: #991b1b; }
|
||||
.status-EXEMPT { background: #f1f5f9; color: #475569; }
|
||||
.device-OFFLINE { background: #e2e8f0; color: #334155; }
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: #64748b;
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
Reference in New Issue
Block a user