Procházet zdrojové kódy

admin-web-new 功能同步

skyline před 1 dnem
rodič
revize
bb38797abd

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