| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599 |
- <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>
|