Layout.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. <template>
  2. <el-container class="layout-container">
  3. <el-aside width="200px">
  4. <div class="logo">浠艾福</div>
  5. <el-menu
  6. :default-active="activeMenu"
  7. class="sidebar-menu"
  8. background-color="transparent"
  9. text-color="#64748B"
  10. active-text-color="#6366F1"
  11. @select="handleMenuSelect"
  12. >
  13. <template v-for="item in menuItems">
  14. <el-submenu v-if="item.children && hasPerm(item.perm)" :key="item.title" :index="item.title">
  15. <template slot="title">
  16. <i :class="item.icon"></i>
  17. <span>{{ item.label || item.title }}</span>
  18. </template>
  19. <el-menu-item
  20. v-for="child in item.children"
  21. :key="child.path"
  22. :index="child.path"
  23. >
  24. <i :class="child.icon"></i>
  25. <span slot="title">{{ child.label }}</span>
  26. </el-menu-item>
  27. </el-submenu>
  28. <el-menu-item v-else-if="hasPerm(item.perm)" :key="item.path" :index="item.path">
  29. <i :class="item.icon"></i>
  30. <span slot="title">{{ item.label }}</span>
  31. </el-menu-item>
  32. </template>
  33. </el-menu>
  34. </el-aside>
  35. <el-container>
  36. <el-header>
  37. <div class="header-right">
  38. <el-button v-if="hasPerm('service:*')" type="text" class="invite-code-btn" @click="showInviteCode" icon="el-icon-share">
  39. 邀请码
  40. </el-button>
  41. <el-dropdown trigger="click" @command="handleProfileCommand">
  42. <span class="el-dropdown-link username">
  43. {{ displayUserLabel }}<i class="el-icon-arrow-down el-icon--right"></i>
  44. </span>
  45. <el-dropdown-menu slot="dropdown">
  46. <el-dropdown-item command="profile" icon="el-icon-user">个人信息</el-dropdown-item>
  47. <el-dropdown-item command="password" icon="el-icon-key">修改密码</el-dropdown-item>
  48. <el-dropdown-item command="logout" icon="el-icon-switch-button" divided>退出登录</el-dropdown-item>
  49. </el-dropdown-menu>
  50. </el-dropdown>
  51. </div>
  52. <!-- 邀请码 dialog -->
  53. <el-dialog title="我的邀请码" :visible.sync="inviteCodeVisible" width="500px" :close-on-click-modal="false">
  54. <div v-loading="inviteCodeLoading">
  55. <div v-if="inviteCodeData" class="invite-code-content">
  56. <div class="invite-code-section">
  57. <div class="invite-code-label">团队邀请码</div>
  58. <div class="invite-code-value">
  59. <span class="code-text">{{ inviteCodeData.code || '无' }}</span>
  60. <el-button size="mini" type="primary" @click="copyCode(inviteCodeData.code)" plain>复制</el-button>
  61. </div>
  62. <div class="invite-code-desc">
  63. 家庭用户通过此码加入您的团队
  64. </div>
  65. </div>
  66. </div>
  67. <div v-else-if="!inviteCodeLoading" class="no-invite-code">
  68. <i class="el-icon-warning-outline"></i>
  69. <p>暂无邀请码,请先生成</p>
  70. <el-button type="primary" @click="generateInviteCode">生成邀请码</el-button>
  71. </div>
  72. </div>
  73. </el-dialog>
  74. </el-header>
  75. <!-- 个人信息 dialog -->
  76. <el-dialog title="个人信息" :visible.sync="profileDialogVisible" width="400px">
  77. <el-form :model="profileForm" label-width="80px">
  78. <el-form-item label="用户名">
  79. <el-input v-model="profileForm.username" disabled></el-input>
  80. </el-form-item>
  81. <el-form-item label="真实姓名">
  82. <el-input v-model="profileForm.realName" disabled></el-input>
  83. </el-form-item>
  84. <el-form-item label="昵称">
  85. <el-input v-model="profileForm.nickname"></el-input>
  86. </el-form-item>
  87. <el-form-item label="手机号">
  88. <el-input v-model="profileForm.phone"></el-input>
  89. </el-form-item>
  90. <el-form-item label="邮箱">
  91. <el-input v-model="profileForm.email"></el-input>
  92. </el-form-item>
  93. </el-form>
  94. <span slot="footer">
  95. <el-button @click="profileDialogVisible = false">取消</el-button>
  96. <el-button type="primary" @click="confirmUpdateInfo" :loading="submitting">保存</el-button>
  97. </span>
  98. </el-dialog>
  99. <!-- 修改密码 dialog -->
  100. <el-dialog title="修改密码" :visible.sync="passwordDialogVisible" width="400px">
  101. <el-form :model="passwordForm" :rules="passwordRules" ref="passwordForm" label-width="100px">
  102. <el-form-item label="原密码" prop="oldPassword">
  103. <el-input v-model="passwordForm.oldPassword" type="password" show-password></el-input>
  104. </el-form-item>
  105. <el-form-item label="新密码" prop="newPassword">
  106. <el-input v-model="passwordForm.newPassword" type="password" show-password></el-input>
  107. </el-form-item>
  108. <el-form-item label="确认密码" prop="confirmPassword">
  109. <el-input v-model="passwordForm.confirmPassword" type="password" show-password></el-input>
  110. </el-form-item>
  111. </el-form>
  112. <span slot="footer">
  113. <el-button @click="passwordDialogVisible = false">取消</el-button>
  114. <el-button type="primary" @click="confirmChangePassword" :loading="submitting">确定</el-button>
  115. </span>
  116. </el-dialog>
  117. <el-main>
  118. <router-view />
  119. </el-main>
  120. </el-container>
  121. </el-container>
  122. </template>
  123. <script>
  124. import { getAdminInfo, changePassword } from '@/api/auth'
  125. import { updateAdminInfo } from '@/api/admin'
  126. import { getMyInviteCode } from '@/api/guide'
  127. import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
  128. export default {
  129. data() {
  130. return {
  131. menuItems: [
  132. // 全局通用
  133. { path: '/dashboard', label: '首页', icon: 'el-icon-s-home', perm: 'dashboard' },
  134. // admin 专属
  135. { title: '家庭管理', icon: 'el-icon-s-home', perm: 'family:*',
  136. children: [
  137. { path: '/families', label: '家庭列表', icon: 'el-icon-s-home', perm: 'family:list' },
  138. { path: '/children', label: '孩子管理', icon: 'el-icon-s-custom', perm: 'family:children' },
  139. { path: '/points', label: '积分管理', icon: 'el-icon-s-money', perm: 'family:points' },
  140. ]},
  141. { title: '任务管理', icon: 'el-icon-s-order', perm: 'task:*',
  142. children: [
  143. { path: '/tasks', label: '任务列表', icon: 'el-icon-s-order', perm: 'task:list' },
  144. { path: '/task-templates', label: '任务模板', icon: 'el-icon-document', perm: 'task:templates' },
  145. ]},
  146. { title: '奖励管理', icon: 'el-icon-s-goods', perm: 'reward:*',
  147. children: [
  148. { path: '/rewards', label: '奖励列表', icon: 'el-icon-s-goods', perm: 'reward:list' },
  149. ]},
  150. // teacher + nutritionist 共享
  151. { title: '我的家庭', icon: 'el-icon-s-home', perm: 'service:family',
  152. children: [
  153. { path: '/my-families', label: '授权家庭', icon: 'el-icon-s-home', perm: 'service:family' },
  154. ]},
  155. // teacher 专属
  156. { path: '/growth-records', label: '成长记录', icon: 'el-icon-document', perm: 'growth:records' },
  157. { path: '/growth-plans', label: '成长计划', icon: 'el-icon-document', perm: 'growth:plans' },
  158. { title: '服务管理', icon: 'el-icon-s-home', perm: 'service:*',
  159. children: [
  160. { path: '/teacher-families', label: '我的家庭', icon: 'el-icon-s-home', perm: 'service:families' },
  161. { path: '/teacher-team', label: '我的团队', icon: 'el-icon-s-custom', perm: 'service:team' },
  162. ]},
  163. { title: '业务管理', icon: 'el-icon-s-marketing', perm: 'biz:*',
  164. children: [
  165. { path: '/teacher-packages', label: '套餐管理', icon: 'el-icon-s-marketing', perm: 'biz:packages' },
  166. { path: '/teacher-orders', label: '订单佣金', icon: 'el-icon-s-order', perm: 'biz:orders' },
  167. ]},
  168. { title: '测评管理', icon: 'el-icon-edit', perm: 'assessment:*',
  169. children: [
  170. { path: '/teacher-assessment', label: 'DAN测评', icon: 'el-icon-edit', perm: 'assessment:dan' },
  171. { path: '/teacher-consult', label: '家长咨询', icon: 'el-icon-chat-dot-round', perm: 'assessment:consult' },
  172. ]},
  173. { path: '/teacher-messages', label: '消息中心', icon: 'el-icon-chat-dot-round', perm: 'messages' },
  174. // nutritionist 专属
  175. { title: '健康档案', icon: 'el-icon-document', perm: 'health:*',
  176. children: [
  177. { path: '/health-reports', label: '健康报告', icon: 'el-icon-document', perm: 'health:reports' },
  178. { path: '/health-indicators', label: '健康指标', icon: 'el-icon-data-line', perm: 'health:indicators' },
  179. { path: '/health-checkins', label: '健康打卡', icon: 'el-icon-s-order', perm: 'health:checkins' },
  180. ]},
  181. { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'energy' },
  182. // article_manager 专属
  183. { title: '文章管理', icon: 'el-icon-document', perm: 'articles:*',
  184. children: [
  185. { path: '/article-categories', label: '文章分类', icon: 'el-icon-document', perm: 'articles:categories' },
  186. { path: '/article-manage', label: '文章管理', icon: 'el-icon-document', perm: 'articles:manage' },
  187. ]},
  188. // activity_manager 专属
  189. { title: '活动管理', icon: 'el-icon-s-home', perm: 'activity:*',
  190. children: [
  191. { path: '/activities', label: '活动列表', icon: 'el-icon-s-home', perm: 'activity:list' },
  192. { path: '/activity-review', label: '活动审核', icon: 'el-icon-s-check', perm: 'activity:review' },
  193. ]},
  194. // diet 管理 (admin)
  195. { title: '饮食管理', icon: 'el-icon-s-management', perm: 'diet:*',
  196. children: [
  197. { path: '/foods', label: '食材管理', icon: 'el-icon-s-management', perm: 'diet:foods' },
  198. { path: '/recipes', label: '食谱管理', icon: 'el-icon-s-management', perm: 'diet:recipes' },
  199. { path: '/seasonal-foods', label: '应季食材', icon: 'el-icon-s-management', perm: 'diet:seasonal' },
  200. ]},
  201. // system 管理 (仅 admin)
  202. { title: '系统管理', icon: 'el-icon-s-tools', perm: 'system:*',
  203. children: [
  204. { path: '/users', label: '用户管理', icon: 'el-icon-user', perm: 'system:users' },
  205. { path: '/package-audit', label: '套餐审核', icon: 'el-icon-s-check', perm: 'system:audit' },
  206. { path: '/guide-audit', label: '规划师审核', icon: 'el-icon-s-check', perm: 'system:audit' },
  207. { path: '/vendor-review', label: '服务商审核', icon: 'el-icon-s-check', perm: 'system:audit' },
  208. { path: '/operation-logs', label: '操作日志', icon: 'el-icon-s-order', perm: 'system:logs' },
  209. { path: '/product-manage', label: '商品管理', icon: 'el-icon-s-goods', perm: 'system:products' },
  210. { path: '/order-manage', label: '订单管理', icon: 'el-icon-s-order', perm: 'system:orders' },
  211. { path: '/danshop-category', label: 'DanShop分类', icon: 'el-icon-s-goods', perm: 'system:danshop' },
  212. { path: '/danshop-profit-rule', label: '分润规则', icon: 'el-icon-s-marketing', perm: 'system:danshop' },
  213. { path: '/danshop-logistics', label: '物流管理', icon: 'el-icon-s-order', perm: 'system:danshop' },
  214. { path: '/sys-config', label: '系统配置', icon: 'el-icon-s-tools', perm: 'system:config' },
  215. { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'system:energy' },
  216. { path: '/education-systems', label: '教育体系', icon: 'el-icon-s-management', perm: 'system:education' },
  217. { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
  218. { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
  219. { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
  220. ]},
  221. ],
  222. profileDialogVisible: false,
  223. passwordDialogVisible: false,
  224. submitting: false,
  225. profileForm: {
  226. nickname: '',
  227. phone: '',
  228. email: '',
  229. username: '',
  230. realName: ''
  231. },
  232. passwordForm: {
  233. oldPassword: '',
  234. newPassword: '',
  235. confirmPassword: ''
  236. },
  237. inviteCodeVisible: false,
  238. inviteCodeLoading: false,
  239. inviteCodeData: null,
  240. passwordRules: {
  241. oldPassword: [
  242. { required: true, message: '请输入原密码', trigger: 'blur' }
  243. ],
  244. newPassword: [
  245. { required: true, message: '请输入新密码', trigger: 'blur' },
  246. { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  247. ],
  248. confirmPassword: [
  249. { required: true, message: '请确认新密码', trigger: 'blur' },
  250. {
  251. validator: (rule, value, callback) => {
  252. if (value !== this.passwordForm.newPassword) {
  253. callback(new Error('两次输入密码不一致'))
  254. } else {
  255. callback()
  256. }
  257. },
  258. trigger: 'blur'
  259. }
  260. ]
  261. }
  262. }
  263. },
  264. computed: {
  265. activeMenu() {
  266. return this.$route.path
  267. },
  268. currentRole() {
  269. return localStorage.getItem('role') || 'admin'
  270. },
  271. vendorType() {
  272. return localStorage.getItem('vendorType') || ''
  273. },
  274. effectivePerms() {
  275. const rolesStr = localStorage.getItem('roles')
  276. const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
  277. return getEffectivePermissions(roles)
  278. },
  279. displayRoles() {
  280. const rolesStr = localStorage.getItem('roles')
  281. const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
  282. return roles.map(function(r) { return getRoleLabel(r) }).join(' / ')
  283. },
  284. displayUserLabel() {
  285. const adminName = localStorage.getItem('adminName')
  286. if (adminName) {
  287. return adminName + ' (' + this.displayRoles + ')'
  288. }
  289. return this.displayRoles
  290. }
  291. },
  292. methods: {
  293. hasPerm(required) {
  294. return hasPermission(this.effectivePerms, required)
  295. },
  296. handleMenuSelect(index) {
  297. if (index && index.startsWith('/')) {
  298. this.$router.push(index)
  299. }
  300. },
  301. handleProfileCommand(command) {
  302. if (command === 'profile') {
  303. this.loadAdminInfo()
  304. this.profileDialogVisible = true
  305. } else if (command === 'password') {
  306. this.passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' }
  307. this.passwordDialogVisible = true
  308. } else if (command === 'logout') {
  309. this.handleLogout()
  310. }
  311. },
  312. async loadAdminInfo() {
  313. try {
  314. const res = await getAdminInfo()
  315. this.profileForm = {
  316. nickname: res.data.nickname || '',
  317. phone: res.data.phone || '',
  318. email: res.data.email || '',
  319. username: res.data.username || '',
  320. realName: res.data.realName || ''
  321. }
  322. } catch (e) {
  323. this.$message.error('加载个人信息失败')
  324. }
  325. },
  326. async confirmUpdateInfo() {
  327. this.submitting = true
  328. try {
  329. await updateAdminInfo({
  330. nickname: this.profileForm.nickname,
  331. phone: this.profileForm.phone,
  332. email: this.profileForm.email
  333. })
  334. if (this.profileForm.nickname) {
  335. localStorage.setItem('adminName', this.profileForm.nickname)
  336. }
  337. this.$message.success('保存成功')
  338. this.profileDialogVisible = false
  339. } catch (e) {
  340. this.$message.error(e.message || '保存失败')
  341. } finally {
  342. this.submitting = false
  343. }
  344. },
  345. async confirmChangePassword() {
  346. this.$refs.passwordForm.validate(async (valid) => {
  347. if (!valid) return
  348. this.submitting = true
  349. try {
  350. await changePassword({
  351. oldPassword: this.passwordForm.oldPassword,
  352. newPassword: this.passwordForm.newPassword
  353. })
  354. this.$message.success('密码修改成功')
  355. this.passwordDialogVisible = false
  356. } catch (e) {
  357. this.$message.error(e.message || '密码修改失败')
  358. } finally {
  359. this.submitting = false
  360. }
  361. })
  362. },
  363. async showInviteCode() {
  364. this.inviteCodeVisible = true
  365. this.inviteCodeLoading = true
  366. this.inviteCodeData = null
  367. try {
  368. const res = await getMyInviteCode()
  369. if (res.data) {
  370. this.inviteCodeData = res.data
  371. }
  372. } catch (e) {
  373. this.$message.error('获取邀请码失败')
  374. } finally {
  375. this.inviteCodeLoading = false
  376. }
  377. },
  378. async generateInviteCode() {
  379. this.inviteCodeLoading = true
  380. try {
  381. const res = await getMyInviteCode()
  382. if (res.data) {
  383. this.inviteCodeData = res.data
  384. this.$message.success('邀请码已生成')
  385. }
  386. } catch (e) {
  387. this.$message.error('生成邀请码失败')
  388. } finally {
  389. this.inviteCodeLoading = false
  390. }
  391. },
  392. copyCode(code) {
  393. if (!code) return
  394. const input = document.createElement('input')
  395. input.value = code
  396. document.body.appendChild(input)
  397. input.select()
  398. document.execCommand('copy')
  399. document.body.removeChild(input)
  400. this.$message.success('已复制邀请码')
  401. },
  402. handleLogout() {
  403. this.$confirm('确定要退出登录吗?', '提示', {
  404. confirmButtonText: '确定',
  405. cancelButtonText: '取消',
  406. type: 'warning'
  407. }).then(() => {
  408. localStorage.removeItem('token')
  409. localStorage.removeItem('role')
  410. localStorage.removeItem('roles')
  411. localStorage.removeItem('adminId')
  412. localStorage.removeItem('adminName')
  413. this.$router.push('/login')
  414. })
  415. }
  416. }
  417. }
  418. </script>
  419. <style>
  420. /* 全局防御:防止 Element UI 弹窗或组件残留 DOM 产生遮罩覆盖 */
  421. .v-modal {
  422. display: none !important;
  423. }
  424. .layout-container {
  425. height: 100vh;
  426. }
  427. .el-aside {
  428. background-color: #304156;
  429. overflow: hidden;
  430. }
  431. .logo {
  432. height: 60px;
  433. line-height: 60px;
  434. text-align: center;
  435. color: #fff;
  436. font-size: 20px;
  437. font-weight: bold;
  438. background-color: #2b3a4a;
  439. }
  440. .el-header {
  441. background-color: #fff;
  442. box-shadow: 0 1px 4px rgba(0,21,41,.08);
  443. display: flex;
  444. justify-content: flex-end;
  445. align-items: center;
  446. padding: 0 20px;
  447. }
  448. .header-right {
  449. display: flex;
  450. align-items: center;
  451. gap: 12px;
  452. }
  453. .username {
  454. margin-right: 20px;
  455. color: #606266;
  456. }
  457. .invite-code-btn {
  458. color: #6366F1;
  459. font-size: 13px;
  460. }
  461. .invite-code-btn:hover {
  462. color: #4F46E5;
  463. }
  464. .invite-code-content {
  465. padding: 8px 0;
  466. }
  467. .invite-code-section {
  468. background: #F8FAFC;
  469. border: 1px solid #E2E8F0;
  470. border-radius: 8px;
  471. padding: 20px;
  472. text-align: center;
  473. }
  474. .invite-code-label {
  475. font-size: 14px;
  476. color: #64748B;
  477. margin-bottom: 12px;
  478. }
  479. .invite-code-value {
  480. display: flex;
  481. align-items: center;
  482. justify-content: center;
  483. gap: 12px;
  484. margin-bottom: 8px;
  485. }
  486. .code-text {
  487. font-size: 28px;
  488. font-weight: 700;
  489. letter-spacing: 6px;
  490. color: #1E293B;
  491. font-family: monospace;
  492. }
  493. .no-invite-code {
  494. text-align: center;
  495. padding: 24px;
  496. color: #94A3B8;
  497. }
  498. .no-invite-code i {
  499. font-size: 48px;
  500. margin-bottom: 12px;
  501. }
  502. .no-invite-code p {
  503. margin-bottom: 16px;
  504. }
  505. .el-main {
  506. background-color: #f0f2f5;
  507. padding: 20px;
  508. position: relative;
  509. z-index: 1;
  510. }
  511. </style>