index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. <template>
  2. <view class="app-center-page">
  3. <!-- 自定义顶栏:头像 + 应用中心 + 搜索 -->
  4. <view class="custom-header">
  5. <view class="header-left" @click="onAvatarClick">
  6. <UserAvatar
  7. :name="currentUser.name"
  8. :id="currentUser.id"
  9. :src="currentUser.avatar"
  10. :size="80"
  11. unit="rpx"
  12. />
  13. <view class="org-info">
  14. <text class="user-name">应用中心</text>
  15. </view>
  16. </view>
  17. <view class="header-right">
  18. <view class="icon-btn" @click="onSearch">
  19. <image class="icon-img" src="/static/icons/search.svg" mode="aspectFit" />
  20. </view>
  21. </view>
  22. </view>
  23. <scroll-view
  24. class="app-center-scroll"
  25. scroll-y
  26. :refresher-enabled="refresherEnabled"
  27. :refresher-triggered="refresherTriggered"
  28. @refresherrefresh="onRefresh"
  29. >
  30. <!-- 最近使用标题 + 4 列网格(最多展示 8 个) -->
  31. <text class="section-title">最近使用</text>
  32. <view class="quick-grid">
  33. <view v-for="app in quickApps" :key="app.id" class="app-tile" @click="openApp(app)">
  34. <view class="tile-icon" :style="{ background: app.iconBg || defaultIconBg }">
  35. <image class="tile-icon-img" :src="app.iconPath" mode="aspectFit" />
  36. <view
  37. v-if="app.badge"
  38. class="tile-badge"
  39. :style="{ background: app.badgeBg || '#fbbf24', color: app.badgeColor || '#fff' }"
  40. >
  41. {{ app.badge }}
  42. </view>
  43. </view>
  44. <text class="tile-name">{{ app.name }}</text>
  45. </view>
  46. </view>
  47. <view class="all-section">
  48. <!-- 水平分类 Tab -->
  49. <view class="category-bar">
  50. <scroll-view
  51. class="category-scroll"
  52. scroll-x
  53. scroll-with-animation
  54. :show-scrollbar="false"
  55. >
  56. <view class="category-list">
  57. <view
  58. v-for="cat in categories"
  59. :key="cat.id"
  60. class="category-item"
  61. :class="{ active: cat.id === activeCategoryId }"
  62. @click="activeCategoryId = cat.id"
  63. >
  64. <text class="category-name">{{ cat.name }}</text>
  65. <view v-if="cat.id === activeCategoryId" class="category-underline" />
  66. </view>
  67. </view>
  68. </scroll-view>
  69. <!-- 右侧菜单 -->
  70. <view class="category-menu" @click="onMore">
  71. <image class="category-menu-icon" src="/static/icons/more.svg" mode="aspectFit" />
  72. </view>
  73. </view>
  74. </view>
  75. <!-- 全部应用 swiper 4 列网格(横向滑动切分类) -->
  76. <swiper
  77. class="apps-swiper"
  78. :current="currentCategoryIndex"
  79. @change="onSwiperChange"
  80. :circular="false"
  81. >
  82. <swiper-item v-for="cat in categories" :key="cat.id">
  83. <view class="apps-grid">
  84. <view
  85. v-for="app in getAppsByCategory(cat.id)"
  86. :key="app.id"
  87. class="app-tile app-tile-4"
  88. @click="openApp(app)"
  89. >
  90. <view class="tile-icon" :style="{ background: app.iconBg || defaultIconBg }">
  91. <image class="tile-icon-img" :src="app.iconPath" mode="aspectFit" />
  92. <view
  93. v-if="app.badge"
  94. class="tile-badge"
  95. :style="{ background: app.badgeBg || '#fbbf24', color: app.badgeColor || '#fff' }"
  96. >
  97. {{ app.badge }}
  98. </view>
  99. </view>
  100. <text class="tile-name tile-name-2">{{ app.name }}</text>
  101. </view>
  102. </view>
  103. </swiper-item>
  104. </swiper>
  105. <view class="bottom-spacer" />
  106. </scroll-view>
  107. </view>
  108. </template>
  109. <script>
  110. import UserAvatar from '../../components/UserAvatar.vue'
  111. import { getToken, getLaunchpadApps, ssoLogin } from '../../utils/api'
  112. const USER_KEY = 'current_user'
  113. const RECENT_APPS_STORAGE_KEY = 'launchpad_recent_apps_v1'
  114. const RECENT_APPS_MAX = 8
  115. /** 渐变色库与 `components/SystemAvatar.vue` 保持一致 */
  116. const AVATAR_GRADIENT_PAIRS = [
  117. ['#1e3a8a', '#93c5fd'],
  118. ['#166534', '#86efac'],
  119. ['#c2410c', '#fdba74'],
  120. ['#b91c1c', '#fca5a5'],
  121. ['#5b21b6', '#c4b5fd'],
  122. ['#0f766e', '#5eead4'],
  123. ['#3730a3', '#a5b4fc'],
  124. ['#0d9488', '#2dd4bf'],
  125. ['#b45309', '#fcd34d'],
  126. ['#be123c', '#fda4af'],
  127. ['#0369a1', '#7dd3fc'],
  128. ['#4d7c0f', '#bef264'],
  129. ['#86198f', '#e879f9'],
  130. ['#475569', '#cbd5e1'],
  131. ['#047857', '#6ee7b7'],
  132. ['#6d28d9', '#ddd6fe'],
  133. ['#1e40af', '#93c5fd'],
  134. ['#ea580c', '#fed7aa'],
  135. ['#0e7490', '#99f6e4'],
  136. ['#881337', '#fbcfe8']
  137. ]
  138. function getGradientIndexByName(name) {
  139. const s = String(name || '').trim() || 'SYSTEM'
  140. let hash = 0
  141. for (let i = 0; i < s.length; i++) {
  142. hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0
  143. }
  144. return Math.abs(hash) % AVATAR_GRADIENT_PAIRS.length
  145. }
  146. function getGradientBgByName(name) {
  147. const idx = getGradientIndexByName(name)
  148. const pair = AVATAR_GRADIENT_PAIRS[idx]
  149. return pair ? `linear-gradient(135deg, ${pair[0]} 0%, ${pair[1]} 100%)` : ''
  150. }
  151. export default {
  152. components: { UserAvatar },
  153. data() {
  154. return {
  155. currentUser: { name: '', id: '', avatar: '' },
  156. defaultIconBg: 'linear-gradient(135deg, #e6f4ea 0%, #d4edda 100%)',
  157. launchpadLoading: false,
  158. // 下拉刷新(避免首屏阶段重复触发)
  159. refresherEnabled: false,
  160. refresherTriggered: false,
  161. // 顶部最近使用(本地按 usedAt 倒序,最多 8 个)
  162. quickApps: [],
  163. categories: [{ id: 'recent', name: '全部应用' }],
  164. activeCategoryId: 'recent',
  165. // 全部应用网格数据:来自接口返回的 items
  166. allApps: []
  167. }
  168. },
  169. async onLoad() {
  170. this.loadCurrentUser()
  171. this.refresherEnabled = false
  172. try {
  173. await this.fetchLaunchpadApps()
  174. } finally {
  175. this.refresherEnabled = true
  176. }
  177. },
  178. computed: {
  179. currentCategoryIndex() {
  180. const idx = this.categories.findIndex((c) => c.id === this.activeCategoryId)
  181. return idx >= 0 ? idx : 0
  182. },
  183. filteredApps() {
  184. // 根据当前 Tab 过滤;如果没有命中则回退显示全部(避免空白)
  185. const active = String(this.activeCategoryId || '')
  186. // “全部应用”Tab 的 id 约定为 'recent'
  187. if (active === 'recent') return this.allApps
  188. const list = this.allApps.filter((a) => String(a.category || '') === active)
  189. return list.length ? list : this.allApps
  190. }
  191. },
  192. methods: {
  193. async onRefresh() {
  194. this.refresherTriggered = true
  195. try {
  196. await this.fetchLaunchpadApps()
  197. } finally {
  198. this.refresherTriggered = false
  199. }
  200. },
  201. loadRecentApps() {
  202. try {
  203. const raw = uni.getStorageSync(RECENT_APPS_STORAGE_KEY)
  204. if (Array.isArray(raw)) return raw
  205. // 兼容旧结构(如后续你把数据包成对象)
  206. if (raw && typeof raw === 'object' && Array.isArray(raw.items)) return raw.items
  207. } catch (e) {}
  208. return []
  209. },
  210. saveRecentApps(list) {
  211. try {
  212. uni.setStorageSync(RECENT_APPS_STORAGE_KEY, Array.isArray(list) ? list : [])
  213. } catch (e) {}
  214. },
  215. refreshQuickApps() {
  216. // 只用已拉取到的 `allApps` + 本地 recent 存储来刷新顶部“最近使用”
  217. // 避免每次点击应用都发起网络请求
  218. const appList = Array.isArray(this.allApps) ? this.allApps : []
  219. if (!appList.length) return
  220. const recentList = this.loadRecentApps()
  221. .filter((x) => x && x.appId != null)
  222. .map((x) => ({ appId: String(x.appId), usedAt: Number(x.usedAt) || 0 }))
  223. .sort((a, b) => (b.usedAt || 0) - (a.usedAt || 0))
  224. const appById = new Map(appList.map((a) => [String(a.id), a]))
  225. const recentApps = []
  226. const recentSeen = new Set()
  227. for (const r of recentList) {
  228. const id = String(r.appId || '')
  229. if (!id || recentSeen.has(id)) continue
  230. if (!appById.has(id)) continue
  231. recentSeen.add(id)
  232. recentApps.push(appById.get(id))
  233. if (recentApps.length >= RECENT_APPS_MAX) break
  234. }
  235. // 不足 8 个时,用接口里的其他应用补齐(但 recents 仍保持按时间倒序)
  236. if (recentApps.length < RECENT_APPS_MAX) {
  237. const rest = appList.filter((a) => !recentSeen.has(String(a.id)))
  238. recentApps.push(...rest.slice(0, RECENT_APPS_MAX - recentApps.length))
  239. }
  240. this.quickApps = recentApps
  241. },
  242. recordRecentApp(appId) {
  243. const id = String(appId ?? '')
  244. if (!id) return
  245. const now = Date.now()
  246. const list = this.loadRecentApps()
  247. // 去重 + 更新为最新,按 usedAt 倒序保留最多 8 个
  248. const next = list
  249. .filter((x) => x && x.appId != null)
  250. .map((x) => ({ appId: String(x.appId), usedAt: Number(x.usedAt) || 0 }))
  251. .filter((x) => x.appId !== id)
  252. next.unshift({ appId: id, usedAt: now })
  253. next.sort((a, b) => (b.usedAt || 0) - (a.usedAt || 0))
  254. this.saveRecentApps(next.slice(0, RECENT_APPS_MAX))
  255. this.refreshQuickApps()
  256. },
  257. async fetchLaunchpadApps() {
  258. this.launchpadLoading = true
  259. try {
  260. const token = getToken()
  261. if (!token) {
  262. this.quickApps = []
  263. this.allApps = []
  264. this.categories = [{ id: 'recent', name: '全部应用' }]
  265. this.activeCategoryId = 'recent'
  266. return
  267. }
  268. const res = await getLaunchpadApps()
  269. const items = Array.isArray(res && res.items) ? res.items : []
  270. const activeItems = items.filter((i) => i && i.is_active !== false)
  271. const appList = activeItems.map((it) => {
  272. const categoryId = it.category_id == null || it.category_id === '' ? 'uncat' : String(it.category_id)
  273. return {
  274. id: it.app_id,
  275. name: it.app_name,
  276. // 与聊天列表 SystemAvatar 保持一致
  277. iconPath: '/static/icons/application.svg',
  278. iconBg: getGradientBgByName(it.app_name),
  279. category: categoryId,
  280. categoryName: it.category_name || '未分类',
  281. description: it.description || '',
  282. protocolType: it.protocol_type,
  283. mappedKey: it.mapped_key,
  284. mappedEmail: it.mapped_email,
  285. isActive: it.is_active
  286. }
  287. })
  288. this.allApps = appList
  289. // 从本地最近列表拼出顶部“最近使用”
  290. const recentList = this.loadRecentApps()
  291. .filter((x) => x && x.appId != null)
  292. .map((x) => ({ appId: String(x.appId), usedAt: Number(x.usedAt) || 0 }))
  293. .sort((a, b) => (b.usedAt || 0) - (a.usedAt || 0))
  294. const appById = new Map(appList.map((a) => [String(a.id), a]))
  295. const recentApps = []
  296. const recentSeen = new Set()
  297. for (const r of recentList) {
  298. const id = String(r.appId || '')
  299. if (!id || recentSeen.has(id)) continue
  300. if (!appById.has(id)) continue
  301. recentSeen.add(id)
  302. recentApps.push(appById.get(id))
  303. if (recentApps.length >= RECENT_APPS_MAX) break
  304. }
  305. // 不足 8 个时,用接口里的其他应用补齐(但 recents 仍保持按时间倒序)
  306. if (recentApps.length < RECENT_APPS_MAX) {
  307. const rest = appList.filter((a) => !recentSeen.has(String(a.id)))
  308. recentApps.push(...rest.slice(0, RECENT_APPS_MAX - recentApps.length))
  309. }
  310. this.quickApps = recentApps
  311. const catMap = new Map()
  312. for (const a of appList) {
  313. if (!catMap.has(a.category)) {
  314. catMap.set(a.category, { id: a.category, name: a.categoryName || '未分类' })
  315. }
  316. }
  317. this.categories = [{ id: 'recent', name: '全部应用' }, ...Array.from(catMap.values())]
  318. this.activeCategoryId = 'recent'
  319. } catch (e) {
  320. // utils/api.js 的 request 已经做了网络/401 提示
  321. uni.showToast({ title: '加载应用失败', icon: 'none' })
  322. } finally {
  323. this.launchpadLoading = false
  324. }
  325. },
  326. loadCurrentUser() {
  327. try {
  328. const raw = uni.getStorageSync(USER_KEY)
  329. if (raw && typeof raw === 'object') {
  330. this.currentUser = {
  331. name: raw.name || raw.nickname || '',
  332. id: String(raw.id ?? raw.user_id ?? ''),
  333. avatar: raw.avatar || raw.avatar_url || ''
  334. }
  335. }
  336. } catch (e) {}
  337. },
  338. onAvatarClick() {
  339. uni.navigateTo({ url: '/pages/profile/index' })
  340. },
  341. onSearch() {
  342. uni.navigateTo({ url: '/pages/search-center/index' })
  343. },
  344. onMore() {
  345. uni.showToast({ title: '更多', icon: 'none' })
  346. },
  347. // swiper 滑动切换分类 -> 同步更新 Tab 高亮
  348. onSwiperChange(e) {
  349. const idx = e && e.detail ? e.detail.current : 0
  350. const cat = this.categories[idx]
  351. if (cat) this.activeCategoryId = cat.id
  352. },
  353. // 根据分类 id 获取该页要渲染的应用列表
  354. getAppsByCategory(catId) {
  355. const active = String(catId || '')
  356. if (active === 'recent') return this.allApps
  357. const list = this.allApps.filter((a) => String(a.category || '') === active)
  358. return list.length ? list : this.allApps
  359. },
  360. async openApp(app) {
  361. if (!app || !app.id) return
  362. uni.showLoading({ title: '打开中...' })
  363. try {
  364. const res = await ssoLogin(app.id)
  365. const redirectUrl = res && (res.redirect_url || res.redirectUrl)
  366. if (!redirectUrl) {
  367. uni.showToast({ title: '打开失败', icon: 'none' })
  368. return
  369. }
  370. // 登录成功并获得 redirect_url 后,把该应用记录为最新使用
  371. this.recordRecentApp(app.id)
  372. const pageUrl =
  373. '/pages/webview/index?url=' +
  374. encodeURIComponent(redirectUrl) +
  375. '&title=' +
  376. encodeURIComponent(app.name || '应用')
  377. uni.navigateTo({ url: pageUrl })
  378. } catch (e) {
  379. uni.showToast({ title: '打开失败', icon: 'none' })
  380. } finally {
  381. uni.hideLoading()
  382. }
  383. }
  384. }
  385. }
  386. </script>
  387. <style scoped>
  388. .app-center-page {
  389. min-height: 100vh;
  390. height: 100vh;
  391. display: flex;
  392. flex-direction: column;
  393. background: #fff;
  394. }
  395. .app-center-scroll {
  396. flex: 1;
  397. min-height: 0;
  398. height: 0;
  399. padding-bottom: calc(24rpx + 120rpx + constant(safe-area-inset-bottom));
  400. padding-bottom: calc(24rpx + 120rpx + env(safe-area-inset-bottom));
  401. box-sizing: border-box;
  402. }
  403. /* 顶部安全区:与消息/通讯录页面 custom-header 对齐 */
  404. .custom-header {
  405. display: flex;
  406. align-items: center;
  407. justify-content: space-between;
  408. padding: 24rpx 24rpx 24rpx 32rpx;
  409. padding-top: 88rpx;
  410. padding-top: max(88rpx, calc(24rpx + constant(safe-area-inset-top)));
  411. padding-top: max(88rpx, calc(24rpx + env(safe-area-inset-top)));
  412. background: #fff;
  413. border-bottom: 1rpx solid #eee;
  414. }
  415. .header-left {
  416. display: flex;
  417. align-items: center;
  418. flex: 1;
  419. min-width: 0;
  420. }
  421. .org-info {
  422. margin-left: 24rpx;
  423. display: flex;
  424. flex-direction: column;
  425. min-width: 0;
  426. }
  427. .user-name {
  428. font-size: 32rpx;
  429. font-weight: 600;
  430. color: #111827;
  431. }
  432. .header-right {
  433. display: flex;
  434. align-items: center;
  435. gap: 24rpx;
  436. }
  437. .icon-btn {
  438. width: 64rpx;
  439. height: 64rpx;
  440. display: flex;
  441. align-items: center;
  442. justify-content: center;
  443. }
  444. .icon-img {
  445. width: 44rpx;
  446. height: 44rpx;
  447. opacity: 0.85;
  448. }
  449. /* 常用应用 4 列网格 */
  450. .quick-grid {
  451. display: flex;
  452. flex-wrap: wrap;
  453. gap: 24rpx;
  454. /* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
  455. padding: 24rpx 24rpx 12rpx 32rpx;
  456. padding-bottom: 12rpx;
  457. }
  458. .app-tile {
  459. width: calc((100% - 24rpx * 3) / 4);
  460. display: flex;
  461. flex-direction: column;
  462. align-items: center;
  463. box-sizing: border-box;
  464. }
  465. .tile-icon {
  466. width: 96rpx;
  467. height: 96rpx;
  468. border-radius: 22rpx;
  469. display: flex;
  470. align-items: center;
  471. justify-content: center;
  472. position: relative;
  473. }
  474. .tile-icon-img {
  475. width: 52rpx;
  476. height: 52rpx;
  477. /* 与聊天列表 SystemAvatar 一致:确保图标居中显示为白色 */
  478. filter: brightness(0) invert(1);
  479. }
  480. .tile-badge {
  481. position: absolute;
  482. bottom: 12rpx;
  483. left: 12rpx;
  484. height: 28rpx;
  485. padding: 0 10rpx;
  486. border-radius: 14rpx;
  487. font-size: 20rpx;
  488. line-height: 28rpx;
  489. font-weight: 600;
  490. }
  491. .tile-name {
  492. margin-top: 14rpx;
  493. font-size: 26rpx;
  494. color: #111827;
  495. text-align: center;
  496. width: 100%;
  497. overflow: hidden;
  498. text-overflow: ellipsis;
  499. white-space: nowrap;
  500. }
  501. .tile-name-2 {
  502. white-space: normal;
  503. line-height: 1.2;
  504. display: -webkit-box;
  505. -webkit-line-clamp: 2;
  506. -webkit-box-orient: vertical;
  507. overflow: hidden;
  508. }
  509. /* 全部应用 */
  510. .all-section {
  511. /* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
  512. padding: 12rpx 24rpx 0 32rpx;
  513. }
  514. .section-title {
  515. font-size: 34rpx;
  516. font-weight: 700;
  517. color: #111827;
  518. /* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
  519. padding-left: 32rpx;
  520. margin-bottom: 18rpx;
  521. }
  522. /* 分类 Tab 行 */
  523. .category-bar {
  524. display: flex;
  525. align-items: center;
  526. gap: 16rpx;
  527. }
  528. .category-scroll {
  529. flex: 1;
  530. min-width: 0;
  531. }
  532. .category-list {
  533. display: flex;
  534. align-items: flex-end;
  535. white-space: nowrap;
  536. }
  537. .category-item {
  538. display: flex;
  539. flex-direction: column;
  540. align-items: center;
  541. padding: 0 22rpx;
  542. flex: 0 0 auto; /* 防止 flex 在容器宽度不足时压缩导致文字换行 */
  543. }
  544. .category-name {
  545. font-size: 28rpx;
  546. color: #6b7280;
  547. white-space: nowrap; /* 不允许换行,过长则由外层横向 scroll-view 滚动显示 */
  548. }
  549. .category-item.active .category-name {
  550. color: #1f6feb;
  551. font-weight: 600;
  552. }
  553. .category-underline {
  554. width: 56rpx;
  555. height: 6rpx;
  556. border-radius: 8rpx;
  557. background: #1f6feb;
  558. margin-top: 12rpx;
  559. }
  560. .category-menu {
  561. width: 56rpx;
  562. height: 56rpx;
  563. display: flex;
  564. align-items: center;
  565. justify-content: center;
  566. }
  567. .category-menu-icon {
  568. width: 36rpx;
  569. height: 36rpx;
  570. opacity: 0.8;
  571. }
  572. .apps-swiper {
  573. width: 100%;
  574. min-height: 600rpx; /* swiper 需要固定/最小高度才能正常展示 */
  575. }
  576. /* 全部应用宫格 */
  577. .apps-grid {
  578. display: flex;
  579. flex-wrap: wrap;
  580. gap: 24rpx;
  581. padding: 24rpx 24rpx 0;
  582. }
  583. .bottom-spacer {
  584. height: 40rpx;
  585. }
  586. </style>