2
0

4 Коммитууд 7cbd749547 ... 3084a7baf4

Эзэн SHA1 Мессеж Огноо
  skyline 3084a7baf4 站点联系方式,洗车指导改静态内容 1 өдөр өмнө
  skyline 1ffd0d5a00 洗车机主板通讯协议 1 өдөр өмнө
  skyline bb38797abd admin-web-new 功能同步 1 өдөр өмнө
  skyline 81f112804e 站点表新增字段 1 өдөр өмнө
23 өөрчлөгдсөн 3580 нэмэгдсэн , 60 устгасан
  1. 12 0
      admin-mp/src/pages/station/detail.vue
  2. 52 0
      admin-web-new/src/api/platform.ts
  3. 16 0
      admin-web-new/src/api/station.ts
  4. 9 0
      admin-web-new/src/router/modules/admin.ts
  5. 103 0
      admin-web-new/src/views/admin/platform/recharge-config-assign-dialog.vue
  6. 80 0
      admin-web-new/src/views/admin/platform/recharge-config-group-dialog.vue
  7. 140 0
      admin-web-new/src/views/admin/platform/recharge-config-item-dialog.vue
  8. 273 0
      admin-web-new/src/views/admin/platform/recharge-config.vue
  9. 126 0
      admin-web-new/src/views/admin/station/device-config.vue
  10. 591 0
      admin-web-new/src/views/admin/station/device-remote.vue
  11. 263 0
      admin-web-new/src/views/admin/station/device-upload.vue
  12. 58 3
      admin-web-new/src/views/admin/station/device.vue
  13. 15 0
      admin-web-new/src/views/admin/station/list.vue
  14. 263 0
      admin-web-new/src/views/admin/station/upload.vue
  15. 1 1
      admin-web/src/views/admin/finance/wallet-flow.vue
  16. 24 0
      admin-web/src/views/admin/station/list/dialog.vue
  17. 3 0
      admin-web/src/views/admin/station/list/index.vue
  18. 15 0
      car-wash-entity/src/main/java/com/kym/entity/WashStation.java
  19. 15 0
      car-wash-entity/src/main/java/com/kym/entity/vo/WashStationVo.java
  20. 52 9
      car-wash-mp/src/components/station/index.vue
  21. 72 46
      car-wash-mp/src/pages-user/faq/index.vue
  22. 1 1
      car-wash-mp/src/pages-wash/station/index.vue
  23. 1396 0
      docs/洗车机MQTT通信协议_优化排版版.md

+ 12 - 0
admin-mp/src/pages/station/detail.vue

@@ -67,6 +67,18 @@
       <view class="info-card">
         <text class="card-title">运营信息</text>
         <view class="info-list">
+          <view class="info-item">
+            <text class="item-label">运营主体</text>
+            <text class="item-value">{{ detail.operationEntity || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="item-label">统一社会信用代码</text>
+            <text class="item-value">{{ detail.creditCode || '-' }}</text>
+          </view>
+          <view class="info-item">
+            <text class="item-label">场站联系人</text>
+            <text class="item-value">{{ detail.contactPerson || '-' }}</text>
+          </view>
           <view class="info-item">
             <text class="item-label">营业时间</text>
             <text class="item-value">{{ detail.businessHours || '-' }}</text>

+ 52 - 0
admin-web-new/src/api/platform.ts

@@ -24,3 +24,55 @@ export const updatePlatformRate = (data: object) => {
 export const removePlatformRate = (id: number) => {
   return http.request<any>("get", `/platform-fee-rate/delete/${id}`);
 };
+
+// ==================== 充值配置 ====================
+
+/** 充值配置分组列表 */
+export const getRechargeConfigGroupList = (params?: object) => {
+  return http.request<any>("get", "/rechargeConfig/group/list", { params });
+};
+
+/** 新增充值配置分组 */
+export const addRechargeConfigGroup = (data: object) => {
+  return http.request<any>("post", "/rechargeConfig/group/add", { data });
+};
+
+/** 修改充值配置分组 */
+export const modifyRechargeConfigGroup = (data: object) => {
+  return http.request<any>("post", "/rechargeConfig/group/modify", { data });
+};
+
+/** 删除充值配置分组 */
+export const removeRechargeConfigGroup = (id: number) => {
+  return http.request<any>("get", `/rechargeConfig/group/remove/${id}`);
+};
+
+/** 关联站点到分组 */
+export const assignStationToGroup = (data: { groupId: number; stationId: string }) => {
+  return http.request<any>("post", "/rechargeConfig/group/assignStation", { data });
+};
+
+/** 从分组移除站点 */
+export const removeStationFromGroup = (data: { groupId: number; stationId: string }) => {
+  return http.request<any>("post", "/rechargeConfig/group/removeStation", { data });
+};
+
+/** 充值配置项列表 */
+export const getRechargeConfigItemList = (params: { groupId: number }) => {
+  return http.request<any>("get", "/rechargeConfig/item/list", { params });
+};
+
+/** 新增充值配置项 */
+export const addRechargeConfigItem = (data: object) => {
+  return http.request<any>("post", "/rechargeConfig/item/add", { data });
+};
+
+/** 修改充值配置项 */
+export const modifyRechargeConfigItem = (data: object) => {
+  return http.request<any>("post", "/rechargeConfig/item/modify", { data });
+};
+
+/** 删除充值配置项 */
+export const removeRechargeConfigItem = (id: number) => {
+  return http.request<any>("get", `/rechargeConfig/item/remove/${id}`);
+};

+ 16 - 0
admin-web-new/src/api/station.ts

@@ -54,3 +54,19 @@ export const removeDevice = (id: number) => {
 export const modifyDevice = (data: object) => {
   return http.request<any>("post", "/washDevice/modify", { data });
 };
+
+/** 导入站点 */
+export const importStation = (data: object) => {
+  return http.request<any>("post", "/station/importStation", { data });
+};
+
+/** 导入设备 (复用站点导入接口) */
+export const importDevice = (data: object) => {
+  return http.request<any>("post", "/station/importStation", { data });
+};
+
+/** 设备远程控制 */
+export const deviceRemoteApi = (url: string, data: object) => {
+  return http.request<any>("post", `/washDevice/remote/${url}`, { data });
+};
+

+ 9 - 0
admin-web-new/src/router/modules/admin.ts

@@ -179,6 +179,15 @@ export default {
             icon: "ri:server-line",
             title: "设备配置"
           }
+        },
+        {
+          path: "/admin/platform/recharge-config",
+          name: "AdminPlatformRechargeConfig",
+          component: () => import("@/views/admin/platform/recharge-config.vue"),
+          meta: {
+            icon: "ri:money-cny-circle-line",
+            title: "充值配置"
+          }
         }
       ]
     },

+ 103 - 0
admin-web-new/src/views/admin/platform/recharge-config-assign-dialog.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { assignStationToGroup } from "@/api/platform";
+import { getStationList } from "@/api/station";
+import { ElMessage } from "element-plus";
+
+defineOptions({ name: "RechargeConfigAssignStationDialog" });
+
+const emit = defineEmits(["refresh"]);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: { groupId: null as number | null, stationId: null as string | null },
+  btnLoading: false,
+  dialog: { isShowDialog: false },
+  stationOptions: [] as Array<{ value: string; label: string }>
+});
+
+const state = reactive(initState());
+
+const open = (groupId: number) => {
+  state.ruleForm.groupId = groupId;
+  state.ruleForm.stationId = null;
+  state.dialog.isShowDialog = true;
+
+  getStationList({ pageNum: 1, pageSize: 200 }).then((res: any) => {
+    state.stationOptions = (res.list || []).map((s: any) => ({
+      value: s.stationId,
+      label: s.stationName + " (" + s.stationId + ")"
+    }));
+  });
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => onClose();
+
+const onSubmit = () => {
+  if (!state.ruleForm.stationId) {
+    ElMessage.warning("请选择站点");
+    return;
+  }
+  state.btnLoading = true;
+  assignStationToGroup({
+    groupId: state.ruleForm.groupId!,
+    stationId: state.ruleForm.stationId
+  })
+    .then(() => {
+      state.btnLoading = false;
+      ElMessage.success("操作成功");
+      onClose();
+      emit("refresh");
+    })
+    .catch(() => {
+      state.btnLoading = false;
+      ElMessage.error("操作失败");
+    });
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <div class="system-dialog-container">
+    <el-dialog
+      title="关联站点到分组"
+      v-model="state.dialog.isShowDialog"
+      width="500px"
+      draggable
+      destroy-on-close
+      :close-on-click-modal="false"
+      align-center
+    >
+      <el-form :model="state.ruleForm" label-position="top" ref="formRef" size="default">
+        <el-form-item label="选择站点">
+          <el-select
+            v-model="state.ruleForm.stationId"
+            placeholder="选择要关联的站点"
+            filterable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="opt in state.stationOptions"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <el-button @click="onCancel" size="default">取 消</el-button>
+        <el-button :loading="state.btnLoading" type="primary" @click="onSubmit" size="default">
+          确 定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>

+ 80 - 0
admin-web-new/src/views/admin/platform/recharge-config-group-dialog.vue

@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addRechargeConfigGroup, modifyRechargeConfigGroup } from "@/api/platform";
+import { ElMessage } from "element-plus";
+
+defineOptions({ name: "RechargeConfigGroupDialog" });
+
+const emit = defineEmits(["refresh"]);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: { id: null as number | null, name: "" },
+  btnLoading: false,
+  dialog: { isShowDialog: false, type: "", title: "", submitTxt: "" }
+});
+
+const state = reactive(initState());
+
+const open = (action: string = "add", row: any) => {
+  state.dialog.title = (action === "add" ? "新增" : "编辑") + "『配置分组』";
+  state.dialog.submitTxt = (action === "add" ? "新增" : "编辑") + "『配置分组』";
+  state.dialog.isShowDialog = true;
+  if (action !== "add" && row) {
+    state.ruleForm.id = row.id;
+    state.ruleForm.name = row.name || "";
+  }
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => onClose();
+
+const onSubmit = () => {
+  state.btnLoading = true;
+  const url = !!state.ruleForm.id ? modifyRechargeConfigGroup : addRechargeConfigGroup;
+  url({ id: state.ruleForm.id, name: state.ruleForm.name || null, stationIds: [] })
+    .then(() => {
+      state.btnLoading = false;
+      ElMessage.success("操作成功");
+      onClose();
+      emit("refresh");
+    })
+    .catch(() => {
+      state.btnLoading = false;
+      ElMessage.error("操作失败");
+    });
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <div class="system-dialog-container">
+    <el-dialog
+      :title="state.dialog.title"
+      v-model="state.dialog.isShowDialog"
+      width="450px"
+      draggable
+      destroy-on-close
+      :close-on-click-modal="false"
+      align-center
+    >
+      <el-form :model="state.ruleForm" label-position="top" ref="formRef" size="default">
+        <el-form-item label="分组名称">
+          <el-input v-model="state.ruleForm.name" placeholder="如:VIP套餐(可选)" clearable />
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <el-button @click="onCancel" size="default">取 消</el-button>
+        <el-button :loading="state.btnLoading" type="primary" @click="onSubmit" size="default">
+          {{ state.dialog.submitTxt }}
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>

+ 140 - 0
admin-web-new/src/views/admin/platform/recharge-config-item-dialog.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { addRechargeConfigItem, modifyRechargeConfigItem } from "@/api/platform";
+import { ElMessage } from "element-plus";
+
+defineOptions({ name: "RechargeConfigItemDialog" });
+
+const emit = defineEmits(["refresh"]);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: {
+    id: null as number | null,
+    groupId: null as number | null,
+    rechargeAmountYuan: 0,
+    grantsAmountYuan: 0,
+    label: ""
+  },
+  btnLoading: false,
+  dialog: {
+    isShowDialog: false,
+    type: "",
+    title: "",
+    submitTxt: ""
+  },
+  rules: {
+    rechargeAmountYuan: [{ required: true, message: "请输入充值金额", trigger: "blur" }]
+  }
+});
+
+const state = reactive(initState());
+
+const open = (action: string = "add", row: any) => {
+  state.dialog.title = (action === "add" ? "新增" : "编辑") + "『配置项』";
+  state.dialog.submitTxt = (action === "add" ? "新增" : "编辑") + "『配置项』";
+  state.dialog.isShowDialog = true;
+  if (action !== "add" && row) {
+    state.ruleForm = {
+      id: row.id,
+      groupId: row.groupId,
+      rechargeAmountYuan: (row.rechargeAmount || 0) / 100,
+      grantsAmountYuan: (row.grantsAmount || 0) / 100,
+      label: row.label || ""
+    };
+  } else if (row) {
+    state.ruleForm.groupId = row.groupId;
+  }
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => onClose();
+
+const onSubmit = () => {
+  formRef.value.validate((valid: boolean) => {
+    if (valid) {
+      state.btnLoading = true;
+      const payload = {
+        id: state.ruleForm.id,
+        groupId: state.ruleForm.groupId,
+        rechargeAmount: Math.round(state.ruleForm.rechargeAmountYuan * 100),
+        grantsAmount: Math.round(state.ruleForm.grantsAmountYuan * 100),
+        label: state.ruleForm.label || null
+      };
+      const url = !!state.ruleForm.id ? modifyRechargeConfigItem : addRechargeConfigItem;
+      url(payload)
+        .then(() => {
+          state.btnLoading = false;
+          ElMessage.success("操作成功");
+          onClose();
+          emit("refresh");
+        })
+        .catch(() => {
+          state.btnLoading = false;
+          ElMessage.error("操作失败");
+        });
+    } else {
+      ElMessage.error("请先完整填写表单");
+    }
+  });
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <div class="system-dialog-container">
+    <el-dialog
+      :title="state.dialog.title"
+      v-model="state.dialog.isShowDialog"
+      width="500px"
+      draggable
+      destroy-on-close
+      :close-on-click-modal="false"
+      align-center
+    >
+      <el-form
+        :model="state.ruleForm"
+        :rules="state.rules"
+        label-position="top"
+        ref="formRef"
+        size="default"
+      >
+        <el-form-item label="充值金额(元)" prop="rechargeAmountYuan">
+          <el-input-number
+            v-model="state.ruleForm.rechargeAmountYuan"
+            placeholder="充值金额"
+            :min="0"
+            :step="10"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="赠款金额(元)" prop="grantsAmountYuan">
+          <el-input-number
+            v-model="state.ruleForm.grantsAmountYuan"
+            placeholder="赠款金额(0表示不赠送)"
+            :min="0"
+            :step="5"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="标签" prop="label">
+          <el-input v-model="state.ruleForm.label" placeholder="如:推荐、限时" clearable />
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="onCancel" size="default">取 消</el-button>
+          <el-button :loading="state.btnLoading" type="primary" @click="onSubmit" size="default">
+            {{ state.dialog.submitTxt }}
+          </el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>

+ 273 - 0
admin-web-new/src/views/admin/platform/recharge-config.vue

@@ -0,0 +1,273 @@
+<script setup lang="ts">
+import { reactive, onMounted, ref } from "vue";
+import {
+  getRechargeConfigGroupList,
+  removeRechargeConfigGroup,
+  getRechargeConfigItemList,
+  removeRechargeConfigItem,
+  removeStationFromGroup
+} from "@/api/platform";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { ElMessage, ElMessageBox } from "element-plus";
+import GroupDialog from "./recharge-config-group-dialog.vue";
+import AssignStationDialog from "./recharge-config-assign-dialog.vue";
+import ItemDialog from "./recharge-config-item-dialog.vue";
+
+defineOptions({ name: "AdminRechargeConfig" });
+
+const groupDialogRef = ref();
+const assignStationDialogRef = ref();
+const itemDialogRef = ref();
+
+interface GroupRow {
+  group: any;
+  stationIds: string[];
+  items: any[];
+  itemsLoading: boolean;
+}
+
+const state = reactive({
+  loading: false,
+  groups: [] as GroupRow[]
+});
+
+onMounted(() => {
+  loadData();
+});
+
+const loadData = () => {
+  state.loading = true;
+  getRechargeConfigGroupList()
+    .then((groups: any) => {
+      state.groups = (groups || []).map((g: any) => ({
+        group: g.group,
+        stationIds: g.stationIds || [],
+        items: [],
+        itemsLoading: false
+      }));
+      state.groups.forEach(g => loadItems(g));
+      state.loading = false;
+    })
+    .catch(() => {
+      state.loading = false;
+    });
+};
+
+const loadItems = (g: GroupRow) => {
+  g.itemsLoading = true;
+  getRechargeConfigItemList({ groupId: g.group.id })
+    .then((items: any) => {
+      g.items = items || [];
+      g.itemsLoading = false;
+    })
+    .catch(() => {
+      g.itemsLoading = false;
+    });
+};
+
+const handleGroupAdd = () => {
+  groupDialogRef.value.open("add", null);
+};
+
+const handleGroupRemove = (group: any) => {
+  ElMessageBox.confirm("此操作将永久删除该分组及其所有配置项,是否继续?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    removeRechargeConfigGroup(group.id)
+      .then(() => {
+        ElMessage.success("删除成功");
+        loadData();
+      })
+      .catch(() => {
+        ElMessage.error("删除失败");
+      });
+  });
+};
+
+const handleAssignStation = (groupId: number) => {
+  assignStationDialogRef.value.open(groupId);
+};
+
+const handleRemoveStation = (groupId: number, stationId: string) => {
+  removeStationFromGroup({ groupId, stationId })
+    .then(() => {
+      ElMessage.success("已移除");
+      loadData();
+    });
+};
+
+const handleItemAdd = (groupId: number) => {
+  itemDialogRef.value.open("add", { groupId });
+};
+
+const handleItemEdit = (groupId: number, row: any) => {
+  itemDialogRef.value.open("edit", { ...row, groupId });
+};
+
+const handleItemRemove = (row: any) => {
+  ElMessageBox.confirm("此操作将永久删除该配置项,是否继续?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    removeRechargeConfigItem(row.id)
+      .then(() => {
+        ElMessage.success("删除成功");
+        loadData();
+      })
+      .catch(() => {
+        ElMessage.error("删除失败");
+      });
+  });
+};
+
+const formatMoney = (fen: number) => {
+  if (fen == null) return "-";
+  return (fen / 100).toFixed(2) + " 元";
+};
+</script>
+
+<template>
+  <div class="page-container">
+    <el-card shadow="hover">
+      <div class="page-header mb10">
+        <el-button size="default" type="success" :icon="useRenderIcon('ri/folder-add-line')" @click="handleGroupAdd">
+          创建分组
+        </el-button>
+      </div>
+
+      <div v-loading="state.loading">
+        <el-empty v-if="!state.loading && state.groups.length === 0" description="暂无充值配置分组" />
+
+        <div v-for="g in state.groups" :key="g.group.id" class="group-card">
+          <div class="group-header">
+            <div class="group-info">
+              <span class="group-title">
+                <el-tag v-if="g.group.isDefault" type="danger" effect="light">默认</el-tag>
+                {{ g.group.name || (g.group.isDefault ? '平台默认配置' : '未命名分组') }}
+              </span>
+              <div class="station-tags">
+                <el-tag
+                  v-for="sid in g.stationIds"
+                  :key="sid"
+                  type="success"
+                  size="small"
+                  closable
+                  @close="handleRemoveStation(g.group.id, sid)"
+                >
+                  {{ sid }}
+                </el-tag>
+                <el-button size="small" text type="primary" @click="handleAssignStation(g.group.id)">
+                  + 关联站点
+                </el-button>
+              </div>
+            </div>
+            <div class="group-actions">
+              <el-button size="small" type="primary" plain @click="handleItemAdd(g.group.id)">
+                + 配置项
+              </el-button>
+              <el-button size="small" type="danger" plain @click="handleGroupRemove(g.group)">
+                删除
+              </el-button>
+            </div>
+          </div>
+          <div class="item-table-wrap">
+            <el-table border :data="g.items" size="small" v-loading="g.itemsLoading">
+              <template #empty>
+                <el-empty :image-size="40" description="暂无配置项" />
+              </template>
+              <el-table-column label="充值金额" prop="rechargeAmount" width="140">
+                <template #default="{ row }">
+                  {{ formatMoney(row.rechargeAmount) }}
+                </template>
+              </el-table-column>
+              <el-table-column label="赠款金额" prop="grantsAmount" width="140">
+                <template #default="{ row }">
+                  {{ formatMoney(row.grantsAmount) }}
+                </template>
+              </el-table-column>
+              <el-table-column label="标签" prop="label" min-width="120">
+                <template #default="{ row }">
+                  {{ row.label || '-' }}
+                </template>
+              </el-table-column>
+              <el-table-column label="操作" prop="action" width="160" align="center" fixed="right">
+                <template #default="{ row }">
+                  <el-button type="warning" size="small" text @click="handleItemEdit(g.group.id, row)">
+                    编辑
+                  </el-button>
+                  <el-button type="danger" size="small" text @click="handleItemRemove(row)">
+                    删除
+                  </el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+        </div>
+      </div>
+    </el-card>
+  </div>
+
+  <GroupDialog ref="groupDialogRef" @refresh="loadData" />
+  <AssignStationDialog ref="assignStationDialogRef" @refresh="loadData" />
+  <ItemDialog ref="itemDialogRef" @refresh="loadData" />
+</template>
+
+<style scoped lang="scss">
+.page-container {
+  padding: 15px;
+}
+
+.page-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.group-card {
+  margin-bottom: 16px;
+
+  .group-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 16px;
+    background-color: var(--el-fill-color-light);
+    border-radius: 6px 6px 0 0;
+    border: 1px solid var(--el-border-color-light);
+    border-bottom: none;
+
+    .group-info {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+
+      .group-title {
+        font-weight: 600;
+        font-size: 15px;
+      }
+    }
+
+    .group-actions {
+      display: flex;
+      gap: 8px;
+    }
+  }
+
+  .item-table-wrap {
+    border: 1px solid var(--el-border-color-light);
+    border-top: none;
+    border-radius: 0 0 6px 6px;
+    padding: 0;
+  }
+}
+
+.station-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-top: 4px;
+}
+</style>

