Layout.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  1. <template>
  2. <el-container class="layout-container">
  3. <!-- Mobile overlay -->
  4. <div v-if="isMobile && sidebarVisible" class="sidebar-overlay" @click="sidebarVisible = false"></div>
  5. <el-aside :class="['el-aside', { 'sidebar-mobile': isMobile, 'sidebar-open': isMobile && sidebarVisible, 'sidebar-closed': isMobile && !sidebarVisible }]" :width="isMobile ? '200px' : '200px'">
  6. <div class="logo">浠艾福</div>
  7. <el-scrollbar wrap-class="sidebar-scrollbar-wrap">
  8. <el-menu
  9. :default-active="activeMenu"
  10. class="sidebar-menu"
  11. background-color="transparent"
  12. text-color="#64748B"
  13. active-text-color="#6366F1"
  14. @select="handleMenuSelect"
  15. >
  16. <template v-for="item in menuItems">
  17. <el-submenu v-if="item.children && hasPerm(item.perm)" :key="item.title || item.path" :index="item.title || item.path">
  18. <template slot="title">
  19. <i :class="item.icon"></i>
  20. <span>{{ item.label || item.title }}</span>
  21. </template>
  22. <template v-for="child in item.children">
  23. <!-- 子项有 children = 三级菜单 -->
  24. <el-submenu v-if="child.children && hasPerm(child.perm)" :key="child.title" :index="child.title">
  25. <template slot="title">
  26. <i :class="child.icon"></i>
  27. <span>{{ child.title }}</span>
  28. </template>
  29. <el-menu-item
  30. v-for="grandson in child.children"
  31. :key="grandson.path"
  32. :index="grandson.path"
  33. >
  34. <i :class="grandson.icon"></i>
  35. <span slot="title">{{ grandson.label }}</span>
  36. </el-menu-item>
  37. </el-submenu>
  38. <!-- 普通二级菜单项 -->
  39. <el-menu-item v-else-if="hasPerm(child.perm)" :key="child.path" :index="child.path">
  40. <i :class="child.icon"></i>
  41. <span slot="title">{{ child.label }}</span>
  42. </el-menu-item>
  43. </template>
  44. </el-submenu>
  45. <el-menu-item v-else-if="hasPerm(item.perm)" :key="item.path" :index="getMenuIndex(item)">
  46. <i :class="item.icon"></i>
  47. <span slot="title">{{ item.label }}</span>
  48. </el-menu-item>
  49. </template>
  50. </el-menu>
  51. </el-scrollbar>
  52. </el-aside>
  53. <el-container>
  54. <el-header>
  55. <div class="header-left">
  56. <el-button v-if="isMobile" type="text" class="hamburger-btn" @click="toggleSidebar">
  57. <i :class="sidebarVisible ? 'el-icon-s-unfold' : 'el-icon-s-fold'"></i>
  58. </el-button>
  59. </div>
  60. <div class="header-right">
  61. <el-badge :value="unreadMessageCount" :hidden="unreadMessageCount === 0" class="msg-badge">
  62. <el-button type="text" class="msg-btn" @click="handleMessageClick" icon="el-icon-bell"></el-button>
  63. </el-badge>
  64. <el-button v-if="hasPerm('service:*')" type="text" class="invite-code-btn" @click="showInviteCode" icon="el-icon-share">
  65. 邀请码
  66. </el-button>
  67. <el-dropdown trigger="click" @command="handleProfileCommand">
  68. <span class="el-dropdown-link username">
  69. {{ displayUserLabel }}<i class="el-icon-arrow-down el-icon--right"></i>
  70. </span>
  71. <el-dropdown-menu slot="dropdown">
  72. <el-dropdown-item command="profile" icon="el-icon-user">个人信息</el-dropdown-item>
  73. <el-dropdown-item command="password" icon="el-icon-key">修改密码</el-dropdown-item>
  74. <el-dropdown-item command="logout" icon="el-icon-switch-button" divided>退出登录</el-dropdown-item>
  75. </el-dropdown-menu>
  76. </el-dropdown>
  77. </div>
  78. <!-- 邀请码 dialog -->
  79. <el-dialog title="我的邀请码" :visible.sync="inviteCodeVisible" width="500px" :close-on-click-modal="false">
  80. <div v-loading="inviteCodeLoading">
  81. <div v-if="inviteCodeData" class="invite-code-content">
  82. <div class="invite-code-section">
  83. <div class="invite-code-label">团队邀请码</div>
  84. <div class="invite-code-value">
  85. <span class="code-text">{{ inviteCodeData.code || '无' }}</span>
  86. <el-button size="mini" type="primary" @click="copyCode(inviteCodeData.code)" plain>复制</el-button>
  87. </div>
  88. <div class="invite-code-desc">
  89. 家庭用户通过此码加入您的团队
  90. </div>
  91. </div>
  92. </div>
  93. <div v-else-if="!inviteCodeLoading" class="no-invite-code">
  94. <i class="el-icon-warning-outline"></i>
  95. <p>暂无邀请码,请先生成</p>
  96. <el-button type="primary" @click="generateInviteCode">生成邀请码</el-button>
  97. </div>
  98. </div>
  99. </el-dialog>
  100. </el-header>
  101. <!-- 个人信息 dialog -->
  102. <el-dialog title="个人信息" :visible.sync="profileDialogVisible" width="400px">
  103. <el-form :model="profileForm" label-width="80px">
  104. <el-form-item label="用户名">
  105. <el-input v-model="profileForm.username" disabled></el-input>
  106. </el-form-item>
  107. <el-form-item label="真实姓名">
  108. <el-input v-model="profileForm.realName" disabled></el-input>
  109. </el-form-item>
  110. <el-form-item label="昵称">
  111. <el-input v-model="profileForm.nickname"></el-input>
  112. </el-form-item>
  113. <el-form-item label="手机号">
  114. <el-input v-model="profileForm.phone"></el-input>
  115. </el-form-item>
  116. <el-form-item label="邮箱">
  117. <el-input v-model="profileForm.email"></el-input>
  118. </el-form-item>
  119. </el-form>
  120. <span slot="footer">
  121. <el-button @click="profileDialogVisible = false">取消</el-button>
  122. <el-button type="primary" @click="confirmUpdateInfo" :loading="submitting">保存</el-button>
  123. </span>
  124. </el-dialog>
  125. <!-- 修改密码 dialog -->
  126. <el-dialog title="修改密码" :visible.sync="passwordDialogVisible" width="400px">
  127. <el-form :model="passwordForm" :rules="passwordRules" ref="passwordForm" label-width="100px">
  128. <el-form-item label="原密码" prop="oldPassword">
  129. <el-input v-model="passwordForm.oldPassword" type="password" show-password></el-input>
  130. </el-form-item>
  131. <el-form-item label="新密码" prop="newPassword">
  132. <el-input v-model="passwordForm.newPassword" type="password" show-password></el-input>
  133. </el-form-item>
  134. <el-form-item label="确认密码" prop="confirmPassword">
  135. <el-input v-model="passwordForm.confirmPassword" type="password" show-password></el-input>
  136. </el-form-item>
  137. </el-form>
  138. <span slot="footer">
  139. <el-button @click="passwordDialogVisible = false">取消</el-button>
  140. <el-button type="primary" @click="confirmChangePassword" :loading="submitting">确定</el-button>
  141. </span>
  142. </el-dialog>
  143. <el-main>
  144. <div class="main-wrapper">
  145. <router-view />
  146. </div>
  147. </el-main>
  148. </el-container>
  149. </el-container>
  150. </template>
  151. <script>
  152. import { getAdminInfo, changePassword } from '@/api/auth'
  153. import { updateAdminInfo } from '@/api/admin'
  154. import { getMyInviteCode } from '@/api/guide'
  155. import { getUnreadMessageCount } from '@/api/teacherMessage'
  156. import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
  157. export default {
  158. data() {
  159. return {
  160. menuItems: [
  161. // ===== 1. 首页 (ALL) =====
  162. { path: '/dashboard', label: '首页', icon: 'el-icon-s-home', perm: 'dashboard' },
  163. // ===== 2. 家庭运营 (admin) =====
  164. { title: '家庭运营', icon: 'el-icon-s-custom', perm: 'operation',
  165. children: [
  166. { path: '/families', label: '家庭列表', icon: 'el-icon-s-custom', perm: 'family:list' },
  167. { path: '/children', label: '孩子管理', icon: 'el-icon-user-solid', perm: 'family:children' },
  168. { path: '/points', label: '积分管理', icon: 'el-icon-s-finance', perm: 'family:points' },
  169. { path: '/points-log', label: '积分记录', icon: 'el-icon-document', perm: 'family:points' },
  170. { path: '/wishes', label: '心愿管理', icon: 'el-icon-star-off', perm: 'family:wishes' },
  171. { path: '/rewards', label: '奖励管理', icon: 'el-icon-s-goods', perm: 'reward:list' },
  172. ]},
  173. // ===== 3. 任务中心 (admin) =====
  174. { title: '任务中心', icon: 'el-icon-s-order', perm: 'task:*',
  175. children: [
  176. { path: '/tasks', label: '任务列表', icon: 'el-icon-s-order', perm: 'task:list' },
  177. { path: '/task-templates', label: '任务模板', icon: 'el-icon-document', perm: 'task:templates' },
  178. { path: '/growth-task', label: '成长任务管理', icon: 'el-icon-s-management', perm: 'task:list' },
  179. ]},
  180. // ===== 4. 审核中心 (admin) =====
  181. { path: '/review-center', label: '审核中心', icon: 'el-icon-s-check', perm: 'audit' },
  182. { path: '/service-role-applications', label: '服务角色申请审核', icon: 'el-icon-s-check', perm: 'audit' },
  183. // ===== 5. 商城营销 (admin) =====
  184. { title: '商城营销', icon: 'el-icon-s-goods', perm: 'commerce',
  185. children: [
  186. { path: '/product-manage', label: '商品管理', icon: 'el-icon-s-goods', perm: 'commerce:products' },
  187. { path: '/inventory', label: '库存管理', icon: 'el-icon-document', perm: 'commerce:products' },
  188. { path: '/ecom-supplier', label: '供应商管理', icon: 'el-icon-s-shop', perm: 'commerce:products' },
  189. { path: '/order-manage', label: '订单管理', icon: 'el-icon-s-order', perm: 'commerce:orders' },
  190. { path: '/pending-refund', label: '待退款管理', icon: 'el-icon-warning', perm: 'commerce:orders' },
  191. { path: '/product-profit-rate', label: '产品利润率', icon: 'el-icon-data-line', perm: 'commerce:profit' },
  192. { path: '/coupon', label: '优惠券管理', icon: 'el-icon-ticket', perm: 'marketing:coupon' },
  193. { path: '/coupon-grant-log', label: '发券记录', icon: 'el-icon-document', perm: 'marketing:coupon' },
  194. { path: '/promotion', label: '推广管理', icon: 'el-icon-s-marketing', perm: 'marketing:promotion' },
  195. { path: '/family-earnings', label: '家庭收益', icon: 'el-icon-s-money', perm: 'marketing:promotion' },
  196. { path: '/family-earnings-withdraw', label: '收益提现审核', icon: 'el-icon-document-checked', perm: 'audit:withdraw' },
  197. ]},
  198. // ===== 6. 知识中心 (admin + article_manager + activity_manager) =====
  199. { title: '知识中心', icon: 'el-icon-document', perm: 'content',
  200. children: [
  201. { path: '/article-categories', label: '知识分类', icon: 'el-icon-folder', perm: 'articles:categories' },
  202. { path: '/article-manage', label: '知识管理', icon: 'el-icon-document', perm: 'articles:manage' },
  203. { path: '/comment-review', label: '评论审核', icon: 'el-icon-chat-dot-round', perm: 'articles:manage' },
  204. { path: '/knowledge-tags', label: '知识标签', icon: 'el-icon-price-tag', perm: 'articles:categories' },
  205. { path: '/knowledge-base', label: '知识库', icon: 'el-icon-reading', perm: 'system:config' },
  206. { path: '/health-knowledge', label: '健康知识库', icon: 'el-icon-first-aid-kit', perm: 'system:config' },
  207. ]},
  208. // ===== 6.5 活动管理(独立模块) =====
  209. { title: '活动管理', icon: 'el-icon-date', perm: 'activity:*', children: [
  210. { path: '/activities', label: '活动列表', icon: 'el-icon-date', perm: 'activity:list' },
  211. { path: '/activity-review', label: '活动审核', icon: 'el-icon-document-checked', perm: 'activity:review' },
  212. { path: '/activity-registration-review', label: '活动报名审核', icon: 'el-icon-document-checked', perm: 'activity:review' },
  213. ]},
  214. // ===== 7. 健康饮食 (admin + nutritionist) =====
  215. { title: '健康饮食', icon: 'el-icon-first-aid-kit', perm: 'health',
  216. children: [
  217. { path: '/health-reports', label: '健康报告', icon: 'el-icon-document', perm: 'health:reports' },
  218. { path: '/health-indicators', label: '健康指标', icon: 'el-icon-data-line', perm: 'health:indicators' },
  219. { path: '/health-checkins', label: '健康打卡', icon: 'el-icon-s-order', perm: 'health:checkins' },
  220. { path: '/emotion-alert', label: '情绪告警', icon: 'el-icon-warning', perm: 'health:checkins' },
  221. { path: '/nutrition-mappings', label: '营养素映射', icon: 'el-icon-connection', perm: 'health:mappings' },
  222. { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'energy' },
  223. { path: '/energy-rule', label: '能量规则管理', icon: 'el-icon-setting', perm: 'energy' },
  224. { path: '/energy-behavior-config', label: '能量行为配置', icon: 'el-icon-edit-outline', perm: 'energy' },
  225. { path: '/periodic-service-config', label: '周期性服务配置', icon: 'el-icon-refresh', perm: 'energy' },
  226. { path: '/foods', label: '食材管理', icon: 'el-icon-apple', perm: 'diet:foods' },
  227. { path: '/recipes', label: '食谱管理', icon: 'el-icon-dish', perm: 'diet:recipes' },
  228. { path: '/langgraph-admin', label: 'LangGraph 管理', icon: 'el-icon-cpu', perm: 'system:config' },
  229. { path: '/nutrition-products', label: '营养产品', icon: 'el-icon-first-aid-kit', perm: 'health:*' },
  230. ]},
  231. // ===== 报告解析系统 (admin) =====
  232. { title: '报告解析系统', icon: 'el-icon-cpu', perm: 'system:config',
  233. children: [
  234. { path: '/report-types', label: '报告类型管理', icon: 'el-icon-collection', perm: 'system:config' },
  235. { path: '/report-parser-import', label: '解析器导入', icon: 'el-icon-upload2', perm: 'system:config' },
  236. { path: '/report-unknown-clusters', label: '未知报告审核', icon: 'el-icon-question', perm: 'system:config' },
  237. { path: '/report-auto-learn', label: '报告自学习', icon: 'el-icon-magic-stick', perm: 'system:config' },
  238. ]},
  239. { title: '家庭服务', icon: 'el-icon-s-custom', perm: 'service:*',
  240. children: [
  241. { path: '/my-families', label: '我的家庭', icon: 'el-icon-s-custom', perm: 'service:family' },
  242. { path: '/teacher-team', label: '我的团队', icon: 'el-icon-s-custom', perm: 'service:team' },
  243. ]},
  244. { title: '业务管理', icon: 'el-icon-s-marketing', perm: 'biz:*',
  245. children: [
  246. { path: '/teacher-packages', label: '任务模板管理', icon: 'el-icon-s-goods', perm: 'biz:packages' },
  247. { path: '/teacher-orders', label: '订单佣金', icon: 'el-icon-s-order', perm: 'biz:orders' },
  248. ]},
  249. { title: '测评咨询', icon: 'el-icon-edit', perm: 'assessment:*',
  250. children: [
  251. { path: '/teacher-assessment', label: 'DAN测评', icon: 'el-icon-edit', perm: 'assessment:dan' },
  252. { path: '/teacher-consult', label: '家长咨询', icon: 'el-icon-chat-dot-round', perm: 'assessment:consult' },
  253. { path: '/growth-records', label: '成长记录', icon: 'el-icon-document', perm: 'growth:records' },
  254. { path: '/growth-plans', label: '成长计划', icon: 'el-icon-document', perm: 'growth:plans' },
  255. ]},
  256. // ===== 9. 系统配置 (admin) =====
  257. { title: '系统配置', icon: 'el-icon-s-tools', perm: 'system:*',
  258. children: [
  259. // --- 基础管理 ---
  260. { title: '基础管理', icon: 'el-icon-s-tools', perm: 'system:config',
  261. children: [
  262. { path: '/sys-config', label: '系统配置', icon: 'el-icon-s-tools', perm: 'system:config' },
  263. { path: '/users', label: '用户管理', icon: 'el-icon-user', perm: 'system:users' },
  264. { path: '/operation-logs', label: '操作日志', icon: 'el-icon-s-order', perm: 'system:logs' },
  265. { path: '/membership-center', label: '会员中心', icon: 'el-icon-s-custom', perm: 'system:config' },
  266. { path: '/badge-manage', label: '勋章管理', icon: 'el-icon-medal', perm: 'system:config' },
  267. { path: '/virtual-goods-config', label: '虚拟支付道具', icon: 'el-icon-goods', perm: 'system:config' },
  268. { path: '/gates', label: '关卡配置', icon: 'el-icon-key', perm: 'system:config' },
  269. ]},
  270. // --- 配置中心 ---
  271. { title: '配置中心', icon: 'el-icon-setting', perm: 'system:config',
  272. children: [
  273. { path: '/dimension-config', label: '维度配置', icon: 'el-icon-data-line', perm: 'system:config' },
  274. ]},
  275. // --- 供应商体系 ---
  276. { title: '供应商体系', icon: 'el-icon-s-management', perm: 'system:supply',
  277. children: [
  278. { path: '/supply-system', label: '供应商体系', icon: 'el-icon-s-management', perm: 'system:supply' },
  279. ]},
  280. // --- 供应商管理 (supplier_admin) ---
  281. { title: '供应商管理', icon: 'el-icon-s-shop', perm: 'supply:manage',
  282. children: [
  283. { path: '/supply-manage', label: '供应商管理', icon: 'el-icon-s-shop', perm: 'supply:manage' },
  284. { path: '/supplier-products', label: '商品管理', icon: 'el-icon-s-goods', perm: 'commerce:product' }
  285. ]},
  286. // --- 健康配置 ---
  287. { title: '健康配置', icon: 'el-icon-first-aid-kit', perm: 'system:config',
  288. children: [
  289. { path: '/health-norm-config', label: '健康常模', icon: 'el-icon-data-line', perm: 'system:config' },
  290. { path: '/health-data-source', label: '健康数据源', icon: 'el-icon-data-line', perm: 'system:config' },
  291. { path: '/health-energy-config', label: '七维能量配置', icon: 'el-icon-data-line', perm: 'system:config' },
  292. ]},
  293. // --- 人生维度 ---
  294. { title: '人生维度', icon: 'el-icon-s-custom', perm: 'system:config',
  295. children: [
  296. { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
  297. { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
  298. { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
  299. ]},
  300. // --- 测评管理 ---
  301. { title: '测评管理', icon: 'el-icon-edit', perm: 'assessment:dan',
  302. children: [
  303. { path: '/assessment-admin', label: '测评管理', icon: 'el-icon-edit', perm: 'assessment:dan' },
  304. { path: '/assessment-orders', label: '测评订单', icon: 'el-icon-s-order', perm: 'assessment:dan' },
  305. { path: '/assessment-assign', label: '待分配规划师', icon: 'el-icon-user', perm: 'assessment:dan' },
  306. ]},
  307. // --- 虚拟团队 ---
  308. { title: '虚拟团队', icon: 'el-icon-s-custom', perm: 'system:config',
  309. children: [
  310. { path: '/virtual-teams', label: '虚拟团队', icon: 'el-icon-s-custom', perm: 'system:config' },
  311. ]},
  312. ]},
  313. ],
  314. profileDialogVisible: false,
  315. passwordDialogVisible: false,
  316. submitting: false,
  317. profileForm: {
  318. nickname: '',
  319. phone: '',
  320. email: '',
  321. username: '',
  322. realName: ''
  323. },
  324. passwordForm: {
  325. oldPassword: '',
  326. newPassword: '',
  327. confirmPassword: ''
  328. },
  329. inviteCodeVisible: false,
  330. inviteCodeLoading: false,
  331. inviteCodeData: null,
  332. unreadMessageCount: 0,
  333. isMobile: window.innerWidth <= 768,
  334. sidebarVisible: window.innerWidth > 768,
  335. passwordRules: {
  336. oldPassword: [
  337. { required: true, message: '请输入原密码', trigger: 'blur' }
  338. ],
  339. newPassword: [
  340. { required: true, message: '请输入新密码', trigger: 'blur' },
  341. { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  342. ],
  343. confirmPassword: [
  344. { required: true, message: '请确认新密码', trigger: 'blur' },
  345. {
  346. validator: (rule, value, callback) => {
  347. if (value !== this.passwordForm.newPassword) {
  348. callback(new Error('两次输入密码不一致'))
  349. } else {
  350. callback()
  351. }
  352. },
  353. trigger: 'blur'
  354. }
  355. ]
  356. }
  357. }
  358. },
  359. computed: {
  360. activeMenu() {
  361. return this.$route.path
  362. },
  363. currentRole() {
  364. return localStorage.getItem('role') || 'admin'
  365. },
  366. vendorType() {
  367. return localStorage.getItem('vendorType') || ''
  368. },
  369. effectivePerms() {
  370. const rolesStr = localStorage.getItem('roles')
  371. const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
  372. return getEffectivePermissions(roles)
  373. },
  374. displayRoles() {
  375. const rolesStr = localStorage.getItem('roles')
  376. const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
  377. return roles.map(function(r) { return getRoleLabel(r) }).join(' / ')
  378. },
  379. displayUserLabel() {
  380. const adminName = localStorage.getItem('adminName')
  381. if (adminName) {
  382. return adminName + ' (' + this.displayRoles + ')'
  383. }
  384. return this.displayRoles
  385. }
  386. },
  387. watch: {
  388. '$route'() {
  389. if (this.isMobile) {
  390. this.sidebarVisible = false
  391. }
  392. }
  393. },
  394. mounted() {
  395. // 清除可能的 beforeunload 处理,防止页面刷新弹出"离开网站?"提示
  396. window.onbeforeunload = null
  397. // 响应式监听
  398. this.handleResize = this.handleResize.bind(this)
  399. window.addEventListener('resize', this.handleResize)
  400. // 有消息权限(老师/营养师角色)才加载未读数,管理员不调用
  401. if (hasPermission(this.effectivePerms, 'messages') && this.currentRole !== 'admin') {
  402. this.fetchUnreadCount()
  403. }
  404. },
  405. beforeDestroy() {
  406. window.removeEventListener('resize', this.handleResize)
  407. },
  408. methods: {
  409. hasPerm(required) {
  410. return hasPermission(this.effectivePerms, required)
  411. },
  412. getMenuIndex(item) {
  413. return item.path === '/dashboard' && this.currentRole === 'teacher'
  414. ? '/teacher-dashboard'
  415. : item.path
  416. },
  417. handleMenuSelect(index) {
  418. if (index && index.startsWith('/')) {
  419. this.$router.push(index)
  420. }
  421. if (this.isMobile) {
  422. this.sidebarVisible = false
  423. }
  424. },
  425. toggleSidebar() {
  426. this.sidebarVisible = !this.sidebarVisible
  427. },
  428. handleResize() {
  429. const mobile = window.innerWidth <= 768
  430. if (mobile !== this.isMobile) {
  431. this.isMobile = mobile
  432. if (!mobile) {
  433. this.sidebarVisible = true
  434. } else {
  435. this.sidebarVisible = false
  436. }
  437. }
  438. },
  439. handleProfileCommand(command) {
  440. if (command === 'profile') {
  441. this.loadAdminInfo()
  442. this.profileDialogVisible = true
  443. } else if (command === 'password') {
  444. this.passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' }
  445. this.passwordDialogVisible = true
  446. } else if (command === 'logout') {
  447. this.handleLogout()
  448. }
  449. },
  450. async loadAdminInfo() {
  451. try {
  452. const res = await getAdminInfo()
  453. this.profileForm = {
  454. nickname: res.data.nickname || '',
  455. phone: res.data.phone || '',
  456. email: res.data.email || '',
  457. username: res.data.username || '',
  458. realName: res.data.realName || ''
  459. }
  460. } catch (e) {
  461. this.$message.error('加载个人信息失败')
  462. }
  463. },
  464. async confirmUpdateInfo() {
  465. this.submitting = true
  466. try {
  467. await updateAdminInfo({
  468. nickname: this.profileForm.nickname,
  469. phone: this.profileForm.phone,
  470. email: this.profileForm.email
  471. })
  472. if (this.profileForm.nickname) {
  473. localStorage.setItem('adminName', this.profileForm.nickname)
  474. }
  475. this.$message.success('保存成功')
  476. this.profileDialogVisible = false
  477. } catch (e) {
  478. this.$message.error(e.message || '保存失败')
  479. } finally {
  480. this.submitting = false
  481. }
  482. },
  483. async confirmChangePassword() {
  484. this.$refs.passwordForm.validate(async (valid) => {
  485. if (!valid) return
  486. this.submitting = true
  487. try {
  488. await changePassword({
  489. oldPassword: this.passwordForm.oldPassword,
  490. newPassword: this.passwordForm.newPassword
  491. })
  492. this.$message.success('密码修改成功')
  493. this.passwordDialogVisible = false
  494. } catch (e) {
  495. this.$message.error(e.message || '密码修改失败')
  496. } finally {
  497. this.submitting = false
  498. }
  499. })
  500. },
  501. async showInviteCode() {
  502. this.inviteCodeVisible = true
  503. this.inviteCodeLoading = true
  504. this.inviteCodeData = null
  505. try {
  506. const res = await getMyInviteCode()
  507. if (res.data) {
  508. this.inviteCodeData = res.data
  509. }
  510. } catch (e) {
  511. this.$message.error('获取邀请码失败')
  512. } finally {
  513. this.inviteCodeLoading = false
  514. }
  515. },
  516. async generateInviteCode() {
  517. this.inviteCodeLoading = true
  518. try {
  519. const res = await getMyInviteCode()
  520. if (res.data) {
  521. this.inviteCodeData = res.data
  522. this.$message.success('邀请码已生成')
  523. }
  524. } catch (e) {
  525. this.$message.error('生成邀请码失败')
  526. } finally {
  527. this.inviteCodeLoading = false
  528. }
  529. },
  530. copyCode(code) {
  531. if (!code) return
  532. const input = document.createElement('input')
  533. input.value = code
  534. document.body.appendChild(input)
  535. input.select()
  536. document.execCommand('copy')
  537. document.body.removeChild(input)
  538. this.$message.success('已复制邀请码')
  539. },
  540. async fetchUnreadCount() {
  541. try {
  542. const res = await getUnreadMessageCount()
  543. if (res.data !== undefined) {
  544. this.unreadMessageCount = res.data || 0
  545. }
  546. } catch (e) {
  547. // silent: not critical
  548. }
  549. },
  550. handleMessageClick() {
  551. this.$router.push('/teacher-messages')
  552. },
  553. handleLogout() {
  554. // 不使用 modal 遮罩(全局 .v-modal { display:none } 会与 $confirm 的遮罩冲突)
  555. this.$confirm('确定要退出登录吗?', '提示', {
  556. confirmButtonText: '确定',
  557. cancelButtonText: '取消',
  558. type: 'warning',
  559. modal: false,
  560. customClass: 'cfc-logout-confirm'
  561. }).then(() => {
  562. localStorage.removeItem('token')
  563. localStorage.removeItem('role')
  564. localStorage.removeItem('roles')
  565. localStorage.removeItem('adminId')
  566. localStorage.removeItem('adminName')
  567. this.$router.push('/login')
  568. })
  569. }
  570. }
  571. }
  572. </script>
  573. <style>
  574. /* 全局防御:防止 Element UI 弹窗或组件残留 DOM 产生遮罩覆盖 */
  575. .v-modal {
  576. display: none !important;
  577. }
  578. .layout-container {
  579. height: 100vh;
  580. }
  581. .el-aside {
  582. background-color: #304156;
  583. overflow: visible;
  584. display: flex;
  585. flex-direction: column;
  586. }
  587. .logo {
  588. height: 60px;
  589. line-height: 60px;
  590. text-align: center;
  591. color: #fff;
  592. font-size: 20px;
  593. font-weight: bold;
  594. background-color: #2b3a4a;
  595. flex-shrink: 0;
  596. }
  597. /* 侧边栏滚动条样式 */
  598. .sidebar-scrollbar-wrap {
  599. height: calc(100vh - 60px) !important;
  600. overflow-x: hidden !important;
  601. overflow-y: auto !important;
  602. }
  603. .sidebar-scrollbar-wrap .el-scrollbar__view {
  604. height: 100%;
  605. }
  606. .sidebar-scrollbar-wrap::-webkit-scrollbar {
  607. width: 6px;
  608. }
  609. .sidebar-scrollbar-wrap::-webkit-scrollbar-track {
  610. background: #304156;
  611. }
  612. .sidebar-scrollbar-wrap::-webkit-scrollbar-thumb {
  613. background: #4a5568;
  614. border-radius: 3px;
  615. }
  616. .sidebar-scrollbar-wrap::-webkit-scrollbar-thumb:hover {
  617. background: #5a6578;
  618. }
  619. .el-header {
  620. background-color: #fff;
  621. box-shadow: 0 1px 4px rgba(0,21,41,.08);
  622. display: flex;
  623. justify-content: space-between;
  624. align-items: center;
  625. padding: 0 20px;
  626. }
  627. .header-right {
  628. display: flex;
  629. align-items: center;
  630. gap: 12px;
  631. }
  632. .username {
  633. color: #606266;
  634. }
  635. .invite-code-btn {
  636. color: #6366F1;
  637. font-size: 13px;
  638. }
  639. .invite-code-btn:hover {
  640. color: #4F46E5;
  641. }
  642. .msg-badge {
  643. margin-right: 4px;
  644. }
  645. .msg-badge .el-badge__content.is-fixed {
  646. top: 10px;
  647. right: 6px;
  648. }
  649. .msg-btn {
  650. font-size: 18px;
  651. color: #606266;
  652. padding: 4px;
  653. }
  654. .msg-btn:hover {
  655. color: #6366F1;
  656. }
  657. .invite-code-content {
  658. padding: 8px 0;
  659. }
  660. .invite-code-section {
  661. background: #F8FAFC;
  662. border: 1px solid #E2E8F0;
  663. border-radius: 8px;
  664. padding: 20px;
  665. text-align: center;
  666. }
  667. .invite-code-label {
  668. font-size: 14px;
  669. color: #64748B;
  670. margin-bottom: 12px;
  671. }
  672. .invite-code-value {
  673. display: flex;
  674. align-items: center;
  675. justify-content: center;
  676. gap: 12px;
  677. margin-bottom: 8px;
  678. }
  679. .code-text {
  680. font-size: 28px;
  681. font-weight: 700;
  682. letter-spacing: 6px;
  683. color: #1E293B;
  684. font-family: monospace;
  685. }
  686. .no-invite-code {
  687. text-align: center;
  688. padding: 24px;
  689. color: #94A3B8;
  690. }
  691. .no-invite-code i {
  692. font-size: 48px;
  693. margin-bottom: 12px;
  694. }
  695. .no-invite-code p {
  696. margin-bottom: 16px;
  697. }
  698. .el-main {
  699. background-color: #f0f2f5;
  700. padding: 20px;
  701. position: relative;
  702. z-index: 1;
  703. display: flex;
  704. flex-direction: column;
  705. min-height: 0;
  706. overflow-y: auto;
  707. }
  708. .main-wrapper {
  709. flex: 1;
  710. display: flex;
  711. flex-direction: column;
  712. min-height: 0;
  713. }
  714. /* ========== 全局 .admin-page 样式 ========== */
  715. /* 所有后台维护页面根元素使用此 class,使页面内容充满可视区 */
  716. .admin-page {
  717. flex: 1;
  718. display: flex;
  719. flex-direction: column;
  720. min-height: 0;
  721. }
  722. /* 当 admin-page 的直接子元素是 el-card 时让卡片自动填充 */
  723. .admin-page > .el-card {
  724. flex: 1;
  725. display: flex;
  726. flex-direction: column;
  727. min-height: 0;
  728. }
  729. .admin-page > .el-card .el-card__body {
  730. flex: 1;
  731. display: flex;
  732. flex-direction: column;
  733. min-height: 0;
  734. padding: 20px;
  735. }
  736. /* 当 admin-page > div > el-card(如 ArticleManage 的嵌套 div) */
  737. .admin-page > div:only-child {
  738. flex: 1;
  739. display: flex;
  740. flex-direction: column;
  741. min-height: 0;
  742. }
  743. .admin-page > div:only-child > .el-card {
  744. flex: 1;
  745. display: flex;
  746. flex-direction: column;
  747. min-height: 0;
  748. }
  749. .admin-page > div:only-child > .el-card .el-card__body {
  750. flex: 1;
  751. display: flex;
  752. flex-direction: column;
  753. min-height: 0;
  754. padding: 20px;
  755. }
  756. /* ========== 响应式:侧边栏折叠 ========== */
  757. @media (max-width: 768px) {
  758. .sidebar-overlay {
  759. position: fixed;
  760. top: 0;
  761. left: 0;
  762. right: 0;
  763. bottom: 0;
  764. background: rgba(0, 0, 0, 0.4);
  765. z-index: 1001;
  766. }
  767. .sidebar-mobile {
  768. position: fixed !important;
  769. top: 0;
  770. left: 0;
  771. height: 100vh !important;
  772. z-index: 1002;
  773. transition: transform 0.25s ease;
  774. transform: translateX(-100%);
  775. }
  776. .sidebar-mobile.sidebar-open {
  777. transform: translateX(0);
  778. }
  779. .sidebar-mobile.sidebar-closed {
  780. transform: translateX(-100%);
  781. }
  782. .header-left {
  783. display: flex;
  784. align-items: center;
  785. }
  786. .hamburger-btn {
  787. font-size: 20px;
  788. color: #606266;
  789. padding: 8px;
  790. }
  791. .hamburger-btn:hover {
  792. color: #6366F1;
  793. }
  794. .el-header {
  795. padding: 0 10px !important;
  796. }
  797. .el-main {
  798. padding: 12px !important;
  799. }
  800. .admin-page > .el-card .el-card__body {
  801. padding: 12px !important;
  802. }
  803. .admin-page > div:only-child > .el-card .el-card__body {
  804. padding: 12px !important;
  805. }
  806. .username {
  807. font-size: 13px;
  808. }
  809. /* el-table 小屏字号 */
  810. .el-table {
  811. font-size: 12px;
  812. }
  813. .el-table .cell {
  814. padding-left: 4px !important;
  815. padding-right: 4px !important;
  816. }
  817. /* 表格横向滚动优化 */
  818. .el-table {
  819. min-width: 600px !important;
  820. table-layout: auto !important;
  821. }
  822. .el-table__body-wrapper {
  823. overflow-x: auto !important;
  824. -webkit-overflow-scrolling: touch;
  825. }
  826. /* 搜索表单:form-item 换行 */
  827. .el-form-item--inline {
  828. display: block !important;
  829. margin-bottom: 12px !important;
  830. }
  831. .el-form-item--inline .el-form-item__content {
  832. display: flex !important;
  833. width: 100% !important;
  834. margin-left: 0 !important;
  835. }
  836. .el-form-item--inline .el-form-item__label {
  837. display: block !important;
  838. text-align: left !important;
  839. margin-bottom: 4px !important;
  840. }
  841. /* 搜索表单中 el-input/el-select 宽度 100% */
  842. .el-form-item--inline .el-input,
  843. .el-form-item--inline .el-select,
  844. .el-form-item--inline .el-date-editor {
  845. width: 100% !important;
  846. }
  847. /* 文章编辑等复杂表单 */
  848. .el-form--inline .el-form-item {
  849. display: block !important;
  850. }
  851. .el-form--inline .el-form-item__content {
  852. display: block !important;
  853. width: 100% !important;
  854. margin-left: 0 !important;
  855. }
  856. .el-form--inline .el-form-item__label {
  857. text-align: left !important;
  858. display: block !important;
  859. }
  860. /* 通用:卡片内容紧凑 */
  861. .stat-card {
  862. margin-bottom: 12px !important;
  863. }
  864. }
  865. /* 桌面端隐藏 header-left(无汉堡按钮) */
  866. @media (min-width: 769px) {
  867. .header-left {
  868. display: none;
  869. }
  870. }
  871. </style>