diff --git a/backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py b/backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py new file mode 100644 index 00000000..24e82b07 --- /dev/null +++ b/backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""add_user_team_favorites_table + +Revision ID: 2a3b4c5d6e7f +Revises: 1a2b3c4d5e6f +Create Date: 2025-07-16 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2a3b4c5d6e7f' +down_revision = '1a2b3c4d5e6f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create user_team_favorites table + op.create_table( + 'user_team_favorites', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4', + mysql_collate='utf8mb4_unicode_ci' + ) + op.create_index('ix_user_team_favorites_id', 'user_team_favorites', ['id'], unique=False) + op.create_index('ix_user_team_favorites_user_id', 'user_team_favorites', ['user_id'], unique=False) + op.create_index('ix_user_team_favorites_team_id', 'user_team_favorites', ['team_id'], unique=False) + op.create_index('idx_user_team_favorite', 'user_team_favorites', ['user_id', 'team_id'], unique=True) + + +def downgrade() -> None: + op.drop_index('idx_user_team_favorite', table_name='user_team_favorites') + op.drop_index('ix_user_team_favorites_team_id', table_name='user_team_favorites') + op.drop_index('ix_user_team_favorites_user_id', table_name='user_team_favorites') + op.drop_index('ix_user_team_favorites_id', table_name='user_team_favorites') + op.drop_table('user_team_favorites') diff --git a/backend/app/api/endpoints/adapter/teams.py b/backend/app/api/endpoints/adapter/teams.py index 4a4eeb28..2ddb8753 100644 --- a/backend/app/api/endpoints/adapter/teams.py +++ b/backend/app/api/endpoints/adapter/teams.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: Apache-2.0 +from typing import Any, Dict, List + from fastapi import APIRouter, Depends, Query, status from sqlalchemy.orm import Session @@ -24,6 +26,7 @@ ) from app.services.adapters.team_kinds import team_kinds_service from app.services.shared_team import shared_team_service +from app.services.team_favorite import team_favorite_service router = APIRouter() @@ -40,6 +43,14 @@ def list_teams( items = team_kinds_service.get_user_teams( db=db, user_id=current_user.id, skip=skip, limit=limit ) + + # Add is_favorited field to each team + favorite_team_ids = team_favorite_service.get_user_favorite_team_ids( + db=db, user_id=current_user.id + ) + for item in items: + item["is_favorited"] = item["id"] in favorite_team_ids + if page == 1 and len(items) < limit: total = len(items) else: @@ -139,3 +150,94 @@ def join_shared_team( return shared_team_service.join_shared_team( db=db, share_token=request.share_token, user_id=current_user.id ) + + +@router.post("/{team_id}/favorite") +def add_team_to_favorites( + team_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """Add a team to user's favorites""" + return team_favorite_service.add_favorite( + db=db, team_id=team_id, user_id=current_user.id + ) + + +@router.delete("/{team_id}/favorite") +def remove_team_from_favorites( + team_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """Remove a team from user's favorites""" + return team_favorite_service.remove_favorite( + db=db, team_id=team_id, user_id=current_user.id + ) + + +@router.get("/showcase/recommended", response_model=List[Dict[str, Any]]) +def get_recommended_teams( + limit: int = Query(6, ge=1, le=20, description="Max teams to return"), + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +): + """Get recommended teams (is_recommended=true)""" + from app.schemas.kind import Team + + # Get all teams where isRecommended is true + teams = db.query(Kind).filter(Kind.kind == "Team", Kind.is_active == True).all() + + recommended_teams = [] + favorite_team_ids = team_favorite_service.get_user_favorite_team_ids( + db=db, user_id=current_user.id + ) + + for team in teams: + team_crd = Team.model_validate(team.json) + if team_crd.spec.isRecommended: + team_dict = team_kinds_service._convert_to_team_dict(team, db, team.user_id) + team_dict["is_favorited"] = team.id in favorite_team_ids + recommended_teams.append(team_dict) + if len(recommended_teams) >= limit: + break + + return recommended_teams + + +@router.get("/showcase/favorites", response_model=List[Dict[str, Any]]) +def get_favorite_teams( + limit: int = Query(6, ge=1, le=20, description="Max teams to return"), + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +): + """Get user's favorite teams""" + from app.models.user_team_favorite import UserTeamFavorite + + # Get user's favorite team IDs + favorites = ( + db.query(UserTeamFavorite) + .filter(UserTeamFavorite.user_id == current_user.id) + .order_by(UserTeamFavorite.created_at.desc()) + .limit(limit) + .all() + ) + + favorite_teams = [] + for favorite in favorites: + team = ( + db.query(Kind) + .filter( + Kind.id == favorite.team_id, + Kind.kind == "Team", + Kind.is_active == True, + ) + .first() + ) + + if team: + team_dict = team_kinds_service._convert_to_team_dict(team, db, team.user_id) + team_dict["is_favorited"] = True + favorite_teams.append(team_dict) + + return favorite_teams diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 65bf81ad..8c51c32e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,5 +13,6 @@ # Do NOT import Base here to avoid conflicts with app.db.base.Base # All models should import Base directly from app.db.base from app.models.user import User +from app.models.user_team_favorite import UserTeamFavorite -__all__ = ["User", "Kind", "Subtask", "SharedTeam", "SkillBinary"] +__all__ = ["User", "Kind", "Subtask", "SharedTeam", "SkillBinary", "UserTeamFavorite"] diff --git a/backend/app/models/user_team_favorite.py b/backend/app/models/user_team_favorite.py new file mode 100644 index 00000000..13fd806f --- /dev/null +++ b/backend/app/models/user_team_favorite.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from datetime import datetime + +from sqlalchemy import Column, DateTime, Index, Integer + +from app.db.base import Base + + +class UserTeamFavorite(Base): + """User team favorite model for maintaining user-team favorite relationships""" + + __tablename__ = "user_team_favorites" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False, index=True) # User who favorited the team + team_id = Column(Integer, nullable=False, index=True) # Team that was favorited + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_user_team_favorite", "user_id", "team_id", unique=True), + {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) diff --git a/backend/app/schemas/kind.py b/backend/app/schemas/kind.py index 028a5fb5..ad9fa895 100644 --- a/backend/app/schemas/kind.py +++ b/backend/app/schemas/kind.py @@ -206,6 +206,8 @@ class TeamSpec(BaseModel): members: List[TeamMember] collaborationModel: str # pipeline、route、coordinate、collaborate + icon: Optional[str] = None # Lucide icon name (e.g., "Users", "Bot", "Zap") + isRecommended: bool = False # Whether this team is recommended class TeamStatus(Status): diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 4f0bf0f1..4f6db1cb 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -42,6 +42,8 @@ class TeamBase(BaseModel): bots: List[BotInfo] workflow: Optional[dict[str, Any]] = None is_active: bool = True + icon: Optional[str] = None # Lucide icon name (e.g., "Users", "Bot", "Zap") + is_recommended: bool = False # Whether this team is recommended class TeamCreate(TeamBase): @@ -57,6 +59,8 @@ class TeamUpdate(BaseModel): bots: Optional[List[BotInfo]] = None workflow: Optional[dict[str, Any]] = None is_active: Optional[bool] = None + icon: Optional[str] = None # Lucide icon name + is_recommended: Optional[bool] = None # Whether this team is recommended class TeamInDB(TeamBase): @@ -69,6 +73,7 @@ class TeamInDB(TeamBase): user: Optional[dict[str, Any]] = None share_status: int = 0 # 0-private, 1-sharing, 2-shared from others agent_type: Optional[str] = None # agno, claude, dify, etc. + is_favorited: bool = False # Whether current user has favorited this team class Config: from_attributes = True @@ -86,6 +91,9 @@ class TeamDetail(BaseModel): updated_at: datetime user: Optional[UserInDB] = None share_status: int = 0 # 0-private, 1-sharing, 2-shared from others + icon: Optional[str] = None # Lucide icon name + is_recommended: bool = False # Whether this team is recommended + is_favorited: bool = False # Whether current user has favorited this team class Config: from_attributes = True diff --git a/backend/app/services/adapters/team_kinds.py b/backend/app/services/adapters/team_kinds.py index b17428d8..96958d2c 100644 --- a/backend/app/services/adapters/team_kinds.py +++ b/backend/app/services/adapters/team_kinds.py @@ -136,7 +136,12 @@ def create_with_user( # Create Team JSON team_json = { "kind": "Team", - "spec": {"members": members, "collaborationModel": collaboration_model}, + "spec": { + "members": members, + "collaborationModel": collaboration_model, + "icon": getattr(obj_in, "icon", None), + "isRecommended": getattr(obj_in, "is_recommended", False), + }, "status": {"state": "Available"}, "metadata": {"name": obj_in.name, "namespace": "default"}, "apiVersion": "agent.wecode.io/v1", @@ -511,6 +516,14 @@ def update_with_user( team_crd.spec.collaborationModel = collaboration_model + # Handle icon update + if "icon" in update_data: + team_crd.spec.icon = update_data["icon"] + + # Handle is_recommended update + if "is_recommended" in update_data: + team_crd.spec.isRecommended = update_data["is_recommended"] + # Save the updated team CRD team.json = team_crd.model_dump(mode="json") team.updated_at = datetime.now() @@ -886,6 +899,8 @@ def _convert_to_team_dict( "created_at": team.created_at, "updated_at": team.updated_at, "agent_type": agent_type, # Add agent_type field + "icon": team_crd.spec.icon, # Lucide icon name + "is_recommended": team_crd.spec.isRecommended, # Whether this team is recommended } def _get_bot_summary(self, bot: Kind, db: Session, user_id: int) -> Dict[str, Any]: diff --git a/backend/app/services/team_favorite.py b/backend/app/services/team_favorite.py new file mode 100644 index 00000000..3219868f --- /dev/null +++ b/backend/app/services/team_favorite.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any, Dict, List, Set + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.kind import Kind +from app.models.user_team_favorite import UserTeamFavorite + + +class TeamFavoriteService: + """Service for team favorite operations""" + + def add_favorite( + self, db: Session, *, team_id: int, user_id: int + ) -> Dict[str, Any]: + """Add a team to user's favorites""" + # Check if team exists + team = ( + db.query(Kind) + .filter(Kind.id == team_id, Kind.kind == "Team", Kind.is_active == True) + .first() + ) + + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Check if already favorited + existing = ( + db.query(UserTeamFavorite) + .filter( + UserTeamFavorite.user_id == user_id, UserTeamFavorite.team_id == team_id + ) + .first() + ) + + if existing: + return {"message": "Team already in favorites", "is_favorited": True} + + # Create favorite record + favorite = UserTeamFavorite(user_id=user_id, team_id=team_id) + db.add(favorite) + db.commit() + + return {"message": "Team added to favorites", "is_favorited": True} + + def remove_favorite( + self, db: Session, *, team_id: int, user_id: int + ) -> Dict[str, Any]: + """Remove a team from user's favorites""" + favorite = ( + db.query(UserTeamFavorite) + .filter( + UserTeamFavorite.user_id == user_id, UserTeamFavorite.team_id == team_id + ) + .first() + ) + + if not favorite: + return {"message": "Team not in favorites", "is_favorited": False} + + db.delete(favorite) + db.commit() + + return {"message": "Team removed from favorites", "is_favorited": False} + + def get_user_favorite_team_ids(self, db: Session, *, user_id: int) -> Set[int]: + """Get set of team IDs that user has favorited""" + favorites = ( + db.query(UserTeamFavorite.team_id) + .filter(UserTeamFavorite.user_id == user_id) + .all() + ) + return {f.team_id for f in favorites} + + def is_team_favorited(self, db: Session, *, team_id: int, user_id: int) -> bool: + """Check if a team is in user's favorites""" + favorite = ( + db.query(UserTeamFavorite) + .filter( + UserTeamFavorite.user_id == user_id, UserTeamFavorite.team_id == team_id + ) + .first() + ) + return favorite is not None + + +team_favorite_service = TeamFavoriteService() diff --git a/frontend/src/apis/team.ts b/frontend/src/apis/team.ts index 5d5e6eac..44e56fcd 100644 --- a/frontend/src/apis/team.ts +++ b/frontend/src/apis/team.ts @@ -12,6 +12,8 @@ export interface CreateTeamRequest { bots?: TeamBot[]; workflow?: Record; is_active?: boolean; + icon?: string; // Lucide icon name + is_recommended?: boolean; // Whether this team is recommended } export interface TeamListResponse { @@ -82,4 +84,16 @@ export const teamApis = { async getTeamInputParameters(teamId: number): Promise { return apiClient.get(`/teams/${teamId}/input-parameters`); }, + async addTeamToFavorites(teamId: number): Promise<{ message: string; is_favorited: boolean }> { + return apiClient.post(`/teams/${teamId}/favorite`); + }, + async removeTeamFromFavorites(teamId: number): Promise<{ message: string; is_favorited: boolean }> { + return apiClient.delete(`/teams/${teamId}/favorite`); + }, + async getRecommendedTeams(limit: number = 6): Promise { + return apiClient.get(`/teams/showcase/recommended?limit=${limit}`); + }, + async getFavoriteTeams(limit: number = 6): Promise { + return apiClient.get(`/teams/showcase/favorites?limit=${limit}`); + }, }; diff --git a/frontend/src/components/ui/icon-picker.tsx b/frontend/src/components/ui/icon-picker.tsx new file mode 100644 index 00000000..dfefeb2e --- /dev/null +++ b/frontend/src/components/ui/icon-picker.tsx @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import * as LucideIcons from 'lucide-react'; +import { useTranslation } from '@/hooks/useTranslation'; + +// Common icons for team selection (curated list of ~60 icons) +const COMMON_ICONS = [ + 'Users', + 'Bot', + 'Zap', + 'Rocket', + 'Star', + 'Heart', + 'Brain', + 'Lightbulb', + 'Code', + 'Terminal', + 'Database', + 'Server', + 'Cloud', + 'Globe', + 'Network', + 'Cpu', + 'Sparkles', + 'Wand2', + 'Magic', + 'Target', + 'Award', + 'Trophy', + 'Medal', + 'Crown', + 'Building', + 'Home', + 'Briefcase', + 'Folder', + 'FileCode', + 'GitBranch', + 'Github', + 'Gitlab', + 'MessageSquare', + 'MessagesSquare', + 'Mail', + 'Send', + 'Phone', + 'Video', + 'Mic', + 'Headphones', + 'Search', + 'Eye', + 'Settings', + 'Wrench', + 'Hammer', + 'Puzzle', + 'Layers', + 'Layout', + 'Palette', + 'Paintbrush', + 'PenTool', + 'Image', + 'Camera', + 'Film', + 'Music', + 'Play', + 'Shield', + 'Lock', + 'Key', + 'Fingerprint', + 'UserCheck', + 'UserPlus', + 'Users2', + 'UsersRound', +]; + +interface IconPickerProps { + value?: string; + onChange: (icon: string) => void; + disabled?: boolean; + className?: string; + teamName?: string; +} + +export default function IconPicker({ + value, + onChange, + disabled = false, + className, + teamName = '', +}: IconPickerProps) { + const { t } = useTranslation('common'); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + // Get the icon component dynamically + const IconComponent = value + ? (LucideIcons as unknown as Record>)[value] + : null; + + // Filter icons based on search + const filteredIcons = useMemo(() => { + if (!search.trim()) return COMMON_ICONS; + const searchLower = search.toLowerCase(); + return COMMON_ICONS.filter(icon => icon.toLowerCase().includes(searchLower)); + }, [search]); + + // Get first letter for default avatar + const firstLetter = teamName.trim().charAt(0).toUpperCase() || 'T'; + + const handleIconSelect = (iconName: string) => { + onChange(iconName); + setOpen(false); + setSearch(''); + }; + + return ( + + + + + +
+ setSearch(e.target.value)} + className="h-8" + /> +
+ {filteredIcons.map(iconName => { + const Icon = ( + LucideIcons as unknown as Record< + string, + React.ComponentType<{ className?: string }> + > + )[iconName]; + if (!Icon) return null; + const isSelected = value === iconName; + return ( + + ); + })} +
+ {filteredIcons.length === 0 && ( +

{t('teams.no_match')}

+ )} + {value && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/features/settings/components/TeamEdit.tsx b/frontend/src/features/settings/components/TeamEdit.tsx index b3ad9cc8..945ad196 100644 --- a/frontend/src/features/settings/components/TeamEdit.tsx +++ b/frontend/src/features/settings/components/TeamEdit.tsx @@ -24,6 +24,7 @@ import { TeamMode, getFilteredBotsForMode, AgentType } from './team-modes'; import { createTeam, updateTeam } from '../services/teams'; import TeamEditDrawer from './TeamEditDrawer'; import { useTranslation } from '@/hooks/useTranslation'; +import IconPicker from '@/components/ui/icon-picker'; // Import mode-specific editors import SoloModeEditor from './team-modes/SoloModeEditor'; @@ -66,6 +67,7 @@ export default function TeamEdit(props: TeamEditProps) { // Left column: Team Name, Mode, Description const [name, setName] = useState(''); const [mode, setMode] = useState('solo'); + const [icon, setIcon] = useState(''); // Right column: LeaderBot (single select), Bots Transfer (multi-select) // Use string key for antd Transfer, stringify bot.id here @@ -199,6 +201,7 @@ export default function TeamEdit(props: TeamEditProps) { setName(formTeam.name); const m = (formTeam.workflow?.mode as TeamMode) || 'pipeline'; setMode(m); + setIcon(formTeam.icon || ''); const ids = formTeam.bots.map(b => String(b.bot_id)); setSelectedBotKeys(ids); const leaderBot = formTeam.bots.find(b => b.role === 'leader'); @@ -206,6 +209,7 @@ export default function TeamEdit(props: TeamEditProps) { } else { setName(''); setMode('solo'); + setIcon(''); setSelectedBotKeys([]); setLeaderBotId(null); } @@ -395,6 +399,7 @@ export default function TeamEdit(props: TeamEditProps) { name: name.trim(), workflow, bots: botsData, + icon: icon || undefined, }); setTeams(prev => prev.map(team => (team.id === updated.id ? updated : team))); } else { @@ -402,6 +407,7 @@ export default function TeamEdit(props: TeamEditProps) { name: name.trim(), workflow, bots: botsData, + icon: icon || undefined, }); setTeams(prev => [created, ...prev]); } @@ -468,22 +474,24 @@ export default function TeamEdit(props: TeamEditProps) {
{/* Left column */}
- {/* Team Name */} + {/* Team Name with Icon Picker */}
- setName(e.target.value)} - placeholder={t('team.name_placeholder')} - className="w-full px-4 py-1 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-transparent text-base h-9" - /> +
+ + setName(e.target.value)} + placeholder={t('team.name_placeholder')} + className="flex-1 px-4 py-1 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-transparent text-base h-9" + /> +
- {/* Mode component */}
diff --git a/frontend/src/features/settings/components/TeamList.tsx b/frontend/src/features/settings/components/TeamList.tsx index e1e9ce0a..98450d56 100644 --- a/frontend/src/features/settings/components/TeamList.tsx +++ b/frontend/src/features/settings/components/TeamList.tsx @@ -7,8 +7,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import '@/features/common/scrollbar.css'; -import { AiOutlineTeam } from 'react-icons/ai'; import { RiRobot2Line } from 'react-icons/ri'; +import * as LucideIcons from 'lucide-react'; import LoadingState from '@/features/common/LoadingState'; import { PencilIcon, @@ -16,9 +16,11 @@ import { DocumentDuplicateIcon, ChatBubbleLeftEllipsisIcon, ShareIcon, + StarIcon, } from '@heroicons/react/24/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { Bot, Team } from '@/types/api'; -import { fetchTeamsList, deleteTeam, shareTeam } from '../services/teams'; +import { fetchTeamsList, deleteTeam, shareTeam, toggleTeamFavorite } from '../services/teams'; import { fetchBotsList } from '../services/bots'; import TeamEdit from './TeamEdit'; import BotList from './BotList'; @@ -55,6 +57,7 @@ export default function TeamList() { const [shareData, setShareData] = useState<{ teamName: string; shareUrl: string } | null>(null); const [sharingId, setSharingId] = useState(null); const [_deletingId, setDeletingId] = useState(null); + const [togglingFavoriteId, setTogglingFavoriteId] = useState(null); const [botListVisible, setBotListVisible] = useState(false); const router = useRouter(); const isEditing = editingTeamId !== null; @@ -207,6 +210,27 @@ export default function TeamList() { return !team.share_status || team.share_status === 0 || team.share_status === 1; // Personal teams (no share_status or share_status=0) show share button }; + // Handle toggle favorite + const handleToggleFavorite = async (team: Team) => { + if (togglingFavoriteId === team.id) return; + + setTogglingFavoriteId(team.id); + try { + const result = await toggleTeamFavorite(team.id, team.is_favorited || false); + // Update team's favorite status in the list + setTeamsSorted(prev => + prev.map(t => (t.id === team.id ? { ...t, is_favorited: result.is_favorited } : t)) + ); + } catch { + toast({ + variant: 'destructive', + title: t('teams.favorite_failed'), + }); + } finally { + setTogglingFavoriteId(null); + } + }; + return ( <>
@@ -250,7 +274,29 @@ export default function TeamList() { >
- + {(() => { + // Get the icon component dynamically + const IconComponent = team.icon + ? ( + LucideIcons as unknown as Record< + string, + React.ComponentType<{ className?: string }> + > + )[team.icon] + : null; + // Get first letter for default avatar + const firstLetter = team.name.trim().charAt(0).toUpperCase() || 'T'; + + return ( +
+ {IconComponent ? ( + + ) : ( + {firstLetter} + )} +
+ ); + })()}

@@ -286,6 +332,24 @@ export default function TeamList() {

+
+ + {/* Team Showcase - Recommended and Favorites */} +
diff --git a/frontend/src/features/tasks/components/TeamCard.tsx b/frontend/src/features/tasks/components/TeamCard.tsx new file mode 100644 index 00000000..bf5bf646 --- /dev/null +++ b/frontend/src/features/tasks/components/TeamCard.tsx @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React from 'react'; +import { Star } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Team } from '@/types/api'; + +interface TeamCardProps { + team: Team; + onSelect: (team: Team) => void; + onToggleFavorite: (team: Team) => void; + isTogglingFavorite?: boolean; +} + +export default function TeamCard({ + team, + onSelect, + onToggleFavorite, + isTogglingFavorite = false, +}: TeamCardProps) { + // Get the icon component dynamically + const IconComponent = team.icon + ? (LucideIcons as unknown as Record>)[ + team.icon + ] + : null; + + // Get first letter for default avatar + const firstLetter = team.name.trim().charAt(0).toUpperCase() || 'T'; + + const handleFavoriteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isTogglingFavorite) { + onToggleFavorite(team); + } + }; + + return ( +
onSelect(team)} + > + {/* Header: Icon + Name + Favorite */} +
+
+ {/* Team Icon/Avatar */} +
+ {IconComponent ? ( + + ) : ( + {firstLetter} + )} +
+ + {/* Team Name */} +
+

{team.name}

+ {team.user?.user_name && team.share_status === 2 && ( +

by {team.user.user_name}

+ )} +
+
+ + {/* Favorite Button */} + +
+ + {/* Agent Type Badge */} + {team.agent_type && ( +
+ + {team.agent_type} + +
+ )} +
+ ); +} diff --git a/frontend/src/features/tasks/components/TeamShowcase.tsx b/frontend/src/features/tasks/components/TeamShowcase.tsx new file mode 100644 index 00000000..1fbdf7f7 --- /dev/null +++ b/frontend/src/features/tasks/components/TeamShowcase.tsx @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { teamApis } from '@/apis/team'; +import type { Team } from '@/types/api'; +import TeamCard from './TeamCard'; +import { Loader2 } from 'lucide-react'; + +interface TeamShowcaseProps { + onSelectTeam: (team: Team) => void; + className?: string; +} + +export default function TeamShowcase({ onSelectTeam, className = '' }: TeamShowcaseProps) { + const { t } = useTranslation('common'); + const [recommendedTeams, setRecommendedTeams] = useState([]); + const [favoriteTeams, setFavoriteTeams] = useState([]); + const [isLoadingRecommended, setIsLoadingRecommended] = useState(true); + const [isLoadingFavorites, setIsLoadingFavorites] = useState(true); + const [togglingFavoriteIds, setTogglingFavoriteIds] = useState>(new Set()); + + // Fetch recommended teams + useEffect(() => { + const fetchRecommended = async () => { + try { + const teams = await teamApis.getRecommendedTeams(6); + setRecommendedTeams(teams); + } catch (error) { + console.error('Failed to fetch recommended teams:', error); + } finally { + setIsLoadingRecommended(false); + } + }; + fetchRecommended(); + }, []); + + // Fetch favorite teams + useEffect(() => { + const fetchFavorites = async () => { + try { + const teams = await teamApis.getFavoriteTeams(6); + setFavoriteTeams(teams); + } catch (error) { + console.error('Failed to fetch favorite teams:', error); + } finally { + setIsLoadingFavorites(false); + } + }; + fetchFavorites(); + }, []); + + // Toggle favorite status + const handleToggleFavorite = useCallback(async (team: Team) => { + if (togglingFavoriteIds.has(team.id)) return; + + setTogglingFavoriteIds(prev => new Set(prev).add(team.id)); + + try { + if (team.is_favorited) { + await teamApis.removeTeamFromFavorites(team.id); + // Update both lists + setRecommendedTeams(prev => + prev.map(t => (t.id === team.id ? { ...t, is_favorited: false } : t)) + ); + setFavoriteTeams(prev => prev.filter(t => t.id !== team.id)); + } else { + await teamApis.addTeamToFavorites(team.id); + // Update recommended list + setRecommendedTeams(prev => + prev.map(t => (t.id === team.id ? { ...t, is_favorited: true } : t)) + ); + // Add to favorites if not already there + if (!favoriteTeams.some(t => t.id === team.id)) { + setFavoriteTeams(prev => [{ ...team, is_favorited: true }, ...prev].slice(0, 6)); + } + } + } catch (error) { + console.error('Failed to toggle favorite:', error); + } finally { + setTogglingFavoriteIds(prev => { + const next = new Set(prev); + next.delete(team.id); + return next; + }); + } + }, [togglingFavoriteIds, favoriteTeams]); + + const hasContent = recommendedTeams.length > 0 || favoriteTeams.length > 0; + const isLoading = isLoadingRecommended || isLoadingFavorites; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!hasContent) { + return null; + } + + return ( +
+ {/* Recommended Teams Section */} + {recommendedTeams.length > 0 && ( +
+

+ {t('teams.recommended')} +

+
+ {recommendedTeams.map(team => ( + + ))} +
+
+ )} + + {/* Favorite Teams Section */} + {favoriteTeams.length > 0 && ( +
+

+ {t('teams.favorites')} +

+
+ {favoriteTeams.map(team => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 4ddfdd14..f8f60f02 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -183,6 +183,13 @@ "copy_success": "Copied successfully", "share_instructions_content1": "Copy and send this link to others, they can use your shared team to complete tasks directly through the link.", "share_instructions_content2": "After sharing the team, you can still manage and edit this team, and others can apply the latest team configuration but cannot edit the team settings.", + "recommended": "Recommended Apps", + "favorites": "My Favorites", + "set_recommended": "Set as Recommended", + "add_favorite": "Add to Favorites", + "remove_favorite": "Remove from Favorites", + "favorite_failed": "Failed to toggle favorite", + "select_icon": "Select Icon", "share": { "title": "Team Share", "confirm_message": "Do you want to use {{userName}}'s {{teamName}} to execute tasks?", diff --git a/frontend/src/i18n/locales/zh-CN/common.json b/frontend/src/i18n/locales/zh-CN/common.json index 6d9bebaa..27f366cd 100644 --- a/frontend/src/i18n/locales/zh-CN/common.json +++ b/frontend/src/i18n/locales/zh-CN/common.json @@ -184,6 +184,13 @@ "copy_success": "复制成功", "share_instructions_content1": "复制链接发送给其他人,他们可以通过链接使用你分享的团队来直接完成任务。 ", "share_instructions_content2": "团队共享后,你仍然可以管理和编辑这个团队,其他人可以应用最新的团队配置但不能编辑团队设置。", + "recommended": "推荐应用", + "favorites": "我的收藏", + "set_recommended": "设为推荐", + "add_favorite": "添加收藏", + "remove_favorite": "取消收藏", + "favorite_failed": "收藏操作失败", + "select_icon": "选择图标", "share": { "title": "团队分享", "confirm_message": "确定要使用 {{userName}} 的 {{teamName}} 执行任务吗?", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index afc1fd66..5afc06c1 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -96,6 +96,9 @@ export interface Team { share_status?: number; // 0: 个人团队, 1: 分享中, 2: 共享团队 agent_type?: string; // agno, claude, dify, etc. is_mix_team?: boolean; // true if team has multiple different agent types (e.g., ClaudeCode + Agno) + icon?: string; // Lucide icon name (e.g., "Users", "Bot", "Zap") + is_recommended?: boolean; // Whether this team is recommended + is_favorited?: boolean; // Whether current user has favorited this team user?: { user_name: string; };