+ 126 - 0
admin-web-new/src/views/admin/station/device-config.vue

@@ -0,0 +1,126 @@
+<script setup lang="ts">
+import { reactive, ref } from "vue";
+import { ElMessage } from "element-plus";
+import { batchModifyDeviceConfig, getDeviceConfigList } from "@/api/device-config";
+
+defineOptions({ name: "DeviceConfigDialog" });
+
+const emit = defineEmits(["refresh"]);
+const formRef = ref();
+
+const initState = () => ({
+  ruleForm: {
+    id: 0,
+    deviceConfigId: null as number | null,
+    deviceIds: [] as number[]
+  },
+  btnLoading: false,
+  dialog: {
+    isShowDialog: false,
+    type: "",
+    title: "",
+    submitTxt: ""
+  },
+  deviceConfigOptions: [] as Array<{ label: string; value: number }>
+});
+
+const state = reactive(initState());
+
+const open = (deviceIds: any) => {
+  state.ruleForm.deviceIds = deviceIds;
+  state.dialog.submitTxt = "绑定";
+  state.dialog.isShowDialog = true;
+  loadDeviceConfigOptions();
+};
+
+const loadDeviceConfigOptions = () => {
+  getDeviceConfigList({ pageNum: 1, pageSize: 200 }).then((res: any) => {
+    const list = res?.list || [];
+    state.deviceConfigOptions = list.map((item: any) => ({
+      label: item.name,
+      value: item.id
+    }));
+  });
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const onCancel = () => {
+  onClose();
+};
+
+const onSubmit = () => {
+  if (!state.ruleForm.deviceConfigId) {
+    ElMessage.warning("请选择要绑定的配置");
+    return;
+  }
+  state.btnLoading = true;
+  batchModifyDeviceConfig({
+    deviceConfigId: state.ruleForm.deviceConfigId,
+    deviceIds: state.ruleForm.deviceIds
+  })
+    .then(() => {
+      state.btnLoading = false;
+      ElMessage.success("绑定成功");
+      onClose();
+      emit("refresh");
+    })
+    .catch(() => {
+      state.btnLoading = false;
+    });
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <div class="system-dialog-container">
+    <el-dialog
+      title="批量绑定设备配置"
+      v-model="state.dialog.isShowDialog"
+      width="420px"
+      draggable
+      destroy-on-close
+      :close-on-click-modal="false"
+      align-center
+    >
+      <el-form
+        inline
+        :model="state.ruleForm"
+        label-position="top"
+        ref="formRef"
+        size="default"
+        label-width="100px"
+        class="mt5"
+      >
+        <el-form-item label="选择配置" style="width: 100%">
+          <el-select
+            v-model="state.ruleForm.deviceConfigId"
+            placeholder="请选择要绑定的配置"
+            clearable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="opt in state.deviceConfigOptions"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="onCancel" size="default">取 消</el-button>
+          <el-button :loading="state.btnLoading" type="primary" @click="onSubmit" size="default">
+            {{ state.dialog.submitTxt }}
+          </el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>

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

@@ -0,0 +1,591 @@
+<script setup lang="ts">
+import { reactive, computed } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import { Refresh, Reading, ChatDotSquare, CloseBold, SwitchButton, Loading } from "@element-plus/icons-vue";
+import { deviceRemoteApi } from "@/api/station";
+
+defineOptions({ name: "DeviceRemoteDialog" });
+
+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 api = (url: string, data: any = {}) => {
+  return deviceRemoteApi(url, { productKey, deviceName, ...data });
+};
+
+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 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) {
+    ElMessage.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;
+    ElMessage.success("配置读取成功");
+  } catch (e) {
+    ElMessage.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;
+    }
+    ElMessage.success("配置写入成功");
+  } catch (e) {
+    ElMessage.error("写入配置失败");
+  } finally {
+    state.loading = false;
+  }
+};
+
+const editPrice = (fn: any) => {
+  if (fn.price == null) {
+    ElMessage.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;
+    ElMessage.success(`${state.priceDialog.name}价格已更新`);
+  } catch (e) {
+    ElMessage.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;
+    ElMessage.success("消息已发送");
+  } catch (e) {
+    ElMessage.error("发送消息失败");
+  } finally {
+    state.loading = false;
+  }
+};
+
+const handleHideMsgbox = async () => {
+  state.loading = true;
+  try {
+    await api("hideMsgbox");
+    ElMessage.success("消息已清除");
+  } catch (e) {
+    ElMessage.error("清除消息失败");
+  } finally {
+    state.loading = false;
+  }
+};
+
+const handleReboot = () => {
+  ElMessageBox.confirm("确定要重启设备吗?重启有3-5秒延迟。", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(async () => {
+    state.loading = true;
+    try {
+      await api("reboot");
+      ElMessage.success("重启命令已发送");
+    } catch (e) {
+      ElMessage.error("重启失败");
+    } finally {
+      state.loading = false;
+    }
+  });
+};
+
+const handleForceCloseOrder = () => {
+  ElMessageBox.confirm("确定要强制结算当前订单吗?此操作将立即关闭订单并释放设备。", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(async () => {
+    state.loading = true;
+    try {
+      await api("forceCloseOrder");
+      ElMessage.success("强制结算命令已发送");
+      emit("refresh");
+      setTimeout(() => loadDeviceState(), 2000);
+    } catch (e) {
+      ElMessage.error("强制结算失败");
+    } finally {
+      state.loading = false;
+    }
+  });
+};
+
+defineExpose({ open });
+</script>
+
+<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>
+
+<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;
+  }
+}
+</style>

