Dashboard.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. <template>
  2. <div class="dashboard-container">
  3. <el-container class="main-container">
  4. <el-aside width="240px" class="aside">
  5. <div class="logo-container">
  6. <img src="/logo.png" alt="Logo" class="logo-img" />
  7. <span class="logo-text">统一登录平台</span>
  8. </div>
  9. <el-menu
  10. router
  11. :default-active="$route.path"
  12. class="el-menu-vertical"
  13. >
  14. <el-menu-item index="/dashboard/launchpad">
  15. <el-icon><Menu /></el-icon>
  16. <span>快捷导航</span>
  17. </el-menu-item>
  18. <el-menu-item index="/dashboard/messages">
  19. <el-icon><ChatDotRound /></el-icon>
  20. <span>消息中心</span>
  21. <span v-if="messageStore.totalUnread > 0" class="menu-unread-badge">
  22. {{ messageStore.totalUnread > 99 ? '99+' : messageStore.totalUnread }}
  23. </span>
  24. </el-menu-item>
  25. <el-menu-item
  26. v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')"
  27. index="/dashboard/apps"
  28. >
  29. <el-icon><Grid /></el-icon>
  30. <span>应用管理</span>
  31. </el-menu-item>
  32. <el-menu-item
  33. v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')"
  34. index="/dashboard/client-distributions"
  35. >
  36. <el-icon><Upload /></el-icon>
  37. <span>客户端分发</span>
  38. </el-menu-item>
  39. <el-menu-item index="/dashboard/mappings">
  40. <el-icon><Connection /></el-icon>
  41. <span>平台账号管理</span>
  42. </el-menu-item>
  43. <el-menu-item v-if="user && user.role === 'SUPER_ADMIN'" index="/dashboard/users">
  44. <el-icon><User /></el-icon>
  45. <span>用户管理</span>
  46. </el-menu-item>
  47. <el-sub-menu index="ops" v-if="user && user.role === 'SUPER_ADMIN'">
  48. <template #title>
  49. <el-icon><Monitor /></el-icon>
  50. <span>运维服务</span>
  51. </template>
  52. <el-menu-item index="/dashboard/system-logs">
  53. <el-icon><Document /></el-icon>
  54. <span>后台日志</span>
  55. </el-menu-item>
  56. <el-menu-item index="/dashboard/system-status">
  57. <el-icon><Monitor /></el-icon>
  58. <span>服务器资源监控</span>
  59. </el-menu-item>
  60. <el-menu-item index="/dashboard/login-logs">
  61. <el-icon><List /></el-icon>
  62. <span>登录日志</span>
  63. </el-menu-item>
  64. <el-menu-item index="/dashboard/backup">
  65. <el-icon><Download /></el-icon>
  66. <span>数据备份</span>
  67. </el-menu-item>
  68. <el-menu-item index="/dashboard/restore">
  69. <el-icon><RefreshRight /></el-icon>
  70. <span>数据还原</span>
  71. </el-menu-item>
  72. <el-menu-item index="/dashboard/ssl-config">
  73. <el-icon><Lock /></el-icon>
  74. <span>证书配置</span>
  75. </el-menu-item>
  76. <el-menu-item index="/dashboard/login-config">
  77. <el-icon><Setting /></el-icon>
  78. <span>登录配置</span>
  79. </el-menu-item>
  80. <el-menu-item index="/dashboard/app-categories">
  81. <el-icon><Folder /></el-icon>
  82. <span>应用分类管理</span>
  83. </el-menu-item>
  84. </el-sub-menu>
  85. <el-sub-menu
  86. v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')"
  87. index="help-docs"
  88. >
  89. <template #title>
  90. <el-icon><QuestionFilled /></el-icon>
  91. <span>帮助文档</span>
  92. </template>
  93. <el-menu-item index="/dashboard/help">
  94. <el-icon><Document /></el-icon>
  95. <span>接口开发文档</span>
  96. </el-menu-item>
  97. <el-menu-item index="/dashboard/api-skill">
  98. <el-icon><MagicStick /></el-icon>
  99. <span>接口 skill</span>
  100. </el-menu-item>
  101. </el-sub-menu>
  102. </el-menu>
  103. </el-aside>
  104. <el-container class="content-container">
  105. <el-header>
  106. <div class="header-content">
  107. <div class="breadcrumb">
  108. <!-- Breadcrumb placeholder -->
  109. </div>
  110. <div class="user-actions">
  111. <el-popover
  112. placement="bottom"
  113. :width="260"
  114. trigger="hover"
  115. @show="handleDownloadPopoverShow"
  116. >
  117. <template #reference>
  118. <el-button
  119. type="primary"
  120. link
  121. class="header-download-link"
  122. :disabled="downloadLinksLoading || !clientDownloadHref"
  123. @click="openClientDownload"
  124. >
  125. 下载客户端
  126. </el-button>
  127. </template>
  128. <div class="download-popover">
  129. <div v-if="downloadQrDataUrl" class="download-qr-wrap">
  130. <img :src="downloadQrDataUrl" alt="客户端下载二维码" class="download-qr-img" />
  131. </div>
  132. <p v-else class="download-qr-empty">暂无可用下载链接</p>
  133. <div class="download-popover-actions">
  134. <el-button
  135. size="small"
  136. :disabled="!downloadQrDataUrl || copyDownloadQrLoading"
  137. :loading="copyDownloadQrLoading"
  138. @click="copyDownloadQrImage"
  139. >
  140. 复制图片
  141. </el-button>
  142. </div>
  143. </div>
  144. </el-popover>
  145. <el-dropdown trigger="click" @command="handleCommand">
  146. <span class="el-dropdown-link">
  147. <span v-if="user" class="username">{{ user.mobile }}</span>
  148. <el-icon class="el-icon--right"><arrow-down /></el-icon>
  149. </span>
  150. <template #dropdown>
  151. <el-dropdown-menu>
  152. <el-dropdown-item command="identityQr">
  153. <span class="dropdown-item-with-icon">
  154. <el-icon><Postcard /></el-icon>
  155. 个人二维码
  156. </span>
  157. </el-dropdown-item>
  158. <el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
  159. <el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
  160. </el-dropdown-menu>
  161. </template>
  162. </el-dropdown>
  163. </div>
  164. </div>
  165. </el-header>
  166. <el-main>
  167. <router-view></router-view>
  168. </el-main>
  169. </el-container>
  170. </el-container>
  171. <!-- 个人身份二维码 -->
  172. <el-dialog
  173. v-model="identityQrVisible"
  174. title="个人二维码"
  175. width="420px"
  176. align-center
  177. destroy-on-close
  178. @closed="onIdentityQrDialogClosed"
  179. >
  180. <div v-loading="identityQrLoading" class="identity-qr-body">
  181. <p class="identity-qr-hint">请向核验人员出示,密文约 1 分钟内有效。</p>
  182. <div v-if="identityQrDataUrl" class="identity-qr-img-wrap">
  183. <img :src="identityQrDataUrl" alt="个人二维码" class="identity-qr-img" />
  184. </div>
  185. <p v-if="identityQrExpiresText" class="identity-qr-expires">
  186. 有效期至:{{ identityQrExpiresText }}
  187. </p>
  188. </div>
  189. <template #footer>
  190. <el-button @click="identityQrVisible = false">关闭</el-button>
  191. <el-button type="primary" :loading="identityQrLoading" @click="fetchIdentityQr">
  192. 刷新
  193. </el-button>
  194. </template>
  195. </el-dialog>
  196. <!-- Change Password Dialog -->
  197. <el-dialog v-model="changePwdVisible" title="修改密码" width="400px">
  198. <el-form :model="pwdForm" :rules="pwdRules" ref="pwdFormRef" label-width="100px">
  199. <el-form-item label="旧密码" prop="old_password">
  200. <el-input v-model="pwdForm.old_password" type="password" show-password />
  201. </el-form-item>
  202. <el-form-item label="新密码" prop="new_password">
  203. <el-input v-model="pwdForm.new_password" type="password" show-password />
  204. </el-form-item>
  205. <el-form-item label="确认新密码" prop="confirm_password">
  206. <el-input v-model="pwdForm.confirm_password" type="password" show-password />
  207. </el-form-item>
  208. </el-form>
  209. <template #footer>
  210. <el-button @click="changePwdVisible = false">取消</el-button>
  211. <el-button type="primary" @click="submitChangePwd" :loading="changingPwd">确定</el-button>
  212. </template>
  213. </el-dialog>
  214. </div>
  215. </template>
  216. <script setup lang="ts">
  217. import { computed, onMounted, onUnmounted, ref, reactive } from 'vue'
  218. import { useRouter } from 'vue-router'
  219. import { useAuthStore } from '../store/auth'
  220. import { Grid, List, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound, Folder, Menu, Upload, Postcard, MagicStick, QuestionFilled } from '@element-plus/icons-vue'
  221. import { ElMessage, FormInstance, FormRules } from 'element-plus'
  222. import QRCode from 'qrcode'
  223. import api from '../utils/request'
  224. import { getDownloadLinks, type DownloadLinksPublic } from '../api/public'
  225. import { useMessageStore } from '../store/message'
  226. const router = useRouter()
  227. const authStore = useAuthStore()
  228. const messageStore = useMessageStore()
  229. const user = computed(() => authStore.user)
  230. const downloadLinks = ref<DownloadLinksPublic | null>(null)
  231. const downloadLinksLoading = ref(false)
  232. const downloadQrDataUrl = ref('')
  233. const copyDownloadQrLoading = ref(false)
  234. const isMobileUa = () =>
  235. /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
  236. const clientDownloadHref = computed(() => {
  237. const links = downloadLinks.value
  238. if (!links) return ''
  239. if (isMobileUa()) {
  240. return links.mobile || links.pc || ''
  241. }
  242. return links.pc || links.mobile || ''
  243. })
  244. const qrDownloadHref = computed(() => {
  245. const links = downloadLinks.value
  246. if (!links) return ''
  247. // 扫码场景默认给移动端(安卓)下载地址
  248. return links.mobile || links.pc || ''
  249. })
  250. const ensureDownloadLinksLoaded = async () => {
  251. if (downloadLinks.value || downloadLinksLoading.value) return
  252. downloadLinksLoading.value = true
  253. try {
  254. const res = await getDownloadLinks()
  255. downloadLinks.value = res.data
  256. } catch {
  257. downloadLinks.value = null
  258. } finally {
  259. downloadLinksLoading.value = false
  260. }
  261. }
  262. const ensureDownloadQrGenerated = async () => {
  263. if (!qrDownloadHref.value) {
  264. downloadQrDataUrl.value = ''
  265. return
  266. }
  267. if (downloadQrDataUrl.value) return
  268. downloadQrDataUrl.value = await QRCode.toDataURL(qrDownloadHref.value, {
  269. width: 220,
  270. margin: 2,
  271. errorCorrectionLevel: 'M'
  272. })
  273. }
  274. const handleDownloadPopoverShow = async () => {
  275. await ensureDownloadLinksLoaded()
  276. await ensureDownloadQrGenerated()
  277. }
  278. const openClientDownload = async () => {
  279. await ensureDownloadLinksLoaded()
  280. if (!clientDownloadHref.value) {
  281. ElMessage.warning('暂无可用下载链接')
  282. return
  283. }
  284. window.open(clientDownloadHref.value, '_blank', 'noopener,noreferrer')
  285. }
  286. const copyDownloadQrImage = async () => {
  287. if (!downloadQrDataUrl.value) return
  288. if (!window.isSecureContext || !('clipboard' in navigator) || !('ClipboardItem' in window)) {
  289. ElMessage.warning('当前环境不支持复制图片,请手动保存二维码')
  290. return
  291. }
  292. copyDownloadQrLoading.value = true
  293. try {
  294. const qrBlob = await (await fetch(downloadQrDataUrl.value)).blob()
  295. await navigator.clipboard.write([new ClipboardItem({ 'image/png': qrBlob })])
  296. ElMessage.success('二维码图片已复制')
  297. } catch {
  298. ElMessage.error('复制失败,请检查浏览器权限')
  299. } finally {
  300. copyDownloadQrLoading.value = false
  301. }
  302. }
  303. // Logout & Menu Command
  304. const handleCommand = (command: string) => {
  305. if (command === 'logout') {
  306. handleLogout()
  307. } else if (command === 'changePassword') {
  308. openChangePwd()
  309. } else if (command === 'identityQr') {
  310. openIdentityQr()
  311. }
  312. }
  313. const handleLogout = () => {
  314. authStore.logout()
  315. router.push('/login')
  316. }
  317. // Change Password Logic
  318. const changePwdVisible = ref(false)
  319. const changingPwd = ref(false)
  320. const pwdFormRef = ref<FormInstance>()
  321. const pwdForm = reactive({
  322. old_password: '',
  323. new_password: '',
  324. confirm_password: ''
  325. })
  326. const validatePass2 = (rule: any, value: any, callback: any) => {
  327. if (value === '') {
  328. callback(new Error('请再次输入密码'))
  329. } else if (value !== pwdForm.new_password) {
  330. callback(new Error('两次输入密码不一致!'))
  331. } else {
  332. callback()
  333. }
  334. }
  335. const pwdRules = reactive<FormRules>({
  336. old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
  337. new_password: [
  338. { required: true, message: '请输入新密码', trigger: 'blur' },
  339. { min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
  340. { pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/, message: '密码必须包含字母和数字', trigger: 'blur' }
  341. ],
  342. confirm_password: [{ required: true, validator: validatePass2, trigger: 'blur' }]
  343. })
  344. // 个人身份二维码(GET /identity-qr/)
  345. const identityQrVisible = ref(false)
  346. const identityQrLoading = ref(false)
  347. const identityQrDataUrl = ref('')
  348. const identityQrExpiresText = ref('')
  349. const formatExpiresAt = (iso: string) => {
  350. try {
  351. const d = new Date(iso)
  352. return d.toLocaleString('zh-CN', { hour12: false })
  353. } catch {
  354. return iso
  355. }
  356. }
  357. const fetchIdentityQr = async () => {
  358. identityQrLoading.value = true
  359. identityQrDataUrl.value = ''
  360. identityQrExpiresText.value = ''
  361. try {
  362. const { data } = await api.get<{ token: string; expires_at: string }>('/identity-qr/')
  363. identityQrExpiresText.value = formatExpiresAt(data.expires_at)
  364. identityQrDataUrl.value = await QRCode.toDataURL(data.token, {
  365. width: 280,
  366. margin: 2,
  367. errorCorrectionLevel: 'M'
  368. })
  369. } catch {
  370. // 全局已提示
  371. } finally {
  372. identityQrLoading.value = false
  373. }
  374. }
  375. const openIdentityQr = () => {
  376. identityQrVisible.value = true
  377. fetchIdentityQr()
  378. }
  379. const onIdentityQrDialogClosed = () => {
  380. identityQrDataUrl.value = ''
  381. identityQrExpiresText.value = ''
  382. }
  383. const openChangePwd = () => {
  384. pwdForm.old_password = ''
  385. pwdForm.new_password = ''
  386. pwdForm.confirm_password = ''
  387. changePwdVisible.value = true
  388. }
  389. const submitChangePwd = async () => {
  390. if (!pwdFormRef.value) return
  391. await pwdFormRef.value.validate(async (valid) => {
  392. if (valid) {
  393. changingPwd.value = true
  394. try {
  395. await api.post('/simple/me/change-password', {
  396. old_password: pwdForm.old_password,
  397. new_password: pwdForm.new_password
  398. })
  399. ElMessage.success('密码修改成功')
  400. changePwdVisible.value = false
  401. } catch (e) {
  402. // handled
  403. } finally {
  404. changingPwd.value = false
  405. }
  406. }
  407. })
  408. }
  409. onMounted(() => {
  410. if (!user.value) {
  411. authStore.fetchUser()
  412. }
  413. messageStore.initWebSocket()
  414. messageStore.fetchUnreadCount()
  415. ensureDownloadLinksLoaded()
  416. })
  417. onUnmounted(() => {
  418. messageStore.disconnect()
  419. })
  420. </script>
  421. <style scoped>
  422. .dashboard-container {
  423. height: 100%;
  424. width: 100%;
  425. }
  426. .main-container {
  427. height: 100%;
  428. }
  429. .content-container {
  430. height: 100%;
  431. overflow: hidden;
  432. }
  433. .aside {
  434. background-color: #304156;
  435. color: #fff;
  436. height: 100%;
  437. }
  438. .logo-container {
  439. height: 60px;
  440. display: flex;
  441. align-items: center;
  442. justify-content: center;
  443. background-color: #2b3649;
  444. padding: 0 10px;
  445. }
  446. .logo-img {
  447. height: 32px;
  448. margin-right: 10px;
  449. }
  450. .logo-text {
  451. font-weight: bold;
  452. font-size: 16px;
  453. white-space: nowrap;
  454. }
  455. .el-menu-vertical {
  456. border-right: none;
  457. background-color: #304156;
  458. }
  459. :deep(.el-menu-item) {
  460. color: #bfcbd9;
  461. }
  462. :deep(.el-menu-item.is-active) {
  463. color: #409eff;
  464. background-color: #263445;
  465. }
  466. :deep(.el-menu-item:hover) {
  467. background-color: #263445;
  468. }
  469. :deep(.el-sub-menu__title) {
  470. color: #bfcbd9 !important;
  471. }
  472. :deep(.el-sub-menu__title:hover) {
  473. background-color: #263445 !important;
  474. }
  475. /* Fix nested menu background color */
  476. :deep(.el-sub-menu .el-menu) {
  477. background-color: #1f2d3d !important;
  478. }
  479. :deep(.el-sub-menu .el-menu-item) {
  480. background-color: #1f2d3d !important;
  481. }
  482. :deep(.el-sub-menu .el-menu-item:hover) {
  483. background-color: #001528 !important;
  484. }
  485. .menu-unread-badge {
  486. background: #ff4d4f;
  487. color: #fff;
  488. border-radius: 10px;
  489. padding: 0 6px;
  490. font-size: 12px;
  491. height: 18px;
  492. line-height: 18px;
  493. margin-left: auto;
  494. }
  495. .el-header {
  496. padding: 0;
  497. }
  498. .header-content {
  499. display: flex;
  500. justify-content: space-between;
  501. align-items: center;
  502. height: 60px;
  503. border-bottom: 1px solid #dcdfe6;
  504. padding: 0 20px;
  505. background-color: #fff;
  506. }
  507. .user-actions {
  508. display: flex;
  509. align-items: center;
  510. gap: 12px;
  511. }
  512. .header-download-link {
  513. font-size: 14px;
  514. }
  515. .el-dropdown-link {
  516. cursor: pointer;
  517. display: flex;
  518. align-items: center;
  519. color: #333;
  520. }
  521. .username {
  522. margin-right: 5px;
  523. font-size: 14px;
  524. }
  525. .dropdown-item-with-icon {
  526. display: inline-flex;
  527. align-items: center;
  528. gap: 6px;
  529. }
  530. .identity-qr-body {
  531. min-height: 120px;
  532. }
  533. .identity-qr-hint {
  534. margin: 0 0 12px;
  535. font-size: 13px;
  536. color: #606266;
  537. line-height: 1.5;
  538. }
  539. .identity-qr-img-wrap {
  540. display: flex;
  541. justify-content: center;
  542. padding: 8px 0;
  543. }
  544. .identity-qr-img {
  545. display: block;
  546. width: 280px;
  547. height: 280px;
  548. }
  549. .identity-qr-expires {
  550. margin: 12px 0 0;
  551. text-align: center;
  552. font-size: 13px;
  553. color: #909399;
  554. }
  555. .download-popover {
  556. display: flex;
  557. flex-direction: column;
  558. gap: 10px;
  559. }
  560. .download-qr-wrap {
  561. display: flex;
  562. justify-content: center;
  563. }
  564. .download-qr-img {
  565. width: 220px;
  566. height: 220px;
  567. display: block;
  568. }
  569. .download-qr-empty {
  570. margin: 0;
  571. text-align: center;
  572. color: #909399;
  573. font-size: 13px;
  574. }
  575. .download-popover-actions {
  576. display: flex;
  577. justify-content: center;
  578. }
  579. .el-main {
  580. background-color: #f0f2f5;
  581. padding: 20px;
  582. height: calc(100% - 60px);
  583. overflow-y: auto;
  584. }
  585. </style>