skyline 2 өдөр өмнө
parent
commit
7cbd749547

+ 19 - 1
admin-web/src/views/admin/station/device/index.vue

@@ -69,6 +69,7 @@
   </div>
   <DeviceDialog ref="device_dialog_ref" @refresh="loadData(true)"/>
   <DeviceConfigDialog ref="device_config_dialog_ref" @refresh="loadData(true)"/>
+  <DeviceRemoteDialog ref="device_remote_dialog_ref" @refresh="loadData(true)"/>
 </template>
 
 <script setup lang="ts" name="adminStationDevice">
@@ -90,12 +91,14 @@ import {ElButton} from 'element-plus'
 
 import DeviceDialog from '/@/views/admin/station/device/dialog.vue'
 import DeviceConfigDialog from '/@/views/admin/station/device/config.vue'
+import DeviceRemoteDialog from '/@/views/admin/station/device/remote.vue'
 
 
 //定义引用
 const queryRef = ref();
 const device_dialog_ref = ref();
 const device_config_dialog_ref = ref();
+const device_remote_dialog_ref = ref();
 
 //定义变量
 const state = reactive({
@@ -161,8 +164,9 @@ const state = reactive({
     {label: '创建时间', prop: 'createTime', query: false, sortable: 'custom', type: 'datetime', resizable: true, conf: {format: (val: any) => u.fmt.fmtDateTime(val)}},
     {label: '更新时间', prop: 'updateTime', query: false, sortable: 'custom', type: 'datetime', resizable: true, conf: {format: (val: any) => u.fmt.fmtDateTime(val)}},
     {
-      label: '操作', prop: 'action', type: 'render', width: 180, align: 'center', fixed: 'right',
+      label: '操作', prop: 'action', type: 'render', width: 260, align: 'center', fixed: 'right',
       render: (h: any, row: any,rowData:any) => {
+        const canRemote = !['sleep', 'maintenance', 'fault'].includes(rowData.state);
         return (
             h('div', null, [
               proxy.$auth('washDevice.modify') ?
@@ -174,6 +178,16 @@ const state = reactive({
                       handleRowClick('edit', rowData)
                     }
                   }, () => '编辑') : '',
+              proxy.$auth('washDevice.list') ?
+                  h(ElButton, {
+                    type: 'success',
+                    text: true,
+                    size: 'small',
+                    disabled: !canRemote,
+                    onClick: () => {
+                      handleRemoteClick(rowData)
+                    }
+                  }, () => '远程控制') : '',
               proxy.$auth('washDevice.modify') ?
                   h(ElButton, {
                     type: 'primary',
@@ -286,6 +300,10 @@ const handleBatchDeviceConfig = () => {
   device_config_dialog_ref.value.open(state.chooseDeviceList);
 }
 
+const handleRemoteClick = (row: any) => {
+  device_remote_dialog_ref.value.open(row);
+};
+
 const handleChooseDeviceChange = (deviceList) => {
   console.log(deviceList)
   state.chooseDeviceList = deviceList.map(k=>k.id);

+ 599 - 0
admin-web/src/views/admin/station/device/remote.vue

@@ -0,0 +1,599 @@
+<style scoped lang="scss">
+.remote-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.device-status-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  background: #1a1a2e;
+  border-radius: 8px;
+  color: #00ff88;
+  font-family: 'Courier New', monospace;
+  font-size: 14px;
+
+  .status-left {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+  }
+
+  .status-dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+    display: inline-block;
+    margin-right: 6px;
+
+    &.busy { background: #ff4444; animation: blink 1s infinite; }
+    &.idle { background: #00ff88; }
+    &.init { background: #ffaa00; }
+    &.fault { background: #ff0000; }
+    &.maintenance { background: #ff8800; }
+    &.sleep { background: #888888; }
+  }
+
+  @keyframes blink {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.3; }
+  }
+
+  .status-label {
+    color: #aaa;
+    font-size: 12px;
+  }
+
+  .status-value {
+    color: #fff;
+    font-weight: bold;
+  }
+}
+
+.func-button-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px;
+
+  .func-btn {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 16px 8px;
+    border: 1px solid #ddd;
+    border-radius: 10px;
+    cursor: pointer;
+    transition: all 0.2s;
+    background: #f8f9fa;
+    user-select: none;
+
+    &:hover {
+      background: #e8f4fd;
+      border-color: #409eff;
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
+    }
+
+    &:active {
+      transform: translateY(0);
+      background: #d9ecff;
+    }
+
+    .func-icon {
+      font-size: 24px;
+      margin-bottom: 6px;
+    }
+
+    .func-name {
+      font-size: 13px;
+      font-weight: 600;
+      color: #333;
+      margin-bottom: 4px;
+    }
+
+    .func-price {
+      font-size: 12px;
+      color: #f56c6c;
+      font-weight: 500;
+    }
+  }
+}
+
+.system-actions {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+  padding: 12px 0;
+  border-top: 1px solid #ebeef5;
+  border-bottom: 1px solid #ebeef5;
+
+  .action-btn {
+    flex: 1;
+    min-width: 100px;
+  }
+}
+
+.order-info-card {
+  background: #fffbf0;
+  border: 1px solid #f0d78c;
+  border-radius: 8px;
+  padding: 12px 16px;
+  margin-top: 8px;
+
+  .order-title {
+    font-weight: 600;
+    color: #e6a23c;
+    margin-bottom: 8px;
+  }
+
+  .order-row {
+    display: flex;
+    justify-content: space-between;
+    font-size: 13px;
+    color: #666;
+    margin-bottom: 4px;
+
+    span:last-child {
+      color: #333;
+      font-weight: 500;
+    }
+  }
+}
+
+.config-editor {
+  margin-top: 8px;
+  padding: 12px;
+  background: #f5f7fa;
+  border-radius: 8px;
+
+  .config-title {
+    font-weight: 600;
+    margin-bottom: 10px;
+    color: #333;
+  }
+
+  :deep(.el-form-item) {
+    margin-bottom: 12px;
+  }
+}
+
+.price-tag {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-size: 12px;
+  cursor: pointer;
+  margin-left: 6px;
+
+  &:hover {
+    opacity: 0.8;
+  }
+}
+</style>
+
+<template>
+  <div class="system-dialog-container">
+    <el-dialog
+        :title="'远程控制 — ' + deviceName"
+        v-model="state.dialog.isShowDialog"
+        width="800px"
+        draggable
+        destroy-on-close
+        :close-on-click-modal="false"
+        align-center
+        @close="onClose">
+
+      <div v-if="!state.ready" style="text-align:center;padding:60px 0;">
+        <el-icon class="is-loading" style="font-size:32px;color:#409eff;"><Loading /></el-icon>
+        <p style="color:#999;margin-top:12px;">正在连接设备...</p>
+      </div>
+      <div class="remote-panel" v-else>
+        <!-- 设备状态栏 -->
+        <div class="device-status-bar">
+          <div class="status-left">
+            <span>
+              <span class="status-dot" :class="statusClass"></span>
+              {{ statusLabel }}
+            </span>
+            <span><span class="status-label">运行时长</span> <span class="status-value">{{ uptimeDisplay }}</span></span>
+            <span><span class="status-label">温度</span> <span class="status-value">{{ temperatureDisplay }}</span></span>
+          </div>
+          <div>
+            <el-button size="small" text type="primary" @click="refreshState" :loading="state.loading">
+              <el-icon><Refresh /></el-icon> 刷新
+            </el-button>
+          </div>
+        </div>
+
+        <!-- 功能按钮面板 -->
+        <div class="func-button-grid">
+          <div class="func-btn" v-for="fn in functionButtons" :key="fn.key" @click="editPrice(fn)">
+            <span class="func-icon">{{ fn.icon }}</span>
+            <span class="func-name">{{ fn.name }}</span>
+            <span class="func-price">{{ fn.price != null ? (fn.price / 100).toFixed(2) + '元/分' : '--' }}</span>
+          </div>
+        </div>
+
+        <!-- 系统操作按钮 -->
+        <div class="system-actions">
+          <el-button class="action-btn" type="primary" plain @click="handleReadConfig" :loading="state.loading">
+            <el-icon><Reading /></el-icon> 读取配置
+          </el-button>
+          <el-button class="action-btn" type="warning" plain @click="handleShowMsgbox" :loading="state.loading">
+            <el-icon><ChatDotSquare /></el-icon> 屏幕消息
+          </el-button>
+          <el-button class="action-btn" type="success" plain @click="handleHideMsgbox" :loading="state.loading">
+            <el-icon><CloseBold /></el-icon> 清除消息
+          </el-button>
+          <el-button class="action-btn" type="danger" plain @click="handleReboot" :loading="state.loading">
+            <el-icon><SwitchButton /></el-icon> 重启设备
+          </el-button>
+        </div>
+
+        <!-- 订单信息(仅忙碌时显示) -->
+        <div class="order-info-card" v-if="state.deviceState === 'busy' && state.orderInfo">
+          <div class="order-title">当前订单</div>
+          <div class="order-row"><span>订单号</span><span>{{ state.orderInfo.order_id || '--' }}</span></div>
+          <div class="order-row"><span>消费金额</span><span>{{ state.orderInfo.amount ? (state.orderInfo.amount / 100).toFixed(2) + '元' : '--' }}</span></div>
+          <div class="order-row"><span>操作剩余</span><span>{{ state.orderInfo.operation_remain_time }}秒</span></div>
+          <div class="order-row"><span>空闲剩余</span><span>{{ state.orderInfo.idle_remain_time }}秒</span></div>
+          <div style="margin-top: 10px;">
+            <el-button size="small" type="danger" @click="handleForceCloseOrder">
+              强制结算
+            </el-button>
+          </div>
+        </div>
+
+        <!-- 配置编辑区 -->
+        <div class="config-editor" v-if="state.showConfigEditor">
+          <div class="config-title">设备配置编辑</div>
+          <el-form :model="state.configForm" label-width="140px" size="small" inline>
+            <el-form-item label="清水单价(分/分)">
+              <el-input-number v-model="state.configForm.priceWater" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="泡沫单价(分/分)">
+              <el-input-number v-model="state.configForm.priceFoam" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="吸尘单价(分/分)">
+              <el-input-number v-model="state.configForm.priceCleaner" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="洗手单价(分/分)">
+              <el-input-number v-model="state.configForm.priceTap" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="扩展单价(分/分)">
+              <el-input-number v-model="state.configForm.priceUserExt" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="镀膜单价(分/分)">
+              <el-input-number v-model="state.configForm.priceCoat" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="吹气单价(分/分)">
+              <el-input-number v-model="state.configForm.priceBlow" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="场地费(分/分)">
+              <el-input-number v-model="state.configForm.priceSpace" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="空闲超时(秒)">
+              <el-input-number v-model="state.configForm.idleTimeout" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="操作超时(秒)">
+              <el-input-number v-model="state.configForm.operationTimeout" :min="0" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="提示音音量">
+              <el-input-number v-model="state.configForm.soundVolume" :min="0" :max="100" size="small" controls-position="right"/>
+            </el-form-item>
+            <el-form-item label="屏幕左下文本">
+              <el-input v-model="state.configForm.userMessage1" size="small" style="width:180px"/>
+            </el-form-item>
+            <el-form-item label="屏幕右下文本">
+              <el-input v-model="state.configForm.userMessage2" size="small" style="width:180px"/>
+            </el-form-item>
+          </el-form>
+          <div style="margin-top: 10px;">
+            <el-button type="primary" size="small" @click="handleWriteConfig" :loading="state.loading">应用配置</el-button>
+            <el-button size="small" @click="state.showConfigEditor = false">收起</el-button>
+          </div>
+        </div>
+
+        <!-- 价格快捷编辑弹窗 -->
+        <el-dialog v-model="state.priceDialog.visible" :title="'修改单价 — ' + state.priceDialog.name" width="350px"
+                   :close-on-click-modal="false" append-to-body>
+          <div style="text-align:center; padding: 20px 0;">
+            <div style="font-size: 14px; color: #666; margin-bottom: 12px;">当前单价:{{ (state.priceDialog.currentPrice / 100).toFixed(2) }} 元/分钟</div>
+            <div style="display: flex; align-items: center; justify-content: center; gap: 8px;">
+              <span>新单价:</span>
+              <el-input-number v-model="state.priceDialog.newPriceYuan" :min="0" :precision="2" :step="0.1" size="default"/>
+              <span>元/分钟</span>
+            </div>
+            <div style="font-size: 12px; color: #999; margin-top: 6px;">({{ Math.round(state.priceDialog.newPriceYuan * 100) }} 分)</div>
+          </div>
+          <template #footer>
+            <el-button @click="state.priceDialog.visible = false">取消</el-button>
+            <el-button type="primary" @click="confirmPriceEdit" :loading="state.loading">确认修改</el-button>
+          </template>
+        </el-dialog>
+
+        <!-- 屏幕消息弹窗 -->
+        <el-dialog v-model="state.msgDialog.visible" title="发送屏幕消息" width="450px"
+                   :close-on-click-modal="false" append-to-body>
+          <el-form label-width="70px" size="default">
+            <el-form-item label="标题">
+              <el-input v-model="state.msgDialog.title" placeholder="消息标题"/>
+            </el-form-item>
+            <el-form-item label="内容">
+              <el-input v-model="state.msgDialog.content" type="textarea" :rows="3" placeholder="消息内容"/>
+            </el-form-item>
+            <el-form-item label="显示时长">
+              <el-input-number v-model="state.msgDialog.seconds" :min="1" :max="300"/> 秒
+            </el-form-item>
+          </el-form>
+          <template #footer>
+            <el-button @click="state.msgDialog.visible = false">取消</el-button>
+            <el-button type="primary" @click="confirmShowMsgbox" :loading="state.loading">发送</el-button>
+          </template>
+        </el-dialog>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="WashDeviceRemoteDialog">
+import {reactive, computed, watch} from 'vue';
+import {Msg} from "/@/utils/message";
+import {$body} from "/@/utils/request";
+import {ElMessage} from 'element-plus';
+import {Refresh, Reading, ChatDotSquare, CloseBold, SwitchButton, Loading} from '@element-plus/icons-vue';
+
+const emit = defineEmits(['refresh']);
+
+const initState = () => ({
+  ready: false,
+  loading: false,
+  deviceState: '',
+  temperatureChip: null as number | null,
+  uptimeMs: '',
+  orderInfo: null as any,
+  showConfigEditor: false,
+  configForm: {} as any,
+  priceDialog: {
+    visible: false,
+    key: '',
+    name: '',
+    currentPrice: 0,
+    newPriceYuan: 0,
+  },
+  msgDialog: {
+    visible: false,
+    title: '',
+    content: '',
+    seconds: 10,
+  },
+  dialog: {
+    isShowDialog: false,
+  },
+});
+
+const state = reactive(initState());
+
+let productKey = '';
+let deviceName = '';
+
+const functionButtons = [
+  {key: 'priceWater',     name: '清水',  icon: '🚿', price: null as number | null},
+  {key: 'priceFoam',      name: '泡沫',  icon: '🫧', price: null as number | null},
+  {key: 'priceCleaner',   name: '吸尘',  icon: '🌀', price: null as number | null},
+  {key: 'priceTap',       name: '洗手',  icon: '🖐️', price: null as number | null},
+  {key: 'priceUserExt',   name: '扩展',  icon: '🔧', price: null as number | null},
+  {key: 'priceCoat',      name: '镀膜',  icon: '✨', price: null as number | null},
+  {key: 'priceBlow',      name: '吹气',  icon: '💨', price: null as number | null},
+  {key: 'priceSpace',     name: '场地费', icon: '🅿️', price: null as number | null},
+];
+
+const statusClass = computed(() => {
+  const map: Record<string, string> = {busy: 'busy', idle: 'idle', init: 'init', fault: 'fault', maintenance: 'maintenance', sleep: 'sleep'};
+  return map[state.deviceState] || '';
+});
+
+const statusLabel = computed(() => {
+  const map: Record<string, string> = {busy: '忙碌', idle: '空闲', init: '初始化', fault: '故障', maintenance: '维护', sleep: '休眠'};
+  return map[state.deviceState] || state.deviceState || '未知';
+});
+
+const uptimeDisplay = computed(() => {
+  if (!state.uptimeMs) return '--';
+  const ms = parseInt(state.uptimeMs);
+  const h = Math.floor(ms / 3600000);
+  const m = Math.floor((ms % 3600000) / 60000);
+  return h > 0 ? `${h}时${m}分` : `${m}分`;
+});
+
+const temperatureDisplay = computed(() => {
+  return state.temperatureChip != null ? `${state.temperatureChip}°C` : '--';
+});
+
+const open = (row: any) => {
+  productKey = row.productKey;
+  deviceName = row.deviceName;
+  state.dialog.isShowDialog = true;
+  loadDeviceState();
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const api = (url: string, data: any = {}) => {
+  return $body(`/washDevice/remote/${url}`, {productKey, deviceName, ...data});
+};
+
+const loadDeviceState = async () => {
+  state.loading = true;
+  try {
+    const res: any = await api('queryState');
+    state.deviceState = res?.device_state?.state || '';
+    state.uptimeMs = res?.device_state?.uptime_ms || '';
+    state.temperatureChip = res?.device_state?.temperature_chip ?? null;
+    state.orderInfo = res?.order_info || null;
+  } catch (e) {
+    Msg.message('查询设备状态失败', 'error');
+  }
+
+  // 自动加载设备配置,填充功能按钮价格
+  try {
+    const config: any = await api('readConfig');
+    state.configForm = {...config};
+    for (const fn of functionButtons) {
+      fn.price = config?.[fn.key] ?? null;
+    }
+  } catch (e) {
+    // 配置加载失败不影响面板使用,功能按钮显示 "--"
+    for (const fn of functionButtons) {
+      fn.price = null;
+    }
+  }
+
+  state.ready = true;
+  state.loading = false;
+};
+
+const refreshState = () => loadDeviceState();
+
+const handleReadConfig = async () => {
+  state.loading = true;
+  try {
+    const res: any = await api('readConfig');
+    state.configForm = {...res};
+    // 同步价格到功能按钮
+    for (const fn of functionButtons) {
+      fn.price = res?.[fn.key] ?? null;
+    }
+    state.showConfigEditor = true;
+    Msg.message('配置读取成功', 'success');
+  } catch (e) {
+    Msg.message('读取配置失败', 'error');
+  } finally {
+    state.loading = false;
+  }
+};
+
+const handleWriteConfig = async () => {
+  state.loading = true;
+  try {
+    await api('writeConfig', {config: state.configForm});
+    // 同步更新按钮显示的价格
+    for (const fn of functionButtons) {
+      fn.price = state.configForm[fn.key] ?? null;
+    }
+    Msg.message('配置写入成功', 'success');
+  } catch (e) {
+    Msg.message('写入配置失败', 'error');
+  } finally {
+    state.loading = false;
+  }
+};
+
+const editPrice = (fn: any) => {
+  if (fn.price == null) {
+    Msg.message('配置加载失败,无法修改价格,请点击「读取配置」重试', 'warning');
+    return;
+  }
+  state.priceDialog.key = fn.key;
+  state.priceDialog.name = fn.name;
+  state.priceDialog.currentPrice = fn.price;
+  state.priceDialog.newPriceYuan = parseFloat((fn.price / 100).toFixed(2));
+  state.priceDialog.visible = true;
+};
+
+const confirmPriceEdit = async () => {
+  const newPriceFen = Math.round(state.priceDialog.newPriceYuan * 100);
+  state.loading = true;
+  try {
+    // 先读当前完整配置,修改单个价格后写回
+    const fullConfig: any = await api('readConfig');
+    fullConfig[state.priceDialog.key] = newPriceFen;
+    await api('writeConfig', {config: fullConfig});
+    // 更新显示
+    const fn = functionButtons.find(f => f.key === state.priceDialog.key);
+    if (fn) fn.price = newPriceFen;
+    state.configForm[state.priceDialog.key] = newPriceFen;
+    state.priceDialog.visible = false;
+    Msg.message(`${state.priceDialog.name}价格已更新`, 'success');
+  } catch (e) {
+    Msg.message('修改价格失败', 'error');
+  } finally {
+    state.loading = false;
+  }
+};
+
+const handleShowMsgbox = () => {
+  state.msgDialog.title = '';
+  state.msgDialog.content = '';
+  state.msgDialog.seconds = 10;
+  state.msgDialog.visible = true;
+};
+
+const confirmShowMsgbox = async () => {
+  state.loading = true;
+  try {
+    await api('showMsgbox', {
+      title: state.msgDialog.title,
+      content: state.msgDialog.content,
+      seconds: state.msgDialog.seconds,
+    });
+    state.msgDialog.visible = false;
+    Msg.message('消息已发送', 'success');
+  } catch (e) {
+    Msg.message('发送消息失败', 'error');
+  } finally {
+    state.loading = false;
+  }
+};
+
+const handleHideMsgbox = async () => {
+  state.loading = true;
+  try {
+    await api('hideMsgbox');
+    Msg.message('消息已清除', 'success');
+  } catch (e) {
+    Msg.message('清除消息失败', 'error');
+  } finally {
+    state.loading = false;
+  }
+};
+
+const handleReboot = () => {
+  Msg.confirm('确定要重启设备吗?重启有3-5秒延迟。').then(async () => {
+    state.loading = true;
+    try {
+      await api('reboot');
+      Msg.message('重启命令已发送', 'success');
+    } catch (e) {
+      Msg.message('重启失败', 'error');
+    } finally {
+      state.loading = false;
+    }
+  });
+};
+
+const handleForceCloseOrder = () => {
+  Msg.confirm('确定要强制结算当前订单吗?此操作将立即关闭订单并释放设备。').then(async () => {
+    state.loading = true;
+    try {
+      await api('forceCloseOrder');
+      Msg.message('强制结算命令已发送', 'success');
+      emit('refresh');
+      setTimeout(() => loadDeviceState(), 2000);
+    } catch (e) {
+      Msg.message('强制结算失败', 'error');
+    } finally {
+      state.loading = false;
+    }
+  });
+};
+
+defineExpose({open});
+</script>

+ 182 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/WashDeviceRemoteController.java

@@ -0,0 +1,182 @@
+package com.kym.admin.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.kym.common.R;
+import com.kym.entity.DeviceConfig;
+import com.kym.entity.WashDevice;
+import com.kym.entity.awoara.OrderInfo;
+import com.kym.entity.awoara.response.HardwareInfo;
+import com.kym.entity.awoara.response.State;
+import com.kym.service.WashDeviceService;
+import com.kym.service.awoara.AwoaraService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 设备远程控制 API(调试用)
+ *
+ * @author skyline
+ */
+@Slf4j
+@RestController
+@RequestMapping("/washDevice/remote")
+public class WashDeviceRemoteController {
+
+    private final WashDeviceService washDeviceService;
+    private final AwoaraService awoaraService;
+
+    public WashDeviceRemoteController(WashDeviceService washDeviceService, AwoaraService awoaraService) {
+        this.washDeviceService = washDeviceService;
+        this.awoaraService = awoaraService;
+    }
+
+    /**
+     * 查询设备实时状态
+     */
+    @SaCheckPermission("washDevice.list")
+    @PostMapping("/queryState")
+    R<?> queryState(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        State state = awoaraService.queryState(productKey, deviceName);
+        return R.success(state);
+    }
+
+    /**
+     * 查询设备上的订单信息
+     */
+    @SaCheckPermission("washDevice.list")
+    @PostMapping("/queryOrder")
+    R<?> queryOrder(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        String orderId = params.get("orderId");
+        OrderInfo orderInfo = awoaraService.queryOrder(productKey, deviceName, orderId);
+        return R.success(orderInfo);
+    }
+
+    /**
+     * 查询硬件信息
+     */
+    @SaCheckPermission("washDevice.list")
+    @PostMapping("/queryHardwareInfo")
+    R<?> queryHardwareInfo(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        HardwareInfo info = awoaraService.queryHardwareInfo(productKey, deviceName);
+        return R.success(info);
+    }
+
+    /**
+     * 读取设备配置
+     */
+    @SaCheckPermission("washDevice.list")
+    @PostMapping("/readConfig")
+    R<?> readConfig(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        DeviceConfig config = awoaraService.readConfig(productKey, deviceName);
+        return R.success(config);
+    }
+
+    /**
+     * 写入设备配置
+     */
+    @SaCheckPermission("washDevice.modify")
+    @PostMapping("/writeConfig")
+    R<?> writeConfig(@RequestBody Map<String, Object> params) {
+        String productKey = (String) params.get("productKey");
+        String deviceName = (String) params.get("deviceName");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> configMap = (Map<String, Object>) params.get("config");
+        DeviceConfig config = new DeviceConfig();
+        if (configMap.get("idleTimeout") != null) config.setIdleTimeout((Integer) configMap.get("idleTimeout"));
+        if (configMap.get("operationTimeout") != null) config.setOperationTimeout((Integer) configMap.get("operationTimeout"));
+        if (configMap.get("priceWater") != null) config.setPriceWater((Integer) configMap.get("priceWater"));
+        if (configMap.get("priceFoam") != null) config.setPriceFoam((Integer) configMap.get("priceFoam"));
+        if (configMap.get("priceCleaner") != null) config.setPriceCleaner((Integer) configMap.get("priceCleaner"));
+        if (configMap.get("priceTap") != null) config.setPriceTap((Integer) configMap.get("priceTap"));
+        if (configMap.get("priceUserExt") != null) config.setPriceUserExt((Integer) configMap.get("priceUserExt"));
+        if (configMap.get("priceCoat") != null) config.setPriceCoat((Integer) configMap.get("priceCoat"));
+        if (configMap.get("priceBlow") != null) config.setPriceBlow((Integer) configMap.get("priceBlow"));
+        if (configMap.get("priceSpace") != null) config.setPriceSpace((Integer) configMap.get("priceSpace"));
+        if (configMap.get("maintenanceMode") != null) config.setMaintenanceMode((Integer) configMap.get("maintenanceMode"));
+        if (configMap.get("soundVolume") != null) config.setSoundVolume((Integer) configMap.get("soundVolume"));
+        if (configMap.get("workMode") != null) config.setWorkMode((Integer) configMap.get("workMode"));
+        if (configMap.get("screenType") != null) config.setScreenType((Integer) configMap.get("screenType"));
+        if (configMap.get("videoSource") != null) config.setVideoSource((Integer) configMap.get("videoSource"));
+        if (configMap.get("videoPlayDelay") != null) config.setVideoPlayDelay((Integer) configMap.get("videoPlayDelay"));
+        if (configMap.get("workLightDelay") != null) config.setWorkLightDelay((Integer) configMap.get("workLightDelay"));
+        if (configMap.get("billDelay") != null) config.setBillDelay((Integer) configMap.get("billDelay"));
+        if (configMap.get("tapOnDelay") != null) config.setTapOnDelay((Integer) configMap.get("tapOnDelay"));
+        if (configMap.get("noticeThresholdIdle") != null) config.setNoticeThresholdIdle((Integer) configMap.get("noticeThresholdIdle"));
+        if (configMap.get("noticeThresholdOperation") != null) config.setNoticeThresholdOperation((Integer) configMap.get("noticeThresholdOperation"));
+        if (configMap.get("quickOpenMoney") != null) config.setQuickOpenMoney((Integer) configMap.get("quickOpenMoney"));
+        if (configMap.get("motorOnDelay") != null) config.setMotorOnDelay((Integer) configMap.get("motorOnDelay"));
+        if (configMap.get("motorOffDelay") != null) config.setMotorOffDelay((Integer) configMap.get("motorOffDelay"));
+        if (configMap.get("motorOnInterval") != null) config.setMotorOnInterval((Integer) configMap.get("motorOnInterval"));
+        if (configMap.get("motorFlowOn") != null) config.setMotorFlowOn((Boolean) configMap.get("motorFlowOn"));
+        if (configMap.get("motorFlowOff") != null) config.setMotorFlowOff((Boolean) configMap.get("motorFlowOff"));
+        if (configMap.get("userMessage1") != null) config.setUserMessage1((String) configMap.get("userMessage1"));
+        if (configMap.get("userMessage2") != null) config.setUserMessage2((String) configMap.get("userMessage2"));
+        if (configMap.get("workTimePeriod1") != null) config.setWorkTimePeriod1((String) configMap.get("workTimePeriod1"));
+        if (configMap.get("workTimePeriod2") != null) config.setWorkTimePeriod2((String) configMap.get("workTimePeriod2"));
+        if (configMap.get("lightTimePeriod1") != null) config.setLightTimePeriod1((String) configMap.get("lightTimePeriod1"));
+        if (configMap.get("lightTimePeriod2") != null) config.setLightTimePeriod2((String) configMap.get("lightTimePeriod2"));
+        awoaraService.writeConfig(productKey, deviceName, config);
+        return R.success();
+    }
+
+    /**
+     * 重启设备
+     */
+    @SaCheckPermission("washDevice.modify")
+    @PostMapping("/reboot")
+    R<?> reboot(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        awoaraService.reboot(productKey, deviceName);
+        return R.success();
+    }
+
+    /**
+     * 在设备屏幕显示消息
+     */
+    @SaCheckPermission("washDevice.modify")
+    @PostMapping("/showMsgbox")
+    R<?> showMsgbox(@RequestBody Map<String, Object> params) {
+        String productKey = (String) params.get("productKey");
+        String deviceName = (String) params.get("deviceName");
+        String title = (String) params.get("title");
+        String content = (String) params.get("content");
+        int seconds = params.get("seconds") != null ? (Integer) params.get("seconds") : 10;
+        awoaraService.showMsgbox(productKey, deviceName, title, content, seconds);
+        return R.success();
+    }
+
+    /**
+     * 隐藏设备屏幕消息
+     */
+    @SaCheckPermission("washDevice.modify")
+    @PostMapping("/hideMsgbox")
+    R<?> hideMsgbox(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        awoaraService.hideMsgbox(productKey, deviceName);
+        return R.success();
+    }
+
+    /**
+     * 强制关闭订单
+     */
+    @SaCheckPermission("washDevice.modify")
+    @PostMapping("/forceCloseOrder")
+    R<?> forceCloseOrder(@RequestBody Map<String, String> params) {
+        String productKey = params.get("productKey");
+        String deviceName = params.get("deviceName");
+        awoaraService.forceCloseOrder(productKey, deviceName);
+        return R.success();
+    }
+}

+ 43 - 14
car-wash-service/src/main/java/com/kym/service/awoara/AwoaraServiceImpl.java

@@ -3,6 +3,7 @@ package com.kym.service.awoara;
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONObject;
 import com.alibaba.fastjson2.TypeReference;
+import com.google.gson.*;
 import com.google.gson.FieldNamingPolicy;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
@@ -38,6 +39,18 @@ public class AwoaraServiceImpl implements AwoaraService {
 
     static final Gson gson = new GsonBuilder()
             .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
+            .registerTypeAdapter(Boolean.class, (JsonDeserializer<Boolean>) (json, typeOfT, context) -> {
+                if (json.isJsonPrimitive()) {
+                    JsonPrimitive primitive = json.getAsJsonPrimitive();
+                    if (primitive.isNumber()) return primitive.getAsInt() != 0;
+                    if (primitive.isBoolean()) return primitive.getAsBoolean();
+                    if (primitive.isString()) {
+                        String s = primitive.getAsString();
+                        return "1".equals(s) || "true".equalsIgnoreCase(s);
+                    }
+                }
+                return false;
+            })
             .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
             .create();
 
@@ -49,24 +62,40 @@ public class AwoaraServiceImpl implements AwoaraService {
     static <T> T doRequest(String productKey, String deviceName, String params, TypeReference<AwoaraResponse<T>>... typeReference) {
         try {
             var response = AliyunLotClient.rRpc(productKey, deviceName, params);
+            if (HTTP_OK != response.getStatusCode()) {
+                log.error("AliyunLot响应码:{}", response.getStatusCode());
+                throw new BusinessException("AliyunLot响应异常");
+            }
+
+            // 检查 RRpc 层是否成功(设备离线等情况会返回 Success=false)
+            var body = response.getBody();
+            if (body.success == null || !body.success) {
+                String rrpcCode = body.rrpcCode != null ? body.rrpcCode : "UNKNOWN";
+                String errorMsg = body.errorMessage != null ? body.errorMessage : "未知错误";
+                log.error("AliyunLot RRpc失败,RrpcCode:{},ErrorMessage:{}", rrpcCode, errorMsg);
+                throw new BusinessException("设备" + ("OFFLINE".equals(rrpcCode) ? "离线" : "响应异常:" + errorMsg));
+            }
+
+            if (body.payloadBase64Byte == null) {
+                log.error("AliyunLot响应payload为空");
+                throw new BusinessException("AliyunLot响应数据为空");
+            }
+
             AwoaraResponse<T> res;
-            if (HTTP_OK == response.getStatusCode()) {
-                if (CommUtil.isNotEmptyAndNull(typeReference)) {
-                    res = JSONObject.parseObject(new String(DECODER.decode(response.getBody().payloadBase64Byte), StandardCharsets.UTF_8), typeReference[0]);
-                    log.debug("AliyunLot返回结果:{}", res);
-                } else {
-                    res = JSONObject.parseObject(new String(DECODER.decode(response.getBody().payloadBase64Byte), StandardCharsets.UTF_8), AwoaraResponse.class);
-                }
-                if (HTTP_OK == res.getCode()) {
-                    return res.getData();
-                } else {
-                    log.error("AliyunLot响应异常:{}", res);
-                    throw new BusinessException("AliyunLot响应异常");
-                }
+            if (CommUtil.isNotEmptyAndNull(typeReference)) {
+                res = JSONObject.parseObject(new String(DECODER.decode(body.payloadBase64Byte), StandardCharsets.UTF_8), typeReference[0]);
+                log.debug("AliyunLot返回结果:{}", res);
             } else {
-                log.error("AliyunLot响应码:{}", response.getStatusCode());
+                res = JSONObject.parseObject(new String(DECODER.decode(body.payloadBase64Byte), StandardCharsets.UTF_8), AwoaraResponse.class);
+            }
+            if (HTTP_OK == res.getCode()) {
+                return res.getData();
+            } else {
+                log.error("AliyunLot响应异常:{}", res);
                 throw new BusinessException("AliyunLot响应异常");
             }
+        } catch (BusinessException e) {
+            throw e;
         } catch (Exception e) {
             log.error("AliyunLot请求异常:{}", e.getMessage());
             throw new RuntimeException(e);