Ver Fonte

Merge branch 'master' of http://git.iwintrue.com/liaoxg/ajy

liyuliangjiazai há 3 meses atrás
pai
commit
a13fd8d2ed
26 ficheiros alterados com 1105 adições e 2257 exclusões
  1. 2 1
      .gitignore
  2. 2 0
      code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java
  3. 1 1
      code/backend/src/main/java/com/aijiuyi/admin/controller/AdminController.java
  4. 1 1
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AdminQueryDTO.java
  5. 1 1
      code/backend/src/main/java/com/aijiuyi/admin/entity/AppUser.java
  6. 68 9
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AdminServiceImpl.java
  7. 1 1
      code/backend/src/main/resources/application.yml
  8. 1 1
      code/backend/src/main/resources/sql/init.sql
  9. 1 1
      code/frontend/src/api/admin.js
  10. 7 1
      code/frontend/src/router/index.js
  11. 1 1
      code/frontend/src/store/user.js
  12. 24 10
      code/frontend/src/views/admin/index.vue
  13. 5 2
      code/frontend/src/views/content/index.vue
  14. 956 162
      code/frontend/src/views/dashboard/index.vue
  15. 3 2
      code/frontend/src/views/device/index.vue
  16. 4 1
      code/frontend/src/views/device/users.vue
  17. 5 2
      code/frontend/src/views/log/index.vue
  18. 5 4
      code/frontend/src/views/plan/index.vue
  19. 2 1
      code/frontend/src/views/system/user-category/index.vue
  20. 5 2
      code/frontend/src/views/user/app-user/index.vue
  21. 2 1
      code/frontend/src/views/user/device/index.vue
  22. 4 3
      code/frontend/src/views/user/plan/index.vue
  23. 4 2
      code/frontend/src/views/user/profile/index.vue
  24. 0 2047
      logs/aijiuyi-admin-dev.log
  25. BIN
      logs/aijiuyi-admin-dev.log.2026-05-20.0.gz
  26. BIN
      logs/aijiuyi-admin-dev.log.2026-05-22.0.gz

+ 2 - 1
.gitignore

@@ -24,4 +24,5 @@ Thumbs.db
 # AGENTS.md - 本地AI agent指南,不提交
 AGENTS.md
 code/app/
-code/ajyApp/.hbuilderx
+code/ajyApp/.hbuilderx
+/logs/

+ 2 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java