+ 263 - 0
admin-web-new/src/views/admin/station/device-upload.vue

@@ -0,0 +1,263 @@
+<script setup lang="ts">
+import { reactive, ref, nextTick } from "vue";
+import { ElMessage } from "element-plus";
+import { importDevice } from "@/api/station";
+import * as XLSX from "xlsx";
+
+defineOptions({ name: "DeviceUploadDialog" });
+
+const emit = defineEmits(["on-import-finish"]);
+const fileInputRef = ref<HTMLInputElement>();
+
+const fieldList = [
+  { label: "序号ID", value: "id" },
+  { label: "站点ID", value: "stationId" },
+  { label: "站点名称", value: "stationName" },
+  { label: "设备短编号", value: "shortId" },
+  { label: "充电桩SN", value: "equipmentId" },
+  { label: "充电口SN", value: "connectorId" },
+  { label: "车位编号", value: "parkingNo" }
+];
+
+const initState = () => ({
+  dialog: { isShowDialog: false },
+  excelForm: {
+    columns: [] as any[],
+    data: [] as any[],
+    importFields: [] as string[],
+    selectFields: [] as Array<{ value: string; label: string }>
+  },
+  uploadFileName: "" as string | null,
+  importLoading: false,
+  tableHeight: 400
+});
+
+const state = reactive(initState());
+
+const open = () => {
+  state.dialog.isShowDialog = true;
+  nextTick(() => {
+    const clientHeight = document.body.clientHeight;
+    state.tableHeight = clientHeight - 60 - 120 - 300;
+  });
+  state.excelForm.selectFields = fieldList.map(k => ({
+    value: k.value,
+    label: k.label
+  }));
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const handleOpenFile = () => {
+  fileInputRef.value?.click();
+};
+
+const handleFileChange = (e: Event) => {
+  const input = e.target as HTMLInputElement;
+  const file = input.files?.[0];
+  if (!file) return;
+  state.uploadFileName = file.name;
+  parseExcel(file);
+  input.value = "";
+};
+
+const parseExcel = (file: File) => {
+  const reader = new FileReader();
+  reader.onload = (e) => {
+    const fileData = e.target?.result;
+    const wb = XLSX.read(fileData, { type: "binary", cellDates: true });
+    const sheet = wb.SheetNames[0];
+    const rowList: any = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1 });
+
+    let maxColumnLength = 1;
+    rowList.forEach((row: any, idx: number) => {
+      if (idx < 10) maxColumnLength = Math.max(maxColumnLength, row.length);
+    });
+
+    const tmpHeaders: any[] = [];
+    const headers = rowList[0] || [];
+    for (let i = 0; i < maxColumnLength; i++) {
+      const header = i >= headers.length ? "" : headers[i];
+      const find = fieldList.find((k: any) => k.label === header || k.value === header);
+      state.excelForm.importFields[i + 1] = find ? find.value : "";
+      tmpHeaders.push({ field: `f${i}`, name: header || `列${i + 1}` });
+    }
+    state.excelForm.columns = [{ field: "idx", name: "#", fixed: "left" }, ...tmpHeaders];
+
+    const tmpContents: any[] = [];
+    for (let i = 1; i < rowList.length; i++) {
+      const dl = rowList[i];
+      const rowItem: any = {};
+      let fullEmpty = true;
+      dl.forEach((rowValue: any, columnIdx: number) => {
+        if (rowValue) fullEmpty = false;
+        rowItem[`f${columnIdx}`] = rowValue;
+      });
+      if (!fullEmpty) tmpContents.push(rowItem);
+    }
+    state.excelForm.data = tmpContents;
+  };
+  reader.readAsBinaryString(file);
+};
+
+const handleConfirm = () => {
+  const checks: string[] = [];
+  let checkPass = true;
+  state.excelForm.importFields.forEach(key => {
+    if (key) {
+      if (checks.includes(key)) {
+        checkPass = false;
+        ElMessage.error("表头不可重复");
+      } else {
+        checks.push(key);
+      }
+    }
+  });
+  if (!checkPass) return;
+
+  state.importLoading = true;
+  const fieldIndexes: number[] = [];
+  state.excelForm.importFields.forEach((key, idx) => {
+    if (key) fieldIndexes.push(idx - 1);
+  });
+  const fields = state.excelForm.importFields.filter(k => !!k);
+  const dataList: any[] = [];
+  state.excelForm.data.forEach(data => {
+    const item: any = {};
+    fieldIndexes.forEach((idx, i) => {
+      item[fields[i]] = data[`f${idx}`] || "";
+    });
+    dataList.push(item);
+  });
+
+  importDevice({ fields, fieldIndexes, dataList })
+    .then(() => {
+      ElMessage.success("导入成功");
+      state.importLoading = false;
+      emit("on-import-finish");
+      state.dialog.isShowDialog = false;
+    })
+    .catch(() => {
+      state.importLoading = false;
+    });
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    :title="'导入『设备信息』'"
+    v-model="state.dialog.isShowDialog"
+    width="75%"
+    destroy-on-close
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    draggable
+  >
+    <el-button @click="handleOpenFile" plain type="success" class="mt5">
+      请选择excel文件上传导入
+    </el-button>
+    <el-text type="primary" class="ml5">
+      {{ state.uploadFileName }}
+    </el-text>
+    <input
+      ref="fileInputRef"
+      type="file"
+      accept=".xls,.xlsx"
+      style="display: none"
+      @change="handleFileChange"
+    />
+
+    <div class="w100 mt5" v-if="state.excelForm.columns.length > 1">
+      <div class="field-select-row">
+        <div
+          v-for="(field, idx) in state.excelForm.columns"
+          :key="'select_' + idx"
+          class="field-select-header"
+        >
+          <template v-if="idx === 0">
+            <div class="idx-header">导入列选择</div>
+          </template>
+          <el-select
+            v-else
+            v-model="state.excelForm.importFields[idx]"
+            placeholder="选择字段"
+            style="width: 135px"
+            clearable
+            filterable
+          >
+            <el-option
+              v-for="opt in state.excelForm.selectFields"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+        </div>
+      </div>
+
+      <div class="table-wrap" :style="{ maxHeight: state.tableHeight + 'px' }">
+        <el-table
+          :data="state.excelForm.data"
+          border
+          :max-height="state.tableHeight"
+          style="width: 100%"
+          size="small"
+        >
+          <el-table-column
+            v-for="(field, idx) in state.excelForm.columns"
+            :key="idx"
+            :prop="field.field"
+            :label="field.name"
+            :width="135"
+            :fixed="field.fixed"
+            show-overflow-tooltip
+          >
+            <template v-if="field.field === 'idx'" #default="{ $index }">
+              <span>{{ $index + 1 }}</span>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="onClose" size="default">取 消</el-button>
+        <el-button type="primary" @click="handleConfirm" :loading="state.importLoading" size="default">
+          导入
+        </el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<style scoped lang="scss">
+.field-select-row {
+  display: flex;
+  overflow-x: auto;
+  margin-bottom: 8px;
+}
+
+.field-select-header {
+  flex-shrink: 0;
+  margin-right: 4px;
+}
+
+.idx-header {
+  width: 135px;
+  height: 32px;
+  line-height: 32px;
+  border: 1px solid #eee;
+  text-align: center;
+  font-size: 13px;
+}
+
+.table-wrap {
+  overflow-x: auto;
+}
+</style>

+ 58 - 3
admin-web-new/src/views/admin/station/device.vue

@@ -6,6 +6,9 @@ import { useRoute, useRouter } from "vue-router";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
 import DeviceDialog from "./device-dialog.vue";
+import DeviceUploadDialog from "./device-upload.vue";
+import DeviceConfigDialog from "./device-config.vue";
+import DeviceRemoteDialog from "./device-remote.vue";
 
 defineOptions({
   name: "AdminStationDevice"
@@ -16,10 +19,14 @@ const router = useRouter();
 const queryRef = ref();
 const tableRef = ref();
 const dialogRef = ref();
+const uploadDialogRef = ref();
+const configDialogRef = ref();
+const remoteDialogRef = ref();
 
 const state = reactive({
   stationId: (route.query.stationId as string) || "",
   stationOptions: [] as Array<{ stationId: string; stationName: string }>,
+  chooseDeviceList: [] as number[],
   formQuery: {
     stationId: "",
     shortId: "",
@@ -174,15 +181,40 @@ const formatDuration = (ms: number) => {
   const hours = Math.floor((seconds % 86400) / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
   const secs = seconds % 60;
-  
+
   const parts = [];
   if (days > 0) parts.push(`${days}天`);
   if (hours > 0) parts.push(`${hours}时`);
   if (minutes > 0) parts.push(`${minutes}分`);
   if (secs > 0 || parts.length === 0) parts.push(`${secs}秒`);
-  
+
   return parts.join("");
 };
+
+const handleSelectionChange = (selection: any[]) => {
+  state.chooseDeviceList = selection.map((k: any) => k.id);
+};
+
+const handleUpload = () => {
+  uploadDialogRef.value?.open();
+};
+
+const handleBatchConfig = () => {
+  if (state.chooseDeviceList.length === 0) {
+    ElMessage.warning("请先选择要配置的设备");
+    return;
+  }
+  configDialogRef.value?.open(state.chooseDeviceList);
+};
+
+const handleRemoteControl = (row: any) => {
+  const canRemote = !["sleep", "maintenance", "fault"].includes(row.state);
+  if (!canRemote) {
+    ElMessage.warning("当前设备状态不支持远程控制");
+    return;
+  }
+  remoteDialogRef.value?.open(row);
+};
 </script>
 
 <template>
@@ -280,6 +312,21 @@ const formatDuration = (ms: number) => {
           >
             新增
           </el-button>
+          <el-button
+            type="warning"
+            :icon="useRenderIcon('ri/upload-line')"
+            @click="handleUpload"
+          >
+            导入
+          </el-button>
+          <el-button
+            type="primary"
+            :icon="useRenderIcon('ri/settings-3-line')"
+            @click="handleBatchConfig"
+            :disabled="state.chooseDeviceList.length === 0"
+          >
+            批量配置
+          </el-button>
         </el-form-item>
       </el-form>
 
@@ -290,7 +337,9 @@ const formatDuration = (ms: number) => {
         :height="state.tableData.height"
         border
         stripe
+        @selection-change="handleSelectionChange"
       >
+        <el-table-column type="selection" width="50" />
         <template #empty>
           <el-empty description="暂无设备数据,点击「新增」按钮添加设备" />
         </template>
@@ -324,8 +373,11 @@ const formatDuration = (ms: number) => {
             </template>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="180" fixed="right">
+        <el-table-column label="操作" width="240" fixed="right">
           <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleRemoteControl(row)">
+              远程控制
+            </el-button>
             <el-button type="warning" link size="small" @click="handleEdit(row)">
               编辑
             </el-button>
@@ -350,6 +402,9 @@ const formatDuration = (ms: number) => {
     </el-card>
 
     <DeviceDialog ref="dialogRef" @refresh="loadData(true)" />
+    <DeviceUploadDialog ref="uploadDialogRef" @on-import-finish="loadData(true)" />
+    <DeviceConfigDialog ref="configDialogRef" @refresh="loadData(true)" />
+    <DeviceRemoteDialog ref="remoteDialogRef" @refresh="loadData(true)" />
   </div>
 </template>
 

+ 15 - 0
admin-web-new/src/views/admin/station/list.vue

@@ -6,6 +6,7 @@ import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
 import StationDialog from "./dialog.vue";
+import StationUploadDialog from "./upload.vue";
 
 defineOptions({
   name: "AdminStationList"
@@ -15,6 +16,7 @@ const router = useRouter();
 const queryRef = ref();
 const tableRef = ref();
 const dialogRef = ref();
+const uploadDialogRef = ref();
 const qrDialogVisible = ref(false);
 const currentQrCode = ref("");
 const currentStationName = ref("");
@@ -139,6 +141,10 @@ const handleShowQrCode = (row: any) => {
   qrDialogVisible.value = true;
 };
 
+const handleUpload = () => {
+  uploadDialogRef.value?.open();
+};
+
 const handleGotoDevice = (row: any) => {
   router.push(`/admin/station/device?stationId=${row.stationId}`);
 };
@@ -209,6 +215,13 @@ const handleGotoDevice = (row: any) => {
           >
             新增
           </el-button>
+          <el-button
+            type="warning"
+            :icon="useRenderIcon('ri/upload-line')"
+            @click="handleUpload"
+          >
+            导入
+          </el-button>
         </el-form-item>
       </el-form>
 
@@ -305,6 +318,8 @@ const handleGotoDevice = (row: any) => {
 
     <StationDialog ref="dialogRef" @refresh="loadData" />
 
+    <StationUploadDialog ref="uploadDialogRef" @on-import-finish="loadData(true)" />
+
     <el-dialog
       v-model="qrDialogVisible"
       :title="`${currentStationName} - 停车减免二维码`"

+ 263 - 0
admin-web-new/src/views/admin/station/upload.vue

@@ -0,0 +1,263 @@
+<script setup lang="ts">
+import { reactive, ref, nextTick } from "vue";
+import { ElMessage } from "element-plus";
+import { importStation } from "@/api/station";
+import * as XLSX from "xlsx";
+
+defineOptions({ name: "StationUploadDialog" });
+
+const emit = defineEmits(["on-import-finish"]);
+const fileInputRef = ref<HTMLInputElement>();
+
+const fieldList = [
+  { label: "序号ID", value: "id" },
+  { label: "站点ID", value: "stationId" },
+  { label: "站点名称", value: "stationName" },
+  { label: "设备短编号", value: "shortId" },
+  { label: "充电桩SN", value: "equipmentId" },
+  { label: "充电口SN", value: "connectorId" },
+  { label: "车位编号", value: "parkingNo" }
+];
+
+const initState = () => ({
+  dialog: { isShowDialog: false },
+  excelForm: {
+    columns: [] as any[],
+    data: [] as any[],
+    importFields: [] as string[],
+    selectFields: [] as Array<{ value: string; label: string }>
+  },
+  uploadFileName: "" as string | null,
+  importLoading: false,
+  tableHeight: 400
+});
+
+const state = reactive(initState());
+
+const open = () => {
+  state.dialog.isShowDialog = true;
+  nextTick(() => {
+    const clientHeight = document.body.clientHeight;
+    state.tableHeight = clientHeight - 60 - 120 - 300;
+  });
+  state.excelForm.selectFields = fieldList.map(k => ({
+    value: k.value,
+    label: k.label
+  }));
+};
+
+const onClose = () => {
+  state.dialog.isShowDialog = false;
+  Object.assign(state, initState());
+};
+
+const handleOpenFile = () => {
+  fileInputRef.value?.click();
+};
+
+const handleFileChange = (e: Event) => {
+  const input = e.target as HTMLInputElement;
+  const file = input.files?.[0];
+  if (!file) return;
+  state.uploadFileName = file.name;
+  parseExcel(file);
+  input.value = "";
+};
+
+const parseExcel = (file: File) => {
+  const reader = new FileReader();
+  reader.onload = (e) => {
+    const fileData = e.target?.result;
+    const wb = XLSX.read(fileData, { type: "binary", cellDates: true });
+    const sheet = wb.SheetNames[0];
+    const rowList: any = XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1 });
+
+    let maxColumnLength = 1;
+    rowList.forEach((row: any, idx: number) => {
+      if (idx < 10) maxColumnLength = Math.max(maxColumnLength, row.length);
+    });
+
+    const tmpHeaders: any[] = [];
+    const headers = rowList[0] || [];
+    for (let i = 0; i < maxColumnLength; i++) {
+      const header = i >= headers.length ? "" : headers[i];
+      const find = fieldList.find((k: any) => k.label === header || k.value === header);
+      state.excelForm.importFields[i + 1] = find ? find.value : "";
+      tmpHeaders.push({ field: `f${i}`, name: header || `列${i + 1}` });
+    }
+    state.excelForm.columns = [{ field: "idx", name: "#", fixed: "left" }, ...tmpHeaders];
+
+    const tmpContents: any[] = [];
+    for (let i = 1; i < rowList.length; i++) {
+      const dl = rowList[i];
+      const rowItem: any = {};
+      let fullEmpty = true;
+      dl.forEach((rowValue: any, columnIdx: number) => {
+        if (rowValue) fullEmpty = false;
+        rowItem[`f${columnIdx}`] = rowValue;
+      });
+      if (!fullEmpty) tmpContents.push(rowItem);
+    }
+    state.excelForm.data = tmpContents;
+  };
+  reader.readAsBinaryString(file);
+};
+
+const handleConfirm = () => {
+  const checks: string[] = [];
+  let checkPass = true;
+  state.excelForm.importFields.forEach(key => {
+    if (key) {
+      if (checks.includes(key)) {
+        checkPass = false;
+        ElMessage.error("表头不可重复");
+      } else {
+        checks.push(key);
+      }
+    }
+  });
+  if (!checkPass) return;
+
+  state.importLoading = true;
+  const fieldIndexes: number[] = [];
+  state.excelForm.importFields.forEach((key, idx) => {
+    if (key) fieldIndexes.push(idx - 1);
+  });
+  const fields = state.excelForm.importFields.filter(k => !!k);
+  const dataList: any[] = [];
+  state.excelForm.data.forEach(data => {
+    const item: any = {};
+    fieldIndexes.forEach((idx, i) => {
+      item[fields[i]] = data[`f${idx}`] || "";
+    });
+    dataList.push(item);
+  });
+
+  importStation({ fields, fieldIndexes, dataList })
+    .then(() => {
+      ElMessage.success("导入成功");
+      state.importLoading = false;
+      emit("on-import-finish");
+      state.dialog.isShowDialog = false;
+    })
+    .catch(() => {
+      state.importLoading = false;
+    });
+};
+
+defineExpose({ open });
+</script>
+
+<template>
+  <el-dialog
+    :title="'导入『站点信息』'"
+    v-model="state.dialog.isShowDialog"
+    width="75%"
+    destroy-on-close
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    draggable
+  >
+    <el-button @click="handleOpenFile" plain type="success" class="mt5">
+      请选择excel文件上传导入
+    </el-button>
+    <el-text type="primary" class="ml5">
+      {{ state.uploadFileName }}
+    </el-text>
+    <input
+      ref="fileInputRef"
+      type="file"
+      accept=".xls,.xlsx"
+      style="display: none"
+      @change="handleFileChange"
+    />
+
+    <div class="w100 mt5" v-if="state.excelForm.columns.length > 1">
+      <div class="field-select-row">
+        <div
+          v-for="(field, idx) in state.excelForm.columns"
+          :key="'select_' + idx"
+          class="field-select-header"
+        >
+          <template v-if="idx === 0">
+            <div class="idx-header">导入列选择</div>
+          </template>
+          <el-select
+            v-else
+            v-model="state.excelForm.importFields[idx]"
+            placeholder="选择字段"
+            style="width: 135px"
+            clearable
+            filterable
+          >
+            <el-option
+              v-for="opt in state.excelForm.selectFields"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+        </div>
+      </div>
+
+      <div class="table-wrap" :style="{ maxHeight: state.tableHeight + 'px' }">
+        <el-table
+          :data="state.excelForm.data"
+          border
+          :max-height="state.tableHeight"
+          style="width: 100%"
+          size="small"
+        >
+          <el-table-column
+            v-for="(field, idx) in state.excelForm.columns"
+            :key="idx"
+            :prop="field.field"
+            :label="field.name"
+            :width="135"
+            :fixed="field.fixed"
+            show-overflow-tooltip
+          >
+            <template v-if="field.field === 'idx'" #default="{ $index }">
+              <span>{{ $index + 1 }}</span>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="onClose" size="default">取 消</el-button>
+        <el-button type="primary" @click="handleConfirm" :loading="state.importLoading" size="default">
+          导入
+        </el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<style scoped lang="scss">
+.field-select-row {
+  display: flex;
+  overflow-x: auto;
+  margin-bottom: 8px;
+}
+
+.field-select-header {
+  flex-shrink: 0;
+  margin-right: 4px;
+}
+
+.idx-header {
+  width: 135px;
+  height: 32px;
+  line-height: 32px;
+  border: 1px solid #eee;
+  text-align: center;
+  font-size: 13px;
+}
+
+.table-wrap {
+  overflow-x: auto;
+}
+</style>

+ 1 - 1
admin-web/src/views/admin/finance/wallet-flow.vue

@@ -204,8 +204,8 @@ const state = reactive({
       {label: '交易后余额', prop: 'afterBalance', width: 120, resizable: true},
       {label: '交易前赠款', prop: 'beforeGrantsBalance', width: 120, resizable: true},
       {label: '交易后赠款', prop: 'afterGrantsBalance', width: 120, resizable: true},
-      {label: '状态', prop: 'status', width: 100, resizable: true},
       {label: '交易时间', prop: 'transactionTime', width: 170, resizable: true, fixed: 'right'},
+      {label: '状态', prop: 'status', width: 100, resizable: true},
       {label: '备注', prop: 'remark', minWidth: 150, resizable: true},
     ],
   },

+ 24 - 0
admin-web/src/views/admin/station/list/dialog.vue

@@ -67,6 +67,30 @@
 
             </el-form-item>
 
+            <el-form-item label="运营主体:" prop="operationEntity" class="wd350">
+              <el-input
+                  v-model="state.ruleForm.operationEntity"
+                  placeholder="运营主体"
+                  clearable
+                  class="wd100">
+              </el-input>
+            </el-form-item>
+            <el-form-item label="统一社会信用代码:" prop="creditCode" class="wd350">
+              <el-input
+                  v-model="state.ruleForm.creditCode"
+                  placeholder="统一社会信用代码"
+                  clearable
+                  class="wd100">
+              </el-input>
+            </el-form-item>
+            <el-form-item label="场站联系人:" prop="contactPerson" class="wd350">
+              <el-input
+                  v-model="state.ruleForm.contactPerson"
+                  placeholder="场站联系人"
+                  clearable
+                  class="wd100">
+              </el-input>
+            </el-form-item>
             <el-form-item label="地址:" prop="address" class="w100">
               <el-input
                   v-model="state.ruleForm.address"

+ 3 - 0
admin-web/src/views/admin/station/list/index.vue

@@ -160,6 +160,9 @@ const state = reactive({
     {label: '站点照片', prop: 'pictures', query: false, type: 'text', resizable: true},
     {label: '站点状态', prop: 'stationStatus', query: true, type: 'dict', conf:{dict:'WashStation.status'},resizable: true},
     {label: '站点类型', prop: 'stationType', query: true, type: 'dict',conf:{dict:'WashStation.type'}, resizable: true},
+    {label: '运营主体', prop: 'operationEntity', query: true, type: 'text', resizable: true},
+    {label: '信用代码', prop: 'creditCode', query: true, type: 'text', resizable: true},
+    {label: '场站联系人', prop: 'contactPerson', query: true, type: 'text', resizable: true},
     {label: '地址', prop: 'address', query: true, type: 'text', resizable: true},
     {label: '服务电话', prop: 'serviceTel', query: true, type: 'text', resizable: true},
     {label: '营业时间', prop: 'businessHours', query: false, type: 'text', resizable: true},

+ 15 - 0
car-wash-entity/src/main/java/com/kym/entity/WashStation.java

@@ -101,4 +101,19 @@ public class WashStation extends BaseEntity {
      * 备注
      */
     private String remark;
+
+    /**
+     * 运营主体
+     */
+    private String operationEntity;
+
+    /**
+     * 统一社会信用代码
+     */
+    private String creditCode;
+
+    /**
+     * 场站联系人
+     */
+    private String contactPerson;
 }

+ 15 - 0
car-wash-entity/src/main/java/com/kym/entity/vo/WashStationVo.java

@@ -85,4 +85,19 @@ public class WashStationVo extends BaseEntity {
      * 备注
      */
     private String remark;
+
+    /**
+     * 运营主体
+     */
+    private String operationEntity;
+
+    /**
+     * 统一社会信用代码
+     */
+    private String creditCode;
+
+    /**
+     * 场站联系人
+     */
+    private String contactPerson;
 }

+ 52 - 9
car-wash-mp/src/components/station/index.vue

@@ -27,13 +27,18 @@
     </view>
 
     <view class="station_item-footer">
-      <view class="footer-row">
-        <uv-icon name="clock" size="14" color="#909399"></uv-icon>
-        <text class="business_hours">营业时间: {{ item.businessHours }}</text>
+      <view class="footer-info">
+        <view class="footer-row">
+          <uv-icon name="clock" size="14" color="#909399"></uv-icon>
+          <text class="business_hours">营业时间: {{ item.businessHours }}</text>
+        </view>
+        <view class="footer-row" v-if="item.parkingFee">
+          <text class="parking-icon">P</text>
+          <text class="parking-fee">{{ item.parkingFee }}</text>
+        </view>
       </view>
-      <view class="footer-row" v-if="item.parkingFee">
-        <text class="parking-icon">P</text>
-        <text class="parking-fee">{{ item.parkingFee }}</text>
+      <view class="footer-phone" v-if="showPhone && (item.serviceTel || item.stationTel)" @click.stop="handleCallStation">
+        <uv-icon name="phone" size="22" color="#C6171E"></uv-icon>
       </view>
     </view>
   </view>
@@ -46,7 +51,11 @@ import {fmtDictName} from "@/utils/common"
 const props = defineProps({
   item: {
     type: Object,
-  }
+  },
+  showPhone: {
+    type: Boolean,
+    default: false,
+  },
 })
 
 
@@ -61,6 +70,22 @@ const handleNavMap = (station: any) => {
   });
 }
 
+const handleCallStation = () => {
+  const phone = props.item.serviceTel || props.item.stationTel;
+  if (!phone) return;
+  uni.showModal({
+    title: "联系站点",
+    content: "是否拨打站点服务电话?",
+    confirmText: "拨打",
+    confirmColor: "#C6171E",
+    success: (res) => {
+      if (res.confirm) {
+        uni.makePhoneCall({ phoneNumber: phone });
+      }
+    },
+  });
+};
+
 const handleNavStation = (station: any) => {
   let currentPages = getCurrentPages();
   let currentPageRoute = currentPages[currentPages.length - 1].route;
@@ -224,11 +249,18 @@ defineExpose({
 
   &-footer {
     display: flex;
-    flex-direction: column;
-    gap: 10rpx;
+    justify-content: space-between;
+    align-items: center;
     padding-top: 16rpx;
     border-top: 1px solid $uni-border-color-light;
 
+    .footer-info {
+      display: flex;
+      flex-direction: column;
+      gap: 10rpx;
+      flex: 1;
+    }
+
     .footer-row {
       display: flex;
       align-items: center;
@@ -240,6 +272,17 @@ defineExpose({
       color: $uni-text-color-tertiary;
     }
 
+    .footer-phone {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 68rpx;
+      height: 68rpx;
+      background: rgba($uni-color-primary, 0.08);
+      border-radius: 50%;
+      flex-shrink: 0;
+    }
+
     .parking-fee {
       font-size: $uni-font-size-sm;
       color: $uni-color-primary;

+ 72 - 46
car-wash-mp/src/pages-user/faq/index.vue

@@ -10,57 +10,52 @@
 </template>
 
 <script setup lang="ts">
-import {onHide, onShow} from "@dcloudio/uni-app";
 import {reactive} from "vue";
-import {body} from "@/utils/https"
-import {getServicePhone} from "@/utils/common";
 
+const guideContent = [
+  {
+    question: "如何使用自助洗车机?",
+    answer: "<p>使用Yeswash自助洗车非常简单,只需三步:</p><p><strong>1. 扫码启动</strong> — 打开小程序,扫描洗车机上的二维码,选择洗车套餐并支付。</p><p><strong>2. 开始洗车</strong> — 支付成功后,洗车机自动启动,按照设备屏幕提示将车辆驶入指定位置。</p><p><strong>3. 完成取车</strong> — 洗车结束后,设备会有语音和灯光提示,将车辆驶出即可。洗车记录会自动保存在「我的订单」中。</p>"
+  },
+  {
+    question: "洗车前需要做哪些准备?",
+    answer: "<p>为了确保洗车效果和车辆安全,请注意以下事项:</p><p>• 关闭所有车窗和天窗,确保完全密闭</p><p>• 收起外后视镜(如车辆支持电动折叠)</p><p>• 检查并收好车外天线(如有可拆卸天线请提前取下)</p><p>• 确认油箱盖已盖紧</p><p>• 车身如有明显大块泥沙、污物,建议先简单清理</p><p>• 车辆改装件(尾翼、行李架等)请确认安装牢固</p>"
+  },
+  {
+    question: "洗车过程中有哪些注意事项?",
+    answer: "<p>洗车过程中请务必注意:</p><p>• 车辆挂P挡、拉好手刹,发动机熄火</p><p>• 不要留在车内,在指定安全区域等候</p><p>• 不要在洗车机运行期间靠近或触摸设备</p><p>• 如遇设备异常或异响,请立即按下急停按钮</p><p>• 不要在雷雨天气使用洗车设备</p>"
+  },
+  {
+    question: "支持哪些支付方式?",
+    answer: "<p>目前Yeswash支持以下支付方式:</p><p>• <strong>账户余额</strong> — 充值后使用余额支付,方便快捷,推荐充值享受更多优惠</p><p>• <strong>微信支付</strong> — 直接调用微信支付完成付款</p><p>充值路径:进入「我的」→「我的钱包」→「充值」,选择金额完成充值即可。</p>"
+  },
+  {
+    question: "洗车后如何查看订单?",
+    answer: "<p>洗车完成后,订单会自动保存,您可以通过以下方式查看:</p><p>进入「我的」→「我的订单」,即可查看所有历史洗车记录。点击具体订单可查看详情,包括洗车时间、消费金额、洗车网点等信息。</p>"
+  },
+  {
+    question: "遇到问题如何联系客服?",
+    answer: "<p>如在使用过程中遇到任何问题,可通过以下方式联系我们:</p><p>• 进入「我的」→「联系我们」,直接拨打客服电话</p><p>• 如有设备故障或洗车质量问题,可通过「纠错上报」提交反馈,我们会尽快处理</p><p>客服工作时间:每日 7:00 - 22:00</p>"
+  },
+  {
+    question: "哪些车型不适合自助洗车?",
+    answer: "<p>以下类型的车辆可能不适合使用自助洗车机:</p><p>• 车身高度超过2.2米的车辆(如大型SUV、房车等)</p><p>• 车身宽度超过2.1米的车辆</p><p>• 装有非原厂大型改装件的车辆</p><p>• 车身存在严重破损或漆面大面积脱落的车辆</p><p>如不确定您的车辆是否适用,请先联系客服确认。</p>"
+  },
+  {
+    question: "洗车套餐有哪些选择?",
+    answer: "<p>不同网点提供的套餐可能略有差异,常见套餐包括:</p><p>• <strong>标准洗</strong> — 高压冲洗+泡沫洗+清水冲洗+风干,适合日常清洁</p><p>• <strong>精洗</strong> — 在标准洗基础上增加刷洗环节,清洁更彻底</p><p>• <strong>打蜡洗</strong> — 洗车同时进行水蜡护理,让车漆更亮丽</p><p>具体套餐和价格以扫码后显示的为准。</p>"
+  },
+];
+
+const state = reactive({
+  questions: guideContent,
+});
 
-const initState = () => ({
-  questions: [] as any[],
-  servicerPhone: "",
-})
-
-const state = reactive(initState())
-
-const handleChange = () => {
-
-}
-
-const close = () => {
-
-}
-
-const open = () => {
-
-}
-
-const call = () => {
-  uni.makePhoneCall({
-    phoneNumber: state.servicerPhone,
-  });
-};
-
-const toggle = (index: number) => {
-  state.questions = state.questions.map((item, i) => {
-    return {
-      ...item,
-      open: item.open ? false : i === index,
-    };
-  });
-};
+const handleChange = () => {};
 
-onShow(() => {
-  state.servicerPhone =getServicePhone();
-  // getApp<any>().globalData.config?.servicePhone;
-  body(`/faq/list`).then((res: any) => {
-    state.questions = res.list;
-  })
-});
+const close = () => {};
 
-onHide(() => {
-  Object.assign(state, initState());
-})
+const open = () => {};
 </script>
 
 <style lang="scss" scoped>
@@ -69,6 +64,37 @@ onHide(() => {
   background: $uni-bg-color-card;
   box-sizing: border-box;
   padding: 40rpx 32rpx;
+
+  // 折叠面板项间距
+  :deep(.uv-collapse-item) {
+    margin-bottom: 24rpx;
+  }
+
+  // 问题标题文字
+  :deep(.uv-cell__title-text) {
+    font-size: 30rpx !important;
+    font-weight: 500;
+    line-height: 1.6 !important;
+  }
+
+  // 答案内容容器
+  :deep(.uv-collapse-item__content__text) {
+    font-size: 28rpx !important;
+    line-height: 2 !important;
+    padding: 16rpx 15px 32rpx !important;
+  }
+
+  // uv-parse 富文本内的段落和文字
+  :deep(._p) {
+    font-size: 28rpx;
+    line-height: 2;
+    margin-bottom: 12rpx;
+  }
+
+  :deep(._strong) {
+    font-size: 28rpx;
+    line-height: 2;
+  }
 }
 
 </style>

+ 1 - 1
car-wash-mp/src/pages-wash/station/index.vue

@@ -22,7 +22,7 @@
 
       <!-- 站点信息 -->
       <view class="station-section">
-        <WashStation :item="state.station" ref="station_ref"></WashStation>
+        <WashStation :item="state.station" ref="station_ref" :showPhone="true"></WashStation>
       </view>
 
       <!-- 设备列表 -->

+ 1396 - 0
docs/洗车机MQTT通信协议_优化排版版.md

@@ -0,0 +1,1396 @@
+# 文档目录
+
+- 简介
+- MQTT 认证
+- MQTT 连接说明
+- MQTT Topic 说明
+- 设备 RPC 通信
+- 数据定义
+- 同步操作接口
+- 事件上传
+- 常见问题解答
+- 版本维护记录
+
+---
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+# 洗车机 MQTT 通信协议
+## 简介
+设备和Web后端通过MQTT协议连接到MQTT Broker,相对于Broker⽽⾔,设备和Web后端都是MQTT客户端。
+MQTT Broker可以使⽤阿⾥云物联⽹平台,也可以⾃⼰基于开源MQTT Broker做⼆次开发。
+## MQTT 认证
+设备出⼚前写入产品标识、设备名称、设备秘钥三项配置信息(后续简称三元组)和MQTT Broker的信息,按照阿
+⾥云物联⽹平台的认证⽅式,计算出MQTT连接需要的客户端标识、⽤户名称、⽤户密码去连接MQTT Broker。
+实例认证信息如下:
+```json
+{
+    "clientId":
+"${ProductKey}.${DeviceName}|securemode=2,signmethod=hmacsha256,timestamp=17547250
+53877|",
+    "username": "${DeviceName}&${ProductKey}",
+    "passwd": "868deee862f87b5d4136e2adf54bfede427ada8e646a4cec52aacfeafc488220",
+    "mqttHostUrl": "${ProductKey}.iot-as-mqtt.cn-shanghai.aliyuncs.com",
+    "port": 1883
+}
+```
+以下字段在出⼚前使⽤上位机⼯具写入:
+ProductKey:产品标识
+DeviceName:设备名称
+DeviceSecure:设备秘钥
+MqttHost: MQTT服务器地址(覆盖上⾯的mqttHostUrl)
+MqttPort: MQTT Broker使⽤的端⼝(覆盖上⾯的port)
+对于⽀持WiFi的版本可以提前写入认证信息(ssid和password),也可以通过触摸屏⾃⾏设置。
+认证⽅式⻅文档mqtt-sign-demo中的⽰例程序。提供有C、Java、Python、Go、C#、JavaScript等语⾔
+的实现。
+clientId字段说明
+securemode 加密⽅式,设备固定是2
+signmethod 签名算法,设备固定是hmacsha256
+timestamp Unix时间戳,单位是毫秒, 系统按照设定的时区来计算。
+username 设备名称&产品标识
+password 按照阿⾥云物联⽹平台的⽅式进⾏计算。
+## MQTT 连接说明
+MQTT连接签名⽰例
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+## MQTT Topic 说明
+设备主动上报的数据都是Topic: `/${ProductKey}/${DeviceName}/user/report` 设备的RPC同步调
+⽤ Topic中符号+的位置可以是不同的数字,表⽰第N次RPC调⽤,设备会把这个数字原样返回,⽅便调
+⽤和分析问题。 设备会使⽤MQTT协议来尝试进⾏NTP时间同步,如果使⽤⾃建的MQTT Broker不⽀持
+也不影响正常使⽤。
+作⽤
+Topic名称
+说明
+设备主动上
+报数据
+/${ProductKey}/${DeviceName}/user/report
+设备发布Topic(后端订阅来
+接收数据)
+接收RPC请
+求
+/sys/${ProductKey}/${DeviceName}/rrpc/request/+
+设备订阅的Topic(后端发布
+Topic)
+回复RPC请
+求
+/sys/${ProductKey}/${DeviceName}/rrpc/response/+
+设备发布Topic(后端订阅来
+接收回复)
+请求NTP时
+间同步
+/ext/ntp/${ProductKey}/${DeviceName}/request
+设备发布Topic(后端订阅来
+接收回复)
+接收NTP时
+间信息
+/ext/ntp/${ProductKey}/${DeviceName}/response
+设备订阅的Topic(后端收到
+请求后,发布Topic来回复
+NTP时间)
+## 设备 RPC 通信
+请求和回复都使⽤JSON字符串格式,通过兼容MQTT的RPC接⼝来实现。下⾯ 为简化调试和接⼝处理,
+配置或参数项只使⽤字符串和整数两种类型(不⽀持布尔类型和浮点类型)。
+设备订阅请求:/sys/${ProductKey}/${DeviceName}/rrpc/request/+
+设备发布应答:/sys/${ProductKey}/${DeviceName}/rrpc/response/+
+参考⽰例如下:
+请求
+请求是指通过阿⾥云物联⽹平台发送给设备的数据。
+version协议版本号,⽬前固定为2.0。
+method 要调⽤的远程⽅法名称。
+params ⽅法要传递的参数。
+```json
+{
+    "version": "2.0",
+    "method": "help",
+    "params": []
+}
+```
+回复
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+回复是设备通过阿⾥云物联⽹平台返回的数据。
+code 操作状态码。
+200 操作成功。
+400 错误的请求。
+404 没有找到数据。
+code_msg 状态码文本解释。
+data ⽅法执⾏返回的数据。
+errors 如果执⾏出错,这⾥返回具体的错误原因,没有错误返回空。
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "api_list": [
+            "help",
+            "reboot",
+            "query_state",
+            "query_hardware_info",
+            "create_order",
+            "close_order",
+            "query_order",
+            "read_config",
+            "write_config",
+            "show_msgbox",
+            "hide_msgbox"
+        ]
+    },
+    "errors": []
+}
+```
+## 数据定义
+### 设备状态
+device_state
+state 设备的状态(重要)。
+init 设备正在初始化(启动阶段,只有在主动上报的启动事件中才可能会出现)。
+idle 设备空闲,可以创建订单,开机洗⻋。
+busy 设备忙碌,正在洗⻋,不能再次创建订单。
+sleep 不在营业时间段,不能创建订单洗⻋。
+maintenance 维护模式,不能创建订单洗⻋。
+fault 设备故障状态,不能创建订单洗⻋(预留,后续4G版本⽀持)。
+fault_reason 故障原因(4G版本⽀持)。
+fsm_state 设备主状态机的当前状态(状态可能增加或减少,仅供调试参考。)。
+uptime_ms 本次上电以来的时间戳。
+boot_flag 启动标识(芯片相关,⼆次开发⽆需关⼼)。
+has_water 是否有⽔
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+-1 不⽀持或未开启
+0 表⽰没有⽔
+1 表⽰有⽔
+has_foam 是否有泡沫
+-1 不⽀持或未开启
+0 表⽰没有泡沫
+1 表⽰有泡沫
+temperature_chip 板载温度传感器的温度值。
+```json
+{
+    "uptime_ms": "47141",
+    "boot_flag": "software",
+    "state": "idle",
+    "fsm_state": "play",
+    "has_water": -1,
+    "has_foam": -1,
+    "temperature_chip": 34
+}
+```
+### ⽹络状态
+type ⽹络类型。
+rssi 信号质量
+ber 误码率。
+imei ⽹络设备的IMEI号。
+```json
+{
+    "type": "2G",
+    "rssi": 13,
+    "ber": 0,
+    "imei": "8646260436889"
+},
+### 订单信息
+order_info 如果当前正在洗⻋,那么会发送回来订单信息(参考query_order接⼝)。
+open_type 开机⽅式。
+button 表⽰使⽤机器的维护按钮快速开机的订单。
+network 表⽰通过⽹络命令推送来的订单。
+coin 表⽰投币开机的订单。
+card 表⽰刷卡卡机的订单。
+注意:后续可能会增加其它的开机⽅式。
+close_type 关机⽅式。
+button ⽤户按关机按钮关闭的机器。
+network 通过⽹络命令关闭的机器。
+no_balance 消费⾦额达到本次开机的最⼤消费⾦额(prepay_money)限制关机的。
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+idle_timeout 设备空闲超时(⻅配置项idle_timeout)关机的。
+operation_timeout 操作超时(⻅配置项operation_timeout)关机的。
+card 表⽰刷卡关机的订单。
+注意:为空表⽰还没有关机,⽤户正在使⽤。后续可能会增加其它的关机⽅式。
+order_id 订单号,和开机命令的order_id相同,如果使⽤按钮快速开机,order_id是空字符串,
+并且open_type为button。
+order_id_local 本机订单号(不保证连续)。
+member_discount 会员折扣比例(0-100,100表⽰不享受折扣,95表⽰9.5折优惠)。
+prepay_money 本次开机的预付⾦额(单位分,本次开机最⼤消费⾦额限制)。
+amount 消费总额,等于各单项费⽤之和(单位分)。
+amount_receivable 应收⾦额,如果⼤于预付⾦额,则限制为预付⾦额并关机,否则等于消费总
+额(单位分)。
+amount_received 实收⾦额等于应收⾦额乘会员折扣(单位分)。
+discount_money 优惠⾦额等于消费总额减去实收⾦额(单位分)。
+coin_money 投币的累计⾦额(仅限刷卡订单,单位分)。
+coin_num 投币的次数仅限刷卡订单)。
+card_type 卡类型(仅限刷卡订单,1表⽰普通卡,2表⽰储值卡)。
+card_id 卡内码,卡出⼚的时候⼚家写入的ID(仅限刷卡订单)。
+card_sn 卡串号,印刷在卡的表⾯,通过电脑上的卡管理⼯具写入卡内。(仅限刷卡订单)。
+card_expired 卡过期时间,从1970年开始的时间戳(仅限刷储值卡的订单有效,普通卡为总是为
+0)。
+card_balance 卡内余额(仅限刷储值卡的订单有效,普通卡为总是为0)。
+operation_remain_time 订单操作剩余操作时间(单位秒),开机后从operation_timeout开始每
+秒减1,减到0关机,关机原因close_type=operation_timeout。
+idle_remain_time 设备空闲关机倒计时剩余时间(单位秒),开机后从idle_timeout开始每秒减
+1,⻅到0关机,关机原因close_type=idle_time(递减过程中按任意功能键,重新开始从
+idle_timeout递减)。
+detail 费⽤明细。 name 名称 price 单价(单位分) seconds 时⻓(单位秒) amount 费⽤(单位分)。
+space ⻋位或场地。
+water ⾼压清⽔。
+foam 泡沫。
+cleaner 吸尘器。
+tap ⽔龙头。
+user_ext ⽤户扩展,消毒或吹⼲等功能。
+coat 镀膜。
+blow 吹⽓。
+{
+    "open_type": "button",
+    "close_type": "",
+    "order_id": "",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "order_id_local": "2",
+    "member_discount": 100,
+    "prepay_money": 1000,
+    "amount": 40,
+    "amount_receivable": 40,
+    "amount_received": 40,
+    "discount_money": 0,
+    "remain_time": 3371,
+    "detail": [
+        {
+            "name": "space",
+            "price": 2,
+            "seconds": 229,
+            "amount": 7
+        },
+        {
+            "name": "water",
+            "price": 100,
+            "seconds": 5,
+            "amount": 8
+        },
+        {
+            "name": "foam",
+            "price": 200,
+            "seconds": 5,
+            "amount": 16
+        },
+        {
+            "name": "tap",
+            "price": 50,
+            "seconds": 1,
+            "amount": 0
+        },
+        {
+            "name": "cleaner",
+            "price": 80,
+            "seconds": 3,
+            "amount": 4
+        },
+        {
+            "name": "user_ext",
+            "price": 100,
+            "seconds": 3,
+            "amount": 5
+        }
+    ]
+}
+```
+应收实收⾦额的计算
+单项费⽤都按秒计算,计算⽅法如下:
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+//根据时间计算价格
+static uint32_t calc_fee(uint32_t seconds, uint32_t price)
+```json
+{
+ return 1.0F * seconds / 60 * price;
+}
+```
+应收⾦额amount_receivable 由单项的费⽤相加所得,应收⾦额达到预付⾦额就会停⽌设备。
+由于存在1秒时间可能增加1分钱或2分钱的情况,那么折扣比例为100时,也存在优惠⾦额不为零的情
+况。
+计算⽅法如下:
+//各收费项之和
+order->amount = money;
+
+//计算应收⾦额
+if (order->amount >= order->prepay_money) {
+    order->amount_receivable = order->prepay_money;
+    stop = 1;
+} else {
+    order->amount_receivable = order->amount;
+}
+
+//检查折扣比例
+if ((order->discount_money < 0) || (order->discount_money > 100)) {
+    order->discount_money = 100;
+}
+
+//计算实收⾦额
+order->amount_received = 1.0F * order->amount_receivable * order-
+>member_discount / 100 + 0.5F;
+
+//计算优惠⾦额
+if (order->amount >= order->amount_received) {
+    order->discount_money = order->amount - order->amount_received;
+} else {
+    order->discount_money = 0;
+}
+### 参数配置项
+maintenance_mode 维护模式
+0 未设置
+1 已设置(屏幕显⽰设备维护界⾯).
+user_message.1 ⽤户⾃定义信息,屏幕左下⾓文本,可以显⽰公司名称、维护电话、客服电话等。
+user_message.2 ⽤户⾃定义信息,屏幕右下⾓文本,可以显⽰公司名称、维护电话、客服电话等。
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+sensor_water 是否安装了⽔流量传感器。
+price_space ⻋位或场地单价(单位:分/分钟)。
+price_water 清⽔单价(单位:分/分钟)。
+price_foam 泡沫单价(单位:分/分钟)。
+price_tap ⽔龙头单价(单位:分/分钟)。
+price_cleaner 吸尘器单价(单位:分/分钟)。
+price_user_ext ⽤户扩展功能单价(消毒、吹⼲等,单位:分/分钟)。
+price_coat 镀膜的单价(单位:分/分钟)。
+price_blow 吹⽓的单价(单位:分/分钟)。
+idle_timeout 空闲超时,多久不操作设备(⽤户开关功能重新开始倒计时),那么就认为⽤户已离开,直接
+关闭设备(单位:秒)。
+operation_timeout 操作超时,订单开始之后多久强制关闭,也就是⽤户必须要再多⻓时间内洗完⻋
+(单位:秒)。
+quick_open_money 快速开机⾦额,按机箱内部的维护按键直接开机(设置为0可以关闭这个功能)。
+work_mode 营业时间段控制模块的⼯作模式
+0 全天24⼩时暂停营业
+1 全天24⼩时营业
+2 由时间段控制营业时间,不在营业时间段⽆法开机,屏幕显⽰营业时间信息。
+例如: 上午营业 "work_time_period.1" = "08:00 - 12:00"和下午营
+业"work_time_period.2 = "13:30 - 20:00"
+work_time_period.1 营业时间段1,24⼩时制,程序会处理跨凌晨的情况。
+work_time_period.2 营业时间段1,24⼩时制,程序会处理跨凌晨的情况。
+light_mode 照明时间段控制模块的⼯作模式
+0 全天24⼩时关闭照明
+1 全天24⼩时开启照明
+2 由时间段控制开灯时间
+例如: 整个晚上亮灯"light_time_period.1" = "18:30 - 07:30" 晚上亮灯
+"light_time_period.1" = "18:30 - 22:30"和早上亮灯"light_time_period.2 = "05:00
+- 7:00"
+light_time_period.1 照明时间段1,参考营业时间段配置。
+light_time_period.2 照明时间段1,参考营业时间段配置。
+sound_volume ⾳量。
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+screen_type 屏幕类型(0:不⽀持视频播放的屏幕,1:⽀持视频播放的屏幕,两种屏型号不同,填写错误会
+导致没有语⾳。)
+video_source 视频源(0:内置视频,1:TF卡内的视频,2:U盘内的视频)。
+video_play_delay 视频播放延时(单位秒)。设备空闲多久开始循环播放⼴告视频。
+work_light_delay 订单结束时延时过久关闭⼯作指⽰灯(单位秒)。
+bill_delay 洗⻋结束后,费⽤明细⻚⾯显⽰多久(单位秒)。
+errors 如果有参数出现错误,这⾥给出提⽰信息。
+notice_throshold_idle 空闲关机倒计时剩余时间⼩于这个数字,发出提⽰⾳(正在倒计时关机,请按
+任意功能键阻⽌关机)。
+notice_throshold_operation 操作时间倒计时剩余时间⼩于这个数字,发出提⽰⾳(您的洗⻋时间剩
+余不多了,请合理使⽤)。
+motor_mode 0:流量不能启动电机();1:有⽔流量的时候启动⾃动启动电机(兼容老客户,新客户请忽略
+该参数,请不要发送这个参数给板⼦。)。
+motor_on_delay 电机启动最少要⼯作多⻓时间(单位毫秒,请勿随意修改。)。
+motor_off_delay 电机关闭后要强制保持关闭状态多⻓时间(单位毫秒,请勿随意修改)。
+motor_on_interval 电机关机灵敏度(有流量计有效,单位毫秒,请勿随意修改)。
+motor_flow_on 电机是否根据流量启动(有流量⾃动开启电机,请勿随意修改)。
+motor_flow_off 电机是否根据流量关闭(⽆流量⾃动关闭电机,请勿随意修改)。
+tap_on_delay ⽔龙头开启延时(单位秒,⽔龙头开启多少秒后⾃动关闭,0表⽰不⾃动关闭。)
+coin_money 投币信号的币值,也就是⼀个币是多少钱单位分(如⼈⺠币1元设置为100,港币5元,设置
+为500)。
+coin_num 投币的数量,对于同时接收超过⼀种币值的投币器,投币数量是⽆效的,这个数值等于投币器
+发出的脉冲数量。
+card_key 卡密码,只有相同密码的卡可以通⽤(注意:这个密码要和使⽤卡管理⼯具初始化卡的时候写
+入的密码⼀致,写入后不能再次修改,否则由于和机器上密码不⼀致就不能使⽤了 ,机器总是提⽰卡读
+取错误!)。
+prepay_money 每次开机的最⼤消费限额(单位分,建议和创建订单的参数prepay_money保持⼀致)。
+coat_mode 为0表⽰关闭,为1表⽰使⽤镀膜功能(注意:这个功能和投币器功能不能同时使⽤。)
+blow_mode 为0表⽰关闭,为1表⽰使⽤吹⽓功能(注意:这个功能和闸机控制功能不能同时使⽤。)
+light_alt_mode 为0表⽰作为照明继电器使⽤;为1表⽰作为复⽤的远程控制继电器使⽤。
+⾃动洗⻋机需要配置的⼏个参数
+money_unit 货币转换单位,如⼈⺠币对应的是100。
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+pay_money_ch1 ⾃动洗⻋机的通道1对应的⾦额。
+pay_money_ch2 ⾃动洗⻋机的通道2对应的⾦额。
+pay_money_ch3 ⾃动洗⻋机的通道3对应的⾦额。
+pay_money_ch4 ⾃动洗⻋机的通道4对应的⾦额。
+```json
+{
+    "maintenance_mode": 0,
+    "user_message.1": "",
+    "user_message.2": "武汉爱⻋轩科技有限公司",
+    "sensor_water": 1,
+    "price_space": 2,
+    "price_water": 100,
+    "price_foam": 200,
+    "price_tap": 50,
+    "price_cleaner": 80,
+    "price_user_ext": 100,
+    "idle_timeout": 600,
+    "operation_timeout": 3600,
+    "quick_open_money": 1000,
+    "work_mode": 1,
+    "work_time_period.1": "08:00 - 20:00",
+    "work_time_period.2": "14:00 - 20:00",
+    "light_mode": 0,
+    "light_time_period.1": "11:46 - 16:48",
+    "light_time_period.2": "13:44 - 13:45",
+    "sound_volume": 70,
+    "screen_type": 1,
+    "video_source": 2,
+    "video_play_delay": 15,
+    "work_light_delay": 10,
+    "bill_delay": 10,
+    "notice_throshold_idle": 300,
+    "notice_throshold_operation": 600,
+    "motor_mode": 0
+}
+```
+### 主板信息
+hard_version 硬件版本号定义。
+chip_id 芯片的唯⼀ID.
+firmware_version 固件版本定义。
+build_time 固件的编译时间。
+```json
+{
+    "hard_version": "3.3",
+    "chip_id": "CAB1001F-00039018-49044E45",
+    "firmware_version": "3.0.0",
+    "build_time": "Aug 11 2020 14:58:27"
+}
+```
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+## 同步操作接口
+### 帮助命令
+获取API列表
+请求
+```json
+{
+    "version": "2.0",
+    "method": "help",
+    "params": []
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "api_list": [
+            "help",
+            "reboot",
+            "query_state",
+            "query_hardware_info",
+            "create_order",
+            "close_order",
+            "query_order",
+            "read_config",
+            "write_config",
+            "show_msgbox",
+            "hide_msgbox"
+        ]
+    },
+    "errors": []
+}
+```
+获取API帮助信息
+请求
+```json
+{
+    "version": "2.0",
+    "method": "help",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "params": {
+        "method": "create_order"
+    }
+}
+```
+回复
+```json
+{、
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "method": "create_order",
+        "help": "打开设备(打开订单);order_id 订单号 必须 字符串类型;prepay_money 预付
+⾦额(本次开机最⼤可消费⾦额) 必须 整数类型 单位分;member_balance 会员余额 可选 整数类型
+单位分 (仅⽤于显⽰);member_name 会员名称 可选 字符串类型;member_discount 会员折扣 可选
+整数类型(0-100,默认为100,表⽰不折扣);"
+    },
+    "errors": []
+}
+```
+### 显⽰消息对话框
+请求
+注意:系统只能显⽰⼀个对话框,新的对话框会把旧的覆盖掉。
+title 参考数据定义中的设备状态。
+content 参考数据定义中的订单信息(只有在洗⻋状态state=busy才会传回来)。
+seconds 显⽰时⻓,单位秒,时间到了对话框⾃动消失。
+```json
+{
+    "version": "2.0",
+    "method": "show_msgbox",
+    "params": {
+        "title": "标题",
+        "content": "内容",
+        "seconds": 600
+    }
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "code_msg": "success",
+    "data": [],
+    "errors": []
+}
+```
+### 隐藏消息对话框
+请求
+```json
+{
+    "version": "2.0",
+    "method": "hide_msgbox",
+    "params": []
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": [],
+    "errors": []
+}
+```
+### 重启设备
+请求
+注意:在洗⻋的时候或者账单明细⻚⾯,设备不会重启(在等待重启),重启前由3-5秒左右的延时(防⽌⽹
+络数据没有发送完成)。
+```json
+{
+    "version": "2.0",
+    "method": "reboot",
+    "params": []
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "code_msg": "success",
+    "data": [],
+    "errors": []
+}
+```
+### 查询设备状态
+请求
+```json
+{
+    "version": "2.0",
+    "method": "query_state",
+    "params": []
+}
+```
+回复
+device_state 参考数据定义中的设备状态。
+order_info 参考数据定义中的订单信息(只有在洗⻋状态state=busy才会传回来)。
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "order_info": {
+            "open_type": "network",
+            "close_type": "",
+            "order_id": "1597482244",
+            "order_id_local": "101128414",
+            "member_discount": 95,
+            "prepay_money": 1000,
+            "amount": 0,
+            "amount_receivable": 0,
+            "amount_received": 0,
+            "discount_money": 0,
+            "operation_remain_time": 3591,
+            "idle_remain_time": 591,
+            "detail": [
+                {
+                    "name": "space",
+                    "price": 2,
+                    "seconds": 8,
+                    "amount": 0
+                },
+                {
+                    "name": "water",
+                    "price": 100,
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "foam",
+                    "price": 200,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "tap",
+                    "price": 50,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "cleaner",
+                    "price": 80,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "user_ext",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                }
+            ]
+        },
+        "device_state": {
+            "uptime_ms": "95282",
+            "boot_flag": "software",
+            "state": "busy",
+            "fsm_state": "work",
+            "has_water": -1,
+            "has_foam": -1,
+            "temperature_chip": 34
+        }
+    },
+    "errors": []
+}
+```
+### 查询硬件信息
+请求
+```json
+{
+    "version": "2.0",
+    "method": "query_hardware_info",
+    "params": []
+}
+```
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+回复
+network 参考数据定义中的⽹络状态。
+board 参考数据定义中的主板信息。
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "network": {
+            "type": "2G",
+            "rssi": 13,
+            "ber": 0,
+            "imei": "8646260436889"
+        },
+        "board": {
+            "hard_version": "3.3",
+            "chip_id": "CAB1001F-00039018-49044E45",
+            "firmware_version": "3.0.0",
+            "build_time": "Aug 11 2020 14:58:27"
+        }
+    },
+    "errors": []
+}
+```
+### 创建订单
+请求
+order_id 订单号,字符串类型,必须。
+member_name 会员名称,字符串类型,可选。
+member_balance 会员余额(单位分,仅在界⾯上显⽰,不参与任何业务处理),可选。
+member_discount 会员折扣比例(95表⽰9.5折优惠,默认100表⽰没有优惠),可选。
+prepay_money 预付⾦额(本次开机最⼤消费⾦额),单位分,必须。
+card_sn 刷卡的串号(对于刷普通卡的⽤户,服务器端应当在收到刷卡消息的时候查询这张卡绑定的账号
+信息,如果这个账号有余额,那么推送开机命令的时候需要把上传的刷卡事件中的card_sn回传回去,
+这样板⼦会把这个开机命令标记为刷卡开机,否则会标记为⽹络开机。)。
+ext_msg 订单的扩展信息,字符串类型。
+对于⾃动洗⻋机,使⽤扩展信息来表⽰需要打开那个通道,也就是对应的套餐,⾃动洗⻋机⽀持4个
+通道,对应的是1到4。格式为: "ext_msg": "ch=1"。上报的其他数据和⾃助洗⻋的都是⼀样的,
+订单没有详细信息。⾃动洗⻋机的订单也不⽀持远程关闭。
+```json
+{
+    "version": "2.0",
+    "method": "create_order",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "params": {
+        "order_id": "1597482244",
+        "member_name": "会员名称",
+        "member_balance": 9800,
+        "member_discount": 95,
+        "prepay_money": 1000
+    }
+}
+```
+回复
+accepted 创建了新订单返回1,订单已存在返回0.
+order_id_local 本机订单号(不保证连续)
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "order_id_local": "101128414",
+        "created": 1
+    },
+    "errors": []
+}
+```
+### 关闭订单
+请求
+以下两个参数根据情况,⼆选⼀即可。推荐指定订单号来关闭(如果指定了订单号,当订单号和当前订单
+的订单号不同时,认为订单已关闭,回复200)。
+order_id 如果当前订单的订单号和order_id相同,则执⾏关闭操作(注意:创建订单和关闭订单的订单
+号需要保持⼀致,否则⽆法关机。)。
+force_close 如果不知道订单号,需要强制关闭设备,那么就⽤这个参数(这时候不要再发送order_id参
+数)。
+```json
+{
+    "version": "2.0",
+    "method": "close_order",
+    "params": {
+        "order_id": "1597482244",
+        "force_close": 1
+    }
+}
+```
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": [],
+    "errors": []
+}
+```
+### 查询订单
+请求
+注意:只能查询当前订单和上⼀个订单的信息,2G版本重启后不能查询之前的订单信息。
+```json
+{
+    "version": "2.0",
+    "method": "query_order",
+    "params": {
+        "order_id": "1597482244"
+    }
+}
+```
+正常回复
+如果存在,回复200,由订单信息回复,如果订单不存在,回复状态码400。
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "order_info": {
+            "open_type": "network",
+            "close_type": "",
+            "order_id": "1597482244",
+            "order_id_local": "101128414",
+            "member_discount": 95,
+            "prepay_money": 1000,
+            "amount": 0,
+            "amount_receivable": 0,
+            "amount_received": 0,
+            "discount_money": 0,
+            "operation_remain_time": 3596,
+            "idle_remain_time": 596,
+            "detail": [
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+                {
+                    "name": "space",
+                    "price": 2,
+                    "seconds": 4,
+                    "amount": 0
+                },
+                {
+                    "name": "water",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "foam",
+                    "price": 200,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "tap",
+                    "price": 50,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "cleaner",
+                    "price": 80,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "user_ext",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                }
+            ]
+        }
+    },
+    "errors": []
+}
+```
+
+未找到订单,回复信息
+注意:只能查询当前订单和上⼀个订单的信息,2G版本重启后不能查询之前的订单信息。
+```json
+{
+    "version": "2.0",
+    "code": 404,
+    "code_msg": "not found",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "data": [],
+    "errors": []
+}
+```
+### 读取配置
+请求
+```json
+{
+    "version": "2.0",
+    "method": "read_config",
+    "params": []
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "maintenance_mode": 0,
+        "user_message.1": "公司名称1597484391",
+        "user_message.2": "客服电话1597484391",
+        "sensor_water": 0,
+        "price_space": 2,
+        "price_water": 110,
+        "price_foam": 220,
+        "price_tap": 55,
+        "price_cleaner": 88,
+        "price_user_ext": 102,
+        "idle_timeout": 2100,
+        "operation_timeout": 2700,
+        "quick_open_money": 1099,
+        "work_mode": 2,
+        "work_time_period.1": "08:00 - 20:00",
+        "work_time_period.2": "14:00 - 20:00",
+        "light_mode": 2,
+        "light_time_period.1": "11:46 - 16:48",
+        "light_time_period.2": "13:44 - 13:45",
+        "sound_volume": 70,
+        "screen_type": 0,
+        "video_source": 0,
+        "video_play_delay": 120,
+        "work_light_delay": 30,
+        "bill_delay": 60,
+        "notice_throshold_idle": 300,
+        "notice_throshold_operation": 600,
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+        "motor_mode": 0
+    },
+    "errors": []
+}
+```
+
+### 修改配置
+请求
+```json
+{
+    "version": "2.0",
+    "method": "write_config",
+    "params": {
+        "maintenance_mode": 0,
+        "user_message.1": "公司名称1597484391",
+        "user_message.2": "客服电话1597484391",
+        "sensor_water": 0,
+        "price_space": 2,
+        "price_water": 110,
+        "price_foam": 220,
+        "price_tap": 55,
+        "price_cleaner": 88,
+        "price_user_ext": 102,
+        "idle_timeout": 2100,
+        "operation_timeout": 2700,
+        "quick_open_money": 1099,
+        "work_mode": 2,
+        "work_time_period.1": "08:00 - 20:00",
+        "work_time_period.2": "14:00 - 20:00",
+        "light_mode": 2,
+        "light_time_period.1": "11:46 - 16:48",
+        "light_time_period.2": "13:44 - 13:45",
+        "sound_volume": 70,
+        "screen_type": 0,
+        "video_source": 0,
+        "video_play_delay": 120,
+        "work_light_delay": 30,
+        "bill_delay": 60,
+        "notice_throshold_idle": 300,
+        "notice_throshold_operation": 600
+    }
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "code": 200,
+    "code_msg": "success",
+    "data": [],
+    "errors": []
+}
+```
+### 继电器控制命令
+这个命令⽤于控制继电器,需要先设置照明继电器复⽤参数light_alt_mode。
+relay 继电器的编号,⽬前固定为1.
+action 要执⾏的动作。
+on 打开继电器。
+off 关闭继电器。
+toggle 切换继电器的状态。
+query 查询继电器的状态。
+delay_ms 动作时间,单位是毫秒,如果为零,表⽰保持在动作后的状态。
+请求
+```json
+{
+    "method": "relay_control",
+    "params": {
+        "relay": 1,
+        "action": "on",
+        "delay_ms": 1000
+    },
+    "version": "2.0"
+}
+```
+回复
+```json
+{
+    "version": "2.0",
+    "code": 200,
+    "code_msg": "success",
+    "data": {
+        "relay_state": 1
+    },
+    "errors": [
+    ]
+}
+```
+## 事件上传
+### 设备启动事件
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+设备上电启动,
+⽰例
+```json
+{
+    "version": "2.0",
+    "event": "boot",
+    "data": {
+        "device_state": {
+            "uptime_ms": "47139",
+            "boot_flag": "software",
+            "state": "idle",
+            "fsm_state": "play",
+            "has_water": -1,
+            "has_foam": -1,
+            "temperature_chip": 34
+        }
+    }
+}
+```
+### 设备状态更新事件
+⽰例
+```json
+{
+    "version": "2.0",
+    "event": "device_state",
+    "data": {
+        "device_state": {
+            "uptime_ms": "29702",
+            "boot_flag": "software",
+            "state": "idle",
+            "fsm_state": "play",
+            "has_water": -1,
+            "has_foam": -1,
+            "temperature_chip": 33
+        }
+    }
+}
+```
+### 收到订单事件
+⽰例
+收到订单时上传,包含订单信息。
+```json
+{
+    "version": "2.0",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    "event": "order_create",
+    "data": {
+        "order_info": {
+            "open_type": "network",
+            "close_type": "",
+            "order_id": "1597485911",
+            "order_id_local": "104657724",
+            "member_discount": 95,
+            "prepay_money": 1000,
+            "amount": 0,
+            "amount_receivable": 0,
+            "amount_received": 0,
+            "discount_money": 0,
+            "operation_remain_time": 3600,
+            "idle_remain_time": 600,
+            "detail": [
+                {
+                    "name": "space",
+                    "price": 2,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "water",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "foam",
+                    "price": 200,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "tap",
+                    "price": 50,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "cleaner",
+                    "price": 80,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "user_ext",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                }
+            ]
+        }
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+    }
+}
+```
+### 关闭订单事件
+⽰例
+关闭设备时上传,包含订单信息。
+```json
+{
+    "version": "2.0",
+    "event": "order_close",
+    "data": {
+        "order_info": {
+            "open_type": "network",
+            "close_type": "network",
+            "order_id": "1597485911",
+            "order_id_local": "104657724",
+            "member_discount": 95,
+            "prepay_money": 1000,
+            "amount": 88,
+            "amount_receivable": 88,
+            "amount_received": 83,
+            "discount_money": 5,
+            "operation_remain_time": 3528,
+            "idle_remain_time": 600,
+            "detail": [
+                {
+                    "name": "space",
+                    "price": 2,
+                    "seconds": 72,
+                    "amount": 2
+                },
+                {
+                    "name": "water",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "foam",
+                    "price": 200,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "tap",
+                    "price": 50,
+                    "seconds": 0,
+                    "amount": 0
+                },
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+                {
+                    "name": "cleaner",
+                    "price": 80,
+                    "seconds": 65,
+                    "amount": 86
+                },
+                {
+                    "name": "user_ext",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                }
+            ]
+        }
+    }
+}
+```
+### 订单状态更新事件
+⽰例
+⽤来让服务器知道订单的当前状态,以下两个条件只要满⾜其中⼀个就上传⼀次。
+订单的应收⾦额和上次上传的应收⾦额⼤于等于1元则上传⼀次。
+如果⾦额不变,每个3分钟上传⼀次。
+```json
+{
+    "version": "2.0",
+    "event": "order_update",
+    "data": {
+        "order_info": {
+            "open_type": "network",
+            "close_type": "",
+            "order_id": "1597485911",
+            "order_id_local": "104657724",
+            "member_discount": 95,
+            "prepay_money": 1000,
+            "amount": 88,
+            "amount_receivable": 88,
+            "amount_received": 83,
+            "discount_money": 5,
+            "operation_remain_time": 3528,
+            "idle_remain_time": 600,
+            "detail": [
+                {
+                    "name": "space",
+                    "price": 2,
+                    "seconds": 72,
+                    "amount": 2
+                },
+                {
+                    "name": "water",
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "foam",
+                    "price": 200,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "tap",
+                    "price": 50,
+                    "seconds": 0,
+                    "amount": 0
+                },
+                {
+                    "name": "cleaner",
+                    "price": 80,
+                    "seconds": 65,
+                    "amount": 86
+                },
+                {
+                    "name": "user_ext",
+                    "price": 100,
+                    "seconds": 0,
+                    "amount": 0
+                }
+            ]
+        }
+    }
+}
+```
+### ⽤户登录事件
+**注意:设备端登录的⽤户名和密码只能是纯数字,对于⽯斑⻥平台⽤户名通常是⼿机号码 **
+```json
+{
+
+"version": "2.0",
+
+"event": "user_login",
+
+"data": {
+
+"name": "123456",
+
+"password": "123456"
+
+}
+}
+```
+### ⽤户刷卡事件
+注意:⽆论⽤户刷的是普通卡还是储值卡,都会上传这个事件。 对于储值卡总是可以刷卡开关机,如果设备在
+线则上传,不在线则不上传。各字段的意义请参考订单信息中的解释。
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+```json
+{
+
+"version": "2.0",
+
+"event": "card_event",
+
+"data": {
+
+"card_type": 1,
+
+"card_id": 982334256,
+
+"card_sn": 123456,
+
+"card_expired": 0,
+
+"card_balance": 0
+
+}
+}
+```
+## 常见问题解答
+如何知道设备是否在在线
+1、从MQTT Broker来查询设备是否在线,需要查阅相关Broker实现的文档(推荐)。 2、通过MQTT遗嘱消息来实
+现,由于阿⾥云物联⽹平台不⽀持该功能,为保持兼容,设备端未实现该⽅法。 3、通过RPC同步调⽤来实时的
+获取设备是否在线。由于4G⽹络延时,可能会出现响应超时,响应较慢。WiFi⽹络下响应较快,有条件可以使
+⽤WiFi⽹络。
+## 版本维护记录
+2025年8⽉8⽇
+增加MQTT协议的Topic和连接MQTT Broker的说明。
+2024年8⽉15⽇
+增加对⾃动洗⻋机的⽀持。在创建订单的时候通过"ext_msg": "ch=1"来指定要打开的通道。
+2023年10⽉26⽇
+增加照明继电器复⽤为远程控制继电器的功能。
+使⽤时需要先设置参数light_alt_mode ,然后使⽤API命令relay_control来控制继电器。
+2023年9⽉5⽇
+增加语⾳播放器类型字段player_type,类型为整形,取值为0使⽤默认的语⾳合成功能播放,取值为1使
+⽤屏幕语⾳文件播放。
+2023年7⽉22⽇
+增加对show_msgbox和hide_msgbox接⼝的描述。这两个api可以使⽤help命令查到都可以使
+⽤。
+2022年11⽉7⽇
+增加镀膜和吹⽓功能的配置项。
+2021年1⽉29⽇
+增加清⽔泡沫是否联动配置项(foam_link_mode)为1表⽰打开泡沫的时候⾃动打开清⽔,为0表⽰
+单独控制模式。
+2021年1⽉12⽇
+增加根据流量收费模式(motor_fee_flow为1表⽰根据流量收费,为0表⽰正常收费模式)。
+2020年12⽉16⽇
+增加投币时上传⼀次订单信息变化事件(order_update),让服务器及时知道⽤户投了多少钱的
+币。
+2020年12⽉15⽇
+
+# 洗车机 MQTT 通信协议
+2025-08-10
+增加刷卡功能,参数card_key表⽰卡密码,相同密码的卡可以通⽤,不同密码的卡不能通⽤。
+卡分为普通卡和储值卡两种。普通卡只能⽹络在线时使⽤,可以通过⽹络充值,挂失(⼆次开发的
+客户请⾃⼰处理)。储值卡⽆论⽹络在线还是离线都可以正常刷卡使⽤,不能⽹络充值和挂失(设备
+⽹络在线就上传记录<订单创建、订单更新、订单关闭>,不在线就不上传)。
+2020年12⽉10⽇
+增加投币功能,参数coin_money 表⽰币值,表⽰⼀个投币信号代表多少钱(单位分,设备⽹络在
+线就上传记录<订单创建、订单更新、订单关闭>,不在线就不上传)。
+2020年11⽉12⽇
+增加打开⽔龙头超时⾃动关闭的功能(参数tap_on_delay)。
+2020年11⽉8⽇
+增加电机控制模块的参数远程修改功能(参数motor_on_delay、motor_off_delay、
+motor_on_interval、motor_flow_on、motor_flow_off)。
+2020年11⽉6⽇
+增加设备端登录功能,⽤户点击登录,上报事件user_login。
+2020年11⽉5⽇
+增加登录界⾯操作超时参数login_timeout(单位秒)。
+2020年11⽉3⽇
+增加洗⻋超时时间配置space_timeout_seconds。
+增加洗⻋超时后的计费⽅式配置值space_timeout_mode(场地费是否包括超时部分的费⽤)。 0表
+⽰超时后只计算超时部分的费⽤,1表⽰超时后包括超时部分的费⽤。
+2020年11⽉2⽇
+增加了流量控制电机启动的配置项motor_mode。
+2020年8⽉29⽇
+修复拼写错误。hard_version => hardware_version
+修复拼写错误。notice_throshold_idle => notice_threshold_idle。
+修复拼写错误。notice_throshold_operation => notice_threshold_operation
+所有参数免重启立刻⽣效。
+优化EEPROM的操作速度。
+设备状态(device.state)变化实时上报事件device_state,如果需要知道那些数据发⽣了变化,
+请在服务器端做比较操作。
+2020年8⽉16⽇
+发布初始版本。