index.vue 18 KB

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