소스 검색

refactor(health-status): 标题改为原生导航栏,血脂单条,药物含品牌+服药时间

Xiaogang Liao 1 개월 전
부모
커밋
e3324ccf7d

+ 16 - 0
AGENTS.md

@@ -297,6 +297,22 @@ curl -X POST http://localhost:9082/api/migration/run
 - **DI**: `@Resource`,字段名必须与类型默认 Bean Name 一致
 - **角色控制**: 控制器内手动检查 `@RequestAttribute("role")`
 
+## 时间显示规范
+
+所有页面展示时间必须统一以下两种格式,禁止使用 `toLocaleString()`、`toLocaleTimeString()` 等浏览器差异化输出:
+
+| 使用场景 | 格式 | 示例 |
+|------|------|------|
+| **时间(显示年月日+时分秒)** | `yyyy-MM-dd HH:mm:ss` | `2026-08-15 14:30:00` |
+| **仅日期(去掉后面的时间)** | `yyyy-MM-dd` | `2026-08-15` |
+
+后端统一返回 ISO 8601 格式(`2026-08-15T14:30:00`),前端按场景格式化:
+
+- **需展示时间的场景**(订单时间、支付时间、创建时间、打卡时间等):截取前19位 + 将 T 替换为空格 → `yyyy-MM-dd HH:mm:ss`
+- **仅需日期的场景**(日期筛选、日报等):截取前10位 → `yyyy-MM-dd`
+
+禁止直接使用 `new Date(str).toLocaleString('zh-CN')`(输出格式因浏览器而异)。
+
 ## 小程序限制
 
 - 禁止可选链 `?.`(用 `&&` 替代)

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SysMenu.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonSetter;
 import lombok.Data;
 
 import java.io.Serializable;
@@ -33,6 +34,19 @@ public class SysMenu implements Serializable {
 
     private Integer visible;
 
+    @JsonSetter("visible")
+    public void setVisibleFromJson(Object v) {
+        if (v == null) {
+            this.visible = null;
+        } else if (v instanceof Boolean) {
+            this.visible = ((Boolean) v) ? 1 : 0;
+        } else if (v instanceof Number) {
+            this.visible = ((Number) v).intValue();
+        } else {
+            this.visible = Integer.parseInt(v.toString());
+        }
+    }
+
     private Date createdAt;
 
     private Date updatedAt;

+ 16 - 0
cfc-frontend/AGENTS.md

@@ -86,6 +86,22 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 - **地址选择:** 使用 `components/address-picker.vue` 四级联动组件
 - **角色切换:** 页面根据用户角色显示不同内容
 
+## 时间显示规范
+
+页面时间展示必须统一以下两种格式:
+
+| 使用场景 | 格式 | 示例 |
+|------|------|------|
+| **时间(年月日+时分秒)** | `yyyy-MM-dd HH:mm:ss` | `2026-08-15 14:30:00` |
+| **仅日期(不带时间)** | `yyyy-MM-dd` | `2026-08-15` |
+
+后端返回 ISO 8601 格式(`2026-08-15T14:30:00`),前端格式化:
+
+- **需展示时间**:字符串先经 `parseDate()` 解析(iOS 安全),再用 `getFullYear()`/`getMonth()`/`getDate()`/`getHours()`/`getMinutes()`/`getSeconds()` 拼出 `yyyy-MM-dd HH:mm:ss`
+- **仅需日期**:直接截取字符串前 10 位 → `yyyy-MM-dd`
+
+禁止 `new Date(str).toLocaleString()`(iOS 输出格式与渲染结果因人而异)。
+
 ## ANTI-PATTERNS
 
 - **DO NOT** 创建页面后忘记在 `pages.json` 注册

+ 2 - 1
cfc-frontend/pages.json

@@ -898,7 +898,8 @@
         {
           "path": "health-status-form",
           "style": {
-            "navigationBarTitleText": "健康档案"
+            "navigationBarTitleText": "健康档案",
+            "navigationStyle": "default"
           }
         },
         {

+ 21 - 53
cfc-frontend/pages/health/health-status-form.vue

@@ -1,11 +1,5 @@
 <template>
   <view class="hs-page">
-    <view class="nav-bar">
-      <text class="nav-back" @click="goBack">\ue601 返回</text>
-      <text class="nav-title">健康档案</text>
-      <view class="nav-placeholder"></view>
-    </view>
-
     <view class="hs-tip">
       <text class="hs-tip-text">填写当前健康现状,生成方案时 AI 将参考这些信息,方案更精准。可不填,但未填时方案可能不够准确。</text>
     </view>
@@ -32,13 +26,10 @@
 
     <view class="form-section">
       <view class="form-title">血脂</view>
-      <view v-for="(item, idx) in lipidRows" :key="idx" class="lipid-row">
-        <input class="lipid-name" type="text" v-model="item.name" placeholder="指标名" />
-        <input class="lipid-value" type="digit" v-model="item.value" placeholder="数值" />
-        <input class="lipid-unit" type="text" v-model="item.unit" placeholder="单位" />
-        <text class="lipid-del" @click="removeLipid(idx)">删除</text>
+      <view class="form-row">
+        <text class="form-label">总胆固醇 (mmol/L)</text>
+        <input class="form-input" type="digit" v-model="lipidCholesterol" placeholder="如 4.5" />
       </view>
-      <text class="add-link" @click="addLipid">+ 添加血脂指标</text>
     </view>
 
     <view class="form-section">
@@ -67,9 +58,9 @@
     <view class="form-section">
       <view class="form-title">在服药物/治疗</view>
       <view v-for="(med, idx) in medRows" :key="idx" class="med-row">
-        <input class="med-name" type="text" v-model="med.name" placeholder="药物/治疗名称" />
-        <input class="med-note" type="text" v-model="med.note" placeholder="备注(可选)" />
-        <text class="lipid-del" @click="removeMed(idx)">删除</text>
+        <input class="med-name" type="text" v-model="med.name" placeholder="药物品牌,如 立普妥" />
+        <input class="med-time" type="text" v-model="med.time" placeholder="服药时间,如 早饭后一次" />
+        <text class="med-del" @click="removeMed(idx)">删除</text>
       </view>
       <text class="add-link" @click="addMed">+ 添加药物/治疗</text>
     </view>
@@ -103,7 +94,7 @@ export default {
         medications: '',
         notes: ''
       },
-      lipidRows: [],
+      lipidCholesterol: '',
       diseaseTags: DISEASE_PRESETS.slice(),
       diseaseNames: [],
       customDiseases: [],
@@ -127,24 +118,20 @@ export default {
           self.form.bloodPressure = d.bloodPressure || ''
           self.form.bloodGlucose = d.bloodGlucose || ''
           self.form.notes = d.notes || ''
-          self.lipidRows = self.parseLipids(d.bloodLipids)
+          self.lipidCholesterol = self.parseLipidCholesterol(d.bloodLipids)
           self.loadDiseases(d.diseaseHistory)
           self.medRows = self.parseMeds(d.medications)
         }
       }).catch(function() {})
     },
