liuq 3 месяцев назад
Родитель
Сommit
498045c20c

+ 116 - 0
frontend/src/components/PlatformIcon.vue

@@ -0,0 +1,116 @@
+<template>
+  <div 
+    class="platform-icon"
+    :style="{
+      width: size + 'px',
+      height: size + 'px',
+      background: backgroundColor,
+      borderRadius: borderRadius + 'px',
+    }"
+    @click="$emit('click')"
+  >
+    <div 
+      class="icon-content"
+      :style="{
+        padding: (size * 0.12) + 'px' 
+      }"
+    >
+      <span 
+        class="icon-text"
+        :style="{
+          fontSize: fontSize + 'px',
+          lineHeight: '1.4',
+          textShadow: '0 1px 2px rgba(0,0,0,0.15)'
+        }"
+      >
+        {{ text }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { stringToColor, calculateFontSize } from '../utils/color'
+
+const props = defineProps<{
+  text: string
+  size: number
+}>()
+
+defineEmits(['click'])
+
+// Now returns a gradient string
+const backgroundColor = computed(() => stringToColor(props.text))
+
+// 22% is standard iOS icon curvature, looks pleasing
+const borderRadius = computed(() => props.size * 0.22)
+
+const fontSize = computed(() => calculateFontSize(props.size, props.text.length))
+</script>
+
+<style scoped>
+.platform-icon {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: white;
+  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+  user-select: none;
+  cursor: pointer;
+  box-shadow: 
+    0 4px 6px -1px rgba(0, 0, 0, 0.1),
+    0 2px 4px -1px rgba(0, 0, 0, 0.06);
+  position: relative;
+  overflow: hidden;
+}
+
+/* 添加一个微弱的光泽层 */
+.platform-icon::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: linear-gradient(180deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0) 100%);
+  pointer-events: none;
+}
+
+.platform-icon:hover {
+  transform: translateY(-2px) scale(1.02);
+  box-shadow: 
+    0 10px 15px -3px rgba(0, 0, 0, 0.1),
+    0 4px 6px -2px rgba(0, 0, 0, 0.05);
+}
+
+.platform-icon:active {
+  transform: translateY(0) scale(0.96);
+  box-shadow: 
+    0 2px 4px -1px rgba(0, 0, 0, 0.1), 
+    0 1px 2px -1px rgba(0, 0, 0, 0.06);
+}
+
+.icon-content {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  box-sizing: border-box;
+  z-index: 1; /* Ensure text is above shine */
+}
+
+.icon-text {
+  font-weight: 600; /*稍微降低一点字重,不要太粗,配合阴影更清晰*/
+  text-align: center;
+  word-break: break-word;
+  white-space: normal;
+  display: -webkit-box;
+  -webkit-line-clamp: 4;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+  letter-spacing: 0.5px;
+}
+</style>

+ 6 - 1
frontend/src/router/index.ts

@@ -43,9 +43,14 @@ const routes: Array<RouteRecordRaw> = [
           // But we can check if there's a token.
           // Better logic: Redirect to a default, and let Dashboard.vue or a Guard handle role-based redirect.
           // Or we use a component that decides.
-          return '/dashboard/apps'
+          return '/dashboard/launchpad'
         }
       },
+      {
+        path: 'launchpad',
+        name: 'PlatformLaunchpad',
+        component: () => import('../views/PlatformLaunchpad.vue')
+      },
       {
         path: 'apps',
         name: 'AppList',

+ 39 - 0
frontend/src/utils/color.ts

@@ -0,0 +1,39 @@
+// 预设的高级感渐变色盘
+const GRADIENTS = [
+  'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', // 深紫
+  'linear-gradient(135deg, #2af598 0%, #009efd 100%)', // 清新蓝绿
+  'linear-gradient(135deg, #b721ff 0%, #21d4fd 100%)', // 紫蓝
+  'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)', // 柔和粉
+  'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', // 橙红
+  'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', // 亮蓝
+  'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', // 翠绿
+  'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', // 暖橙
+  'linear-gradient(135deg, #30cfd0 0%, #330867 100%)', // 深青
+  'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)', // 淡紫
+  'linear-gradient(135deg, #fbc2eb 0%, #a6c1ee 100%)', // 梦幻
+  'linear-gradient(135deg, #8fd3f4 0%, #84fab0 100%)', // 浅绿蓝
+];
+
+// Generate a consistent gradient from a string
+export function stringToColor(str: string): string {
+  let hash = 0;
+  for (let i = 0; i < str.length; i++) {
+    hash = str.charCodeAt(i) + ((hash << 5) - hash);
+  }
+
+  const index = Math.abs(hash) % GRADIENTS.length;
+  return GRADIENTS[index];
+}
+
+// Calculate font size based on container size and text length
+export function calculateFontSize(containerSize: number, textLength: number): number {
+  // 增加 Padding 后的可用空间会变小,所以字体要稍微调小一点,保持呼吸感
+  // 假设 padding 是 10%
+  
+  if (textLength <= 2) return containerSize * 0.35;
+  if (textLength <= 4) return containerSize * 0.25;
+  if (textLength <= 6) return containerSize * 0.18; // 两行,每行3个字
+  if (textLength <= 9) return containerSize * 0.15; // 三行或两行
+  if (textLength <= 15) return containerSize * 0.12;
+  return containerSize * 0.10; // Very long text
+}

+ 5 - 0
frontend/src/views/Dashboard.vue

@@ -11,6 +11,11 @@
           :default-active="$route.path"
           class="el-menu-vertical"
         >
+          <el-menu-item index="/dashboard/launchpad">
+            <el-icon><Menu /></el-icon>
+            <span>快捷导航</span>
+          </el-menu-item>
+
           <el-menu-item 
             v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')" 
             index="/dashboard/apps"

+ 222 - 0
frontend/src/views/PlatformLaunchpad.vue

@@ -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>