Explorar o código

快捷导航新增接口增加分类功能

liuq hai 1 mes
pai
achega
5b7c903928

+ 46 - 2
backend/app/api/v1/endpoints/simple_auth.py

@@ -14,7 +14,7 @@ from app.core.config import settings
 from app.core.utils import generate_english_name, get_client_ip
 from app.core.cache import redis_client
 from app.models.user import User, UserRole, UserStatus
-from app.models.application import Application
+from app.models.application import Application, ProtocolType
 from app.models.mapping import AppUserMapping
 from app.schemas.simple_auth import (
     TicketExchangeRequest, TicketExchangeResponse,
@@ -23,7 +23,8 @@ from app.schemas.simple_auth import (
     SmsLoginRequest,
     UserRegisterRequest, AdminPasswordResetRequest, AdminPasswordResetResponse,
     ChangePasswordRequest, MyMappingsResponse, UserMappingResponse,
-    UserPromoteRequest, SsoLoginRequest, SsoLoginResponse
+    UserPromoteRequest, SsoLoginRequest, SsoLoginResponse,
+    LaunchpadAppsResponse, LaunchpadAppResponse
 )
 from app.services.signature_service import SignatureService
 from app.services.ticket_service import TicketService
@@ -500,6 +501,49 @@ def get_my_mappings(
     
     return {"total": total, "items": result}
 
+@router.get("/me/launchpad-apps", response_model=LaunchpadAppsResponse, summary="快捷导航应用列表")
+def get_launchpad_apps(
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """
+    获取当前用户的快捷导航应用列表(包含分类和描述)
+    仅返回已激活且协议类型为 SIMPLE_API 或 OIDC 的应用
+    """
+    from app.models.app_category import AppCategory
+    
+    # 查询用户的应用映射,join Application 和 AppCategory
+    query = (
+        db.query(AppUserMapping)
+        .join(Application, AppUserMapping.app_id == Application.id)
+        .outerjoin(AppCategory, Application.category_id == AppCategory.id)
+        .filter(
+            AppUserMapping.user_id == current_user.id,
+            AppUserMapping.is_active == True,
+            Application.protocol_type.in_([ProtocolType.SIMPLE_API, ProtocolType.OIDC]),
+            Application.is_deleted == False
+        )
+    )
+    
+    mappings = query.order_by(Application.category_id.asc(), Application.app_name.asc()).all()
+    
+    result = []
+    for m in mappings:
+        app = m.application
+        result.append(LaunchpadAppResponse(
+            app_name=app.app_name if app else "Unknown",
+            app_id=app.app_id if app else "",
+            protocol_type=app.protocol_type.value if app else "",
+            mapped_key=m.mapped_key,
+            mapped_email=m.mapped_email,
+            is_active=m.is_active,
+            description=app.description if app else None,
+            category_id=app.category_id if app else None,
+            category_name=app.category.name if app and app.category else None
+        ))
+    
+    return {"total": len(result), "items": result}
+
 @router.post("/me/change-password", summary="修改密码")
 def change_my_password(
     req: ChangePasswordRequest,

+ 17 - 0
backend/app/schemas/simple_auth.py

@@ -173,3 +173,20 @@ class SsoLoginResponse(BaseModel):
         ..., 
         description="带票据的重定向URL,格式:{应用回调URL}?ticket={票据}。前端应直接跳转到此URL"
     )
+
+class LaunchpadAppResponse(BaseModel):
+    """快捷导航应用响应模型(包含分类和描述)"""
+    app_name: str
+    app_id: str
+    protocol_type: str
+    mapped_key: Optional[str] = None
+    mapped_email: Optional[str] = None
+    is_active: bool
+    description: Optional[str] = None  # 应用描述
+    category_id: Optional[int] = None  # 分类ID
+    category_name: Optional[str] = None  # 分类名称
+
+class LaunchpadAppsResponse(BaseModel):
+    """快捷导航应用列表响应"""
+    total: int  # 总数量(方便前端使用)
+    items: List[LaunchpadAppResponse]

+ 122 - 21
frontend/src/views/PlatformLaunchpad.vue

@@ -3,25 +3,60 @@
     <div class="header">
       <h2>快捷导航</h2>
       <div class="actions">
+        <el-switch
+          v-model="showCategory"
+          active-text="显示分类"
+          inactive-text=""
+          @change="handleCategoryToggle"
+          style="margin-right: 10px;"
+        />
         <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 v-loading="loading">
+      <!-- 按分类显示 -->
+      <template v-if="showCategory">
+        <div
+          v-for="category in categorizedApps"
+          :key="category.categoryId || 'uncategorized'"
+          class="category-section"
+        >
+          <h3 class="category-title">{{ category.categoryName }}</h3>
+          <div class="apps-grid">
+            <div
+              v-for="app in category.apps"
+              :key="app.app_id"
+              class="app-item"
+              :style="{ width: iconSize + 'px' }"
+            >
+              <PlatformIcon
+                :text="app.app_name"
+                :size="iconSize"
+                @click="handleAppClick(app)"
+              />
+            </div>
+          </div>
+        </div>
+      </template>
+
+      <!-- 不按分类显示(原有方式) -->
+      <div v-else 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>
-      
+
       <div v-if="activeApps.length === 0 && !loading" class="empty-state">
         <el-empty description="暂无已配置的平台账号" />
         <el-button type="primary" @click="$router.push('/dashboard/apps')">去配置</el-button>
@@ -57,20 +92,69 @@ interface Mapping {
   app_id: string
   protocol_type: string
   mapped_key: string
+  mapped_email?: string
   is_active: boolean
+  description?: string
+  category_id?: number
+  category_name?: string
+}
+
+interface CategoryGroup {
+  categoryId: number | null
+  categoryName: string
+  apps: Mapping[]
 }
 
 // State
 const mappings = ref<Mapping[]>([])
 const loading = ref(false)
 const showSettings = ref(false)
+const showCategory = ref(false)
 
 // Settings
 const iconSize = ref(100)
 
-// 仅根据激活状态和已支持的协议类型过滤,支持 SIMPLE_API 与 OIDC
+// 新接口已经过滤了激活状态和协议类型,直接使用返回的应用列表
 const activeApps = computed(() => {
-  return mappings.value.filter(m => m.is_active && (m.protocol_type === 'SIMPLE_API' || m.protocol_type === 'OIDC'))
+  return mappings.value
+})
+
+// 按分类分组
+const categorizedApps = computed(() => {
+  const groups: Map<number | null, CategoryGroup> = new Map()
+  
+  // 初始化"未分类"组
+  groups.set(null, {
+    categoryId: null,
+    categoryName: '未分类',
+    apps: []
+  })
+  
+  // 按分类分组
+  activeApps.value.forEach(app => {
+    const categoryId = app.category_id ?? null
+    const categoryName = app.category_name || '未分类'
+    
+    if (!groups.has(categoryId)) {
+      groups.set(categoryId, {
+        categoryId,
+        categoryName,
+        apps: []
+      })
+    }
+    
+    groups.get(categoryId)!.apps.push(app)
+  })
+  
+  // 转换为数组并排序:有分类的在前,未分类在最后
+  const result = Array.from(groups.values())
+  result.sort((a, b) => {
+    if (a.categoryId === null) return 1
+    if (b.categoryId === null) return -1
+    return a.categoryName.localeCompare(b.categoryName, 'zh-CN')
+  })
+  
+  return result
 })
 
 // Load Settings
@@ -80,6 +164,7 @@ const loadSettings = () => {
     try {
       const parsed = JSON.parse(saved)
       if (parsed.iconSize) iconSize.value = parsed.iconSize
+      if (parsed.showCategory !== undefined) showCategory.value = parsed.showCategory
     } catch (e) {
       console.error('Failed to parse settings', e)
     }
@@ -90,20 +175,22 @@ const loadSettings = () => {
 const saveSettings = () => {
   localStorage.setItem('launchpad_settings', JSON.stringify({
     iconSize: iconSize.value,
+    showCategory: showCategory.value,
   }))
 }
 
+// 处理分类开关切换
+const handleCategoryToggle = () => {
+  saveSettings()
+}
+
 // 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', { 
+    // 使用快捷导航专用接口,包含分类和描述信息
+    const res = await api.get('/simple/me/launchpad-apps', { 
       params: { 
-        limit: 100,
         _t: Date.now() 
       } 
     })
@@ -196,6 +283,7 @@ onUnmounted(() => {
 
 .actions {
   display: flex;
+  align-items: center;
   gap: 10px;
 }
 
@@ -212,6 +300,19 @@ onUnmounted(() => {
   gap: 10px;
 }
 
+.category-section {
+  margin-bottom: 40px;
+}
+
+.category-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 20px;
+  padding-bottom: 10px;
+  border-bottom: 2px solid #e4e7ed;
+}
+
 .setting-item {
   margin-bottom: 20px;
   display: flex;