remote.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. <style scoped lang="scss">
  2. .remote-panel {
  3. display: flex;
  4. flex-direction: column;
  5. gap: 16px;
  6. }
  7. .device-status-bar {
  8. display: flex;
  9. align-items: center;
  10. justify-content: space-between;
  11. padding: 12px 16px;
  12. background: #1a1a2e;
  13. border-radius: 8px;
  14. color: #00ff88;
  15. font-family: 'Courier New', monospace;
  16. font-size: 14px;
  17. .status-left {
  18. display: flex;
  19. align-items: center;
  20. gap: 16px;
  21. }
  22. .status-dot {
  23. width: 10px;
  24. height: 10px;
  25. border-radius: 50%;
  26. display: inline-block;
  27. margin-right: 6px;
  28. &.busy { background: #ff4444; animation: blink 1s infinite; }
  29. &.idle { background: #00ff88; }
  30. &.init { background: #ffaa00; }
  31. &.fault { background: #ff0000; }
  32. &.maintenance { background: #ff8800; }
  33. &.sleep { background: #888888; }
  34. }
  35. @keyframes blink {
  36. 0%, 100% { opacity: 1; }
  37. 50% { opacity: 0.3; }
  38. }
  39. .status-label {
  40. color: #aaa;
  41. font-size: 12px;
  42. }
  43. .status-value {
  44. color: #fff;
  45. font-weight: bold;
  46. }
  47. }
  48. .func-button-grid {
  49. display: grid;
  50. grid-template-columns: repeat(4, 1fr);
  51. gap: 10px;
  52. .func-btn {
  53. display: flex;
  54. flex-direction: column;
  55. align-items: center;
  56. justify-content: center;
  57. padding: 16px 8px;
  58. border: 1px solid #ddd;
  59. border-radius: 10px;
  60. cursor: pointer;
  61. transition: all 0.2s;
  62. background: #f8f9fa;
  63. user-select: none;
  64. &:hover {
  65. background: #e8f4fd;
  66. border-color: #409eff;
  67. transform: translateY(-2px);
  68. box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
  69. }
  70. &:active {
  71. transform: translateY(0);
  72. background: #d9ecff;
  73. }
  74. .func-icon {
  75. font-size: 24px;
  76. margin-bottom: 6px;
  77. }
  78. .func-name {
  79. font-size: 13px;
  80. font-weight: 600;
  81. color: #333;
  82. margin-bottom: 4px;
  83. }
  84. .func-price {
  85. font-size: 12px;
  86. color: #f56c6c;
  87. font-weight: 500;
  88. }
  89. }
  90. }
  91. .system-actions {
  92. display: flex;
  93. gap: 10px;
  94. flex-wrap: wrap;
  95. padding: 12px 0;
  96. border-top: 1px solid #ebeef5;
  97. border-bottom: 1px solid #ebeef5;
  98. .action-btn {
  99. flex: 1;
  100. min-width: 100px;
  101. }
  102. }
  103. .order-info-card {
  104. background: #fffbf0;
  105. border: 1px solid #f0d78c;
  106. border-radius: 8px;
  107. padding: 12px 16px;
  108. margin-top: 8px;
  109. .order-title {
  110. font-weight: 600;
  111. color: #e6a23c;
  112. margin-bottom: 8px;
  113. }
  114. .order-row {
  115. display: flex;
  116. justify-content: space-between;
  117. font-size: 13px;
  118. color: #666;
  119. margin-bottom: 4px;
  120. span:last-child {
  121. color: #333;
  122. font-weight: 500;
  123. }
  124. }
  125. }
  126. .config-editor {
  127. margin-top: 8px;
  128. padding: 12px;
  129. background: #f5f7fa;
  130. border-radius: 8px;
  131. .config-title {
  132. font-weight: 600;
  133. margin-bottom: 10px;
  134. color: #333;
  135. }
  136. :deep(.el-form-item) {
  137. margin-bottom: 12px;
  138. }
  139. }
  140. .price-tag {
  141. display: inline-block;
  142. padding: 2px 8px;
  143. border-radius: 4px;
  144. font-size: 12px;
  145. cursor: pointer;
  146. margin-left: 6px;
  147. &:hover {
  148. opacity: 0.8;
  149. }
  150. }
  151. </style>
  152. <template>
  153. <div class="system-dialog-container">
  154. <el-dialog
  155. :title="'远程控制 — ' + deviceName"
  156. v-model="state.dialog.isShowDialog"
  157. width="800px"
  158. draggable
  159. destroy-on-close
  160. :close-on-click-modal="false"
  161. align-center
  162. @close="onClose">
  163. <div v-if="!state.ready" style="text-align:center;padding:60px 0;">
  164. <el-icon class="is-loading" style="font-size:32px;color:#409eff;"><Loading /></el-icon>
  165. <p style="color:#999;margin-top:12px;">正在连接设备...</p>
  166. </div>
  167. <div class="remote-panel" v-else>
  168. <!-- 设备状态栏 -->
  169. <div class="device-status-bar">
  170. <div class="status-left">
  171. <span>
  172. <span class="status-dot" :class="statusClass"></span>
  173. {{ statusLabel }}
  174. </span>
  175. <span><span class="status-label">运行时长</span> <span class="status-value">{{ uptimeDisplay }}</span></span>
  176. <span><span class="status-label">温度</span> <span class="status-value">{{ temperatureDisplay }}</span></span>
  177. </div>
  178. <div>
  179. <el-button size="small" text type="primary" @click="refreshState" :loading="state.loading">
  180. <el-icon><Refresh /></el-icon> 刷新
  181. </el-button>
  182. </div>
  183. </div>
  184. <!-- 功能按钮面板 -->
  185. <div class="func-button-grid">
  186. <div class="func-btn" v-for="fn in functionButtons" :key="fn.key" @click="editPrice(fn)">
  187. <span class="func-icon">{{ fn.icon }}</span>
  188. <span class="func-name">{{ fn.name }}</span>
  189. <span class="func-price">{{ fn.price != null ? (fn.price / 100).toFixed(2) + '元/分' : '--' }}</span>
  190. </div>
  191. </div>
  192. <!-- 系统操作按钮 -->
  193. <div class="system-actions">
  194. <el-button class="action-btn" type="primary" plain @click="handleReadConfig" :loading="state.loading">
  195. <el-icon><Reading /></el-icon> 读取配置
  196. </el-button>
  197. <el-button class="action-btn" type="warning" plain @click="handleShowMsgbox" :loading="state.loading">
  198. <el-icon><ChatDotSquare /></el-icon> 屏幕消息
  199. </el-button>
  200. <el-button class="action-btn" type="success" plain @click="handleHideMsgbox" :loading="state.loading">
  201. <el-icon><CloseBold /></el-icon> 清除消息
  202. </el-button>
  203. <el-button class="action-btn" type="danger" plain @click="handleReboot" :loading="state.loading">
  204. <el-icon><SwitchButton /></el-icon> 重启设备
  205. </el-button>
  206. </div>
  207. <!-- 订单信息(仅忙碌时显示) -->
  208. <div class="order-info-card" v-if="state.deviceState === 'busy' && state.orderInfo">
  209. <div class="order-title">当前订单</div>
  210. <div class="order-row"><span>订单号</span><span>{{ state.orderInfo.order_id || '--' }}</span></div>
  211. <div class="order-row"><span>消费金额</span><span>{{ state.orderInfo.amount ? (state.orderInfo.amount / 100).toFixed(2) + '元' : '--' }}</span></div>
  212. <div class="order-row"><span>操作剩余</span><span>{{ state.orderInfo.operation_remain_time }}秒</span></div>
  213. <div class="order-row"><span>空闲剩余</span><span>{{ state.orderInfo.idle_remain_time }}秒</span></div>
  214. <div style="margin-top: 10px;">
  215. <el-button size="small" type="danger" @click="handleForceCloseOrder">
  216. 强制结算
  217. </el-button>
  218. </div>
  219. </div>
  220. <!-- 配置编辑区 -->
  221. <div class="config-editor" v-if="state.showConfigEditor">
  222. <div class="config-title">设备配置编辑</div>
  223. <el-form :model="state.configForm" label-width="140px" size="small" inline>
  224. <el-form-item label="清水单价(分/分)">
  225. <el-input-number v-model="state.configForm.priceWater" :min="0" size="small" controls-position="right"/>
  226. </el-form-item>
  227. <el-form-item label="泡沫单价(分/分)">
  228. <el-input-number v-model="state.configForm.priceFoam" :min="0" size="small" controls-position="right"/>
  229. </el-form-item>
  230. <el-form-item label="吸尘单价(分/分)">
  231. <el-input-number v-model="state.configForm.priceCleaner" :min="0" size="small" controls-position="right"/>
  232. </el-form-item>
  233. <el-form-item label="洗手单价(分/分)">
  234. <el-input-number v-model="state.configForm.priceTap" :min="0" size="small" controls-position="right"/>
  235. </el-form-item>
  236. <el-form-item label="扩展单价(分/分)">
  237. <el-input-number v-model="state.configForm.priceUserExt" :min="0" size="small" controls-position="right"/>
  238. </el-form-item>
  239. <el-form-item label="镀膜单价(分/分)">
  240. <el-input-number v-model="state.configForm.priceCoat" :min="0" size="small" controls-position="right"/>
  241. </el-form-item>
  242. <el-form-item label="吹气单价(分/分)">
  243. <el-input-number v-model="state.configForm.priceBlow" :min="0" size="small" controls-position="right"/>
  244. </el-form-item>
  245. <el-form-item label="场地费(分/分)">
  246. <el-input-number v-model="state.configForm.priceSpace" :min="0" size="small" controls-position="right"/>
  247. </el-form-item>
  248. <el-form-item label="空闲超时(秒)">
  249. <el-input-number v-model="state.configForm.idleTimeout" :min="0" size="small" controls-position="right"/>
  250. </el-form-item>
  251. <el-form-item label="操作超时(秒)">
  252. <el-input-number v-model="state.configForm.operationTimeout" :min="0" size="small" controls-position="right"/>
  253. </el-form-item>
  254. <el-form-item label="提示音音量">
  255. <el-input-number v-model="state.configForm.soundVolume" :min="0" :max="100" size="small" controls-position="right"/>
  256. </el-form-item>
  257. <el-form-item label="屏幕左下文本">
  258. <el-input v-model="state.configForm.userMessage1" size="small" style="width:180px"/>
  259. </el-form-item>
  260. <el-form-item label="屏幕右下文本">
  261. <el-input v-model="state.configForm.userMessage2" size="small" style="width:180px"/>
  262. </el-form-item>
  263. </el-form>
  264. <div style="margin-top: 10px;">
  265. <el-button type="primary" size="small" @click="handleWriteConfig" :loading="state.loading">应用配置</el-button>
  266. <el-button size="small" @click="state.showConfigEditor = false">收起</el-button>
  267. </div>
  268. </div>
  269. <!-- 价格快捷编辑弹窗 -->
  270. <el-dialog v-model="state.priceDialog.visible" :title="'修改单价 — ' + state.priceDialog.name" width="350px"
  271. :close-on-click-modal="false" append-to-body>
  272. <div style="text-align:center; padding: 20px 0;">
  273. <div style="font-size: 14px; color: #666; margin-bottom: 12px;">当前单价:{{ (state.priceDialog.currentPrice / 100).toFixed(2) }} 元/分钟</div>
  274. <div style="display: flex; align-items: center; justify-content: center; gap: 8px;">
  275. <span>新单价:</span>
  276. <el-input-number v-model="state.priceDialog.newPriceYuan" :min="0" :precision="2" :step="0.1" size="default"/>
  277. <span>元/分钟</span>
  278. </div>
  279. <div style="font-size: 12px; color: #999; margin-top: 6px;">({{ Math.round(state.priceDialog.newPriceYuan * 100) }} 分)</div>
  280. </div>
  281. <template #footer>
  282. <el-button @click="state.priceDialog.visible = false">取消</el-button>
  283. <el-button type="primary" @click="confirmPriceEdit" :loading="state.loading">确认修改</el-button>
  284. </template>
  285. </el-dialog>
  286. <!-- 屏幕消息弹窗 -->
  287. <el-dialog v-model="state.msgDialog.visible" title="发送屏幕消息" width="450px"
  288. :close-on-click-modal="false" append-to-body>
  289. <el-form label-width="70px" size="default">
  290. <el-form-item label="标题">
  291. <el-input v-model="state.msgDialog.title" placeholder="消息标题"/>
  292. </el-form-item>
  293. <el-form-item label="内容">
  294. <el-input v-model="state.msgDialog.content" type="textarea" :rows="3" placeholder="消息内容"/>
  295. </el-form-item>
  296. <el-form-item label="显示时长">
  297. <el-input-number v-model="state.msgDialog.seconds" :min="1" :max="300"/> 秒
  298. </el-form-item>
  299. </el-form>
  300. <template #footer>
  301. <el-button @click="state.msgDialog.visible = false">取消</el-button>
  302. <el-button type="primary" @click="confirmShowMsgbox" :loading="state.loading">发送</el-button>
  303. </template>
  304. </el-dialog>
  305. </div>
  306. </el-dialog>
  307. </div>
  308. </template>
  309. <script setup lang="ts" name="WashDeviceRemoteDialog">
  310. import {reactive, computed, watch} from 'vue';
  311. import {Msg} from "/@/utils/message";
  312. import {$body} from "/@/utils/request";
  313. import {ElMessage} from 'element-plus';
  314. import {Refresh, Reading, ChatDotSquare, CloseBold, SwitchButton, Loading} from '@element-plus/icons-vue';
  315. const emit = defineEmits(['refresh']);
  316. const initState = () => ({
  317. ready: false,
  318. loading: false,
  319. deviceState: '',
  320. temperatureChip: null as number | null,
  321. uptimeMs: '',
  322. orderInfo: null as any,
  323. showConfigEditor: false,
  324. configForm: {} as any,
  325. priceDialog: {
  326. visible: false,
  327. key: '',
  328. name: '',
  329. currentPrice: 0,
  330. newPriceYuan: 0,
  331. },
  332. msgDialog: {
  333. visible: false,
  334. title: '',
  335. content: '',
  336. seconds: 10,
  337. },
  338. dialog: {
  339. isShowDialog: false,
  340. },
  341. });
  342. const state = reactive(initState());
  343. let productKey = '';
  344. let deviceName = '';
  345. const functionButtons = [
  346. {key: 'priceWater', name: '清水', icon: '🚿', price: null as number | null},
  347. {key: 'priceFoam', name: '泡沫', icon: '🫧', price: null as number | null},
  348. {key: 'priceCleaner', name: '吸尘', icon: '🌀', price: null as number | null},
  349. {key: 'priceTap', name: '洗手', icon: '🖐️', price: null as number | null},
  350. {key: 'priceUserExt', name: '扩展', icon: '🔧', price: null as number | null},
  351. {key: 'priceCoat', name: '镀膜', icon: '✨', price: null as number | null},
  352. {key: 'priceBlow', name: '吹气', icon: '💨', price: null as number | null},
  353. {key: 'priceSpace', name: '场地费', icon: '🅿️', price: null as number | null},
  354. ];
  355. const statusClass = computed(() => {
  356. const map: Record<string, string> = {busy: 'busy', idle: 'idle', init: 'init', fault: 'fault', maintenance: 'maintenance', sleep: 'sleep'};
  357. return map[state.deviceState] || '';
  358. });
  359. const statusLabel = computed(() => {
  360. const map: Record<string, string> = {busy: '忙碌', idle: '空闲', init: '初始化', fault: '故障', maintenance: '维护', sleep: '休眠'};
  361. return map[state.deviceState] || state.deviceState || '未知';
  362. });
  363. const uptimeDisplay = computed(() => {
  364. if (!state.uptimeMs) return '--';
  365. const ms = parseInt(state.uptimeMs);
  366. const h = Math.floor(ms / 3600000);
  367. const m = Math.floor((ms % 3600000) / 60000);
  368. return h > 0 ? `${h}时${m}分` : `${m}分`;
  369. });
  370. const temperatureDisplay = computed(() => {
  371. return state.temperatureChip != null ? `${state.temperatureChip}°C` : '--';
  372. });
  373. const open = (row: any) => {
  374. productKey = row.productKey;
  375. deviceName = row.deviceName;
  376. state.dialog.isShowDialog = true;
  377. loadDeviceState();
  378. };
  379. const onClose = () => {
  380. state.dialog.isShowDialog = false;
  381. Object.assign(state, initState());
  382. };
  383. const api = (url: string, data: any = {}) => {
  384. return $body(`/washDevice/remote/${url}`, {productKey, deviceName, ...data});
  385. };
  386. const loadDeviceState = async () => {
  387. state.loading = true;
  388. try {
  389. const res: any = await api('queryState');
  390. state.deviceState = res?.device_state?.state || '';
  391. state.uptimeMs = res?.device_state?.uptime_ms || '';
  392. state.temperatureChip = res?.device_state?.temperature_chip ?? null;
  393. state.orderInfo = res?.order_info || null;
  394. } catch (e) {
  395. Msg.message('查询设备状态失败', 'error');
  396. }
  397. // 自动加载设备配置,填充功能按钮价格
  398. try {
  399. const config: any = await api('readConfig');
  400. state.configForm = {...config};
  401. for (const fn of functionButtons) {
  402. fn.price = config?.[fn.key] ?? null;
  403. }
  404. } catch (e) {
  405. // 配置加载失败不影响面板使用,功能按钮显示 "--"
  406. for (const fn of functionButtons) {
  407. fn.price = null;
  408. }
  409. }
  410. state.ready = true;
  411. state.loading = false;
  412. };
  413. const refreshState = () => loadDeviceState();
  414. const handleReadConfig = async () => {
  415. state.loading = true;
  416. try {
  417. const res: any = await api('readConfig');
  418. state.configForm = {...res};
  419. // 同步价格到功能按钮
  420. for (const fn of functionButtons) {
  421. fn.price = res?.[fn.key] ?? null;
  422. }
  423. state.showConfigEditor = true;
  424. Msg.message('配置读取成功', 'success');
  425. } catch (e) {
  426. Msg.message('读取配置失败', 'error');
  427. } finally {
  428. state.loading = false;
  429. }
  430. };
  431. const handleWriteConfig = async () => {
  432. state.loading = true;
  433. try {
  434. await api('writeConfig', {config: state.configForm});
  435. // 同步更新按钮显示的价格
  436. for (const fn of functionButtons) {
  437. fn.price = state.configForm[fn.key] ?? null;
  438. }
  439. Msg.message('配置写入成功', 'success');
  440. } catch (e) {
  441. Msg.message('写入配置失败', 'error');
  442. } finally {
  443. state.loading = false;
  444. }
  445. };
  446. const editPrice = (fn: any) => {
  447. if (fn.price == null) {
  448. Msg.message('配置加载失败,无法修改价格,请点击「读取配置」重试', 'warning');
  449. return;
  450. }
  451. state.priceDialog.key = fn.key;
  452. state.priceDialog.name = fn.name;
  453. state.priceDialog.currentPrice = fn.price;
  454. state.priceDialog.newPriceYuan = parseFloat((fn.price / 100).toFixed(2));
  455. state.priceDialog.visible = true;
  456. };
  457. const confirmPriceEdit = async () => {
  458. const newPriceFen = Math.round(state.priceDialog.newPriceYuan * 100);
  459. state.loading = true;
  460. try {
  461. // 先读当前完整配置,修改单个价格后写回
  462. const fullConfig: any = await api('readConfig');
  463. fullConfig[state.priceDialog.key] = newPriceFen;
  464. await api('writeConfig', {config: fullConfig});
  465. // 更新显示
  466. const fn = functionButtons.find(f => f.key === state.priceDialog.key);
  467. if (fn) fn.price = newPriceFen;
  468. state.configForm[state.priceDialog.key] = newPriceFen;
  469. state.priceDialog.visible = false;
  470. Msg.message(`${state.priceDialog.name}价格已更新`, 'success');
  471. } catch (e) {
  472. Msg.message('修改价格失败', 'error');
  473. } finally {
  474. state.loading = false;
  475. }
  476. };
  477. const handleShowMsgbox = () => {
  478. state.msgDialog.title = '';
  479. state.msgDialog.content = '';
  480. state.msgDialog.seconds = 10;
  481. state.msgDialog.visible = true;
  482. };
  483. const confirmShowMsgbox = async () => {
  484. state.loading = true;
  485. try {
  486. await api('showMsgbox', {
  487. title: state.msgDialog.title,
  488. content: state.msgDialog.content,
  489. seconds: state.msgDialog.seconds,
  490. });
  491. state.msgDialog.visible = false;
  492. Msg.message('消息已发送', 'success');
  493. } catch (e) {
  494. Msg.message('发送消息失败', 'error');
  495. } finally {
  496. state.loading = false;
  497. }
  498. };
  499. const handleHideMsgbox = async () => {
  500. state.loading = true;
  501. try {
  502. await api('hideMsgbox');
  503. Msg.message('消息已清除', 'success');
  504. } catch (e) {
  505. Msg.message('清除消息失败', 'error');
  506. } finally {
  507. state.loading = false;
  508. }
  509. };
  510. const handleReboot = () => {
  511. Msg.confirm('确定要重启设备吗?重启有3-5秒延迟。').then(async () => {
  512. state.loading = true;
  513. try {
  514. await api('reboot');
  515. Msg.message('重启命令已发送', 'success');
  516. } catch (e) {
  517. Msg.message('重启失败', 'error');
  518. } finally {
  519. state.loading = false;
  520. }
  521. });
  522. };
  523. const handleForceCloseOrder = () => {
  524. Msg.confirm('确定要强制结算当前订单吗?此操作将立即关闭订单并释放设备。').then(async () => {
  525. state.loading = true;
  526. try {
  527. await api('forceCloseOrder');
  528. Msg.message('强制结算命令已发送', 'success');
  529. emit('refresh');
  530. setTimeout(() => loadDeviceState(), 2000);
  531. } catch (e) {
  532. Msg.message('强制结算失败', 'error');
  533. } finally {
  534. state.loading = false;
  535. }
  536. });
  537. };
  538. defineExpose({open});
  539. </script>