Layout.vue 41 KB

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