| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668 |
- <template>
- <view class="app-center-page">
- <!-- 自定义顶栏:头像 + 应用中心 + 搜索 -->
- <view class="custom-header">
- <view class="header-left" @click="onAvatarClick">
- <UserAvatar
- :name="currentUser.name"
- :id="currentUser.id"
- :src="currentUser.avatar"
- :size="80"
- unit="rpx"
- />
- <view class="org-info">
- <text class="user-name">应用中心</text>
- </view>
- </view>
- <view class="header-right">
- <view class="icon-btn" @click="onSearch">
- <image class="icon-img" src="/static/icons/search.svg" mode="aspectFit" />
- </view>
- </view>
- </view>
- <scroll-view
- class="app-center-scroll"
- scroll-y
- :refresher-enabled="refresherEnabled"
- :refresher-triggered="refresherTriggered"
- @refresherrefresh="onRefresh"
- >
- <!-- 最近使用标题 + 4 列网格(最多展示 8 个) -->
- <view class="recent-section">
- <text class="section-title">最近使用</text>
- <view class="quick-grid">
- <view v-for="app in quickApps" :key="app.id" class="app-tile" @click="openApp(app)">
- <view class="tile-icon" :style="{ background: app.iconBg || defaultIconBg }">
- <image class="tile-icon-img" :src="app.iconPath" mode="aspectFit" />
- <view
- v-if="app.badge"
- class="tile-badge"
- :style="{ background: app.badgeBg || '#fbbf24', color: app.badgeColor || '#fff' }"
- >
- {{ app.badge }}
- </view>
- </view>
- <text class="tile-name tile-name-2">{{ app.name }}</text>
- </view>
- </view>
- </view>
- <view class="all-section">
- <!-- 水平分类 Tab -->
- <view class="category-bar">
- <scroll-view
- class="category-scroll"
- scroll-x
- scroll-with-animation
- :show-scrollbar="false"
- :scroll-into-view="scrollIntoTabId"
- >
- <view class="category-list">
- <view
- v-for="(cat, catIdx) in categories"
- :key="cat.id"
- :id="'cat-tab-' + catIdx"
- class="category-item"
- :class="{ active: cat.id === activeCategoryId }"
- @click="onCategoryTabClick(catIdx)"
- >
- <text class="category-name">{{ cat.name }}</text>
- <view v-if="cat.id === activeCategoryId" class="category-underline" />
- </view>
- </view>
- </scroll-view>
- </view>
- </view>
- <!-- 全部应用 swiper 4 列网格(横向滑动切分类) -->
- <swiper
- class="apps-swiper"
- :current="currentCategoryIndex"
- @change="onSwiperChange"
- :circular="false"
- >
- <swiper-item v-for="cat in categories" :key="cat.id">
- <view class="apps-grid">
- <view
- v-for="app in getAppsByCategory(cat.id)"
- :key="app.id"
- class="app-tile app-tile-4"
- @click="openApp(app)"
- >
- <view class="tile-icon" :style="{ background: app.iconBg || defaultIconBg }">
- <image class="tile-icon-img" :src="app.iconPath" mode="aspectFit" />
- <view
- v-if="app.badge"
- class="tile-badge"
- :style="{ background: app.badgeBg || '#fbbf24', color: app.badgeColor || '#fff' }"
- >
- {{ app.badge }}
- </view>
- </view>
- <text class="tile-name tile-name-2">{{ app.name }}</text>
- </view>
- </view>
- </swiper-item>
- </swiper>
- </scroll-view>
- </view>
- </template>
- <script>
- import UserAvatar from '../../components/UserAvatar.vue'
- import { getToken, getLaunchpadApps, ssoLogin, getUserIdFromToken } from '../../utils/api'
- import { openEmbeddedOrSystemBrowser } from '../../utils/openUrlPreference'
- const USER_KEY = 'current_user'
- const RECENT_APPS_KEY_PREFIX = 'launchpad_recent_apps_v1_'
- const RECENT_APPS_MAX = 8
- /** 渐变色库与 `components/SystemAvatar.vue` 保持一致 */
- const AVATAR_GRADIENT_PAIRS = [
- ['#1e3a8a', '#93c5fd'],
- ['#166534', '#86efac'],
- ['#c2410c', '#fdba74'],
- ['#b91c1c', '#fca5a5'],
- ['#5b21b6', '#c4b5fd'],
- ['#0f766e', '#5eead4'],
- ['#3730a3', '#a5b4fc'],
- ['#0d9488', '#2dd4bf'],
- ['#b45309', '#fcd34d'],
- ['#be123c', '#fda4af'],
- ['#0369a1', '#7dd3fc'],
- ['#4d7c0f', '#bef264'],
- ['#86198f', '#e879f9'],
- ['#db2777', '#fbcfe8'],
- ['#047857', '#6ee7b7'],
- ['#6d28d9', '#ddd6fe'],
- ['#1e40af', '#93c5fd'],
- ['#ea580c', '#fed7aa'],
- ['#0e7490', '#99f6e4'],
- ['#881337', '#fbcfe8']
- ]
- function getGradientIndexByName(name) {
- const s = String(name || '').trim() || 'SYSTEM'
- let hash = 0
- for (let i = 0; i < s.length; i++) {
- hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0
- }
- return Math.abs(hash) % AVATAR_GRADIENT_PAIRS.length
- }
- function getGradientBgByName(name) {
- const idx = getGradientIndexByName(name)
- const pair = AVATAR_GRADIENT_PAIRS[idx]
- return pair ? `linear-gradient(135deg, ${pair[0]} 0%, ${pair[1]} 100%)` : ''
- }
- export default {
- components: { UserAvatar },
- data() {
- return {
- currentUser: { name: '', id: '', avatar: '' },
- defaultIconBg: 'linear-gradient(135deg, #e6f4ea 0%, #d4edda 100%)',
- launchpadLoading: false,
- // 下拉刷新(避免首屏阶段重复触发)
- refresherEnabled: false,
- refresherTriggered: false,
- // 顶部最近使用(本地按 usedAt 倒序,最多 8 个)
- quickApps: [],
- categories: [{ id: 'recent', name: '全部应用' }],
- activeCategoryId: 'recent',
- // 全部应用网格数据:来自接口返回的 items
- allApps: [],
- /** 横向分类 Tab 与 swiper 同步时滚入视口(与 chat 页 scroll-into-view 用法一致) */
- scrollIntoTabId: ''
- }
- },
- async onLoad() {
- this.loadCurrentUser()
- this.refresherEnabled = false
- try {
- await this.fetchLaunchpadApps()
- } finally {
- this.refresherEnabled = true
- }
- },
- async onShow() {
- this.loadCurrentUser()
- await this.fetchLaunchpadApps()
- },
- computed: {
- currentCategoryIndex() {
- const idx = this.categories.findIndex((c) => c.id === this.activeCategoryId)
- return idx >= 0 ? idx : 0
- },
- filteredApps() {
- // 根据当前 Tab 过滤;如果没有命中则回退显示全部(避免空白)
- const active = String(this.activeCategoryId || '')
- // “全部应用”Tab 的 id 约定为 'recent'
- if (active === 'recent') return this.allApps
- const list = this.allApps.filter((a) => String(a.category || '') === active)
- return list.length ? list : this.allApps
- }
- },
- methods: {
- async onRefresh() {
- this.refresherTriggered = true
- try {
- await this.fetchLaunchpadApps()
- } finally {
- this.refresherTriggered = false
- }
- },
- /** 当前登录用户 id,用于分用户存储「最近使用」 */
- getRecentAppsStorageKey() {
- let uid = ''
- try {
- const raw = uni.getStorageSync(USER_KEY)
- if (raw && typeof raw === 'object') {
- const id = raw.id ?? raw.user_id
- if (id != null && id !== '') uid = String(id)
- }
- } catch (e) {}
- if (!uid) {
- const token = getToken()
- if (token) uid = String(getUserIdFromToken(token) || '')
- }
- return uid ? RECENT_APPS_KEY_PREFIX + uid : ''
- },
- loadRecentApps() {
- const key = this.getRecentAppsStorageKey()
- if (!key) return []
- try {
- const raw = uni.getStorageSync(key)
- if (Array.isArray(raw)) return raw
- if (raw && typeof raw === 'object' && Array.isArray(raw.items)) return raw.items
- } catch (e) {}
- return []
- },
- saveRecentApps(list) {
- const key = this.getRecentAppsStorageKey()
- if (!key) return
- try {
- uni.setStorageSync(key, Array.isArray(list) ? list : [])
- } catch (e) {}
- },
- refreshQuickApps() {
- // 只用已拉取到的 `allApps` + 本地 recent 存储来刷新顶部“最近使用”
- // 避免每次点击应用都发起网络请求
- const appList = Array.isArray(this.allApps) ? this.allApps : []
- if (!appList.length) return
- const recentList = this.loadRecentApps()
- .filter((x) => x && x.appId != null)
- .map((x) => ({ appId: String(x.appId), usedAt: Number(x.usedAt) || 0 }))
- .sort((a, b) => (b.usedAt || 0) - (a.usedAt || 0))
- const appById = new Map(appList.map((a) => [String(a.id), a]))
- const recentApps = []
- const recentSeen = new Set()
- for (const r of recentList) {
- const id = String(r.appId || '')
- if (!id || recentSeen.has(id)) continue
- if (!appById.has(id)) continue
- recentSeen.add(id)
- recentApps.push(appById.get(id))
- if (recentApps.length >= RECENT_APPS_MAX) break
- }
- // 不足 8 个时,用接口里的其他应用补齐(但 recents 仍保持按时间倒序)
- if (recentApps.length < RECENT_APPS_MAX) {
- const rest = appList.filter((a) => !recentSeen.has(String(a.id)))
- recentApps.push(...rest.slice(0, RECENT_APPS_MAX - recentApps.length))
- }
- this.quickApps = recentApps
- },
- recordRecentApp(appId) {
- const id = String(appId ?? '')
- if (!id) return
- if (!this.getRecentAppsStorageKey()) return
- const now = Date.now()
- const list = this.loadRecentApps()
- // 去重 + 更新为最新,按 usedAt 倒序保留最多 8 个
- const next = list
- .filter((x) => x && x.appId != null)
- .map((x) => ({ appId: String(x.appId), usedAt: Number(x.usedAt) || 0 }))
- .filter((x) => x.appId !== id)
- next.unshift({ appId: id, usedAt: now })
- next.sort((a, b) => (b.usedAt || 0) - (a.usedAt || 0))
- this.saveRecentApps(next.slice(0, RECENT_APPS_MAX))
- this.refreshQuickApps()
- },
- async fetchLaunchpadApps() {
- this.launchpadLoading = true
- try {
- const token = getToken()
- if (!token) {
- this.quickApps = []
- this.allApps = []
- this.categories = [{ id: 'recent', name: '全部应用' }]
- this.activeCategoryId = 'recent'
- return
- }
- const res = await getLaunchpadApps()
- const items = Array.isArray(res && res.items) ? res.items : []
- const activeItems = items.filter((i) => i && i.is_active !== false)
- const appList = activeItems.map((it) => {
- const categoryId = it.category_id == null || it.category_id === '' ? 'uncat' : String(it.category_id)
- return {
- id: it.app_id,
- name: it.app_name,
- // 与聊天列表 SystemAvatar 保持一致
- iconPath: '/static/icons/application.svg',
- iconBg: getGradientBgByName(it.app_name),
- category: categoryId,
- categoryName: it.category_name || '未分类',
- description: it.description || '',
- protocolType: it.protocol_type,
- mappedKey: it.mapped_key,
- mappedEmail: it.mapped_email,
- isActive: it.is_active
- }
- })
- this.allApps = appList
- // 从本地最近列表拼出顶部“最近使用”
- const recentList = this.loadRecentApps()
- .filter((x) => x && x.appId != null)
- .map((x) => ({ appId: String(x.appId), usedAt: Number(x.usedAt) || 0 }))
- .sort((a, b) => (b.usedAt || 0) - (a.usedAt || 0))
- const appById = new Map(appList.map((a) => [String(a.id), a]))
- const recentApps = []
- const recentSeen = new Set()
- for (const r of recentList) {
- const id = String(r.appId || '')
- if (!id || recentSeen.has(id)) continue
- if (!appById.has(id)) continue
- recentSeen.add(id)
- recentApps.push(appById.get(id))
- if (recentApps.length >= RECENT_APPS_MAX) break
- }
- // 不足 8 个时,用接口里的其他应用补齐(但 recents 仍保持按时间倒序)
- if (recentApps.length < RECENT_APPS_MAX) {
- const rest = appList.filter((a) => !recentSeen.has(String(a.id)))
- recentApps.push(...rest.slice(0, RECENT_APPS_MAX - recentApps.length))
- }
- this.quickApps = recentApps
- const catMap = new Map()
- for (const a of appList) {
- if (!catMap.has(a.category)) {
- catMap.set(a.category, { id: a.category, name: a.categoryName || '未分类' })
- }
- }
- this.categories = [{ id: 'recent', name: '全部应用' }, ...Array.from(catMap.values())]
- this.activeCategoryId = 'recent'
- } catch (e) {
- // utils/api.js 的 request 已经做了网络、401/403 登录态提示
- uni.showToast({ title: '加载应用失败', icon: 'none' })
- } finally {
- this.launchpadLoading = false
- }
- },
- loadCurrentUser() {
- try {
- const raw = uni.getStorageSync(USER_KEY)
- if (raw && typeof raw === 'object') {
- this.currentUser = {
- name: raw.name || raw.nickname || '',
- id: String(raw.id ?? raw.user_id ?? ''),
- avatar: raw.avatar || raw.avatar_url || ''
- }
- }
- } catch (e) {}
- },
- onAvatarClick() {
- uni.navigateTo({ url: '/pages/profile/index' })
- },
- onSearch() {
- uni.navigateTo({ url: '/pages/search-center/index' })
- },
- // 先清空再设锚点,避免同 id 时不滚动(与 pages/chat/index.vue 一致)
- scrollCategoryTabIntoView(catIdx) {
- const idx = Number(catIdx)
- if (!Number.isFinite(idx) || idx < 0) return
- const anchor = 'cat-tab-' + idx
- this.scrollIntoTabId = ''
- this.$nextTick(() => {
- this.scrollIntoTabId = anchor
- setTimeout(() => {
- this.scrollIntoTabId = anchor
- }, 50)
- })
- },
- onCategoryTabClick(catIdx) {
- const cat = this.categories[catIdx]
- if (!cat) return
- this.activeCategoryId = cat.id
- this.scrollCategoryTabIntoView(catIdx)
- },
- // swiper 滑动切换分类 -> 同步更新 Tab 高亮
- onSwiperChange(e) {
- const idx = e && e.detail ? e.detail.current : 0
- const cat = this.categories[idx]
- if (cat) this.activeCategoryId = cat.id
- this.scrollCategoryTabIntoView(idx)
- },
- // 根据分类 id 获取该页要渲染的应用列表
- getAppsByCategory(catId) {
- const active = String(catId || '')
- if (active === 'recent') return this.allApps
- const list = this.allApps.filter((a) => String(a.category || '') === active)
- return list.length ? list : this.allApps
- },
- async openApp(app) {
- if (!app || !app.id) return
- uni.showLoading({ title: '打开中...' })
- try {
- const res = await ssoLogin(app.id)
- const redirectUrl = res && (res.redirect_url || res.redirectUrl)
- if (!redirectUrl) {
- uni.showToast({ title: '打开失败', icon: 'none' })
- return
- }
- // 登录成功并获得 redirect_url 后,把该应用记录为最新使用
- this.recordRecentApp(app.id)
- openEmbeddedOrSystemBrowser(redirectUrl, app.name || '应用')
- } catch (e) {
- uni.showToast({ title: '打开失败', icon: 'none' })
- } finally {
- uni.hideLoading()
- }
- }
- }
- }
- </script>
- <style scoped>
- .app-center-page {
- min-height: 100vh;
- height: 100vh;
- display: flex;
- flex-direction: column;
- background: #fff;
- }
- .app-center-scroll {
- flex: 1;
- min-height: 0;
- height: 0;
- /* 与消息/通讯录一致:用框架 tabBar 区域高度,避免重复加 safe-area 造成大块白底或与原生 Tab 错位遮挡 */
- padding-bottom: var(--window-bottom, 50px);
- box-sizing: border-box;
- }
- /* 顶部安全区:与消息/通讯录页面 custom-header 对齐 */
- .custom-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 24rpx 24rpx 24rpx 32rpx;
- padding-top: 88rpx;
- padding-top: max(88rpx, calc(24rpx + constant(safe-area-inset-top)));
- padding-top: max(88rpx, calc(24rpx + env(safe-area-inset-top)));
- background: #fff;
- border-bottom: 1rpx solid #eee;
- }
- .header-left {
- display: flex;
- align-items: center;
- flex: 1;
- min-width: 0;
- }
- .org-info {
- margin-left: 24rpx;
- display: flex;
- flex-direction: column;
- min-width: 0;
- }
- .user-name {
- font-size: 32rpx;
- font-weight: 600;
- color: #111827;
- }
- .header-right {
- display: flex;
- align-items: center;
- gap: 24rpx;
- }
- .icon-btn {
- width: 64rpx;
- height: 64rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .icon-img {
- width: 44rpx;
- height: 44rpx;
- opacity: 0.85;
- }
- /* 最近使用:整体与顶栏拉开一点距离 */
- .recent-section {
- padding-top: 28rpx;
- }
- /* 常用应用 4 列网格 */
- .quick-grid {
- display: flex;
- flex-wrap: wrap;
- gap: 24rpx;
- /* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
- padding: 24rpx 24rpx 40rpx 32rpx;
- }
- .app-tile {
- width: calc((100% - 24rpx * 3) / 4);
- display: flex;
- flex-direction: column;
- align-items: center;
- box-sizing: border-box;
- }
- .tile-icon {
- width: 96rpx;
- height: 96rpx;
- border-radius: 22rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
- }
- .tile-icon-img {
- width: 52rpx;
- height: 52rpx;
- /* 与聊天列表 SystemAvatar 一致:确保图标居中显示为白色 */
- filter: brightness(0) invert(1);
- }
- .tile-badge {
- position: absolute;
- bottom: 12rpx;
- left: 12rpx;
- height: 28rpx;
- padding: 0 10rpx;
- border-radius: 14rpx;
- font-size: 20rpx;
- line-height: 28rpx;
- font-weight: 600;
- }
- .tile-name {
- margin-top: 14rpx;
- font-size: 26rpx;
- color: #111827;
- text-align: center;
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .tile-name-2 {
- white-space: normal;
- line-height: 1.2;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- /* 全部应用(与上方「最近使用」拉开间距) */
- .all-section {
- /* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
- padding: 32rpx 24rpx 0 32rpx;
- }
- .section-title {
- font-size: 34rpx;
- font-weight: 700;
- color: #111827;
- /* 与顶部自定义顶栏的头像左侧对齐:顶栏左 padding = 32rpx */
- padding-left: 32rpx;
- margin-bottom: 18rpx;
- }
- /* 分类 Tab 行 */
- .category-bar {
- display: flex;
- align-items: center;
- gap: 16rpx;
- }
- .category-scroll {
- flex: 1;
- min-width: 0;
- }
- .category-list {
- display: flex;
- align-items: flex-end;
- white-space: nowrap;
- /* 最后一项滚到最右时不贴边裁切 */
- padding-right: 24rpx;
- box-sizing: border-box;
- }
- .category-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 0 22rpx;
- flex: 0 0 auto; /* 防止 flex 在容器宽度不足时压缩导致文字换行 */
- }
- .category-name {
- font-size: 28rpx;
- color: #6b7280;
- white-space: nowrap; /* 不允许换行,过长则由外层横向 scroll-view 滚动显示 */
- }
- .category-item.active .category-name {
- color: #1f6feb;
- font-weight: 600;
- }
- .category-underline {
- width: 56rpx;
- height: 6rpx;
- border-radius: 8rpx;
- background: #1f6feb;
- margin-top: 12rpx;
- }
- .apps-swiper {
- width: 100%;
- min-height: 600rpx; /* swiper 需要固定/最小高度才能正常展示 */
- }
- /* 全部应用宫格 */
- .apps-grid {
- display: flex;
- flex-wrap: wrap;
- gap: 24rpx;
- padding: 24rpx 24rpx 0;
- }
- </style>
|