6 Commity 86ec164371 ... e87d1992ac

Autor SHA1 Wiadomość Data
  skyline e87d1992ac fix: 修复 admin-h5 底部导航图标偏位与切换后高亮残留(Tab 页缓存复用导致) 3 dni temu
  skyline 66d4a8426f feat: 站点切换器改为顶栏可搜索下拉并去掉整页刷新,11 个页面站点筛选联动 3 dni temu
  skyline 9cb50108c8 feat: 设备远程控制支持读取信号值,RSSI 按好/中/差/极差分级着色 3 dni temu
  skyline 21f885968c feat: 用户锁定功能对齐 — 锁定筛选/锁定解锁/锁定记录,退款申请原因改为可选 3 dni temu
  skyline 43aabff49e feat: 订单列表新增新用户订单标记,手机号列加宽并显示品牌红标签 3 dni temu
  skyline 1f64624924 fix: 走势图总金额改为订单实收金额,汇总标签改「总订单实收金额」 3 dni temu

+ 5 - 0
admin-h5/src/App.vue

@@ -77,6 +77,11 @@ page {
   animation: app-fade-in $uni-duration-enter $uni-ease-decelerate;
 }
 
+/* 带底部导航的页面不做整页淡入:底部导航是 fixed 定位于页面内,整页淡入会让导航栏在切换 Tab 时一起闪一下 */
+page:has(.tab-bar) {
+  animation: none;
+}
+
 /* 入场工具类:页面内元素可主动挂载 */
 .fade-up { animation: app-fade-up $uni-duration-motion $uni-ease-out-quint backwards; }
 .fade-in { animation: app-fade-in $uni-duration-enter $uni-ease-decelerate backwards; }

+ 12 - 18
admin-h5/src/components/CustomTabBar.vue

@@ -20,10 +20,14 @@
 </template>
 
 <script setup>
-import { ref, watch, onMounted, getCurrentInstance } from 'vue'
+import { computed } from 'vue'
 import { hapticMedium } from '../utils/haptics.js'
 
-const instance = getCurrentInstance()
+const props = defineProps({
+  // 由页面传入当前选中项,作为唯一数据源
+  selected: { type: Number, default: 0 }
+})
+
 const activeColor = '#C6171E'
 const inactiveColor = '#8C8C8C'
 
@@ -34,20 +38,9 @@ const tabs = [
   { pagePath: '/pages/finance/index',text: '财务', icon: 'dollar' },
 ]
 
