|
|
@@ -0,0 +1,222 @@
|
|
|
+<template>
|
|
|
+ <div class="launchpad-container">
|
|
|
+ <div class="header">
|
|
|
+ <h2>快捷导航</h2>
|
|
|
+ <div class="actions">
|
|
|
+ <el-button @click="fetchMappings" :icon="Refresh" circle title="刷新" />
|
|
|
+ <el-button @click="showSettings = true" :icon="Setting" circle title="图标设置" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-loading="loading" class="apps-grid">
|
|
|
+ <div
|
|
|
+ v-for="app in activeApps"
|
|
|
+ :key="app.app_id"
|
|
|
+ class="app-item"
|
|
|
+ :style="{ width: iconSize + 'px' }"
|
|
|
+ >
|
|
|
+ <PlatformIcon
|
|
|
+ :text="app.app_name"
|
|
|
+ :size="iconSize"
|
|
|
+ @click="handleAppClick(app)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="activeApps.length === 0 && !loading" class="empty-state">
|
|
|
+ <el-empty description="暂无已配置的平台账号" />
|
|
|
+ <el-button type="primary" @click="$router.push('/dashboard/apps')">去配置</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Settings Drawer -->
|
|
|
+ <el-drawer v-model="showSettings" title="显示设置" size="300px">
|
|
|
+ <div class="setting-item">
|
|
|
+ <span class="label">图标大小 ({{ iconSize }}px)</span>
|
|
|
+ <el-slider v-model="iconSize" :min="60" :max="200" @change="saveSettings" />
|
|
|
+ </div>
|
|
|
+ </el-drawer>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
|
|
+import { Setting, Refresh } from '@element-plus/icons-vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import api from '../utils/request'
|
|
|
+import { ssoLogin } from '../api/public'
|
|
|
+import PlatformIcon from '../components/PlatformIcon.vue'
|
|
|
+import { useRouter } from 'vue-router'
|
|
|
+
|
|
|
+const router = useRouter()
|
|
|
+
|
|
|
+// Types
|
|
|
+interface Mapping {
|
|
|
+ app_name: string
|
|
|
+ app_id: string
|
|
|
+ protocol_type: string
|
|
|
+ mapped_key: string
|
|
|
+ is_active: boolean
|
|
|
+}
|
|
|
+
|
|
|
+// State
|
|
|
+const mappings = ref<Mapping[]>([])
|
|
|
+const loading = ref(false)
|
|
|
+const showSettings = ref(false)
|
|
|
+
|
|
|
+// Settings
|
|
|
+const iconSize = ref(100)
|
|
|
+
|
|
|
+const activeApps = computed(() => {
|
|
|
+ return mappings.value.filter(m => m.is_active && m.protocol_type === 'SIMPLE_API')
|
|
|
+})
|
|
|
+
|
|
|
+// Load Settings
|
|
|
+const loadSettings = () => {
|
|
|
+ const saved = localStorage.getItem('launchpad_settings')
|
|
|
+ if (saved) {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(saved)
|
|
|
+ if (parsed.iconSize) iconSize.value = parsed.iconSize
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Failed to parse settings', e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Save Settings
|
|
|
+const saveSettings = () => {
|
|
|
+ localStorage.setItem('launchpad_settings', JSON.stringify({
|
|
|
+ iconSize: iconSize.value,
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+// Fetch Data
|
|
|
+const fetchMappings = async () => {
|
|
|
+ // Prevent parallel loading if already loading (unless it's background refresh which we want to ignore loading state for?
|
|
|
+ // actually for UX, showing loading is fine or we can hide it for background refresh.
|
|
|
+ // But here we'll just show loading to indicate activity if user clicks refresh)
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ // Add timestamp to prevent caching
|
|
|
+ const res = await api.get('/simple/me/mappings', {
|
|
|
+ params: {
|
|
|
+ limit: 100,
|
|
|
+ _t: Date.now()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (res.data) {
|
|
|
+ mappings.value = res.data.items
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// SSO Logic
|
|
|
+const handleAppClick = async (app: Mapping) => {
|
|
|
+ if (!app.is_active) return
|
|
|
+
|
|
|
+ const loadingMsg = ElMessage.info({
|
|
|
+ message: `正在进入 ${app.app_name}...`,
|
|
|
+ duration: 0
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await ssoLogin(
|
|
|
+ { app_id: app.app_id, username: '', password: '' },
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ 'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ if (res.data && res.data.redirect_url) {
|
|
|
+ window.open(res.data.redirect_url, '_blank')
|
|
|
+ loadingMsg.close()
|
|
|
+ ElMessage.success('已打开应用')
|
|
|
+ } else {
|
|
|
+ loadingMsg.close()
|
|
|
+ ElMessage.error('SSO 登录失败:未返回跳转地址')
|
|
|
+ }
|
|
|
+ } catch (error: any) {
|
|
|
+ loadingMsg.close()
|
|
|
+ console.error('SSO 登录失败:', error)
|
|
|
+ ElMessage.error(error.response?.data?.detail || 'SSO 登录失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Auto-refresh when tab becomes visible
|
|
|
+const handleVisibilityChange = () => {
|
|
|
+ if (!document.hidden) {
|
|
|
+ fetchMappings()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ loadSettings()
|
|
|
+ fetchMappings()
|
|
|
+ document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
|
+})
|
|
|
+
|
|
|
+onActivated(() => {
|
|
|
+ fetchMappings()
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.launchpad-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 30px;
|
|
|
+}
|
|
|
+
|
|
|
+.actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.apps-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 30px;
|
|
|
+}
|
|
|
+
|
|
|
+.app-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.setting-item {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.label {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state {
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 50px;
|
|
|
+}
|
|
|
+</style>
|