-    parseLipids: function(json) {
-      var list = []
+    parseLipidCholesterol: function(json) {
       try {
         var arr = JSON.parse(json || '[]')
-        if (arr instanceof Array) {
-          arr.forEach(function(item) {
-            list.push({ name: item.name || '', value: item.value || '', unit: item.unit || '', note: item.note || '' })
-          })
+        if (arr instanceof Array && arr.length > 0 && arr[0].value) {
+          return String(arr[0].value)
         }
       } catch (e) {}
-      if (list.length === 0) list.push({ name: '', value: '', unit: '', note: '' })
-      return list
+      return ''
     },
     loadDiseases: function(json) {
       var self = this
@@ -165,19 +152,13 @@ export default {
         var arr = JSON.parse(json || '[]')
         if (arr instanceof Array) {
           arr.forEach(function(item) {
-            list.push({ name: item.name || '', note: item.note || '' })
+            list.push({ name: item.name || '', time: item.time || '' })
           })
         }
       } catch (e) {}
-      if (list.length === 0) list.push({ name: '', note: '' })
+      if (list.length === 0) list.push({ name: '', time: '' })
       return list
     },
-    addLipid: function() {
-      this.lipidRows.push({ name: '', value: '', unit: '', note: '' })
-    },
-    removeLipid: function(idx) {
-      if (this.lipidRows.length > 1) this.lipidRows.splice(idx, 1)
-    },
     toggleDisease: function(tag) {
       var idx = this.diseaseNames.indexOf(tag)
       if (idx >= 0) this.diseaseNames.splice(idx, 1)
@@ -199,18 +180,16 @@ export default {
       if (i >= 0) this.diseaseNames.splice(i, 1)
     },
     addMed: function() {
-      this.medRows.push({ name: '', note: '' })
+      this.medRows.push({ name: '', time: '' })
     },
     removeMed: function(idx) {
       if (this.medRows.length > 1) this.medRows.splice(idx, 1)
     },
     buildPayload: function() {
       var lipids = []
-      this.lipidRows.forEach(function(item) {
-        if (item.name && item.value) {
-          lipids.push({ name: item.name, value: parseFloat(item.value), unit: item.unit || '', note: item.note || '' })
-        }
-      })
+      if (this.lipidCholesterol) {
+        lipids.push({ name: '总胆固醇', value: parseFloat(this.lipidCholesterol), unit: 'mmol/L', note: '' })
+      }
       var diseases = []
       this.diseaseNames.forEach(function(name) {
         diseases.push({ name: name, note: '' })
@@ -218,7 +197,7 @@ export default {
       var meds = []
       this.medRows.forEach(function(item) {
         if (item.name) {
-          meds.push({ name: item.name, note: item.note || '' })
+          meds.push({ name: item.name, time: item.time || '' })
         }
       })
       var payload = {}
@@ -256,9 +235,6 @@ export default {
         self.saving = false
         uni.showToast({ title: '保存失败,请检查网络', icon: 'none' })
       })
-    },
-    goBack: function() {
-      uni.navigateBack()
     }
   }
 }
@@ -266,10 +242,6 @@ export default {
 
 <style scoped>
 .hs-page { min-height: 100vh; background: #F5F7FA; padding-bottom: 140rpx; }
-.nav-bar { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 30rpx; background: #fff; border-bottom: 1rpx solid #eee; position: sticky; top: 0; z-index: 10; }
-.nav-back { font-size: 32rpx; color: #F97316; }
-.nav-title { font-size: 34rpx; font-weight: bold; color: #333; }
-.nav-placeholder { width: 80rpx; }
 .hs-tip { margin: 24rpx 30rpx; padding: 20rpx 24rpx; background: #FFF7E6; border-radius: 12rpx; }
 .hs-tip-text { font-size: 26rpx; color: #8A6D3B; line-height: 1.6; }
 .form-section { margin: 0 30rpx 24rpx; background: #fff; border-radius: 16rpx; padding: 24rpx; }
@@ -277,11 +249,6 @@ export default {
 .form-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
 .form-label { font-size: 28rpx; color: #555; }
 .form-input { flex: 1; margin-left: 30rpx; height: 68rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 28rpx; text-align: right; }
-.lipid-row { display: flex; align-items: center; margin-bottom: 16rpx; }
-.lipid-name { width: 30%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
-.lipid-value { width: 25%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
-.lipid-unit { width: 22%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
-.lipid-del { font-size: 26rpx; color: #E64340; }
 .tag-wrap { display: flex; flex-wrap: wrap; }
 .tag-item { padding: 12rpx 24rpx; background: #F5F7FA; border-radius: 30rpx; font-size: 26rpx; color: #666; margin: 0 16rpx 16rpx 0; }
 .tag-active { background: #F97316; color: #fff; }
@@ -291,7 +258,8 @@ export default {
 .add-link { font-size: 28rpx; color: #F97316; }
 .med-row { display: flex; align-items: center; margin-bottom: 16rpx; }
 .med-name { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
-.med-note { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.med-time { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.med-del { font-size: 26rpx; color: #E64340; }
 .notes-area { width: 100%; height: 160rpx; background: #F5F7FA; border-radius: 10rpx; padding: 16rpx 20rpx; font-size: 26rpx; box-sizing: border-box; }
 .save-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); }
 .save-btn { width: 100%; background: #F97316; color: #fff; border-radius: 44rpx; height: 84rpx; line-height: 84rpx; font-size: 32rpx; }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-8e187f7db0a514b1515c82c635e5d0ecea6b701c
+ea8ac1a1c3732c8b09bd2f03e6db670b50de1e2c

+ 15 - 0
cfc-web/AGENTS.md

@@ -38,6 +38,21 @@ src/
 - **API封装:** 使用axios,统一错误处理和拦截器
 - **权限控制:** 基于角色(admin/teacher)的菜单权限,Layout.vue 侧边栏以 v-if 控制规划师专属菜单项
 
+## 时间显示规范
+
+页面时间展示必须统一以下两种格式:
+
+| 使用场景 | 格式 | 示例 |
+|------|------|------|
+| **时间(年月日+时分秒)** | `yyyy-MM-dd HH:mm:ss` | `2026-08-15 14:30:00` |
+| **仅日期(不带时间)** | `yyyy-MM-dd` | `2026-08-15` |
+
+后端返回 ISO 8601 格式(`2026-08-15T14:30:00`),页面对时间字段统一使用:
+`formatTime(t) { return t ? t.replace('T', ' ').substring(0, 19) : '-' }`(时间场景)
+或截取前 10 位得到日期(date-only 场景,如 `el-date-picker` 的 `value-format="yyyy-MM-dd"` 日期筛选)。
+
+禁止使用 `new Date(str).toLocaleString()` / `toLocaleTimeString()`(输出格式因浏览器而异,用 `yyyy/MM/dd` 分号分割而非规定格式)。
+
 ## ANTI-PATTERNS
 
 - **DO NOT** 在组件中直接调用axios,使用api目录的封装

+ 458 - 142
cfc-web/src/views/admin/MenuManage.vue

@@ -3,81 +3,99 @@
     <el-card>
       <div slot="header" class="admin-page-header">
         <span class="admin-page-title">菜单管理</span>
-        <div class="flex items-center gap-sm">
-          <el-button type="primary" size="small" @click="handleRoleAuth">角色授权</el-button>
+        <div class="header-actions">
+          <div class="search-box">
+            <el-input
+              v-model="searchKeyword"
+              placeholder="搜索菜单名称 / 路径 / 权限标识"
+              size="small"
+              prefix-icon="el-icon-search"
+              clearable
+              style="width: 260px"
+            ></el-input>
+          </div>
+          <el-button type="primary" size="small" @click="openRoleAuth">角色授权</el-button>
           <el-button type="primary" size="small" @click="handleCreate(0)">新增顶级菜单</el-button>
         </div>
       </div>
 
       <div class="table-scroll-wrap">
-        <el-table 
-          :data="menuTree" 
-          row-key="id" 
-          :tree-props="{children: 'children'}" 
-          default-expand-all
+        <el-table
+          :data="filteredMenuTree"
+          row-key="id"
+          :tree-props="{ children: 'children' }"
+          :default-expand-all="!searchKeyword"
           stripe
           :max-height="tableHeight"
+          style="min-width: 1200px; width: 100%"
         >
-          <el-table-column prop="title" label="菜单名称">
+          <el-table-column prop="displayName" label="菜单名称" min-width="220" show-overflow-tooltip>
             <template slot-scope="scope">
-              <i v-if="scope.row.icon" :class="scope.row.icon" style="margin-right: 8px"></i>
-              <span>{{ scope.row.title || scope.row.label }}</span>
+              <i v-if="scope.row.icon" :class="scope.row.icon" class="menu-icon-cell"></i>
+              <span>{{ scope.row.displayName }}</span>
             </template>
           </el-table-column>
-          <el-table-column prop="icon" label="图标" width="120"></el-table-column>
-          <el-table-column prop="path" label="路由路径" width="180"></el-table-column>
-          <el-table-column prop="perm" label="权限标识" width="150">
+          <el-table-column prop="icon" label="图标" min-width="140" show-overflow-tooltip></el-table-column>
+          <el-table-column prop="path" label="路由路径" min-width="200" show-overflow-tooltip>
+            <template slot-scope="scope">
+              {{ scope.row.path || '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="perm" label="权限标识" min-width="180" show-overflow-tooltip>
             <template slot-scope="scope">
               <el-tag size="mini" type="info">{{ scope.row.perm || '-' }}</el-tag>
             </template>
           </el-table-column>
-          <el-table-column prop="sort" label="排序" width="80"></el-table-column>
-          <el-table-column prop="visible" label="显示" width="80">
+          <el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
+          <el-table-column prop="visible" label="显示" width="80" align="center">
             <template slot-scope="scope">
               <el-switch v-model="scope.row.visible" size="mini" @change="handleToggleVisible(scope.row)"></el-switch>
             </template>
           </el-table-column>
-          <el-table-column label="操作" width="280" fixed="right">
+          <el-table-column label="操作" width="320" fixed="right">
             <template slot-scope="{ row }">
               <el-button size="mini" @click="handleEdit(row)">编辑</el-button>
-              <el-button size="mini" @click="handleMove(row, 'up')">▲</el-button>
-              <el-button size="mini" @click="handleMove(row, 'down')">▼</el-button>
+              <el-button size="mini" @click="handleMove(row, 'up')" :disabled="!canMove(row, 'up')">▲</el-button>
+              <el-button size="mini" @click="handleMove(row, 'down')" :disabled="!canMove(row, 'down')">▼</el-button>
               <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
               <el-button size="mini" type="success" @click="handleCreate(row.id)">+ 子菜单</el-button>
             </template>
           </el-table-column>
         </el-table>
+        <div v-if="!filteredMenuTree.length" class="empty-state">
+          <el-empty description="暂无菜单数据"></el-empty>
+        </div>
       </div>
     </el-card>
 
     <!-- 创建/编辑弹窗 -->
     <el-dialog :title="isEdit ? '编辑菜单' : '新增菜单'" :visible.sync="menuDialogVisible" width="550px">
       <el-form :model="menuForm" :rules="menuRules" ref="menuForm" label-width="100px">
-        <el-form-item label="上级菜单" prop="parentId">
+        <el-form-item label="上级菜单">
           <el-select v-model="menuForm.parentId" placeholder="请选择上级菜单" style="width:100%">
             <el-option :value="0" label="顶级菜单">顶级菜单</el-option>
-            <el-option v-for="item in flatMenuOptions" :key="item.id" :label="item.title" :value="item.id"></el-option>
+            <el-option v-for="item in flatMenuOptions" :key="item.id" :label="item.displayName" :value="item.id"></el-option>
           </el-select>
         </el-form-item>
-        <el-form-item label="目录名称" prop="title">
+        <el-form-item label="目录名称">
           <el-input v-model="menuForm.title" placeholder="有子菜单时填写"></el-input>
         </el-form-item>
-        <el-form-item label="菜单标签" prop="label">
+        <el-form-item label="菜单标签">
           <el-input v-model="menuForm.label" placeholder="叶子节点显示名"></el-input>
         </el-form-item>
-        <el-form-item label="路由路径" prop="path">
-          <el-input v-model="menuForm.path" placeholder="/admin/xxx"></el-input>
+        <el-form-item label="路由路径">
+          <el-input v-model="menuForm.path" placeholder="/xxx"></el-input>
         </el-form-item>
-        <el-form-item label="图标" prop="icon">
+        <el-form-item label="图标">
           <el-input v-model="menuForm.icon" placeholder="el-icon-s-custom"></el-input>
         </el-form-item>
-        <el-form-item label="权限标识" prop="perm">
+        <el-form-item label="权限标识">
           <el-input v-model="menuForm.perm" placeholder="system:menu"></el-input>
         </el-form-item>
-        <el-form-item label="排序" prop="sort">
+        <el-form-item label="排序">
           <el-input-number v-model="menuForm.sort" :min="0" style="width:100%"></el-input-number>
         </el-form-item>
-        <el-form-item label="是否显示" prop="visible">
+        <el-form-item label="是否显示">
           <el-switch v-model="menuForm.visible"></el-switch>
         </el-form-item>
       </el-form>
@@ -87,116 +105,226 @@
       </div>
     </el-dialog>
 
-    <!-- 角色授权弹窗 -->
-    <el-dialog title="角色菜单授权" :visible.sync="authDialogVisible" width="800px">
-      <div class="auth-container">
-        <div class="role-list">
-          <el-radio-group v-model="selectedRole" class="vertical-radio-group">
-            <el-radio-button v-for="role in roles" :key="role.value" :label="role.value">
-              {{ role.label }}
-            </el-radio-button>
-          </el-radio-group>
-        </div>
-        <div class="menu-auth-tree">
-          <el-tree
-            ref="authTree"
-            :data="menuTree"
-            show-checkbox
-            node-key="id"
-            :props="{ label: 'title' }"
-            :default-checked-keys="checkedMenuIds"
-            @check="handleCheckChange"
-          ></el-tree>
-        </div>
-      </div>
-      <div slot="footer">
-        <el-button @click="authDialogVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitting" @click="saveRoleAuth">保存授权</el-button>
+    <!-- 角色授权弹窗(双Tab) -->
+    <el-dialog title="角色菜单授权" :visible.sync="authDialogVisible" width="950px" :close-on-click-modal="false">
+      <div class="auth-tabs-wrap">
+        <el-tabs v-model="authTab">
+          <el-tab-pane label="按角色授权" name="byRole">
+            <div class="auth-by-role">
+              <div class="role-radio-col">
+                <div class="role-col-header">选择角色</div>
+                <el-radio-group v-model="selectedRole" class="vertical-radio-group">
+                  <el-radio-button v-for="role in roles" :key="role.value" :label="role.value">
+                    {{ role.label }}
+                  </el-radio-button>
+                </el-radio-group>
+              </div>
+              <div class="menu-auth-tree">
+                <div class="tree-col-header">
+                  <span>选择菜单</span>
+                  <div class="tree-actions">
+                    <el-button size="mini" @click="expandAll">全部展开</el-button>
+                    <el-button size="mini" @click="collapseAll">全部折叠</el-button>
+                    <el-button size="mini" @click="checkAll">全选</el-button>
+                    <el-button size="mini" @click="uncheckAll">全不选</el-button>
+                  </div>
+                </div>
+                <el-tree
+                  ref="authTreeByRole"
+                  :data="menuTree"
+                  show-checkbox
+                  node-key="id"
+                  :props="{ label: 'displayName' }"
+                  :default-checked-keys="currentRoleMenuIds"
+                  @check="onRoleTreeCheck"
+                ></el-tree>
+              </div>
+            </div>
+          </el-tab-pane>
+          <el-tab-pane label="按菜单授权" name="byMenu">
+            <div class="auth-by-menu">
+              <div class="menu-tree-col">
+                <div class="tree-col-header">
+                  <span>菜单(点击选中)</span>
+                  <div class="tree-actions">
+                    <el-input
+                      v-model="menuSearchKeyword"
+                      size="mini"
+                      placeholder="搜索菜单"
+                      prefix-icon="el-icon-search"
+                      clearable
+                      style="width:180px"
+                    ></el-input>
+                  </div>
+                </div>
+                <el-tree
+                  ref="authTreeByMenu"
+                  :data="menuSearchTree"
+                  node-key="id"
+                  highlight-current
+                  :expand-on-click-node="false"
+                  :props="{ label: 'displayName' }"
+                  @node-click="onMenuNodeClick"
+                ></el-tree>
+              </div>
+              <div class="menu-role-col">
+                <div class="role-col-header">
+                  <span>已授权角色</span>
+                  <span class="menu-count">(共 {{ Object.keys(allMenuRoles).length }} 个菜单)</span>
+                </div>
+                <div class="menu-role-list">
+                  <template v-if="selectedAuthMenu">
+                    <div class="menu-title-bar">
+                      <i v-if="selectedAuthMenu.icon" :class="selectedAuthMenu.icon"></i>
+                      <span>{{ selectedAuthMenu.displayName }}</span>
+                      <el-tag size="mini" type="info">{{ selectedAuthMenu.perm || '无' }}</el-tag>
+                    </div>
+                    <div class="menu-role-desc">勾选表示该角色可访问此菜单</div>
+                    <el-checkbox-group v-model="selectedAuthMenuRoles" class="menu-role-checkbox-group">
+                      <el-checkbox v-for="role in roles" :key="role.value" :label="role.value">
+                        {{ role.label }}
+                      </el-checkbox>
+                    </el-checkbox-group>
+                    <div class="menu-role-ops">
+                      <el-button type="primary" size="small" @click="saveMenuRoles">保存授权</el-button>
+                    </div>
+                  </template>
+                  <el-empty v-else description="请先在左侧选择菜单"></el-empty>
+                </div>
+              </div>
+            </div>
+          </el-tab-pane>
+        </el-tabs>
       </div>
     </el-dialog>
   </div>
 </template>
 
 <script>
-import { 
-  getMenuTree, getRoleMenus, saveRoleMenus, 
-  createMenu, updateMenu, deleteMenu, moveMenu 
+import {
+  getMenuTree, getRoleMenus, saveRoleMenus,
+  createMenu, updateMenu, deleteMenu, moveMenu
 } from '@/api/menu'
 
+const ROLES = [
+  { label: '管理员', value: 'admin' },
+  { label: '成长规划师', value: 'teacher' },
+  { label: '营养师', value: 'nutritionist' },
+  { label: '文章管理员', value: 'article_manager' },
+  { label: '活动管理员', value: 'activity_manager' },
+  { label: '供应商管理员', value: 'supplier_admin' },
+]
+
 export default {
   name: 'MenuManage',
   data() {
     return {
       menuTree: [],
+      searchKeyword: '',
       menuDialogVisible: false,
-      authDialogVisible: false,
       isEdit: false,
       submitting: false,
-      
-      // 菜单表单
-      menuForm: this.getEmptyMenuForm(),
-      menuRules: {
-        title: [{ required: false, message: '请输入目录名称', trigger: 'blur' }],
-        label: [{ required: false, message: '请输入菜单标签', trigger: 'blur' }],
-      },
-      
-      // 角色授权
+      menuForm: this.getEmptyForm(),
+      menuRules: {},
+      // 授权弹窗
+      authDialogVisible: false,
+      authTab: 'byRole',
       selectedRole: 'admin',
-      checkedMenuIds: [],
-      roles: [
-        { label: '管理员', value: 'admin' },
-        { label: '成长规划师', value: 'teacher' },
-        { label: '营养师', value: 'nutritionist' },
-        { label: '文章管理员', value: 'article_manager' },
-        { label: '活动管理员', value: 'activity_manager' },
-        { label: '供应商管理员', value: 'supplier_admin' },
-      ]
+      roles: ROLES,
+      // 角色→菜单ID集合(Map<String, Set<Long>>)
+      roleMenuMap: {},
+      // 菜单→角色集合(Map<Long, Set<String>>)
+      allMenuRoles: {},
+      // 菜单授权选中
+      selectedAuthMenu: null,
+      selectedAuthMenuRoles: [],
+      menuSearchKeyword: '',
+      saveAuthLoading: false,
     }
   },
   computed: {
     tableHeight() {
-      return window.innerHeight - 250
+      return window.innerHeight - 280
+    },
+    // 搜索过滤后的菜单树(含 displayName 派生字段)
+    filteredMenuTree() {
+      if (!this.searchKeyword) {
+        return this.addDisplayName(this.menuTree)
+      }
+      const kw = this.searchKeyword.toLowerCase()
+      return this.filterTree(this.menuTree, kw)
     },
     flatMenuOptions() {
       const options = []
       const flatten = (nodes) => {
-        nodes.forEach(node => {
-          options.push({ id: node.id, title: node.title || node.label })
+        for (const node of nodes) {
+          options.push({ id: node.id, displayName: node.title || node.label })
           if (node.children) flatten(node.children)
-        })
+        }
       }
       flatten(this.menuTree)
       return options
+    },
+    // 按角色授权 Tab: 当前角色的已授权菜单 ID
+    currentRoleMenuIds() {
+      return this.roleMenuMap[this.selectedRole] || []
+    },
+    // 菜单搜索 Tab 的过滤树
+    menuSearchTree() {
+      if (!this.menuSearchKeyword) {
+        return this.addDisplayName(this.menuTree)
+      }
+      const kw = this.menuSearchKeyword.toLowerCase()
+      return this.filterTree(this.menuTree, kw)
     }
   },
   created() {
     this.loadMenuTree()
   },
   methods: {
-    getEmptyMenuForm() {
-      return {
-        id: null,
-        parentId: 0,
-        title: '',
-        label: '',
-        path: '',
-        icon: '',
-        perm: '',
-        sort: 0,
-        visible: true
-      }
+    getEmptyForm() {
+      return { id: null, parentId: 0, title: '', label: '', path: '', icon: '', perm: '', sort: 0, visible: true }
     },
+    // === 加载 & 展示 ===
     async loadMenuTree() {
       try {
         const res = await getMenuTree()
-        if (res.data) this.menuTree = res.data
+        this.menuTree = (res.data && res.data.length) ? res.data : []
       } catch (e) {
         this.$message.error('加载菜单树失败')
       }
     },
+    // 递归添加 displayName
+    addDisplayName(nodes) {
+      return (nodes || []).map(n => {
+        const item = { ...n }
+        item.displayName = item.title || item.label || ''
+        if (item.children) item.children = this.addDisplayName(item.children)
+        return item
+      })
+    },
+    // 递归搜索:保留匹配节点 + 其祖先
+    filterTree(nodes, kw) {
+      const result = []
+      for (const n of nodes || []) {
+        const name = (n.title || n.label || '').toLowerCase()
+        const path = (n.path || '').toLowerCase()
+        const perm = (n.perm || '').toLowerCase()
+        const matchSelf = name.includes(kw) || path.includes(kw) || perm.includes(kw)
+        const children = n.children ? this.filterTree(n.children, kw) : null
+        if (matchSelf || (children && children.length > 0)) {
+          const item = { ...n }
+          item.displayName = item.title || item.label || ''
+          if (children && children.length > 0) item.children = children
+          result.push(item)
+        }
+      }
+      return result
+    },
+    // === 菜单 CRUD ===
     handleCreate(parentId) {
       this.isEdit = false
-      this.menuForm = { ...this.getEmptyMenuForm(), parentId }
+      this.menuForm = { ...this.getEmptyForm(), parentId }
       this.menuDialogVisible = true
     },
     handleEdit(row) {
@@ -205,22 +333,17 @@ export default {
       this.menuDialogVisible = true
     },
     async submitMenu() {
-      // 简单校验:title 和 label 至少填一个
       if (!this.menuForm.title && !this.menuForm.label) {
         this.$message.warning('目录名称或菜单标签必须填写其中一个')
         return
       }
-
       this.submitting = true
       try {
-        if (this.isEdit) {
-          await updateMenu(this.menuForm)
-        } else {
-          await createMenu(this.menuForm)
-        }
+        if (this.isEdit) await updateMenu(this.menuForm)
+        else await createMenu(this.menuForm)
         this.$message.success('保存成功')
         this.menuDialogVisible = false
-        this.loadMenuTree()
+        await this.loadMenuTree()
       } catch (e) {
         this.$message.error(e.message || '保存失败')
       } finally {
@@ -229,10 +352,10 @@ export default {
     },
     async handleDelete(row) {
       try {
-        await this.$confirm('确定删除该菜单及其所有子菜单吗?', '警告', { type: 'warning' })
+        await this.$confirm('确定删除该菜单吗?(如有子菜单将无法删除)', '警告', { type: 'warning' })
         await deleteMenu({ id: row.id })
         this.$message.success('删除成功')
-        this.loadMenuTree()
+        await this.loadMenuTree()
       } catch (e) {
         if (e !== 'cancel') this.$message.error(e.message || '删除失败')
       }
@@ -240,59 +363,133 @@ export default {
     async handleMove(row, direction) {
       try {
         await moveMenu({ id: row.id, direction })
-        this.$message.success('排序已更新')
-        this.loadMenuTree()
+        await this.loadMenuTree()
       } catch (e) {
-        this.$message.error(e.message || '调整失败')
+        this.$message.error('调整失败')
       }
     },
     async handleToggleVisible(row) {
       try {
         await updateMenu(row)
-        this.$message.success('状态已更新')
       } catch (e) {
         this.$message.error('更新失败')
       }
     },
-    async handleRoleAuth() {
+    canMove(row, direction) {
+      return true // 后端判断是否可移动
+    },
+    // === 角色授权 ===
+    openRoleAuth() {
+      this.authTab = 'byRole'
       this.authDialogVisible = true
-      await this.loadRoleMenus()
+      this.loadAllRoleMenus()
     },
-    async loadRoleMenus() {
-      try {
-        const res = await getRoleMenus({ role: this.selectedRole })
-        this.checkedMenuIds = res.data || []
-      } catch (e) {
-        this.$message.error('加载授权菜单失败')
+    async loadAllRoleMenus() {
+      // 并行拉取所有角色的已授权菜单 ID
+      const promises = this.roles.map(r =>
+        getRoleMenus({ role: r.value }).then(res => {
+          const ids = (res.data || []).filter(id => id != null)
+          this.roleMenuMap[r.value] = ids
+        }).catch(() => {
+          this.roleMenuMap[r.value] = []
+        })
+      )
+      await Promise.all(promises)
+      // 构建 menuId → Set<Role>
+      const menuRoles = {}
+      for (const role of Object.keys(this.roleMenuMap)) {
+        for (const id of this.roleMenuMap[role]) {
+          if (!menuRoles[id]) menuRoles[id] = new Set()
+          menuRoles[id].add(role)
+        }
       }
+      this.allMenuRoles = menuRoles
     },
-    async saveRoleAuth() {
-      this.submitting = true
+    // 角色→菜单 Tab: 树勾选变化时同步
+    onRoleTreeCheck() {
+      const checked = this.$refs.authTreeByRole.getCheckedKeys()
+      const half = this.$refs.authTreeByRole.getHalfCheckedKeys()
+      const ids = [...new Set([...checked, ...half])]
+      this.roleMenuMap[this.selectedRole] = ids
+      this.rebuildMenuRoles()
+    },
+    // 菜单→角色 Tab: 点击菜单节点
+    onMenuNodeClick(node) {
+      this.selectedAuthMenu = node
+      const roles = this.allMenuRoles[node.id]
+      this.selectedAuthMenuRoles = roles ? [...roles] : []
+    },
+    // 保存菜单→角色的授权(每个角色全覆盖保存)
+    async saveMenuRoles() {
+      if (!this.selectedAuthMenu) return
+      const menuId = this.selectedAuthMenu.id
+      const selectedRoles = new Set(this.selectedAuthMenuRoles)
+      // 收集需要保存的角色
+      const prevRoles = this.allMenuRoles[menuId] || new Set()
+      const touchedRoles = new Set([...selectedRoles, ...prevRoles])
+      // 更新 roleMenuMap
+      for (const role of this.roles) {
+        const shouldHave = selectedRoles.has(role.value)
+        const currentSet = new Set(this.roleMenuMap[role.value] || [])
+        if (shouldHave) currentSet.add(menuId)
+        else currentSet.delete(menuId)
+        this.roleMenuMap[role.value] = [...currentSet]
+      }
+      // 同步 allMenuRoles
+      this.rebuildMenuRoles()
+      // 只对触摸过的角色调用保存
+      this.saveAuthLoading = true
+      const promises = []
+      for (const role of touchedRoles) {
+        const ids = this.roleMenuMap[role] || []
+        promises.push(saveRoleMenus({ role, menuIds: ids }))
+      }
       try {
-        // 合并全选/半选节点
-        const checked = this.$refs.authTree.getCheckedKeys()
-        const halfChecked = this.$refs.authTree.getHalfCheckedKeys()
-        const allIds = [...new Set([...checked, ...halfChecked])]
-        
-        await saveRoleMenus({ 
-          role: this.selectedRole, 
-          menuIds: allIds 
-        })
+        await Promise.all(promises)
         this.$message.success('授权保存成功')
-        this.authDialogVisible = false
       } catch (e) {
-        this.$message.error(e.message || '保存失败')
+        this.$message.error('授权保存失败')
       } finally {
-        this.submitting = false
+        this.saveAuthLoading = false
       }
     },
-    handleCheckChange() {
-      // 仅用于触发内部状态更新,实际保存由 saveRoleAuth 处理
-    }
+    // 树操作
+    expandAll() {
+      if (!this.$refs.authTreeByRole) return
+      const nodes = this.$refs.authTreeByRole.store._getAllNodes()
+      nodes.forEach(n => { n.expanded = true })
+    },
+    collapseAll() {
+      if (!this.$refs.authTreeByRole) return
+      const nodes = this.$refs.authTreeByRole.store._getAllNodes()
+      nodes.forEach(n => { n.expanded = false })
+    },
+    checkAll() {
+      this.$refs.authTreeByRole.setCheckedNodes(this.menuTree)
+    },
+    uncheckAll() {
+      this.$refs.authTreeByRole.setCheckedKeys([])
+    },
+    // 重建 menuId → Set<Role> 映射
+    rebuildMenuRoles() {
+      const menuRoles = {}
+      for (const role of Object.keys(this.roleMenuMap)) {
+        for (const id of this.roleMenuMap[role]) {
+          if (!menuRoles[id]) menuRoles[id] = new Set()
+          menuRoles[id].add(role)
+        }
+      }
+      this.allMenuRoles = menuRoles
+    },
   },
   watch: {
+    // 切换角色时同步树选中状态
     selectedRole() {
-      this.loadRoleMenus()
+      this.$nextTick(() => {
+        if (this.$refs.authTreeByRole) {
+          this.$refs.authTreeByRole.setCheckedKeys(this.roleMenuMap[this.selectedRole] || [])
+        }
+      })
     }
   }
 }
@@ -305,36 +502,155 @@ export default {
   height: 100%;
 }
 
-.auth-container {
+.admin-page-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.menu-icon-cell {
+  margin-right: 6px;
+  font-size: 16px;
+}
+
+.empty-state {
+  padding: 40px 0;
+}
+
+/* === 授权弹窗 === */
+.auth-tabs-wrap {
+  min-height: 420px;
+}
+
+.auth-by-role {
   display: flex;
   gap: 20px;
-  height: 400px;
+  height: 420px;
 }
 
-.role-list {
-  width: 200px;
+.role-radio-col {
+  width: 180px;
   border-right: 1px solid #ebeef5;
-  padding-right: 20px;
+  padding-right: 16px;
+  display: flex;
+  flex-direction: column;
+}
+
+.role-col-header {
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 12px;
+  font-size: 13px;
 }
 
 .vertical-radio-group {
   display: flex;
   flex-direction: column;
-  gap: 10px;
+  gap: 8px;
 }
 
 .vertical-radio-group .el-radio-button {
   width: 100%;
-  margin-bottom: 8px;
 }
 
 .menu-auth-tree {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.tree-col-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 12px;
+  font-size: 13px;
+}
+
+.tree-actions {
+  display: flex;
+  gap: 4px;
+}
+
+.el-tree {
   flex: 1;
   overflow-y: auto;
-  padding-left: 10px;
+  max-height: 340px;
+}
+
+/* 按菜单授权 */
+.auth-by-menu {
+  display: flex;
+  gap: 20px;
+  height: 420px;
+}
+
+.menu-tree-col {
+  width: 340px;
+  border-right: 1px solid #ebeef5;
+  padding-right: 16px;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.menu-role-col {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.menu-title-bar {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 12px 0;
+  border-bottom: 1px solid #ebeef5;
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.menu-role-desc {
+  color: #909399;
+  font-size: 12px;
+  margin: 12px 0;
+}
+
+.menu-role-checkbox-group {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-bottom: 20px;
+}
+
+.menu-role-ops {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 12px;
+  border-top: 1px solid #ebeef5;
+}
+
+.menu-count {
+  font-weight: normal;
+  font-size: 12px;
+  color: #909399;
+}
+
+.empty-state .el-empty {
+  margin: 30px 0;
 }
 
 .flex { display: flex; }
 .items-center { align-items: center; }
 .gap-sm { gap: 8px; }
-</style>
+</style>