-const current = ref(0)
-
-const getCurrentRoute = () => {
-  const pages = getCurrentPages()
-  if (pages.length > 0) {
-    const route = '/' + pages[pages.length - 1].route
-    const idx = tabs.findIndex(t => t.pagePath === route)
-    if (idx !== -1) current.value = idx
-  }
-}
-
-onMounted(() => getCurrentRoute())
-
-watch(() => instance?.proxy?.$route, () => getCurrentRoute(), { immediate: true })
+// 激活态只派生自 selected,不在组件内维护可变状态:
+// Tab 页在 uni-app H5 下会被缓存复用,若本地改状态(如点击时乐观更新),离开再回来会残留成错误高亮
+const current = computed(() => props.selected)
 
 const switchTab = (tab, index) => {
   // 重复点击当前 Tab 不切换、不触发触感
@@ -107,10 +100,11 @@ const switchTab = (tab, index) => {
 }
 
 /* 双层图标:灰色/红色各渲染一份,src 永不切换,避免 image 重新解码导致的闪烁 */
+/* 盒子尺寸必须与图标(AppIcon size=24 → 24rpx)一致,否则绝对定位的图标会贴在盒子左上角而偏位 */
 .icon-stack {
   position: relative;
-  width: 48rpx;
-  height: 48rpx;
+  width: 24rpx;
+  height: 24rpx;
 }
 
 .icon-layer {

+ 15 - 0
admin-web-new/src/api/custom.ts

@@ -9,3 +9,18 @@ export const getCustomUserList = (params?: object) => {
 export const getRechargeList = (params?: object) => {
   return http.request<any>("get", "/custom/listRecharge", { params });
 };
+
+/** 锁定用户 */
+export const lockUser = (data: { userId: number; lockRemark?: string; lockNotice: string }) => {
+  return http.request<any>("post", "/custom/lockUser", { data });
+};
+
+/** 解锁用户 */
+export const unlockUser = (userId: number) => {
+  return http.request<any>("post", "/custom/unlockUser", { data: { userId } });
+};
+
+/** 用户锁定记录 */
+export const getUserLockRecord = (userId: number) => {
+  return http.request<any>("get", "/custom/listUserLockRecord", { params: { userId } });
+};

+ 5 - 0
admin-web-new/src/api/finance.ts

@@ -30,6 +30,11 @@ export const triggerSettlement = () => {
   return http.request<any>("post", "/finance/triggerSettlement");
 };
 
+/** 发起退款申请 */
+export const applyRefund = (data: { userId: number; reason?: string }) => {
+  return http.request<any>("post", "/finance/applyRefund", { data });
+};
+
 /** 确认结算 */
 export const confirmSettlement = (id: number) => {
   return http.request<any>("post", "/finance/confirmSettlement", { data: { id } });

+ 53 - 0
admin-web-new/src/hooks/useStationScope.ts

@@ -0,0 +1,53 @@
+import { onMounted, onUnmounted } from "vue";
+import { emitter } from "@/utils/mitt";
+
+//Session 读取(与顶栏站点切换器写入格式一致:sessionStorage + JSON)
+const getCurrentStationId = () => {
+  const value = sessionStorage.getItem("currentStationId");
+  if (!value) return null;
+  try {
+    return JSON.parse(value);
+  } catch {
+    return null;
+  }
+};
+
+/**
+ * 站点范围组合式函数:页面站点筛选与全局"当前站点"联动
+ * 1. 页面初始化时,若站点筛选为空则默认选中全局当前站点(onMounted 中先于页面自身加载执行)
+ * 2. 全局站点切换时(顶栏站点选择器),联动更新页面筛选并刷新列表
+ *
+ * @param formQuery 页面查询条件对象(需包含 stationId 字段)
+ * @param loadData 页面数据加载方法,切换站点时以 refresh=true 重新加载
+ * @param options.follow 是否响应全局站点切换,默认 true
+ */
+export const useStationScope = (
+  formQuery: any,
+  loadData?: (refresh?: boolean) => void,
+  options?: { follow?: boolean }
+) => {
+  const follow = options?.follow !== false;
+
+  const onStationChange = (stationId: any) => {
+    if (!follow || !stationId) return;
+    //站点未变化时不重复加载
+    if (formQuery.stationId === stationId) return;
+    formQuery.stationId = stationId;
+    if (loadData) loadData(true);
+  };
+
+  onMounted(() => {
+    //初始化:站点筛选为空时默认选中全局当前站点
+    if (!formQuery.stationId) {
+      const currentStationId = getCurrentStationId();
+      if (currentStationId) {
+        formQuery.stationId = currentStationId;
+      }
+    }
+    emitter.on("stationChangeRefresh", onStationChange);
+  });
+
+  onUnmounted(() => {
+    emitter.off("stationChangeRefresh", onStationChange);
+  });
+};

+ 257 - 0
admin-web-new/src/layout/components/lay-navbar/StationSwitcher.vue

@@ -0,0 +1,257 @@
+<script setup lang="ts">
+import { reactive, computed, onMounted } from "vue";
+import { emitter } from "@/utils/mitt";
+import { getStationList } from "@/api/station";
+import Location from "~icons/ri/map-pin-line";
+
+defineOptions({ name: "StationSwitcher" });
+
+const Session = {
+  get: (key: string) => {
+    const value = sessionStorage.getItem(key);
+    return value ? JSON.parse(value) : null;
+  },
+  set: (key: string, value: any) => {
+    sessionStorage.setItem(key, JSON.stringify(value));
+  }
+};
+
+const state = reactive({
+  stationList: [] as Array<any>,
+  currentStationId: null as any,
+  options: [] as Array<any>,
+  loading: true,
+  open: false
+});
+
+const placeholder = computed(() => {
+  if (state.loading) return "加载站点中";
+  if (state.stationList.length === 0) return "暂无站点";
+  return "选择站点";
+});
+
+//站点切换:持久化并广播,各页面通过 useStationScope 联动刷新(不整页刷新)
+const handleStationChange = (stationId: string) => {
+  const station = state.stationList.find((k: any) => k.stationId === stationId);
+  if (station) {
+    Session.set("currentStationId", stationId);
+    emitter.emit("stationChangeRefresh", stationId);
+  }
+};
+
+const loadStationList = () => {
+  getStationList({ pageNum: 1, pageSize: 1024 })
+    .then((res: any) => {
+      //必须先结束 loading 再赋值选项:element-plus 在 loading 态下过滤结果为空,
+      //会导致选中项 label 解析失败、回退显示原始站点ID
+      state.loading = false;
+      const { list } = res || {};
+      state.stationList = list || [];
+      state.options = state.stationList.map((k: any) => ({
+        value: k.stationId,
+        label: k.stationName
+      }));
+      if (state.stationList.length > 0) {
+        //优先恢复上次选择的站点,其次默认第一个
+        const savedId = Session.get("currentStationId");
+        const current =
+          state.stationList.find((k: any) => k.stationId === savedId) || state.stationList[0];
+        state.currentStationId = current.stationId;
+        setTimeout(() => {
+          Session.set("currentStationId", current.stationId);
+          emitter.emit("stationChangeRefresh", current.stationId);
+        }, 200);
+      }
+    })
+    .catch(() => {
+      //列表加载失败由请求拦截器提示
+    })
+    .finally(() => {
+      state.loading = false;
+    });
+};
+
+onMounted(() => {
+  loadStationList();
+});
+</script>
+
+<template>
+  <div
+    id="header-station"
+    class="station-switcher"
+    :class="{ 'is-open': state.open, 'is-empty': !state.loading && state.stationList.length === 0 }"
+    title="切换当前查看的站点,页面数据范围会跟随变化"
+  >
+    <el-select-v2
+      v-model="state.currentStationId"
+      :options="state.options"
+      :loading="state.loading"
+      :loading-text="'正在加载站点…'"
+      :no-data-text="'没有匹配的站点'"
+      :disabled="!state.loading && state.stationList.length === 0"
+      :placeholder="placeholder"
+      filterable
+      popper-class="station-select-popper"
+      class="station-select"
+      @change="handleStationChange"
+      @visible-change="state.open = $event"
+    >
+      <template #prefix>
+        <IconifyIconOffline :icon="Location" :size="15" />
+      </template>
+      <template #header>
+        <span>站点范围</span>
+        <span>共 {{ state.stationList.length }} 个站点</span>
+      </template>
+    </el-select-v2>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.station-switcher {
+  display: flex;
+  align-items: center;
+  height: 48px;
+  padding: 0 4px;
+
+  .station-select {
+    width: 180px;
+
+    :deep(.el-select__wrapper) {
+      height: 34px;
+      min-height: 34px;
+      padding: 0 8px;
+      border-radius: 8px;
+      background: transparent;
+      box-shadow: none;
+      transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+
+      &.is-hovering,
+      &.is-focused {
+        background: var(--el-fill-color-light);
+      }
+
+      &.is-disabled {
+        background: transparent;
+      }
+    }
+
+    :deep(.el-select__prefix) {
+      display: flex;
+      align-items: center;
+      margin-right: 6px;
+      color: inherit;
+      transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+    }
+
+    :deep(.el-select__placeholder),
+    :deep(.el-select__input) {
+      color: var(--el-text-color-primary);
+      font-size: 14px;
+      font-weight: 500;
+      transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+    }
+
+    //未选中任何站点时的占位:退一档
+    :deep(.el-select__placeholder.is-transparent) {
+      color: var(--el-text-color-placeholder);
+      font-weight: 400;
+    }
+
+    :deep(.el-select__caret) {
+      color: var(--el-text-color-regular);
+      opacity: 0.6;
+      transform-origin: center;
+      transition:
+        transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+        opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+        color 0.2s;
+    }
+  }
+
+  //悬停时文字与图标转为品牌色(禁用态除外)
+  &:not(.is-empty) .station-select:hover {
+    :deep(.el-select__wrapper .el-select__placeholder),
+    :deep(.el-select__wrapper .el-select__input),
+    :deep(.el-select__wrapper .el-select__prefix),
+    :deep(.el-select__wrapper .el-select__caret) {
+      color: var(--el-color-primary);
+      opacity: 1;
+    }
+  }
+
+  //展开时箭头翻转
+  &.is-open .station-select {
+    :deep(.el-select__caret) {
+      transform: rotate(180deg);
+      opacity: 1;
+      color: var(--el-color-primary);
+    }
+  }
+
+  //禁用态(无站点可用)
+  &:deep(.el-select__wrapper.is-disabled .el-select__placeholder),
+  &:deep(.el-select__wrapper.is-disabled .el-select__prefix),
+  &:deep(.el-select__wrapper.is-disabled .el-select__caret) {
+    color: var(--el-text-color-disabled);
+    opacity: 1;
+  }
+}
+</style>
+
+<style lang="scss">
+// 下拉面板(teleported,需全局样式)
+.station-select-popper {
+  .el-select-dropdown {
+    padding: 6px;
+    border: none;
+    border-radius: 12px;
+    box-shadow:
+      0 10px 15px -3px rgba(0, 0, 0, 0.08),
+      0 4px 6px -4px rgba(0, 0, 0, 0.06);
+  }
+
+  .el-select-dropdown__header {
+    display: flex;
+    justify-content: space-between;
+    padding: 4px 10px 6px;
+    font-size: 12px;
+    font-weight: 500;
+    color: var(--el-text-color-secondary);
+
+    span:last-child {
+      font-weight: 400;
+      color: var(--el-text-color-placeholder);
+    }
+  }
+
+  .el-select-dropdown__list {
+    padding: 2px;
+  }
+
+  .el-select-dropdown__item {
+    height: 34px;
+    margin: 2px 0;
+    padding: 0 10px;
+    border-radius: 8px;
+    font-size: 14px;
+  }
+
+  .el-select-dropdown__item.is-hovering {
+    background: var(--el-fill-color-light);
+  }
+
+  //当前站点:品牌色 + 加粗标记「你在这里」
+  .el-select-dropdown__item.is-selected {
+    color: var(--el-color-primary);
+    font-weight: 600;
+  }
+
+  .el-select-dropdown__empty {
+    padding: 14px 10px;
+    font-size: 13px;
+    color: var(--el-text-color-placeholder);
+  }
+}
+</style>

+ 3 - 108
admin-web-new/src/layout/components/lay-navbar/index.vue

@@ -11,10 +11,7 @@ import AccountSettingsIcon from "~icons/ri/user-settings-line";
 import LogoutCircleRLine from "~icons/ri/logout-circle-r-line";
 import Setting from "~icons/ri/settings-3-line";
 import Check from "~icons/ep/check";
-import Location from "~icons/ri/map-pin-line";
-import { getStationList } from "@/api/station";
-import { emitter } from "@/utils/mitt";
-import { reactive, computed, onMounted } from "vue";
+import StationSwitcher from "./StationSwitcher.vue";
 
 const {
   layout,
@@ -30,73 +27,6 @@ const {
 } = useNav();
 
 const { t, locale, translationCh, translationEn } = useTranslationLang();
-
-// Session 工具
-const Session = {
-  get: (key: string) => {
-    const value = sessionStorage.getItem(key);
-    return value ? JSON.parse(value) : null;
-  },
-  set: (key: string, value: any) => {
-    sessionStorage.setItem(key, JSON.stringify(value));
-  }
-};
-
-const state = reactive({
-  stationList: [] as any[],
-  currentStationId: null as string | null,
-  currentStationName: '' as string
-});
-
-// 加载站点列表
-const loadStationList = () => {
-  getStationList({ pageNum: 1, pageSize: 1024 }).then((res: any) => {
-    const list = res?.data?.list || res?.list || res?.data || res || [];
-    state.stationList = list;
-    
-    // 获取当前选中的站点
-    const savedStationId = Session.get("currentStationId");
-    if (savedStationId && list.length > 0) {
-      const currentStation = list.find((s: any) => s.stationId === savedStationId);
-      if (currentStation) {
-        state.currentStationId = currentStation.stationId;
-        state.currentStationName = currentStation.stationName;
-        emitter.emit("stationChangeRefresh", currentStation.stationId);
-      } else {
-        // 如果保存的站点不在列表中,选择第一个并持久化
-        state.currentStationId = list[0]?.stationId;
-        state.currentStationName = list[0]?.stationName;
-        Session.set("currentStationId", list[0]?.stationId);
-        emitter.emit("stationChangeRefresh", list[0]?.stationId);
-      }
-    } else if (list.length > 0) {
-      // 默认选择第一个站点并持久化
-      state.currentStationId = list[0]?.stationId;
-      state.currentStationName = list[0]?.stationName;
-      Session.set("currentStationId", list[0]?.stationId);
-      emitter.emit("stationChangeRefresh", list[0]?.stationId);
-    }
-  }).catch(() => {
-    console.warn('站点列表加载失败');
-  });
-};
-
-// 切换站点
-const handleStationChange = (station: any) => {
-  state.currentStationId = station.stationId;
-  state.currentStationName = station.stationName;
-  Session.set("currentStationId", station.stationId);
-  emitter.emit("stationChangeRefresh", station.stationId);
-
-  // 刷新页面以加载新站点数据
-  setTimeout(() => {
-    window.location.reload();
-  }, 300);
-};
-
-onMounted(() => {
-  loadStationList();
-});
 </script>
 
 <template>
@@ -110,26 +40,8 @@ onMounted(() => {
 
     <div v-if="layout === 'vertical'" class="vertical-header-right">
       <!-- 站点切换 -->
-      <el-dropdown id="header-station" trigger="click" @command="handleStationChange">
-        <div class="station-switcher navbar-bg-hover cursor-pointer outline-hidden flex items-center">
-          <IconifyIconOffline :icon="Location" class="station-icon" />
-          <span class="dark:text-white truncate">{{ state.currentStationName || '选择站点' }}</span>
-          <i class="ri-arrow-down-s-line ml-1" />
-        </div>
-        <template #dropdown>
-          <el-dropdown-menu>
-            <el-dropdown-item 
-              v-for="station in state.stationList" 
-              :key="station.stationId" 
-              :command="station"
-              :disabled="state.currentStationId === station.stationId"
-            >
-              {{ station.stationName }}
-            </el-dropdown-item>
-          </el-dropdown-menu>
-        </template>
-      </el-dropdown>
-      
+      <StationSwitcher />
+
       <!-- 菜单搜索 -->
       <LaySearch id="header-search" />
       <!-- 国际化 -->
@@ -205,23 +117,6 @@ onMounted(() => {
 </template>
 
 <style lang="scss" scoped>
-.station-switcher {
-  height: 48px;
-  padding: 0 12px;
-  min-width: 120px;
-  max-width: 200px;
-
-  .station-icon {
-    margin-right: 6px;
-    font-size: 16px;
-    flex-shrink: 0;
-  }
-
-  span {
-    font-size: 14px;
-  }
-}
-
 .navbar {
   width: 100%;
   height: 48px;

+ 12 - 9
admin-web-new/src/views/admin/dashboard/index.vue

@@ -123,7 +123,7 @@ const initLineChart = (dataList: Array<any>) => {
     item.seq = Number(item.statTime.join(""));
   });
 
-  state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.totalAmount, 0);
+  state.homeOneExtra.totalIncome = dataList.reduce((k, v) => k + v.amountReceived, 0);
   state.homeOneExtra.totalWashOrders = dataList.reduce((k, v) => k + v.totalOrders, 0);
 
   dataList.sort((a, b) => a.seq - b.seq);
@@ -141,13 +141,13 @@ const initLineChart = (dataList: Array<any>) => {
       trigger: "axis",
       formatter: (params: any) => {
         const bar = params.find((p: any) => p.seriesName === "洗车量");
-        const line = params.find((p: any) => p.seriesName === "总金额");
+        const line = params.find((p: any) => p.seriesName === "订单实收金额");
         const barVal = bar ? `${bar.value} 次` : "";
         const lineVal = line ? `¥${line.value}` : "";
         return `${params[0].axisValue}<br/>${barVal}<br/>${lineVal}`;
       }
     },
-    legend: { data: ["洗车量", "总金额"], right: 10, top: 5 },
+    legend: { data: ["洗车量", "订单实收金额"], right: 10, top: 5 },
     xAxis: {
       data: dataList.map(k => k.startTime),
       axisLine: { lineStyle: { color: state.charts.color } }
@@ -182,13 +182,13 @@ const initLineChart = (dataList: Array<any>) => {
         }
       },
       {
-        name: "总金额",
+        name: "订单实收金额",
         type: "line",
         symbolSize: 6,
         symbol: "circle",
         smooth: true,
         yAxisIndex: 1,
-        data: dataList.map(k => (k.totalAmount / 100).toFixed(2)),
+        data: dataList.map(k => (k.amountReceived / 100).toFixed(2)),
         lineStyle: { color: "#409EFF" },
         itemStyle: { color: "#409EFF", borderColor: "#409EFF" }
       }
@@ -309,9 +309,12 @@ const loadAll = () => {
 
 onMounted(() => {
   emitter.on("stationChangeRefresh", (stationId) => {
-    state.currentStationId = stationId;
-    state.myCharts = [];
-    nextTick(() => loadAll());
+    //站点未变化时不重复加载
+    if (stationId && stationId !== state.currentStationId) {
+      state.currentStationId = stationId;
+      state.myCharts = [];
+      nextTick(() => loadAll());
+    }
   });
 
   const currentStationId = Session.get("currentStationId");
@@ -388,7 +391,7 @@ watch(
               style="width: 280px"
             />
             <div class="chart-summary">
-              总收益:
+              总订单实收金额:
               <el-tag type="success" effect="plain" size="small">{{ fmtMoney(state.homeOneExtra.totalIncome) }}元</el-tag>
               总订单:
               <el-tag type="danger" effect="plain" size="small">{{ state.homeOneExtra.totalWashOrders }}笔</el-tag>

+ 4 - 0
admin-web-new/src/views/admin/finance/settlement.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getSettlementList, triggerSettlement, confirmSettlement } from "@/api/finance";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { getDictOptions, formatDict, getDictColor } from "@/utils/dict";
 
@@ -51,6 +52,9 @@ const state = reactive({
   get statusOptions() { return getDictOptions("Settlement.status"); }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   loadData();

+ 4 - 0
admin-web-new/src/views/admin/finance/split-record.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getSplitRecordList } from "@/api/finance";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage } from "element-plus";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
 
@@ -41,6 +42,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   loadData();

+ 4 - 0
admin-web-new/src/views/admin/finance/withdraw.vue

@@ -2,6 +2,7 @@
 import { reactive, onMounted, ref, nextTick } from "vue";
 import { getWithdrawList } from "@/api/finance";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { http } from "@/utils/http";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { getDictOptions, formatDict, getDictColor } from "@/utils/dict";
@@ -46,6 +47,9 @@ const state = reactive({
   get paymentStatusOptions() { return getDictOptions("WithdrawnRecord.paymentStatus"); }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadData();
   nextTick(() => {

+ 6 - 0
admin-web-new/src/views/admin/investor/index.vue

@@ -2,6 +2,7 @@
 import { reactive, onMounted, ref, nextTick } from "vue";
 import { getInvestorList, removeInvestor } from "@/api/investor";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 import InvestorDialog from "./dialog.vue";
 
@@ -18,6 +19,7 @@ interface ColumnItem {
 
 const state = reactive({
   formQuery: {
+    stationId: "",
     stationName: "",
     contactName: ""
   },
@@ -44,6 +46,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadData();
   nextTick(() => {
@@ -88,6 +93,7 @@ const handleSearch = () => {
 
 const handleReset = () => {
   state.formQuery = {
+    stationId: "",
     stationName: "",
     contactName: ""
   };

+ 26 - 1
admin-web-new/src/views/admin/ordering/index.vue

@@ -36,7 +36,7 @@ const state = reactive({
       { label: "消费站点", prop: "stationName", width: 140 },
       { label: "归属站点", prop: "userStationName", width: 140 },
       { label: "订单号", prop: "orderId", width: 220 },
-      { label: "用户手机号", prop: "mobilePhone", width: 120 },
+      { label: "用户手机号", prop: "mobilePhone", width: 180 },
       { label: "设备编号", prop: "shortId", width: 100 },
       { label: "开始时间", prop: "startTime", width: 160 },
       { label: "结束时间", prop: "endTime", width: 160 },
@@ -262,6 +262,20 @@ const processDetailData = (detail: any[]) => {
             <template v-if="isMoneyField(col.prop)">
               ¥{{ formatMoney(row[col.prop]) }}
             </template>
+            <template v-else-if="col.prop === 'mobilePhone'">
+              <div class="phone-cell">
+                <span>{{ row.mobilePhone }}</span>
+                <el-tag
+                  v-if="row.newUser"
+                  class="new-user-tag"
+                  type="danger"
+                  size="small"
+                  effect="light"
+                >
+                  新用户
+                </el-tag>
+              </div>
+            </template>
             <template v-else-if="col.prop === 'orderStatus'">
               <ExtDLabel type="Order.status" :model-value="row.orderStatus" />
             </template>
@@ -336,6 +350,17 @@ const processDetailData = (detail: any[]) => {
   padding: 10px;
 }
 
+.phone-cell {
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+}
+
+.new-user-tag {
+  margin-left: 6px;
+  flex-shrink: 0;
+}
+
 .no-detail {
   padding: 20px;
   text-align: center;

+ 4 - 0
admin-web-new/src/views/admin/parking-coupon-record/index.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getParkingCouponRecordPage } from "@/api/parking-coupon";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 
 defineOptions({ name: "AdminParkingCouponRecord" });
 
@@ -36,6 +37,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStations();
   loadData();

+ 4 - 0
admin-web-new/src/views/admin/station/account.vue

@@ -2,6 +2,7 @@
 import { reactive, onMounted, ref, nextTick } from "vue";
 import { getStationAccount, applyWithdrawn, getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({
@@ -36,6 +37,9 @@ const state = reactive({
   stationOptions: [] as Array<{ stationId: string; stationName: string }>
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   loadData();

+ 152 - 1
admin-web-new/src/views/admin/station/device-remote.vue

@@ -1,7 +1,7 @@
 <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 { Refresh, Reading, ChatDotSquare, CloseBold, SwitchButton, Loading, Odometer } from "@element-plus/icons-vue";
 import { deviceRemoteApi } from "@/api/station";
 
 defineOptions({ name: "DeviceRemoteDialog" });
@@ -16,6 +16,7 @@ const initState = () => ({
   uptimeMs: "",
   hasPa: false,
   orderInfo: null as any,
+  hardwareInfo: null as any,
   showConfigEditor: false,
   configForm: {} as any,
   priceDialog: {
@@ -74,6 +75,33 @@ const temperatureDisplay = computed(() => {
   return state.temperatureChip != null ? `${state.temperatureChip}°C` : "--";
 });
 
+// 信号质量等级(ASU: 0-31,99 表示未知/无信号;好>24,中14~24,差9~14,极差≤9)
+const rssiLevelMap: Record<string, string> = { good: "好", mid: "中", bad: "差", "very-bad": "极差" };
+
+const getRssiLevel = (rssi: number | null | undefined) => {
+  if (rssi == null || rssi >= 99) return "";
+  if (rssi > 24) return "good";
+  if (rssi >= 14) return "mid";
+  if (rssi >= 9) return "bad";
+  return "very-bad";
+};
+
+const rssiLevel = computed(() => {
+  const level = getRssiLevel(state.hardwareInfo?.network?.rssi);
+  return level ? `signal-${level}` : "";
+});
+
+const rssiDisplay = computed(() => {
+  const rssi = state.hardwareInfo?.network?.rssi;
+  if (rssi == null || rssi >= 99) return "--";
+  return `${rssi}`;
+});
+
+const rssiLabel = computed(() => {
+  const level = getRssiLevel(state.hardwareInfo?.network?.rssi);
+  return level ? rssiLevelMap[level] : "";
+});
+
 const api = (url: string, data: any = {}) => {
   return deviceRemoteApi(url, { productKey, deviceName, ...data });
 };
@@ -184,6 +212,19 @@ const confirmPriceEdit = async () => {
   }
 };
 
+const handleReadHardwareInfo = async () => {
+  state.loading = true;
+  try {
+    const res: any = await api("queryHardwareInfo");
+    state.hardwareInfo = res || null;
+    ElMessage.success("信号值读取成功");
+  } catch (e) {
+    ElMessage.error("读取信号值失败");
+  } finally {
+    state.loading = false;
+  }
+};
+
 const handleShowMsgbox = () => {
   state.msgDialog.title = "";
   state.msgDialog.content = "";
@@ -310,6 +351,9 @@ defineExpose({ open });
           <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="info" plain @click="handleReadHardwareInfo" :loading="state.loading">
+            <el-icon><Odometer /></el-icon> 读取信号值
+          </el-button>
           <el-button class="action-btn" type="warning" plain @click="handleShowMsgbox" :loading="state.loading">
             <el-icon><ChatDotSquare /></el-icon> 屏幕消息
           </el-button>
@@ -321,6 +365,48 @@ defineExpose({ open });
           </el-button>
         </div>
 
+        <!-- 信号值信息(点击「读取信号值」后显示) -->
+        <div class="signal-info-card" v-if="state.hardwareInfo">
+          <div class="signal-title">
+            <span>设备信号值</span>
+            <el-button size="small" text type="primary" @click="state.hardwareInfo = null">收起</el-button>
+          </div>
+          <div class="signal-grid">
+            <div class="signal-item">
+              <span class="signal-label">网络类型</span>
+              <span class="signal-value">{{ state.hardwareInfo.network?.type || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">信号质量(RSSI)</span>
+              <span class="signal-value" :class="rssiLevel">{{ rssiDisplay }}<span v-if="rssiLabel" class="signal-level-tag">{{ rssiLabel }}</span></span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">误码率(BER)</span>
+              <span class="signal-value">{{ state.hardwareInfo.network?.ber ?? '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">IMEI</span>
+              <span class="signal-value">{{ state.hardwareInfo.network?.imei || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">硬件版本</span>
+              <span class="signal-value">{{ state.hardwareInfo.board?.hard_version || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">固件版本</span>
+              <span class="signal-value">{{ state.hardwareInfo.board?.firmware_version || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">编译时间</span>
+              <span class="signal-value">{{ state.hardwareInfo.board?.build_time || '--' }}</span>
+            </div>
+            <div class="signal-item">
+              <span class="signal-label">芯片ID</span>
+              <span class="signal-value chip-id">{{ state.hardwareInfo.board?.chip_id || '--' }}</span>
+            </div>
+          </div>
+        </div>
+
         <!-- 订单信息(仅忙碌时显示) -->
         <div class="order-info-card" v-if="state.deviceState === 'busy' && state.orderInfo">
           <div class="order-title">当前订单</div>
@@ -591,4 +677,69 @@ defineExpose({ open });
     margin-bottom: 12px;
   }
 }
+
+.signal-info-card {
+  margin-top: 8px;
+  padding: 12px 16px;
+  background: #f5f7fa;
+  border-radius: 8px;
+
+  .signal-title {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-weight: 600;
+    margin-bottom: 10px;
+    color: #333;
+  }
+
+  .signal-grid {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 6px 24px;
+  }
+
+  .signal-item {
+    display: flex;
+    justify-content: space-between;
+    font-size: 13px;
+    padding: 5px 0;
+    border-bottom: 1px dashed #e4e7ed;
+
+    .signal-label {
+      color: #909399;
+      flex-shrink: 0;
+      margin-right: 12px;
+    }
+
+    .signal-value {
+      color: #303133;
+      font-weight: 600;
+      word-break: break-all;
+      text-align: right;
+    }
+
+    .signal-value.chip-id {
+      font-size: 12px;
+      max-width: 220px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .signal-good { color: #67c23a; }
+  .signal-mid { color: #e6a23c; }
+  .signal-bad { color: #ff9800; }
+  .signal-very-bad { color: #f56c6c; }
+
+  .signal-level-tag {
+    margin-left: 6px;
+    font-size: 11px;
+    font-weight: 600;
+    padding: 1px 6px;
+    border-radius: 4px;
+    border: 1px solid currentColor;
+  }
+}
 </style>

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

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick, watch } from "vue";
 import { getStationDeviceList, removeDevice, getStationList } from "@/api/station";
 import { stopDevice } from "@/api/device";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { useRoute, useRouter } from "vue-router";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { ExtDLabel, ExtDSelect } from "@/components/ExtForm";
@@ -77,6 +78,9 @@ watch(
   }
 );
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadStationOptions();
   if (state.stationId) {

+ 16 - 0
admin-web-new/src/views/admin/station/fault/index.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick, onBeforeUnmount } from "vue";
 import { getSubscriberList, generateQrcode, unsubscribe, getFaultRecords } from "@/api/fault-subscriber";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({ name: "AdminStationFault" });
@@ -45,8 +46,23 @@ const faultTypeLabel = (type: string) => {
   }
 };
 
+//站点筛选与全局当前站点联动:切换站点时同步订阅与故障记录两个页签的筛选
+const reloadFaultPage = (refresh?: boolean) => {
+  state.faultQuery.stationId = state.formQuery.stationId;
+  if (state.activeTab === "faultRecord") {
+    loadFaultRecords(!!refresh);
+  }
+  loadSubscribers();
+};
+useStationScope(state.formQuery, (refresh?: boolean) => reloadFaultPage(refresh));
+
 onMounted(() => {
   loadStations();
+  //默认选中全局当前站点时,自动加载订阅列表
+  if (state.formQuery.stationId) {
+    state.faultQuery.stationId = state.formQuery.stationId;
+    loadSubscribers();
+  }
   nextTick(() => {
     const bodyHeight = document.body.clientHeight;
     state.tableHeight = bodyHeight - 280;

+ 4 - 0
admin-web-new/src/views/admin/statistics/daily-stat.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getDailyStatList, syncDailyStatBatch } from "@/api/stat";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({ name: "AdminDailyStat" });
@@ -45,6 +46,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   const yesterday = new Date();
   yesterday.setDate(yesterday.getDate() - 1);

+ 4 - 0
admin-web-new/src/views/admin/statistics/month-stat.vue

@@ -3,6 +3,7 @@ import { reactive, onMounted, ref, nextTick } from "vue";
 import { getMonthStatList, syncMonthStat } from "@/api/stat";
 import { getStationList } from "@/api/station";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ElMessage, ElMessageBox } from "element-plus";
 
 defineOptions({ name: "AdminMonthStat" });
@@ -44,6 +45,9 @@ const state = reactive({
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   const now = new Date();
   const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);

+ 273 - 6
admin-web-new/src/views/admin/user/index.vue

@@ -1,8 +1,12 @@
 <script setup lang="ts">
 import { reactive, onMounted, ref, nextTick } from "vue";
-import { getCustomUserList } from "@/api/custom";
+import { getCustomUserList, lockUser, unlockUser, getUserLockRecord } from "@/api/custom";
+import { applyRefund } from "@/api/finance";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import { useStationScope } from "@/hooks/useStationScope";
 import { ExtDLabel } from "@/components/ExtForm";
+import { ElMessageBox } from "element-plus";
+import { message } from "@/utils/message";
 import UserDetail from "./detail.vue";
 
 defineOptions({
@@ -17,7 +21,8 @@ const state = reactive({
   formQuery: {
     mobilePhone: "",
     userId: "",
-    stationId: ""
+    stationId: "",
+    locked: null as number | null
   },
   pageQuery: {
     pageNum: 1,
@@ -37,11 +42,31 @@ const state = reactive({
       { label: "洗车次数", prop: "washTimes", width: 90 },
       { label: "消费总额", prop: "amount", width: 120 },
       { label: "状态", prop: "status", width: 80 },
+      { label: "锁定备注", prop: "lockRemark", width: 150 },
+      { label: "锁定次数", prop: "lockTimes", width: 90 },
+      { label: "锁定时间", prop: "lockTime", width: 170 },
       { label: "注册时间", prop: "registerTime", width: 170 }
     ]
+  },
+  lockDialog: {
+    visible: false,
+    loading: false,
+    userId: null as number | null,
+    mobilePhone: "",
+    lockRemark: "",
+    lockNotice: ""
+  },
+  lockRecordDialog: {
+    visible: false,
+    loading: false,
+    mobilePhone: "",
+    list: [] as Array<any>
   }
 });
 
+//站点筛选与全局当前站点联动(回调懒引用,规避 loadData 声明顺序的 TDZ 错误)
+useStationScope(state.formQuery, (refresh?: boolean) => loadData(refresh));
+
 onMounted(() => {
   loadData();
   nextTick(() => {
@@ -56,7 +81,12 @@ const loadData = (refresh: boolean = false) => {
     state.pageQuery.pageNum = 1;
   }
   state.tableData.loading = true;
-  getCustomUserList({ ...state.formQuery, ...state.pageQuery })
+  const params: any = { ...state.formQuery, ...state.pageQuery };
+  //锁定状态未选时不传参,避免空值被绑定为 Integer 报错
+  if (params.locked === "" || params.locked === null || params.locked === undefined) {
+    delete params.locked;
+  }
+  getCustomUserList(params)
     .then((res: any) => {
       const { list, total } = res || {};
       state.tableData.data = list || [];
@@ -88,7 +118,8 @@ const handleReset = () => {
   state.formQuery = {
     mobilePhone: "",
     userId: "",
-    stationId: ""
+    stationId: "",
+    locked: null
   };
   loadData(true);
 };
@@ -100,6 +131,98 @@ const formatMoney = (value: number) => {
 
 const handleDetail = (row: any) => detailRef.value?.open(row);
 
+const handleUserRefundApply = (row: any) => {
+  ElMessageBox.confirm("确认执行本操作吗?", "退款申请", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning"
+  }).then(() => {
+    ElMessageBox.prompt("请输入退款申请原因", "退款申请", {
+      confirmButtonText: "提交",
+      cancelButtonText: "取消",
+      inputType: "textarea",
+      draggable: true
+    })
+      .then(({ value }) => {
+        applyRefund({ userId: row.userId, reason: (value || "").trim() })
+          .then(() => {
+            message("退款已发起", { type: "success" });
+            loadData(true);
+          })
+          .catch(() => {
+            // 错误提示由响应拦截器统一处理
+          });
+      })
+      .catch(() => {});
+  }).catch(() => {});
+};
+
+// 打开锁定用户弹窗
+const openLockDialog = (row: any) => {
+  state.lockDialog.userId = row.userId;
+  state.lockDialog.mobilePhone = row.mobilePhone;
+  state.lockDialog.lockRemark = "";
+  state.lockDialog.lockNotice = "";
+  state.lockDialog.visible = true;
+};
+
+// 提交锁定
+const confirmLockUser = () => {
+  state.lockDialog.loading = true;
+  lockUser({
+    userId: state.lockDialog.userId as number,
+    lockRemark: state.lockDialog.lockRemark,
+    lockNotice: state.lockDialog.lockNotice.trim()
+  })
+    .then(() => {
+      message("锁定成功", { type: "success" });
+      state.lockDialog.visible = false;
+      loadData(true);
+    })
+    .catch(() => {
+      // 错误提示由响应拦截器统一处理
+    })
+    .finally(() => {
+      state.lockDialog.loading = false;
+    });
+};
+
+// 打开锁定记录弹窗
+const openLockRecordDialog = (row: any) => {
+  state.lockRecordDialog.mobilePhone = row.mobilePhone;
+  state.lockRecordDialog.list = [];
+  state.lockRecordDialog.visible = true;
+  state.lockRecordDialog.loading = true;
+  getUserLockRecord(row.userId)
+    .then((res: any) => {
+      state.lockRecordDialog.list = res || [];
+    })
+    .catch(() => {
+      // 错误提示由响应拦截器统一处理
+    })
+    .finally(() => {
+      state.lockRecordDialog.loading = false;
+    });
+};
+
+// 解锁用户
+const handleUnlockUser = (row: any) => {
+  ElMessageBox.confirm(
+    `确认解锁用户『${row.mobilePhone}』?解锁后该用户可正常启动设备与退款`,
+    "提示",
+    {
+      confirmButtonText: "确定",
+      cancelButtonText: "取消",
+      type: "warning"
+    }
+  ).then(() => {
+    unlockUser(row.userId).then(() => {
+      message("解锁成功", { type: "success" });
+      loadData(true);
+    });
+  }).catch(() => {});
+};
+
 const isMoneyField = (prop: string) => {
   return ["balance", "rechargeAmount", "amount"].includes(prop);
 };
@@ -133,6 +256,18 @@ const isMoneyField = (prop: string) => {
             @keyup.enter="handleSearch"
           />
         </el-form-item>
+        <el-form-item label="锁定状态">
+          <el-select
+            v-model="state.formQuery.locked"
+            placeholder="请选择锁定状态"
+            clearable
+            style="width: 150px"
+            @change="handleSearch"
+          >
+            <el-option label="已锁定" :value="1" />
+            <el-option label="未锁定" :value="0" />
+          </el-select>
+        </el-form-item>
         <el-form-item>
           <el-button
             type="primary"
@@ -174,16 +309,52 @@ const isMoneyField = (prop: string) => {
               {{ formatMoney(row[col.prop]) }}
             </template>
             <template v-else-if="col.prop === 'status'">
-              <ExtDLabel type="User.status" :model-value="row.status" />
+              <el-tag v-if="row.lockNotice" type="danger" effect="dark" size="small">锁定</el-tag>
+              <ExtDLabel v-else type="User.status" :model-value="row.status" />
+            </template>
+            <template v-else-if="col.prop === 'lockTimes'">
+              <el-button
+                v-if="row.lockTimes > 0"
+                link
+                type="danger"
+                size="small"
+                @click="openLockRecordDialog(row)"
+              >
+                {{ row.lockTimes }}
+              </el-button>
+              <span v-else>0</span>
             </template>
             <template v-else>
               {{ row[col.prop] }}
             </template>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="100" fixed="right">
+        <el-table-column label="操作" width="200" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
+            <el-button type="primary" link size="small" @click="handleUserRefundApply(row)">
+              申请退款
+            </el-button>
+            <el-button
+              v-if="!row.lockNotice"
+              v-auth="'user.lock'"
+              type="danger"
+              link
+              size="small"
+              @click="openLockDialog(row)"
+            >
+              锁定
+            </el-button>
+            <el-button
+              v-else
+              v-auth="'user.lock'"
+              type="success"
+              link
+              size="small"
+              @click="handleUnlockUser(row)"
+            >
+              解锁
+            </el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -200,6 +371,97 @@ const isMoneyField = (prop: string) => {
         />
       </div>
     </el-card>
+
+    <!-- 锁定用户弹窗 -->
+    <el-dialog
+      v-model="state.lockDialog.visible"
+      :title="`锁定用户『${state.lockDialog.mobilePhone || ''}』`"
+      width="520px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <el-form label-width="90px" size="default">
+        <el-form-item label="锁定备注">
+          <el-input
+            v-model="state.lockDialog.lockRemark"
+            type="textarea"
+            :rows="2"
+            maxlength="200"
+            show-word-limit
+            placeholder="仅平台人员可见,用于记录锁定原因"
+          />
+        </el-form-item>
+        <el-form-item label="提示内容" required>
+          <el-input
+            v-model="state.lockDialog.lockNotice"
+            type="textarea"
+            :rows="3"
+            maxlength="200"
+            show-word-limit
+            placeholder="用户启动设备时强制弹出展示"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="state.lockDialog.visible = false">取 消</el-button>
+        <el-button
+          :loading="state.lockDialog.loading"
+          :disabled="!state.lockDialog.lockNotice || !state.lockDialog.lockNotice.trim()"
+          type="danger"
+          @click="confirmLockUser"
+        >
+          确定锁定
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 锁定记录弹窗 -->
+    <el-dialog
+      v-model="state.lockRecordDialog.visible"
+      :title="`锁定记录『${state.lockRecordDialog.mobilePhone || ''}』`"
+      width="900px"
+      append-to-body
+    >
+      <el-table
+        border
+        stripe
+        :data="state.lockRecordDialog.list"
+        v-loading="state.lockRecordDialog.loading"
+        max-height="420"
+      >
+        <template #empty>
+          <el-empty description="暂无锁定记录" />
+        </template>
+        <el-table-column label="序号" type="index" width="60" align="center" />
+        <el-table-column label="锁定时间" prop="lockTime" width="160" align="center" />
+        <el-table-column label="锁定人" prop="lockOperator" width="110" show-overflow-tooltip />
+        <el-table-column
+          label="锁定备注(仅平台人员可见)"
+          prop="lockRemark"
+          min-width="160"
+          show-overflow-tooltip
+        />
+        <el-table-column label="提示内容" prop="lockNotice" min-width="160" show-overflow-tooltip />
+        <el-table-column label="状态" width="90" align="center">
+          <template #default="{ row }">
+            <el-tag v-if="row.status === 1" type="danger" effect="dark" size="small">锁定中</el-tag>
+            <el-tag v-else type="info" size="small">已解锁</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="解锁信息" width="200">
+          <template #default="{ row }">
+            <div v-if="row.unlockTime">{{ row.unlockTime }}</div>
+            <div v-if="row.unlockOperator" class="unlock-operator">
+              解锁人:{{ row.unlockOperator }}
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+      <template #footer>
+        <el-button @click="state.lockRecordDialog.visible = false">关 闭</el-button>
+      </template>
+    </el-dialog>
+
     <UserDetail ref="detailRef" />
   </div>
 </template>
@@ -220,4 +482,9 @@ const isMoneyField = (prop: string) => {
   justify-content: flex-end;
   margin-top: 15px;
 }
+
+.unlock-operator {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
 </style>