@@ -52,6 +52,8 @@ public enum ResultCode {
     SMS_SEND_TOO_FREQUENT(1106, "请勿频繁发送,请60秒后再试"),
     /** 短信发送达到每日上限 */
     SMS_DAILY_LIMIT(1107, "今日短信发送次数已达上限,请明天再试"),
+    /** 手机号格式不正确 */
+    PHONE_FORMAT_INVALID(1108, "手机号格式不正确"),
 
     // ======================== 子用户相关 1200-1299 ========================
     /** 子用户不存在 */

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/AdminController.java

@@ -52,7 +52,7 @@ public class AdminController {
     /**
      * 新增管理员
      *
-     * @param admin 管理员信息(username/password/nickname/adminRole/status 必填)
+     * @param admin 管理员信息(username/password/nickname/adminRole/status 必填,phone 选填
      * @return 操作结果
      */
     @PostMapping

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AdminQueryDTO.java

@@ -14,7 +14,7 @@ public class AdminQueryDTO {
     /** 姓名/昵称(模糊查询) */
     private String nickname;
 
-    /** 管理员角色:1=超级管理员,2=运营人员,3=只读用户 */
+    /** 管理员角色:1=超级管理员,2=设备管理员,3=圣手管理 */
     private Integer adminRole;
 
     /** 状态:1=启用,0=禁用 */

+ 1 - 1
code/backend/src/main/java/com/aijiuyi/admin/entity/AppUser.java

@@ -47,7 +47,7 @@ public class AppUser {
     private Integer userType;
 
     /**
-     * 管理员角色:1=超级管理员,2=运营人员,3=只读用户(仅 userType=3 时有效)
+     * 管理员角色:1=超级管理员,2=设备管理员,3=圣手管理(仅 userType=3 时有效)
      */
     private Integer adminRole;
 

+ 68 - 9
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AdminServiceImpl.java

@@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
 import org.springframework.util.DigestUtils;
 import org.springframework.util.StringUtils;
 
+import java.time.LocalDateTime;
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -29,6 +31,8 @@ import java.util.List;
 @Service
 public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implements AdminService {
 
+    private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
+
     @Autowired
     private AdminPermissionMapper adminPermissionMapper;
 
@@ -63,6 +67,7 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
      */
     @Override
     public void addAdmin(AppUser admin) {
+        normalizePhone(admin);
         // 校验用户名唯一性
         if (!StringUtils.hasText(admin.getUsername())) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
@@ -71,6 +76,12 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         if (usernameCount > 0) {
             throw new BusinessException(ResultCode.USERNAME_EXISTS);
         }
+        if (StringUtils.hasText(admin.getPhone())) {
+            long phoneCount = lambdaQuery().eq(AppUser::getPhone, admin.getPhone()).count();
+            if (phoneCount > 0) {
+                throw new BusinessException(ResultCode.PHONE_EXISTS);
+            }
+        }
         // 强制 userType=3(管理员)
         admin.setUserType(3);
         // 密码加密
@@ -93,6 +104,7 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         if (exist == null || !Integer.valueOf(3).equals(exist.getUserType())) {
             throw new BusinessException(ResultCode.ADMIN_NOT_FOUND);
         }
+        boolean clearPhone = normalizePhone(admin);
         // 若修改了用户名,校验唯一性
         if (StringUtils.hasText(admin.getUsername()) && !admin.getUsername().equals(exist.getUsername())) {
             long count = lambdaQuery().eq(AppUser::getUsername, admin.getUsername()).count();
@@ -100,6 +112,13 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
                 throw new BusinessException(ResultCode.USERNAME_EXISTS);
             }
         }
+        // 若修改了手机号,校验格式与唯一性;传空字符串表示清空手机号
+        if (StringUtils.hasText(admin.getPhone()) && !admin.getPhone().equals(exist.getPhone())) {
+            long count = lambdaQuery().eq(AppUser::getPhone, admin.getPhone()).count();
+            if (count > 0) {
+                throw new BusinessException(ResultCode.PHONE_EXISTS);
+            }
+        }
         // 密码处理
         if (StringUtils.hasText(admin.getPassword())) {
             admin.setPassword(DigestUtils.md5DigestAsHex(admin.getPassword().getBytes()));
@@ -109,9 +128,34 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         // 强制 userType 不变
         admin.setUserType(null);
         updateById(admin);
+        if (clearPhone) {
+            lambdaUpdate().eq(AppUser::getId, admin.getId()).set(AppUser::getPhone, null).update();
+        }
         LogUtil.info(AdminServiceImpl.class, "修改管理员[{}]", admin.getId());
     }
 
+    /**
+     * 手机号为选填项:有值时按中国大陆手机号校验,无值时规范为 null。
+     *
+     * @param admin 管理员实体
+     * @return true 表示本次请求显式清空手机号
+     */
+    private boolean normalizePhone(AppUser admin) {
+        if (admin.getPhone() == null) {
+            return false;
+        }
+        String phone = admin.getPhone().trim();
+        if (!StringUtils.hasText(phone)) {
+            admin.setPhone(null);
+            return true;
+        }
+        if (!phone.matches(PHONE_PATTERN)) {
+            throw new BusinessException(ResultCode.PHONE_FORMAT_INVALID);
+        }
+        admin.setPhone(phone);
+        return false;
+    }
+
     /**
      * 删除管理员(逻辑删除)
      * adminRole=1 超级管理员不可删除
@@ -127,7 +171,7 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         if (Integer.valueOf(1).equals(admin.getAdminRole())) {
             throw new BusinessException(ResultCode.ADMIN_CANNOT_DELETE);
         }
-        removeById(id);
+        logicDeleteAdminsAndReleaseUniqueFields(Collections.singletonList(id));
         LogUtil.info(AdminServiceImpl.class, "删除管理员[{}]", id);
     }
 
@@ -139,20 +183,35 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
      */
     @Override
     public void batchDeleteAdmin(List<Long> ids) {
-        for (Long id : ids) {
-            AppUser admin = getById(id);
-            if (admin != null && Integer.valueOf(1).equals(admin.getAdminRole())) {
+        if (ids == null || ids.isEmpty()) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        List<AppUser> admins = lambdaQuery()
+                .in(AppUser::getId, ids)
+                .eq(AppUser::getUserType, 3)
+                .list();
+        for (AppUser admin : admins) {
+            if (Integer.valueOf(1).equals(admin.getAdminRole())) {
                 throw new BusinessException(ResultCode.ADMIN_CANNOT_DELETE);
             }
         }
-        // 仅删除 userType=3 的记录,防止误删其他类型用户
-        LambdaQueryWrapper<AppUser> wrapper = new LambdaQueryWrapper<AppUser>()
-                .in(AppUser::getId, ids)
-                .eq(AppUser::getUserType, 3);
-        remove(wrapper);
+        if (!admins.isEmpty()) {
+            logicDeleteAdminsAndReleaseUniqueFields(ids);
+        }
         LogUtil.info(AdminServiceImpl.class, "批量删除管理员,ID列表:{}", ids);
     }
 
+    private void logicDeleteAdminsAndReleaseUniqueFields(List<Long> ids) {
+        lambdaUpdate()
+                .in(AppUser::getId, ids)
+                .eq(AppUser::getUserType, 3)
+                .set(AppUser::getUsername, null)
+                .set(AppUser::getPhone, null)
+                .set(AppUser::getDeleted, 1)
+                .set(AppUser::getUpdateTime, LocalDateTime.now())
+                .update();
+    }
+
     /**
      * 修改管理员状态(禁用/启用)
      * 不允许禁用当前登录账号自身

+ 1 - 1
code/backend/src/main/resources/application.yml

@@ -1,6 +1,6 @@
 spring:
   profiles:
-    active: liyu
+    active: dev
 
   # Jackson 日期时间格式配置
   jackson:

+ 1 - 1
code/backend/src/main/resources/sql/init.sql

@@ -605,7 +605,7 @@ CREATE TABLE IF NOT EXISTS `admin_permission` (
 
 -- app_user 表追加管理员相关字段(首次执行时添加,若字段已存在请注释掉此段)
 ALTER TABLE `app_user`
-    ADD COLUMN `admin_role`      TINYINT  DEFAULT NULL COMMENT '管理员角色:1=超级管理员,2=运营人员,3=只读用户(仅 userType=3 时有效)',
+    ADD COLUMN `admin_role`      TINYINT  DEFAULT NULL COMMENT '管理员角色:1=超级管理员,2=设备管理员,3=圣手管理(仅 userType=3 时有效)',
     ADD COLUMN `last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间';
 
 -- 更新初始管理员为超级管理员角色

+ 1 - 1
code/frontend/src/api/admin.js

@@ -23,7 +23,7 @@ export function getAdminById(id) {
 
 /**
  * 新增管理员
- * @param {Object} data 管理员信息(username/password/nickname/adminRole/status)
+ * @param {Object} data 管理员信息(username/password/nickname/phone/adminRole/status)
  */
 export function addAdmin(data) {
   return request.post('/admin', data)

+ 7 - 1
code/frontend/src/router/index.js

@@ -17,8 +17,14 @@ const routes = [
     path: '/',
     component: () => import('@/layout/index.vue'),
     meta: { requiresAuth: true },
-    redirect: '/user/app',
+    redirect: '/dashboard',
     children: [
+      {
+        path: 'dashboard',
+        name: 'Dashboard',
+        component: () => import('@/views/dashboard/index.vue'),
+        meta: { requiresAuth: true, title: '首页', icon: 'House' }
+      },
       {
         path: 'profile',
         name: 'Profile',

+ 1 - 1
code/frontend/src/store/user.js

@@ -20,7 +20,7 @@ export const useUserStore = defineStore('user', () => {
   const avatar = computed(() => userInfo.value?.avatar || '/default-avatar.png')
 
   /**
-   * 管理员角色:1=超级管理员,2=运营人员,3=只读用户
+   * 管理员角色:1=超级管理员,2=设备管理员,3=圣手管理
    * 超级管理员拥有所有菜单权限
    */
   const adminRole = computed(() => userInfo.value?.adminRole || null)

+ 24 - 10
code/frontend/src/views/admin/index.vue

@@ -13,10 +13,10 @@
           />
         </el-form-item>
         <el-form-item label="角色">
-          <el-select v-model="query.adminRole" placeholder="全部" clearable style="width: 130px">
+          <el-select v-model="query.adminRole" placeholder="全部" clearable style="width: 140px">
             <el-option label="超级管理员" :value="1" />
-            <el-option label="运营人员" :value="2" />
-            <el-option label="只读用户" :value="3" />
+            <el-option label="设备管理员" :value="2" />
+            <el-option label="圣手管理" :value="3" />
           </el-select>
         </el-form-item>
         <el-form-item label="状态">
@@ -62,6 +62,9 @@
         <el-table-column label="管理员ID" prop="id" width="100" align="center" />
         <el-table-column label="用户名" prop="username" min-width="120" />
         <el-table-column label="姓名" prop="nickname" min-width="120" />
+        <el-table-column label="手机号" prop="phone" width="130">
+          <template #default="{ row }">{{ row.phone || '—' }}</template>
+        </el-table-column>
         <el-table-column label="角色" prop="adminRole" width="120" align="center">
           <template #default="{ row }">
             <el-tag :type="roleTagType(row.adminRole)" size="small">
@@ -77,7 +80,7 @@
           </template>
         </el-table-column>
         <el-table-column label="最后登录时间" prop="lastLoginTime" width="170" align="center">
-          <template #default="{ row }">{{ row.lastLoginTime || '—' }}</template>
+          <template #default="{ row }">{{ formatDateTime(row.lastLoginTime) }}</template>
         </el-table-column>
         <el-table-column label="操作" width="230" align="center" fixed="right">
           <template #default="{ row }">
@@ -119,6 +122,7 @@
           v-model:page-size="query.pageSize"
           :page-sizes="[10, 20, 50]"
           :total="total"
+          :hide-on-single-page="false"
           layout="total, sizes, prev, pager, next, jumper"
           background
           @size-change="loadData"
@@ -139,7 +143,7 @@
         <el-form-item label="用户名" prop="username">
           <el-input v-model="formData.username" placeholder="4-20个字符,唯一" :disabled="formMode === 'edit'" />
         </el-form-item>
-        <el-form-item label="密码" prop="password">
+        <el-form-item label="密码" prop="password" :required="formMode === 'add'">
           <el-input
             v-model="formData.password"
             type="password"
@@ -150,11 +154,14 @@
         <el-form-item label="姓名" prop="nickname">
           <el-input v-model="formData.nickname" placeholder="最长50字符" />
         </el-form-item>
+        <el-form-item label="手机号" prop="phone">
+          <el-input v-model.trim="formData.phone" placeholder="请输入手机号(选填)" maxlength="11" />
+        </el-form-item>
         <el-form-item label="角色" prop="adminRole">
           <el-select v-model="formData.adminRole" placeholder="请选择角色" style="width: 100%">
             <el-option label="超级管理员" :value="1" />
-            <el-option label="运营人员" :value="2" />
-            <el-option label="只读用户" :value="3" />
+            <el-option label="设备管理员" :value="2" />
+            <el-option label="圣手管理" :value="3" />
           </el-select>
         </el-form-item>
         <el-form-item label="状态" prop="status">
@@ -212,6 +219,7 @@ import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
 import { adminApi } from '@/api'
+import { formatDateTime } from '@/utils/date'
 
 // ======================== 可配置的菜单路由列表 ========================
 const allMenuRoutes = [
@@ -253,7 +261,7 @@ async function loadData() {
     }
     const res = await adminApi.getAdminPage(params)
     tableData.value = res.data.records || []
-    total.value = res.data.total || 0
+    total.value = Number(res.data.total) || 0
   } finally {
     loading.value = false
   }
@@ -278,7 +286,7 @@ function handleSelectionChange(rows) {
 
 // ======================== 角色标签 ========================
 function roleLabel(role) {
-  const map = { 1: '超级管理员', 2: '运营人员', 3: '只读用户' }
+  const map = { 1: '超级管理员', 2: '设备管理员', 3: '圣手管理' }
   return map[role] || '未知'
 }
 
@@ -298,6 +306,7 @@ const formData = reactive({
   username: '',
   password: '',
   nickname: '',
+  phone: '',
   adminRole: null,
   status: 1
 })
@@ -327,13 +336,16 @@ const formRules = {
     { required: true, message: '请输入姓名', trigger: 'blur' },
     { max: 50, message: '姓名最长50字符', trigger: 'blur' }
   ],
+  phone: [
+    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
+  ],
   adminRole: [{ required: true, message: '请选择角色', trigger: 'change' }],
   status: [{ required: true, message: '请选择状态', trigger: 'change' }]
 }
 
 function handleAdd() {
   formMode.value = 'add'
-  Object.assign(formData, { id: null, username: '', password: '', nickname: '', adminRole: null, status: 1 })
+  Object.assign(formData, { id: null, username: '', password: '', nickname: '', phone: '', adminRole: null, status: 1 })
   formVisible.value = true
 }
 
@@ -344,6 +356,7 @@ function handleEdit(row) {
     username: row.username,
     password: '',
     nickname: row.nickname,
+    phone: row.phone || '',
     adminRole: row.adminRole,
     status: row.status
   })
@@ -359,6 +372,7 @@ async function handleSubmit() {
   submitLoading.value = true
   try {
     const payload = { ...formData }
+    if (formMode.value === 'add' && !payload.phone) payload.phone = null
     if (formMode.value === 'add') {
       await adminApi.addAdmin(payload)
       ElMessage.success('新增管理员成功')

+ 5 - 2
code/frontend/src/views/content/index.vue

@@ -65,9 +65,11 @@
           <template #default="{ row }">{{ row.viewCount || 0 }}</template>
         </el-table-column>
         <el-table-column prop="publishTime" label="发布时间" width="165">
-          <template #default="{ row }">{{ row.publishTime || '-' }}</template>
+          <template #default="{ row }">{{ formatDateTime(row.publishTime) }}</template>
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间" width="165">
+          <template #default="{ row }">{{ formatDateTime(row.updateTime) }}</template>
         </el-table-column>
-        <el-table-column prop="updateTime" label="更新时间" width="165" />
         <el-table-column prop="watermarkText" label="水印" width="100" align="center" show-overflow-tooltip>
           <template #default="{ row }">
             <el-tag v-if="row.watermarkText" type="success" size="small">已设置</el-tag>
@@ -246,6 +248,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import { Search, Refresh, Plus, CopyDocument, View } from '@element-plus/icons-vue'
 import RichEditor from '@/components/RichEditor.vue'
 import { useUserStore } from '@/store/user'
+import { formatDateTime } from '@/utils/date'
 import {
   getArticlePage,
   getArticleById,

+ 956 - 162
code/frontend/src/views/dashboard/index.vue

@@ -1,233 +1,1027 @@
 <template>
-  <div class="dashboard">
-    <!-- 欢迎卡片 -->
-    <div class="welcome-card">
-      <div class="welcome-info">
-        <h2>欢迎回来,{{ userStore.nickname }} 👋</h2>
-        <p>{{ greeting }},祝您工作愉快!</p>
+  <div class="dashboard-page">
+    <section class="overview-strip">
+      <div>
+        <div class="eyebrow">运营首页</div>
+        <h1>艾灸椅业务总览</h1>
+        <p>设备投产、圣手服务、审批待办与异常报警集中看板</p>
       </div>
-      <div class="welcome-date">
-        <div class="date">{{ currentDate }}</div>
-        <div class="time">{{ currentTime }}</div>
+      <div class="overview-actions">
+        <el-tag effect="plain" type="success">系统运行正常</el-tag>
+        <el-button :icon="Refresh" type="primary" plain>刷新数据</el-button>
       </div>
-    </div>
-
-    <!-- 统计卡片 -->
-    <el-row :gutter="20" class="stat-row">
-      <el-col :xs="24" :sm="12" :lg="6" v-for="stat in stats" :key="stat.title">
-        <div class="stat-card" :style="{ '--card-color': stat.color }">
-          <div class="stat-icon">
-            <el-icon><component :is="stat.icon" /></el-icon>
+    </section>
+
+    <section class="metric-grid">
+      <article
+        v-for="item in metrics"
+        :key="item.title"
+        class="metric-card"
+        :style="{ '--accent': item.color, '--accent-soft': item.softColor }"
+      >
+        <div class="metric-icon">
+          <el-icon><component :is="item.icon" /></el-icon>
+        </div>
+        <div class="metric-body">
+          <span>{{ item.title }}</span>
+          <strong>{{ item.value }}</strong>
+          <em>{{ item.tip }}</em>
+        </div>
+      </article>
+    </section>
+
+    <section class="main-grid">
+      <div class="panel map-panel">
+        <div class="panel-header">
+          <div>
+            <h2>设备分布图</h2>
+            <p>按中国省份级区域统计投产设备数量</p>
+          </div>
+          <el-tag type="info" effect="plain">合计 {{ totalDeviceCount }} 台</el-tag>
+        </div>
+
+        <div class="distribution-layout">
+          <div class="province-map" aria-label="中国省份设备分布">
+            <div
+              v-for="province in provinceData"
+              :key="province.name"
+              :class="['province-tile', heatClass(province.count)]"
+              :style="{ gridColumn: province.col, gridRow: province.row }"
+            >
+              <span>{{ province.name }}</span>
+              <strong>{{ province.count }}</strong>
+            </div>
+          </div>
+
+          <div class="province-rank">
+            <div class="legend">
+              <span class="legend-item level-1"></span>
+              <span>1-9</span>
+              <span class="legend-item level-2"></span>
+              <span>10-29</span>
+              <span class="legend-item level-3"></span>
+              <span>30+</span>
+            </div>
+            <div class="rank-list">
+              <div v-for="province in topProvinces" :key="province.name" class="rank-item">
+                <span>{{ province.name }}</span>
+                <div class="rank-track">
+                  <i :style="{ width: `${province.percent}%` }"></i>
+                </div>
+                <strong>{{ province.count }}</strong>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="panel device-panel">
+        <div class="panel-header">
+          <div>
+            <h2>设备统计图</h2>
+            <p>艾灸椅设备与其他设备占比</p>
+          </div>
+          <el-icon><PieChart /></el-icon>
+        </div>
+
+        <div class="donut-wrap">
+          <div class="donut-chart">
+            <span>100%</span>
+            <small>艾灸椅设备</small>
+          </div>
+          <div class="device-summary">
+            <div class="device-row">
+              <span><i class="dot primary"></i>艾灸椅设备</span>
+              <strong>{{ totalDeviceCount }} 台</strong>
+            </div>
+            <el-progress :percentage="100" :stroke-width="10" :show-text="false" />
+            <div class="device-row muted">
+              <span><i class="dot empty"></i>其他设备</span>
+              <strong>0 台</strong>
+            </div>
+            <p class="panel-note">其他设备暂未维护,当前仅展示艾灸椅设备。</p>
           </div>
-          <div class="stat-info">
-            <div class="stat-value">{{ stat.value }}</div>
-            <div class="stat-title">{{ stat.title }}</div>
+        </div>
+      </div>
+    </section>
+
+    <section class="panel chart-panel">
+      <div class="panel-header">
+        <div>
+          <h2>圣手模式统计</h2>
+          <p>统计圣手沟通数量与发布圣手方案数量</p>
+        </div>
+        <el-radio-group v-model="period" size="small">
+          <el-radio-button label="week">周</el-radio-button>
+          <el-radio-button label="month">月</el-radio-button>
+          <el-radio-button label="quarter">季度</el-radio-button>
+        </el-radio-group>
+      </div>
+
+      <div class="bar-chart">
+        <div v-for="item in chartBars" :key="item.label" class="bar-group">
+          <div class="bar-stack">
+            <el-tooltip :content="`沟通 ${item.communicate} 次`" placement="top">
+              <span class="bar communicate" :style="{ height: `${item.communicateHeight}%` }"></span>
+            </el-tooltip>
+            <el-tooltip :content="`发布方案 ${item.plan} 个`" placement="top">
+              <span class="bar plan" :style="{ height: `${item.planHeight}%` }"></span>
+            </el-tooltip>
+          </div>
+          <span class="bar-label">{{ item.label }}</span>
+        </div>
+      </div>
+      <div class="chart-legend">
+        <span><i class="bar-dot communicate"></i>圣手沟通数量</span>
+        <span><i class="bar-dot plan"></i>发布圣手方案数量</span>
+      </div>
+    </section>
+
+    <section class="bottom-grid">
+      <div class="panel list-panel">
+        <div class="panel-header">
+          <div>
+            <h2>待办事项</h2>
+            <p>管理员审批任务</p>
+          </div>
+          <el-badge :value="todoTotal" type="warning">
+            <el-icon><Checked /></el-icon>
+          </el-badge>
+        </div>
+
+        <div class="work-list">
+          <div v-for="todo in todoList" :key="todo.title" class="work-item">
+            <div class="work-icon todo">
+              <el-icon><component :is="todo.icon" /></el-icon>
+            </div>
+            <div class="work-content">
+              <div class="work-title">
+                <strong>{{ todo.title }}</strong>
+                <el-tag :type="todo.type" size="small" effect="plain">{{ todo.status }}</el-tag>
+              </div>
+              <p>{{ todo.desc }}</p>
+            </div>
+            <div class="work-count">
+              <strong>{{ todo.count }}</strong>
+              <span>项</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="panel list-panel">
+        <div class="panel-header">
+          <div>
+            <h2>异常报警</h2>
+            <p>设备故障与用户报修信息</p>
           </div>
+          <el-badge :value="alarmList.length" type="danger">
+            <el-icon><WarningFilled /></el-icon>
+          </el-badge>
         </div>
-      </el-col>
-    </el-row>
-
-    <!-- 框架信息 -->
-    <el-row :gutter="20">
-      <el-col :span="24">
-        <el-card class="info-card" header="框架技术栈">
-          <el-row :gutter="20">
-            <el-col :xs="24" :sm="12" :md="6" v-for="tech in techStack" :key="tech.name">
-              <div class="tech-item">
-                <div class="tech-name">{{ tech.name }}</div>
-                <div class="tech-version">{{ tech.version }}</div>
-                <div class="tech-desc">{{ tech.desc }}</div>
+
+        <div class="work-list alarm-list">
+          <div v-for="alarm in alarmList" :key="alarm.id" class="work-item">
+            <div :class="['work-icon', alarm.level]">
+              <el-icon><BellFilled /></el-icon>
+            </div>
+            <div class="work-content">
+              <div class="work-title">
+                <strong>{{ alarm.title }}</strong>
+                <el-tag :type="alarm.tagType" size="small">{{ alarm.levelText }}</el-tag>
               </div>
-            </el-col>
-          </el-row>
-        </el-card>
-      </el-col>
-    </el-row>
+              <p>{{ alarm.device }} · {{ alarm.location }}</p>
+            </div>
+            <time>{{ alarm.time }}</time>
+          </div>
+        </div>
+      </div>
+    </section>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted } from 'vue'
-import { User, Document, Odometer, DataLine } from '@element-plus/icons-vue'
-import { useUserStore } from '@/store/user'
-
-const userStore = useUserStore()
-
-// 当前时间
-const currentTime = ref('')
-const currentDate = ref('')
-let timer = null
-
-// 问候语
-const greeting = computed(() => {
-  const hour = new Date().getHours()
-  if (hour < 6) return '凌晨好'
-  if (hour < 12) return '上午好'
-  if (hour < 14) return '中午好'
-  if (hour < 18) return '下午好'
-  return '晚上好'
-})
+import { computed, ref } from 'vue'
+import {
+  BellFilled,
+  Checked,
+  ChatDotRound,
+  DocumentChecked,
+  GoldMedal,
+  Monitor,
+  Notebook,
+  PieChart,
+  Promotion,
+  Refresh,
+  UserFilled,
+  WarningFilled
+} from '@element-plus/icons-vue'
 
-// 统计卡片数据(实际项目中从后端接口获取)
-const stats = [
-  { title: '今日访问量', value: '--', icon: 'DataLine', color: '#1890ff' },
-  { title: '用户总数', value: '--', icon: 'User', color: '#52c41a' },
-  { title: '操作日志', value: '--', icon: 'Document', color: '#faad14' },
-  { title: '系统状态', value: '正常', icon: 'Odometer', color: '#13c2c2' }
+const metrics = [
+  {
+    title: '当前使用用户数量',
+    value: '12,836',
+    tip: '较昨日 +328',
+    icon: UserFilled,
+    color: '#2f80ed',
+    softColor: 'rgba(47, 128, 237, 0.12)'
+  },
+  {
+    title: '布衣圣手数量',
+    value: '286',
+    tip: '本月新增 18',
+    icon: GoldMedal,
+    color: '#b7791f',
+    softColor: 'rgba(183, 121, 31, 0.14)'
+  },
+  {
+    title: '投产设备数量',
+    value: '395',
+    tip: '在线率 92.4%',
+    icon: Monitor,
+    color: '#0f9f8f',
+    softColor: 'rgba(15, 159, 143, 0.12)'
+  },
+  {
+    title: '圣手方案数量',
+    value: '1,248',
+    tip: '待审核 23',
+    icon: Notebook,
+    color: '#c05621',
+    softColor: 'rgba(192, 86, 33, 0.13)'
+  }
 ]
 
-// 技术栈信息
-const techStack = [
-  { name: 'Spring Boot', version: '2.7.18', desc: '后端基础框架' },
-  { name: 'MyBatis Plus', version: '3.5.3', desc: 'ORM 框架' },
-  { name: 'Redis', version: '3.x', desc: '缓存 & Token 存储' },
-  { name: 'Vue 3', version: '3.x + Vite', desc: '前端框架' }
+const provinceData = [
+  { name: '新疆', count: 2, col: '1 / span 2', row: '2' },
+  { name: '内蒙古', count: 2, col: '4 / span 3', row: '1' },
+  { name: '黑龙江', count: 4, col: '9 / span 2', row: '1' },
+  { name: '吉林', count: 3, col: '9', row: '2' },
+  { name: '辽宁', count: 6, col: '8', row: '2' },
+  { name: '甘肃', count: 3, col: '4', row: '2' },
+  { name: '宁夏', count: 1, col: '4', row: '3' },
+  { name: '北京', count: 14, col: '7', row: '2' },
+  { name: '天津', count: 5, col: '8', row: '3' },
+  { name: '河北', count: 9, col: '7', row: '3' },
+  { name: '山西', count: 5, col: '6', row: '3' },
+  { name: '青海', count: 1, col: '3', row: '3' },
+  { name: '西藏', count: 0, col: '2 / span 2', row: '5' },
+  { name: '陕西', count: 8, col: '5', row: '4' },
+  { name: '山东', count: 31, col: '8', row: '4' },
+  { name: '河南', count: 21, col: '6', row: '4' },
+  { name: '江苏', count: 39, col: '9', row: '4' },
+  { name: '上海', count: 13, col: '10', row: '5' },
+  { name: '安徽', count: 9, col: '8', row: '5' },
+  { name: '湖北', count: 18, col: '6', row: '5' },
+  { name: '重庆', count: 10, col: '5', row: '5' },
+  { name: '四川', count: 24, col: '4', row: '5' },
+  { name: '贵州', count: 4, col: '5', row: '6' },
+  { name: '湖南', count: 16, col: '6', row: '6' },
+  { name: '江西', count: 6, col: '7', row: '6' },
+  { name: '浙江', count: 42, col: '9', row: '6' },
+  { name: '福建', count: 15, col: '8', row: '7' },
+  { name: '广东', count: 68, col: '7', row: '7' },
+  { name: '广西', count: 7, col: '6', row: '7' },
+  { name: '云南', count: 6, col: '4 / span 2', row: '7' },
+  { name: '海南', count: 3, col: '7', row: '8' },
+  { name: '台湾', count: 0, col: '9', row: '7' },
+  { name: '香港', count: 0, col: '8', row: '8' },
+  { name: '澳门', count: 0, col: '6', row: '8' }
 ]
 
-// 更新时间
-function updateTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  currentTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-  currentDate.value = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${['日', '一', '二', '三', '四', '五', '六'][now.getDay()]}曜日`
+const period = ref('week')
+
+const chartSource = {
+  week: [
+    { label: '周一', communicate: 96, plan: 18 },
+    { label: '周二', communicate: 128, plan: 24 },
+    { label: '周三', communicate: 116, plan: 21 },
+    { label: '周四', communicate: 152, plan: 35 },
+    { label: '周五', communicate: 141, plan: 28 },
+    { label: '周六', communicate: 86, plan: 16 },
+    { label: '周日', communicate: 73, plan: 11 }
+  ],
+  month: [
+    { label: '第1周', communicate: 642, plan: 104 },
+    { label: '第2周', communicate: 718, plan: 126 },
+    { label: '第3周', communicate: 803, plan: 142 },
+    { label: '第4周', communicate: 756, plan: 137 }
+  ],
+  quarter: [
+    { label: '一月', communicate: 2380, plan: 392 },
+    { label: '二月', communicate: 2194, plan: 361 },
+    { label: '三月', communicate: 2616, plan: 448 }
+  ]
 }
 
-onMounted(() => {
-  updateTime()
-  timer = setInterval(updateTime, 1000)
+const todoList = [
+  {
+    title: '圣手申请审批',
+    desc: '新提交资质材料待管理员审核',
+    count: 12,
+    status: '高优先级',
+    type: 'warning',
+    icon: Promotion
+  },
+  {
+    title: '圣手方案发布审批',
+    desc: '圣手方案发布前内容与步骤确认',
+    count: 23,
+    status: '待处理',
+    type: 'primary',
+    icon: DocumentChecked
+  },
+  {
+    title: '用户反馈复核',
+    desc: '用户对服务体验与设备使用的反馈',
+    count: 7,
+    status: '跟进中',
+    type: 'info',
+    icon: ChatDotRound
+  }
+]
+
+const alarmList = [
+  {
+    id: 'ALM001',
+    title: '温控模块异常',
+    device: 'AJY-2026-GD-0318',
+    location: '广东省广州市',
+    time: '10分钟前',
+    level: 'critical',
+    levelText: '严重',
+    tagType: 'danger'
+  },
+  {
+    id: 'ALM002',
+    title: '用户报修待派单',
+    device: 'AJY-2025-ZJ-0094',
+    location: '浙江省杭州市',
+    time: '32分钟前',
+    level: 'warning',
+    levelText: '报修',
+    tagType: 'warning'
+  },
+  {
+    id: 'ALM003',
+    title: '设备离线超过 24 小时',
+    device: 'AJY-2026-SC-0211',
+    location: '四川省成都市',
+    time: '1小时前',
+    level: 'offline',
+    levelText: '离线',
+    tagType: 'info'
+  }
+]
+
+const totalDeviceCount = computed(() => provinceData.reduce((sum, item) => sum + item.count, 0))
+
+const topProvinces = computed(() => {
+  const max = Math.max(...provinceData.map(item => item.count))
+  return [...provinceData]
+    .sort((a, b) => b.count - a.count)
+    .slice(0, 6)
+    .map(item => ({
+      ...item,
+      percent: Math.max(8, Math.round((item.count / max) * 100))
+    }))
 })
 
-onUnmounted(() => {
-  clearInterval(timer)
+const chartBars = computed(() => {
+  const data = chartSource[period.value]
+  const max = Math.max(...data.flatMap(item => [item.communicate, item.plan]))
+  return data.map(item => ({
+    ...item,
+    communicateHeight: Math.max(8, Math.round((item.communicate / max) * 100)),
+    planHeight: Math.max(8, Math.round((item.plan / max) * 100))
+  }))
 })
+
+const todoTotal = computed(() => todoList.reduce((sum, item) => sum + item.count, 0))
+
+function heatClass(count) {
+  if (count >= 30) return 'heat-3'
+  if (count >= 10) return 'heat-2'
+  if (count > 0) return 'heat-1'
+  return 'heat-0'
+}
 </script>
 
 <style lang="scss" scoped>
-.dashboard {
+.dashboard-page {
   display: flex;
   flex-direction: column;
-  gap: 20px;
+  gap: 18px;
+  min-height: calc(100vh - 104px);
+  color: #1f2933;
 }
 
-// 欢迎卡片
-.welcome-card {
-  background: linear-gradient(135deg, #1890ff, #36cfc9);
-  border-radius: 12px;
-  padding: 28px 32px;
+.overview-strip,
+.panel,
+.metric-card {
+  background: #fff;
+  border: 1px solid #edf0f5;
+  box-shadow: 0 8px 24px rgba(29, 41, 57, 0.06);
+}
+
+.overview-strip {
   display: flex;
+  align-items: center;
   justify-content: space-between;
+  gap: 16px;
+  padding: 22px 24px;
+  border-radius: 8px;
+
+  .eyebrow {
+    margin-bottom: 6px;
+    color: #0f9f8f;
+    font-size: 13px;
+    font-weight: 700;
+  }
+
+  h1 {
+    margin: 0;
+    color: #172033;
+    font-size: 24px;
+    line-height: 1.25;
+    font-weight: 700;
+    letter-spacing: 0;
+  }
+
+  p {
+    margin-top: 8px;
+    color: #697586;
+  }
+}
+
+.overview-actions {
+  display: flex;
   align-items: center;
-  color: #fff;
+  gap: 10px;
+  flex-shrink: 0;
+}
 
-  .welcome-info {
-    h2 {
-      font-size: 22px;
-      font-weight: 600;
-      margin: 0 0 8px;
-    }
-    p {
-      margin: 0;
-      opacity: 0.85;
-      font-size: 14px;
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 16px;
+}
+
+.metric-card {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  min-height: 116px;
+  padding: 20px;
+  border-radius: 8px;
+
+  .metric-icon {
+    width: 52px;
+    height: 52px;
+    display: grid;
+    place-items: center;
+    flex-shrink: 0;
+    border-radius: 8px;
+    background: var(--accent-soft);
+    color: var(--accent);
+
+    .el-icon {
+      font-size: 26px;
     }
   }
+}
 
-  .welcome-date {
-    text-align: right;
-    .date {
-      font-size: 14px;
-      opacity: 0.85;
-    }
-    .time {
-      font-size: 32px;
-      font-weight: 200;
-      letter-spacing: 2px;
-      font-variant-numeric: tabular-nums;
-    }
+.metric-body {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+
+  span {
+    color: #697586;
+    font-size: 13px;
+  }
+
+  strong {
+    margin-top: 8px;
+    color: #111827;
+    font-size: 28px;
+    line-height: 1;
+    font-weight: 760;
+    font-variant-numeric: tabular-nums;
+  }
+
+  em {
+    margin-top: 9px;
+    color: #7c8798;
+    font-size: 12px;
+    font-style: normal;
   }
 }
 
-// 统计卡片行
-.stat-row {
-  margin-bottom: 0 !important;
+.main-grid,
+.bottom-grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.8fr);
+  gap: 18px;
 }
 
-.stat-card {
-  background: #fff;
-  border-radius: 12px;
-  padding: 24px;
+.bottom-grid {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.panel {
+  padding: 20px;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.panel-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 14px;
+  margin-bottom: 18px;
+
+  h2 {
+    margin: 0;
+    color: #172033;
+    font-size: 17px;
+    font-weight: 700;
+    letter-spacing: 0;
+  }
+
+  p {
+    margin-top: 6px;
+    color: #7c8798;
+    font-size: 13px;
+  }
+
+  > .el-icon {
+    color: #0f9f8f;
+    font-size: 22px;
+  }
+}
+
+.distribution-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 230px;
+  gap: 18px;
+  align-items: stretch;
+}
+
+.province-map {
+  display: grid;
+  grid-template-columns: repeat(10, minmax(44px, 1fr));
+  grid-template-rows: repeat(8, 52px);
+  gap: 6px;
+  min-height: 450px;
+  padding: 18px;
+  border-radius: 8px;
+  background:
+    linear-gradient(135deg, rgba(15, 159, 143, 0.08), rgba(47, 128, 237, 0.08)),
+    #f7fafc;
+}
+
+.province-tile {
   display: flex;
+  flex-direction: column;
   align-items: center;
+  justify-content: center;
+  min-width: 0;
+  border-radius: 7px;
+  border: 1px solid rgba(15, 159, 143, 0.14);
+  color: #1f2933;
+  text-align: center;
+
+  span {
+    max-width: 100%;
+    overflow: hidden;
+    color: inherit;
+    font-size: 12px;
+    line-height: 1.2;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  strong {
+    margin-top: 4px;
+    font-size: 16px;
+    line-height: 1;
+    font-variant-numeric: tabular-nums;
+  }
+}
+
+.heat-0 {
+  color: #98a2b3;
+  background: #eef2f6;
+}
+
+.heat-1 {
+  background: #d8f3ed;
+}
+
+.heat-2 {
+  color: #0f433b;
+  background: #86dbc9;
+}
+
+.heat-3 {
+  color: #fff;
+  background: #0f9f8f;
+  box-shadow: 0 8px 20px rgba(15, 159, 143, 0.22);
+}
+
+.province-rank {
+  display: flex;
+  flex-direction: column;
   gap: 16px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
-  transition: box-shadow 0.2s;
+}
+
+.legend {
+  display: grid;
+  grid-template-columns: auto 1fr auto 1fr auto 1fr;
+  align-items: center;
+  gap: 6px;
+  color: #697586;
+  font-size: 12px;
+}
+
+.legend-item {
+  width: 18px;
+  height: 10px;
+  border-radius: 999px;
+}
+
+.level-1 {
+  background: #d8f3ed;
+}
+
+.level-2 {
+  background: #86dbc9;
+}
+
+.level-3 {
+  background: #0f9f8f;
+}
+
+.rank-list {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.rank-item {
+  display: grid;
+  grid-template-columns: 42px 1fr 34px;
+  align-items: center;
+  gap: 10px;
+  color: #4b5563;
+  font-size: 13px;
+
+  strong {
+    color: #172033;
+    text-align: right;
+    font-variant-numeric: tabular-nums;
+  }
+}
+
+.rank-track {
+  height: 8px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: #eef2f6;
 
-  &:hover {
-    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
+  i {
+    display: block;
+    height: 100%;
+    border-radius: inherit;
+    background: linear-gradient(90deg, #0f9f8f, #2f80ed);
   }
+}
 
-  .stat-icon {
-    width: 52px;
-    height: 52px;
-    border-radius: 12px;
-    background: color-mix(in srgb, var(--card-color) 15%, transparent);
+.donut-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 24px;
+  padding-top: 8px;
+}
+
+.donut-chart {
+  width: 186px;
+  height: 186px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background:
+    radial-gradient(circle at center, #fff 0 56%, transparent 57%),
+    conic-gradient(#2f80ed 0 100%, #e5e7eb 0);
+  box-shadow: inset 0 0 0 1px rgba(47, 128, 237, 0.08);
+
+  span {
+    color: #172033;
+    font-size: 34px;
+    font-weight: 760;
+    line-height: 1;
+  }
+
+  small {
+    margin-top: 8px;
+    color: #697586;
+  }
+}
+
+.device-summary {
+  width: 100%;
+}
+
+.device-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10px;
+  color: #344054;
+
+  span {
     display: flex;
     align-items: center;
-    justify-content: center;
-    flex-shrink: 0;
+    gap: 8px;
+  }
 
-    .el-icon {
-      font-size: 24px;
-      color: var(--card-color);
+  strong {
+    color: #172033;
+  }
+
+  &.muted {
+    margin-top: 16px;
+    color: #98a2b3;
+
+    strong {
+      color: #98a2b3;
     }
   }
+}
 
-  .stat-value {
-    font-size: 24px;
-    font-weight: 600;
-    color: #262626;
+.dot {
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+}
+
+.dot.primary {
+  background: #2f80ed;
+}
+
+.dot.empty {
+  background: #d0d5dd;
+}
+
+.panel-note {
+  margin-top: 16px;
+  padding: 12px;
+  border-radius: 7px;
+  background: #f8fafc;
+  color: #697586;
+  font-size: 13px;
+}
+
+.bar-chart {
+  display: grid;
+  grid-template-columns: repeat(7, minmax(56px, 1fr));
+  gap: 18px;
+  height: 300px;
+  padding: 18px 10px 8px;
+  border-radius: 8px;
+  background:
+    repeating-linear-gradient(to top, #edf0f5 0, #edf0f5 1px, transparent 1px, transparent 58px),
+    #fbfcfe;
+}
+
+.bar-group {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: flex-end;
+  min-width: 0;
+}
+
+.bar-stack {
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  gap: 8px;
+  width: 100%;
+  height: 240px;
+}
+
+.bar {
+  width: 18px;
+  min-height: 16px;
+  border-radius: 8px 8px 3px 3px;
+}
+
+.bar.communicate {
+  background: linear-gradient(180deg, #2f80ed, #74aef7);
+}
+
+.bar.plan {
+  background: linear-gradient(180deg, #c05621, #f6ad55);
+}
+
+.bar-label {
+  margin-top: 12px;
+  color: #697586;
+  font-size: 13px;
+}
+
+.chart-legend {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 24px;
+  margin-top: 14px;
+  color: #697586;
+  font-size: 13px;
+
+  span {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+  }
+}
+
+.bar-dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+}
+
+.bar-dot.communicate {
+  background: #2f80ed;
+}
+
+.bar-dot.plan {
+  background: #c05621;
+}
+
+.work-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.work-item {
+  display: grid;
+  grid-template-columns: 44px minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 12px;
+  padding: 14px;
+  border-radius: 8px;
+  background: #f8fafc;
+}
+
+.work-icon {
+  width: 44px;
+  height: 44px;
+  display: grid;
+  place-items: center;
+  border-radius: 8px;
+  color: #fff;
+  background: #2f80ed;
+
+  .el-icon {
+    font-size: 20px;
+  }
+
+  &.todo {
+    background: #0f9f8f;
+  }
+
+  &.critical {
+    background: #d92d20;
+  }
+
+  &.warning {
+    background: #c05621;
+  }
+
+  &.offline {
+    background: #667085;
   }
+}
+
+.work-content {
+  min-width: 0;
 
-  .stat-title {
+  p {
+    margin-top: 6px;
+    overflow: hidden;
+    color: #7c8798;
     font-size: 13px;
-    color: #8c8c8c;
-    margin-top: 2px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 }
 
-// 信息卡片
-.info-card {
-  border-radius: 12px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+.work-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
 
-  :deep(.el-card__header) {
-    font-weight: 600;
-    color: #262626;
+  strong {
+    overflow: hidden;
+    color: #172033;
+    font-size: 14px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 }
 
-.tech-item {
-  padding: 16px;
-  border-radius: 8px;
-  background: #f8f9fb;
-  margin-bottom: 16px;
-  border-left: 3px solid #1890ff;
+.work-count {
+  min-width: 48px;
+  text-align: right;
 
-  .tech-name {
-    font-weight: 600;
-    color: #262626;
-    margin-bottom: 4px;
+  strong {
+    display: block;
+    color: #172033;
+    font-size: 22px;
+    line-height: 1;
+    font-variant-numeric: tabular-nums;
   }
 
-  .tech-version {
+  span {
+    color: #98a2b3;
     font-size: 12px;
-    color: #1890ff;
-    margin-bottom: 6px;
   }
+}
 
-  .tech-desc {
-    font-size: 12px;
-    color: #8c8c8c;
+.alarm-list time {
+  color: #7c8798;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+@media (max-width: 1400px) {
+  .metric-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .main-grid,
+  .bottom-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .distribution-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .province-rank {
+    display: grid;
+    grid-template-columns: 1fr 2fr;
+  }
+}
+
+@media (max-width: 900px) {
+  .overview-strip {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .overview-actions {
+    width: 100%;
+    justify-content: space-between;
+  }
+
+  .metric-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .province-map {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    grid-template-rows: none;
+    min-height: 0;
+  }
+
+  .province-tile {
+    grid-column: auto !important;
+    grid-row: auto !important;
+  }
+
+  .province-rank {
+    grid-template-columns: 1fr;
+  }
+
+  .bar-chart {
+    grid-template-columns: repeat(4, minmax(56px, 1fr));
+    height: auto;
+    overflow-x: auto;
+  }
+
+  .bar-stack {
+    height: 200px;
   }
 }
 </style>

+ 3 - 2
code/frontend/src/views/device/index.vue

@@ -122,8 +122,8 @@
         <el-table-column label="所在地区" min-width="160">
           <template #default="{ row }">{{ formatRegion(row) }}</template>
         </el-table-column>
-        <el-table-column prop="lastOnlineTime" label="最后在线时间" width="140">
-          <template #default="{ row }">{{ row.lastOnlineTime || '-' }}</template>
+        <el-table-column prop="lastOnlineTime" label="最后在线时间" width="170">
+          <template #default="{ row }">{{ formatDateTime(row.lastOnlineTime) }}</template>
         </el-table-column>
         <el-table-column label="操作" width="170" align="center" fixed="right">
           <template #default="{ row }">
@@ -277,6 +277,7 @@ import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
 import { regionData, codeToText } from 'element-china-area-data'
 import ExcelImport from '@/components/ExcelImport.vue'
 import ExcelExport from '@/components/ExcelExport.vue'
+import { formatDateTime } from '@/utils/date'
 import {
   getDevicePageList,
   addDevice,

+ 4 - 1
code/frontend/src/views/device/users.vue

@@ -42,7 +42,9 @@
             {{ row.phone ? row.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '-' }}
           </template>
         </el-table-column>
-        <el-table-column prop="bindTime" label="绑定时间" width="170" />
+        <el-table-column prop="bindTime" label="绑定时间" width="170">
+          <template #default="{ row }">{{ formatDateTime(row.bindTime) }}</template>
+        </el-table-column>
         <el-table-column label="是否主用户" width="100" align="center">
           <template #default="{ row }">
             <el-tag :type="row.isPrimary === 1 ? 'warning' : 'info'" size="small">
@@ -120,6 +122,7 @@ import { ref, computed, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ArrowLeft, Plus, Delete, Search } from '@element-plus/icons-vue'
+import { formatDateTime } from '@/utils/date'
 import {
   getDeviceUsers,
   addDeviceUser,

+ 5 - 2
code/frontend/src/views/log/index.vue

@@ -54,6 +54,7 @@
             range-separator="~"
             start-placeholder="开始日期"
             end-placeholder="结束日期"
+            format="YYYY-MM-DD HH:mm:ss"
             value-format="YYYY-MM-DD HH:mm:ss"
             style="width: 360px"
           />
@@ -128,7 +129,7 @@
             <span v-else class="empty-text">—</span>
           </template>
         </el-table-column>
-        <el-table-column prop="createTime" label="操作时间" width="170" />
+        <el-table-column prop="createTime" label="操作时间" width="170" :formatter="dateTimeFormatter" />
       </el-table>
 
       <!-- 分页 -->
@@ -168,6 +169,7 @@ import { ElMessage } from 'element-plus'
 import { Search, Refresh } from '@element-plus/icons-vue'
 import { getLogPage, exportLog, getSysUserOptions } from '@/api/log'
 import ExcelExport from '@/components/ExcelExport.vue'
+import { dateTimeFormatter, formatDateTime } from '@/utils/date'
 
 // ==================== 查询表单 ====================
 const queryForm = reactive({
@@ -287,7 +289,8 @@ async function fetchExportData() {
   // 补充操作人类型文字描述
   return list.map(item => ({
     ...item,
-    userTypeName: item.userType === 1 ? '系统管理员' : 'App用户'
+    userTypeName: item.userType === 1 ? '系统管理员' : 'App用户',
+    createTime: formatDateTime(item.createTime)
   }))
 }
 

+ 5 - 4
code/frontend/src/views/plan/index.vue

@@ -101,8 +101,8 @@
             <el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="createTime" label="创建时间" width="160">
-          <template #default="{ row }">{{ row.createTime?.substring(0, 10) || '-' }}</template>
+        <el-table-column prop="createTime" label="创建时间" width="170">
+          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
         </el-table-column>
         <el-table-column label="操作" width="220" align="center" fixed="right">
           <template #default="{ row }">
@@ -330,8 +330,8 @@
           <el-descriptions-item label="方案描述" :span="2">{{ detailData.description || '-' }}</el-descriptions-item>
           <el-descriptions-item label="使用人数">{{ detailData.useCount || 0 }}人</el-descriptions-item>
           <el-descriptions-item label="平均评分">{{ detailData.avgRating ?? '-' }}分</el-descriptions-item>
-          <el-descriptions-item label="创建时间">{{ detailData.createTime || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="更新时间">{{ detailData.updateTime || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ formatDateTime(detailData.createTime) }}</el-descriptions-item>
+          <el-descriptions-item label="更新时间">{{ formatDateTime(detailData.updateTime) }}</el-descriptions-item>
         </el-descriptions>
 
         <el-divider>治疗步骤</el-divider>
@@ -375,6 +375,7 @@ import {
 import { getAcupointList } from '@/api/acupoint'
 import { getTechniqueList } from '@/api/moxibustion'
 import { getAppUserList } from '@/api/auth'
+import { formatDateTime } from '@/utils/date'
 // ============ 枚举常量 ============
 const effectOptions = ['驱寒', '祛湿', '祛风', '化瘀', '活血', '化痰', '养颜', '扶阳']
 const symptomsOptions = ['怕冷', '感冒', '咳嗽', '痰多', '疼痛', '腰酸', '失眠', '疲劳', '皮肤暗沉', '手脚冰凉', '其他']

+ 2 - 1
code/frontend/src/views/system/user-category/index.vue

@@ -63,7 +63,7 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="创建时间" prop="createTime" width="170" align="center" />
+        <el-table-column label="创建时间" prop="createTime" width="170" align="center" :formatter="dateTimeFormatter" />
         <el-table-column label="操作" width="220" align="center" fixed="right">
           <template #default="{ row }">
             <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
@@ -170,6 +170,7 @@ import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Search, Refresh, Plus } from '@element-plus/icons-vue'
 import { userCategoryApi } from '@/api'
+import { dateTimeFormatter } from '@/utils/date'
 
 // ======================== 列表 ========================
 const loading = ref(false)

+ 5 - 2
code/frontend/src/views/user/app-user/index.vue

@@ -64,7 +64,7 @@
         </el-table-column>
          <el-table-column prop="s" label="绑定设备" width="170" />
           <el-table-column prop="s" label="子用户" width="170" />
-        <el-table-column prop="createTime" label="注册时间" width="170" />
+        <el-table-column prop="createTime" label="注册时间" width="170" :formatter="dateTimeFormatter" />
         <el-table-column label="操作" width="240" align="center" fixed="right">
           <template #default="{ row }">
             <el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
@@ -88,6 +88,7 @@
         v-model:page-size="queryForm.pageSize"
         :page-sizes="[10, 20, 50]"
         :total="total"
+        :hide-on-single-page="false"
         layout="total, sizes, prev, pager, next, jumper"
         class="pagination"
         @size-change="loadData"
@@ -147,6 +148,7 @@ import {
   updateAppUserStatus
 } from '@/api/user'
 import { getUserCategoryList } from '@/api/userCategory'
+import { dateTimeFormatter } from '@/utils/date'
 
 const router = useRouter()
 
@@ -208,7 +210,7 @@ async function loadData() {
   try {
     const res = await getAppUserPage(queryForm)
     tableData.value = res.data?.records || []
-    total.value = res.data?.total || 0
+    total.value = Number(res.data?.total) || 0
   } finally {
     loading.value = false
   }
@@ -325,6 +327,7 @@ onMounted(() => {
 
 .pagination {
   margin-top: 16px;
+  display: flex;
   justify-content: flex-end;
 }
 </style>

+ 2 - 1
code/frontend/src/views/user/device/index.vue

@@ -37,7 +37,7 @@
         <el-table-column prop="deviceModel" label="设备型号" min-width="110">
           <template #default="{ row }">{{ row.deviceModel || '-' }}</template>
         </el-table-column>
-        <el-table-column prop="bindTime" label="绑定时间" width="170" />
+        <el-table-column prop="bindTime" label="绑定时间" width="170" :formatter="dateTimeFormatter" />
         <el-table-column label="是否主设备" width="105" align="center">
           <template #default="{ row }">
             <el-tag :type="row.isPrimary === 1 ? 'warning' : 'info'" size="small">
@@ -143,6 +143,7 @@ import {
   batchUnbindDevices
 } from '@/api/user'
 import { getDevicePageList } from '@/api/device'
+import { dateTimeFormatter } from '@/utils/date'
 
 const route = useRoute()
 const router = useRouter()

+ 4 - 3
code/frontend/src/views/user/plan/index.vue

@@ -38,8 +38,8 @@
 
       <el-table :data="tableData" v-loading="loading" border stripe>
         <el-table-column type="index" label="序号" width="60" align="center" />
-        <el-table-column prop="createTime" label="时间" width="160">
-          <template #default="{ row }">{{ row.createTime?.substring(0, 16) || '-' }}</template>
+        <el-table-column prop="createTime" label="时间" width="170">
+          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
         </el-table-column>
         <el-table-column label="模式类型" width="110" align="center">
           <template #default="{ row }">
@@ -81,7 +81,7 @@
           <el-descriptions-item label="方案编码">{{ detailData.planCode }}</el-descriptions-item>
           <el-descriptions-item label="模式类型">{{ modeTypeLabel(detailData.modeType) }}</el-descriptions-item>
           <el-descriptions-item label="创建者">{{ creatorLabel(detailData.authorName) }}</el-descriptions-item>
-          <el-descriptions-item label="订阅时间">{{ detailData.createTime || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="订阅时间">{{ formatDateTime(detailData.createTime) }}</el-descriptions-item>
           <el-descriptions-item label="使用次数">{{ detailData.useCount || 0 }}次</el-descriptions-item>
         </el-descriptions>
 
@@ -124,6 +124,7 @@
 import { ref, computed, onMounted } from 'vue'
 import { getUserPlanPage, getUserPlanDetail } from '@/api/plan'
 import { getAllUserList } from '@/api/user'
+import { formatDateTime } from '@/utils/date'
 
 // 用户列表
 const userList = ref([])

+ 4 - 2
code/frontend/src/views/user/profile/index.vue

@@ -64,9 +64,10 @@
             range-separator="至"
             start-placeholder="开始日期"
             end-placeholder="结束日期"
+            format="YYYY-MM-DD HH:mm:ss"
             value-format="YYYY-MM-DD HH:mm:ss"
             :default-time="[new Date(0,0,0,0,0,0), new Date(0,0,0,23,59,59)]"
-            style="width:240px"
+            style="width:360px"
           />
         </el-form-item>
         <el-form-item label="穴位表">
@@ -175,7 +176,7 @@
             <el-tag v-else type="info" size="small">未生成</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="createTime" label="注册时间" width="170" />
+        <el-table-column prop="createTime" label="注册时间" width="170" :formatter="dateTimeFormatter" />
         <el-table-column label="操作" width="200" align="center" fixed="right">
           <template #default="{ row }">
             <el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
@@ -352,6 +353,7 @@ import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
 import { regionData, codeToText } from 'element-china-area-data'
 import ExcelExport from '@/components/ExcelExport.vue'
 import ExcelImport from '@/components/ExcelImport.vue'
+import { dateTimeFormatter } from '@/utils/date'
 import {
   getProfilePage,
   addProfile,

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 2047
logs/aijiuyi-admin-dev.log


BIN
logs/aijiuyi-admin-dev.log.2026-05-20.0.gz


BIN
logs/aijiuyi-admin-dev.log.2026-05-22.0.gz


Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff