From 876789fac175e7b56b2df649d1d61711c8fa6b54 Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Sat, 4 Jul 2026 01:00:22 +0800 Subject: [PATCH] feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements - Backend: workspace_id isolation for 14 model tables + safe migration/backfill - Backend: RBAC system with 4 roles and 23 permissions, seeded on startup - Backend: workspace admin endpoints (list/manage all workspaces) - Backend: admin user management API (CRUD, reset password) - Backend: billing API with subscription plans, usage tracking, rate limiting - Backend: fix system_logs.py UNION query and wrong column references - Backend: WebSocket JWT auth and workspace enforcement - Frontend: sidebar navigation replacing top dropdown menu - Frontend: user management page (Users.vue) for admins - Frontend: enhanced Workspaces.vue with admin table view - Frontend: workspace RBAC computed properties in user store - Android: agent marketplace, billing/subscription UI, onboarding wizard - Android: phone login, analytics tracker, crash handler, network diagnostics - Android: splash screen, encrypted token storage, app update enhancements - Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle.kts | 6 + android/app/src/main/AndroidManifest.xml | 11 + .../java/com/tiangong/aiagent/MainActivity.kt | 10 + .../java/com/tiangong/aiagent/TiangongApp.kt | 10 +- .../aiagent/data/local/AppDatabase.kt | 2 +- .../aiagent/data/local/ConversationDao.kt | 9 +- .../aiagent/data/local/ConversationEntity.kt | 3 +- .../aiagent/data/local/CredentialStore.kt | 25 + .../aiagent/data/local/TokenDataStore.kt | 8 + .../aiagent/data/remote/ApiService.kt | 113 +++- .../tiangong/aiagent/data/remote/dto/Dtos.kt | 240 ++++++++ .../data/repository/AgentMarketRepository.kt | 95 +++ .../aiagent/data/repository/AuthRepository.kt | 10 +- .../data/repository/BillingRepository.kt | 35 ++ .../aiagent/data/repository/ChatRepository.kt | 91 ++- .../java/com/tiangong/aiagent/di/AppModule.kt | 8 +- .../aiagent/domain/model/Conversation.kt | 3 +- .../aiagent/ui/agents/AgentListScreen.kt | 21 +- .../tiangong/aiagent/ui/chat/ChatScreen.kt | 144 ++++- .../tiangong/aiagent/ui/chat/ChatViewModel.kt | 26 + .../aiagent/ui/common/ZoomableImage.kt | 126 ++++ .../ui/history/ConversationListScreen.kt | 56 +- .../tiangong/aiagent/ui/login/LoginScreen.kt | 416 +++++++------ .../aiagent/ui/login/PhoneLoginScreen.kt | 294 +++++++++ .../aiagent/ui/login/PrivacyPolicyRow.kt | 38 ++ .../aiagent/ui/market/MarketScreen.kt | 555 +++++++++++++++++ .../aiagent/ui/market/MarketViewModel.kt | 233 +++++++ .../aiagent/ui/navigation/NavGraph.kt | 97 ++- .../aiagent/ui/onboarding/OnboardingScreen.kt | 183 ++++++ .../aiagent/ui/settings/AppUpdateDialogs.kt | 176 ++++++ .../ui/settings/NetworkDiagnoseScreen.kt | 281 +++++++++ .../aiagent/ui/settings/SettingsScreen.kt | 48 +- .../aiagent/ui/splash/SplashScreen.kt | 150 +++++ .../ui/subscription/SubscriptionScreen.kt | 329 ++++++++++ .../ui/subscription/SubscriptionViewModel.kt | 124 ++++ .../aiagent/ui/subscription/UpgradeDialog.kt | 34 ++ .../aiagent/ui/subscription/UsageIndicator.kt | 100 +++ .../tiangong/aiagent/util/AnalyticsTracker.kt | 99 +++ .../tiangong/aiagent/util/AppUpdateManager.kt | 288 ++++++--- .../com/tiangong/aiagent/util/CrashHandler.kt | 90 +++ .../com/tiangong/aiagent/util/UmengHelper.kt | 179 ++++++ .../com/tiangong/aiagent/util/VersionUtils.kt | 67 ++ android/app/src/main/res/xml/file_paths.xml | 7 + android/gradle/libs.versions.toml | 6 + backend/alembic/versions/025_billing.py | 98 +++ backend/app/api/agent_chat.py | 230 ++++++- backend/app/api/agent_schedules.py | 7 +- backend/app/api/agent_sessions.py | 180 ++++++ backend/app/api/agents.py | 23 +- backend/app/api/alert_rules.py | 14 +- backend/app/api/analytics.py | 229 +++++++ backend/app/api/auth.py | 239 +++++++- backend/app/api/billing.py | 303 ++++++++++ backend/app/api/collaboration.py | 20 + backend/app/api/data_sources.py | 6 +- backend/app/api/deps.py | 137 ++++- backend/app/api/knowledge_dashboard.py | 47 +- backend/app/api/model_configs.py | 6 +- backend/app/api/plugins.py | 17 +- backend/app/api/system_logs.py | 111 +++- backend/app/api/tools.py | 9 +- backend/app/api/users.py | 264 ++++++++ backend/app/api/websocket.py | 87 ++- backend/app/api/workflows.py | 11 +- backend/app/api/workspaces.py | 163 +++++ backend/app/core/config.py | 3 + backend/app/core/database.py | 241 ++++++++ backend/app/core/sms_service.py | 83 +++ backend/app/core/usage_middleware.py | 119 ++++ backend/app/main.py | 11 +- backend/app/models/__init__.py | 4 +- backend/app/models/agent.py | 2 + backend/app/models/agent_execution_log.py | 3 +- backend/app/models/agent_llm_log.py | 1 + backend/app/models/agent_session.py | 24 + backend/app/models/alert_rule.py | 4 +- backend/app/models/billing.py | 93 +++ backend/app/models/chat_message.py | 2 + backend/app/models/conversation_branch.py | 3 +- backend/app/models/knowledge_entry.py | 5 +- backend/app/models/node_template.py | 2 + backend/app/models/plugin.py | 3 +- backend/app/models/user.py | 9 +- backend/app/models/workflow_template.py | 2 + backend/app/websocket/manager.py | 52 +- backend/scripts/seed_billing_plans.py | 112 ++++ docs/index.md | 1 + docs/multi-tenant-rbac-guide.md | 570 ++++++++++++++++++ frontend/src/components/MainLayout.vue | 476 ++++++++------- frontend/src/router/index.ts | 6 + frontend/src/stores/user.ts | 15 + frontend/src/views/Users.vue | 408 +++++++++++++ frontend/src/views/Workspaces.vue | 171 +++++- 93 files changed, 8803 insertions(+), 669 deletions(-) create mode 100644 android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt create mode 100644 android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 backend/alembic/versions/025_billing.py create mode 100644 backend/app/api/agent_sessions.py create mode 100644 backend/app/api/analytics.py create mode 100644 backend/app/api/billing.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/core/sms_service.py create mode 100644 backend/app/core/usage_middleware.py create mode 100644 backend/app/models/agent_session.py create mode 100644 backend/app/models/billing.py create mode 100644 backend/scripts/seed_billing_plans.py create mode 100644 docs/multi-tenant-rbac-guide.md create mode 100644 frontend/src/views/Users.vue diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f4c2918..da1a05e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -126,4 +126,10 @@ dependencies { // Bugly (Tencent crash reporting) implementation(libs.bugly.crashreport) implementation(libs.bugly.native) + + // Umeng Analytics (from zhini_im) + implementation(libs.umeng.common) + implementation(libs.umeng.asms) + implementation(libs.umeng.uyumao) + implementation(libs.umeng.abtest) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bc54b15..27f1e89 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,17 @@ + + + + + > - @Query("SELECT * FROM conversations ORDER BY lastMessageAt DESC") + @Query("SELECT * FROM conversations ORDER BY isPinned DESC, lastMessageAt DESC") suspend fun getAllConversationsSync(): List - @Query("SELECT * FROM conversations WHERE agentId = :agentId ORDER BY lastMessageAt DESC") + @Query("SELECT * FROM conversations WHERE agentId = :agentId ORDER BY isPinned DESC, lastMessageAt DESC") suspend fun getByAgentId(agentId: String): List @Query("SELECT * FROM conversations WHERE sessionId = :sessionId LIMIT 1") @@ -26,4 +26,7 @@ interface ConversationDao { @Query("DELETE FROM conversations WHERE sessionId = :sessionId") suspend fun deleteById(sessionId: String) + + @Query("UPDATE conversations SET isPinned = :isPinned WHERE sessionId = :sessionId") + suspend fun updatePinStatus(sessionId: String, isPinned: Boolean) } diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt index 7d67f9b..5592b99 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/ConversationEntity.kt @@ -11,5 +11,6 @@ data class ConversationEntity( val title: String?, val lastMessage: String?, val lastMessageAt: Long, - val messageCount: Int + val messageCount: Int, + val isPinned: Boolean = false ) diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt index 0594525..5812f86 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/CredentialStore.kt @@ -41,4 +41,29 @@ class CredentialStore @Inject constructor( .remove("password") .apply() } + + // ─── Encrypted tokens ─── + + fun saveToken(token: String) { + prefs.edit().putString("access_token", token).apply() + } + + fun getToken(): String? { + return prefs.getString("access_token", null) + } + + fun saveRefreshToken(refreshToken: String) { + prefs.edit().putString("refresh_token", refreshToken).apply() + } + + fun getRefreshToken(): String? { + return prefs.getString("refresh_token", null) + } + + fun clearTokens() { + prefs.edit() + .remove("access_token") + .remove("refresh_token") + .apply() + } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt index 131a4b2..bb80b03 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt @@ -30,6 +30,7 @@ class TokenDataStore @Inject constructor( private val KEY_NOTIFICATION_ENABLED = booleanPreferencesKey("notification_enabled") private val KEY_NOTIFICATION_QUIET_START = stringPreferencesKey("notification_quiet_start") private val KEY_NOTIFICATION_QUIET_END = stringPreferencesKey("notification_quiet_end") + private val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") } // ─── Token ─── @@ -137,6 +138,13 @@ class TokenDataStore @Inject constructor( context.dataStore.edit { it[KEY_THEME_MODE] = mode } } + // ─── Onboarding (v1.1.0) ─── + val onboardingCompleted: Flow = context.dataStore.data.map { it[KEY_ONBOARDING_COMPLETED] ?: false } + + suspend fun setOnboardingCompleted() { + context.dataStore.edit { it[KEY_ONBOARDING_COMPLETED] = true } + } + // ─── Clear All ─── suspend fun clearAll() { context.dataStore.edit { prefs -> diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt index c082bbf..a614fee 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt @@ -125,9 +125,32 @@ interface ApiService { @GET("api/v1/agent-chat/{agentId}/sessions") suspend fun getAgentSessions( @Path("agentId") agentId: String, - @Query("limit") limit: Int = 50 + @Query("limit") limit: Int = 50, + @Query("search") search: String? = null ): SessionListResponse + @PATCH("api/v1/agent-chat/{agentId}/sessions/{sessionId}") + suspend fun updateSession( + @Path("agentId") agentId: String, + @Path("sessionId") sessionId: String, + @Body request: UpdateSessionRequest + ): UpdateSessionResponse + + @DELETE("api/v1/agent-chat/{agentId}/sessions/{sessionId}") + suspend fun deleteSession( + @Path("agentId") agentId: String, + @Path("sessionId") sessionId: String + ): DeleteSessionResponse + + // ─── Message Search ─── + @GET("api/v1/agent-chat/{agentId}/search-messages") + suspend fun searchMessages( + @Path("agentId") agentId: String, + @Query("q") query: String, + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 50 + ): SearchMessagesResponse + // ─── Agent Memory ─── @GET("api/v1/agents/{agentId}/memory/global-knowledge") suspend fun getGlobalKnowledge( @@ -228,4 +251,92 @@ interface ApiService { @Path("agentId") agentId: String, @Path("memoryId") memoryId: String ): Response + + // ─── Analytics ─── + @POST("api/v1/analytics/events") + suspend fun sendAnalyticsEvents(@Body request: AnalyticsEventsRequest): Response + + // ─── Phone Login ─── + @POST("api/v1/auth/phone/send-code") + suspend fun sendPhoneCode(@Body request: PhoneSendCodeRequest): PhoneSendCodeResponse + + @POST("api/v1/auth/login/phone") + suspend fun phoneLogin(@Body request: PhoneLoginRequest): PhoneLoginResponse + + // ─── Agent Market ─── + @GET("api/v1/agent-market") + suspend fun browseMarket( + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 20, + @Query("search") search: String? = null, + @Query("category") category: String? = null, + @Query("sort_by") sortBy: String = "created_at", + @Query("sort_order") sortOrder: String = "desc", + @Query("featured_only") featuredOnly: Boolean = false + ): List + + @GET("api/v1/agent-market/{agentId}") + suspend fun getMarketAgentDetail(@Path("agentId") agentId: String): AgentMarketDetailDto + + @POST("api/v1/agent-market/{agentId}/install") + suspend fun installMarketAgent(@Path("agentId") agentId: String): InstallAgentResponse + + @POST("api/v1/agent-market/{agentId}/rate") + suspend fun rateMarketAgent( + @Path("agentId") agentId: String, + @Body request: RateAgentRequest + ): Response + + @POST("api/v1/agent-market/{agentId}/favorite") + suspend fun favoriteMarketAgent(@Path("agentId") agentId: String): Response + + @DELETE("api/v1/agent-market/{agentId}/favorite") + suspend fun unfavoriteMarketAgent(@Path("agentId") agentId: String): Response + + @GET("api/v1/agent-market/my/favorites") + suspend fun getMyFavorites( + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 20 + ): List + + @GET("api/v1/agent-market/categories/list") + suspend fun getMarketCategories(): List + + // ─── Template Market ─── + @GET("api/v1/template-market") + suspend fun browseTemplates( + @Query("skip") skip: Int = 0, + @Query("limit") limit: Int = 20, + @Query("search") search: String? = null, + @Query("category") category: String? = null + ): List + + @POST("api/v1/template-market/{templateId}/use") + suspend fun useTemplate(@Path("templateId") templateId: String): AgentResponse + + // ─── Scene Templates ─── + @GET("api/v1/platform/scene-templates") + suspend fun getSceneTemplates(): List + + @POST("api/v1/platform/agents/from-template") + suspend fun createFromSceneTemplate(@Body request: CreateFromTemplateRequest): AgentResponse + + // ─── Billing (v1.2) ─── + @GET("api/v1/billing/plans") + suspend fun getBillingPlans(): List + + @GET("api/v1/billing/user/subscription") + suspend fun getUserSubscription(): UserSubscriptionDto + + @GET("api/v1/billing/user/usage") + suspend fun getUserUsage(): UserUsageDto + + @POST("api/v1/billing/orders") + suspend fun createOrder(@Body request: CreateOrderRequest): OrderResponseDto + + @GET("api/v1/billing/orders/{orderId}") + suspend fun getOrder(@Path("orderId") orderId: String): OrderResponseDto + + @POST("api/v1/billing/orders/{orderId}/pay") + suspend fun payOrder(@Path("orderId") orderId: String): PayOrderResponse } diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt index d2a5ffe..d275f0c 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt @@ -336,6 +336,7 @@ data class SessionItemDto( val title: String? = null, @SerializedName("last_message") val lastMessage: String? = null, @SerializedName("message_count") val messageCount: Int = 0, + @SerializedName("is_pinned") val isPinned: Boolean = false, @SerializedName("created_at") val createdAt: String? = null, @SerializedName("updated_at") val updatedAt: String? = null ) @@ -344,6 +345,64 @@ data class SessionListResponse( val sessions: List ) +// ─────────── Session Management ─────────── + +data class UpdateSessionRequest( + val title: String? = null, + @SerializedName("is_pinned") val isPinned: Boolean? = null +) + +data class UpdateSessionResponse( + val id: String, + val title: String? = null, + @SerializedName("is_pinned") val isPinned: Boolean = false, + @SerializedName("created_at") val createdAt: String? = null, + @SerializedName("updated_at") val updatedAt: String? = null +) + +data class DeleteSessionResponse( + val deleted: Boolean, + @SerializedName("messages_deleted") val messagesDeleted: Int = 0 +) + +// ─────────── Analytics ─────────── + +data class AnalyticsEventItem( + @SerializedName("event_type") val eventType: String, + @SerializedName("screen_name") val screenName: String? = null, + val category: String? = null, + val action: String? = null, + val label: String? = null, + @SerializedName("agent_id") val agentId: String? = null, + val timestamp: String? = null +) + +data class AnalyticsEventsRequest( + val events: List +) + +// ─────────── Phone Login ─────────── + +data class PhoneSendCodeRequest( + val phone: String +) + +data class PhoneSendCodeResponse( + val message: String? = null, + @SerializedName("dev_code") val devCode: String? = null +) + +data class PhoneLoginRequest( + val phone: String, + val code: String +) + +data class PhoneLoginResponse( + @SerializedName("access_token") val accessToken: String, + @SerializedName("token_type") val tokenType: String = "bearer", + @SerializedName("refresh_token") val refreshToken: String? = null +) + // ─────────── Feedback ─────────── @@ -477,3 +536,184 @@ data class VectorMemoryListResponse( val items: List, val total: Int ) + +// ─────────── Agent Market ─────────── + +data class AgentMarketItemDto( + val id: String, + val name: String, + val description: String? = null, + val category: String? = null, + val tags: List? = null, + val thumbnail: String? = null, + @SerializedName("is_featured") val isFeatured: Boolean = false, + @SerializedName("rating_avg") val ratingAvg: Double = 0.0, + @SerializedName("rating_count") val ratingCount: Int = 0, + @SerializedName("use_count") val useCount: Int = 0, + @SerializedName("view_count") val viewCount: Int = 0, + val version: Int = 1, + @SerializedName("user_id") val userId: String? = null, + @SerializedName("creator_username") val creatorUsername: String? = null, + @SerializedName("is_favorited") val isFavorited: Boolean? = null, + @SerializedName("user_rating") val userRating: Int? = null, + @SerializedName("created_at") val createdAt: String? = null, + @SerializedName("updated_at") val updatedAt: String? = null +) + +data class AgentMarketDetailDto( + val id: String, + val name: String, + val description: String? = null, + val category: String? = null, + val tags: List? = null, + val thumbnail: String? = null, + @SerializedName("is_featured") val isFeatured: Boolean = false, + @SerializedName("rating_avg") val ratingAvg: Double = 0.0, + @SerializedName("rating_count") val ratingCount: Int = 0, + @SerializedName("use_count") val useCount: Int = 0, + @SerializedName("view_count") val viewCount: Int = 0, + val version: Int = 1, + @SerializedName("user_id") val userId: String? = null, + @SerializedName("creator_username") val creatorUsername: String? = null, + @SerializedName("is_favorited") val isFavorited: Boolean? = null, + @SerializedName("user_rating") val userRating: Int? = null, + @SerializedName("workflow_config") val workflowConfig: Map? = null, + @SerializedName("budget_config") val budgetConfig: Map? = null, + @SerializedName("created_at") val createdAt: String? = null, + @SerializedName("updated_at") val updatedAt: String? = null +) + +data class InstallAgentRequest( + @SerializedName("agent_id") val agentId: String? = null +) + +data class InstallAgentResponse( + val message: String, + @SerializedName("agent_id") val agentId: String, + @SerializedName("agent_name") val agentName: String, + @SerializedName("forked_from_id") val forkedFromId: String, + @SerializedName("upstream_version") val upstreamVersion: Int +) + +data class RateAgentRequest( + val rating: Int, + val comment: String? = null +) + +data class MarketCategoryDto( + val category: String, + val count: Int +) + +// ─────────── Template Market ─────────── + +data class TemplateItemDto( + val id: String, + val name: String, + val description: String? = null, + val category: String? = null, + val tags: List? = null, + val thumbnail: String? = null, + @SerializedName("use_count") val useCount: Int = 0, + @SerializedName("rating_avg") val ratingAvg: Double = 0.0, + @SerializedName("user_id") val userId: String? = null, + @SerializedName("creator_username") val creatorUsername: String? = null, + @SerializedName("created_at") val createdAt: String? = null +) + +data class SceneTemplateItemDto( + val id: String, + val name: String, + val description: String? = null, + val category: String? = null, + val icon: String? = null, + @SerializedName("workflow_config") val workflowConfig: Map? = null +) + +data class CreateFromTemplateRequest( + val name: String, + val description: String? = null, + @SerializedName("scene_template_id") val sceneTemplateId: String? = null +) + +// ─────────── Search ─────────── + +data class SearchMessageItemDto( + val id: String, + @SerializedName("session_id") val sessionId: String, + @SerializedName("agent_id") val agentId: String? = null, + val role: String, + val content: String? = null, + @SerializedName("session_title") val sessionTitle: String? = null, + @SerializedName("created_at") val createdAt: String? = null +) + +data class SearchMessagesResponse( + val messages: List, + val total: Int +) + +// ─────────── Billing (v1.2) ─────────── + +data class BillingPlanDto( + val id: String, + val name: String, + val tier: String, + @SerializedName("price_monthly") val priceMonthly: Double, + @SerializedName("price_yearly") val priceYearly: Double, + @SerializedName("daily_quota") val dailyQuota: Int, + @SerializedName("agent_limit") val agentLimit: Int, + @SerializedName("knowledge_limit") val knowledgeLimit: Int, + @SerializedName("file_upload_mb") val fileUploadMb: Int = 5, + val models: List? = null, + val features: List? = null, + @SerializedName("is_active") val isActive: Boolean = true, + @SerializedName("sort_order") val sortOrder: Int = 0 +) + +data class UserSubscriptionDto( + val tier: String = "free", + val status: String = "active", + @SerializedName("plan_name") val planName: String? = null, + @SerializedName("started_at") val startedAt: String? = null, + @SerializedName("expires_at") val expiresAt: String? = null, + @SerializedName("auto_renew") val autoRenew: Boolean = false, + @SerializedName("daily_quota") val dailyQuota: Int = 50, + @SerializedName("agent_limit") val agentLimit: Int = 3, + @SerializedName("knowledge_limit") val knowledgeLimit: Int = 10, + @SerializedName("file_upload_mb") val fileUploadMb: Int = 5, + val models: List? = null, + val features: List? = null +) + +data class UserUsageDto( + @SerializedName("daily_used") val dailyUsed: Int = 0, + @SerializedName("daily_limit") val dailyLimit: Int = 50, + @SerializedName("daily_remaining") val dailyRemaining: Int = 50, + @SerializedName("is_exhausted") val isExhausted: Boolean = false +) + +data class CreateOrderRequest( + @SerializedName("plan_id") val planId: String, + val period: String = "monthly", + @SerializedName("payment_method") val paymentMethod: String = "mock" +) + +data class OrderResponseDto( + val id: String, + @SerializedName("plan_id") val planId: String? = null, + val amount: Double, + val period: String, + val status: String, + @SerializedName("payment_method") val paymentMethod: String? = null, + @SerializedName("payment_ref") val paymentRef: String? = null, + @SerializedName("created_at") val createdAt: String? = null, + @SerializedName("paid_at") val paidAt: String? = null +) + +data class PayOrderResponse( + val success: Boolean = false, + val tier: String = "", + @SerializedName("expires_at") val expiresAt: String? = null, + val message: String = "" +) diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt new file mode 100644 index 0000000..9710d0d --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentMarketRepository.kt @@ -0,0 +1,95 @@ +package com.tiangong.aiagent.data.repository + +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AgentMarketRepository @Inject constructor( + private val apiService: ApiService +) { + // ─── Market Browse ─── + + suspend fun browseAgents( + skip: Int = 0, + limit: Int = 20, + search: String? = null, + category: String? = null, + sortBy: String = "created_at", + featuredOnly: Boolean = false + ): Result> = runCatching { + apiService.browseMarket(skip, limit, search, category, sortBy, "desc", featuredOnly) + } + + suspend fun getFeaturedAgents(limit: Int = 10): Result> = runCatching { + apiService.browseMarket(0, limit, featuredOnly = true, sortBy = "rating_avg") + } + + suspend fun getAgentDetail(agentId: String): Result = runCatching { + apiService.getMarketAgentDetail(agentId) + } + + // ─── Install ─── + + suspend fun installAgent(agentId: String): Result = runCatching { + apiService.installMarketAgent(agentId) + } + + // ─── Rate ─── + + suspend fun rateAgent(agentId: String, rating: Int, comment: String? = null): Result = runCatching { + apiService.rateMarketAgent(agentId, RateAgentRequest(rating, comment)) + } + + // ─── Favorites ─── + + suspend fun favoriteAgent(agentId: String): Result = runCatching { + apiService.favoriteMarketAgent(agentId) + } + + suspend fun unfavoriteAgent(agentId: String): Result = runCatching { + apiService.unfavoriteMarketAgent(agentId) + } + + suspend fun getMyFavorites(skip: Int = 0, limit: Int = 20): Result> = runCatching { + apiService.getMyFavorites(skip, limit) + } + + // ─── Categories ─── + + suspend fun getCategories(): Result> = runCatching { + apiService.getMarketCategories() + } + + // ─── Templates ─── + + suspend fun browseTemplates( + skip: Int = 0, + limit: Int = 20, + search: String? = null, + category: String? = null + ): Result> = runCatching { + apiService.browseTemplates(skip, limit, search, category) + } + + suspend fun useTemplate(templateId: String): Result = runCatching { + apiService.useTemplate(templateId) + } + + // ─── Scene Templates ─── + + suspend fun getSceneTemplates(): Result> = runCatching { + apiService.getSceneTemplates() + } + + suspend fun createFromSceneTemplate( + name: String, + description: String? = null, + sceneTemplateId: String? = null + ): Result = runCatching { + apiService.createFromSceneTemplate( + CreateFromTemplateRequest(name, description, sceneTemplateId) + ) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt index 9e45a91..5866702 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt @@ -1,12 +1,15 @@ package com.tiangong.aiagent.data.repository +import android.content.Context import com.tiangong.aiagent.data.local.CredentialStore import com.tiangong.aiagent.data.local.TokenDataStore import com.tiangong.aiagent.data.remote.ApiService import com.tiangong.aiagent.data.remote.dto.LoginResponse import com.tiangong.aiagent.data.remote.dto.RegisterRequest import com.tiangong.aiagent.data.remote.dto.RegisterResponse +import com.tiangong.aiagent.util.UmengHelper import com.google.gson.JsonParser +import dagger.hilt.android.qualifiers.ApplicationContext import retrofit2.HttpException import java.io.IOException import javax.inject.Inject @@ -17,7 +20,8 @@ class AuthRepository @Inject constructor( private val apiService: ApiService, private val tokenDataStore: TokenDataStore, private val credentialStore: CredentialStore, - private val authInterceptor: com.tiangong.aiagent.data.remote.AuthInterceptor + private val authInterceptor: com.tiangong.aiagent.data.remote.AuthInterceptor, + @ApplicationContext private val context: Context ) { suspend fun login(username: String, password: String): Result { @@ -30,6 +34,8 @@ class AuthRepository @Inject constructor( credentialStore.saveCredentials(username, password) // Update AuthInterceptor memory cache so subsequent requests carry the token authInterceptor.updateToken(response.accessToken) + // Umeng: track user sign-in + UmengHelper.onProfileSignIn(username) Result.success(response) } catch (e: Exception) { Result.failure(e) @@ -99,6 +105,8 @@ class AuthRepository @Inject constructor( tokenDataStore.clearAll() credentialStore.clearCredentials() authInterceptor.updateToken(null) + // Umeng: track user sign-out + UmengHelper.onProfileSignOff() } suspend fun getToken(): String? { diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt new file mode 100644 index 0000000..f7fd8b3 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/BillingRepository.kt @@ -0,0 +1,35 @@ +package com.tiangong.aiagent.data.repository + +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BillingRepository @Inject constructor( + private val apiService: ApiService +) { + suspend fun getPlans(): Result> = runCatching { + apiService.getBillingPlans() + } + + suspend fun getUserSubscription(): Result = runCatching { + apiService.getUserSubscription() + } + + suspend fun getUserUsage(): Result = runCatching { + apiService.getUserUsage() + } + + suspend fun createOrder(request: CreateOrderRequest): Result = runCatching { + apiService.createOrder(request) + } + + suspend fun getOrder(orderId: String): Result = runCatching { + apiService.getOrder(orderId) + } + + suspend fun payOrder(orderId: String): Result = runCatching { + apiService.payOrder(orderId) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt index d00df80..8f5be4e 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/ChatRepository.kt @@ -12,6 +12,8 @@ import com.tiangong.aiagent.data.remote.dto.MessageHistoryResponse import com.tiangong.aiagent.data.remote.dto.MessageItemDto import com.tiangong.aiagent.data.remote.dto.SessionListResponse import com.tiangong.aiagent.data.remote.dto.SessionItemDto +import com.tiangong.aiagent.data.remote.dto.SearchMessagesResponse +import com.tiangong.aiagent.data.remote.dto.UpdateSessionRequest import com.tiangong.aiagent.data.remote.dto.TtsRequest import com.tiangong.aiagent.domain.model.Conversation import com.tiangong.aiagent.domain.model.Message @@ -101,7 +103,8 @@ class ChatRepository @Inject constructor( title = title, lastMessage = content.take(100), lastMessageAt = now, - messageCount = count + messageCount = count, + isPinned = existing?.isPinned ?: false ) ) } @@ -121,7 +124,8 @@ class ChatRepository @Inject constructor( title = entity.title, lastMessage = entity.lastMessage, lastMessageAt = entity.lastMessageAt, - messageCount = entity.messageCount + messageCount = entity.messageCount, + isPinned = entity.isPinned ) } } @@ -129,18 +133,20 @@ class ChatRepository @Inject constructor( private suspend fun saveMessagesFromResponse(response: ChatResponse, agentId: String?) { val conversationId = response.sessionId ?: return val now = System.currentTimeMillis() + val existing = database.conversationDao().getById(conversationId) - // Save conversation - val title = response.steps.firstOrNull()?.content?.take(50) ?: "New Chat" + // Save conversation (preserve existing pinned state and title if present) + val title = existing?.title ?: (response.steps.firstOrNull()?.content?.take(50) ?: "New Chat") database.conversationDao().insert( ConversationEntity( sessionId = conversationId, - agentId = agentId, - agentName = null, + agentId = agentId ?: existing?.agentId, + agentName = existing?.agentName, title = title, lastMessage = response.content.take(100), lastMessageAt = now, - messageCount = response.steps.size + messageCount = response.steps.size, + isPinned = existing?.isPinned ?: false ) ) @@ -176,7 +182,8 @@ class ChatRepository @Inject constructor( title = entity.title, lastMessage = entity.lastMessage, lastMessageAt = entity.lastMessageAt, - messageCount = entity.messageCount + messageCount = entity.messageCount, + isPinned = entity.isPinned ) } } @@ -204,15 +211,72 @@ class ChatRepository @Inject constructor( } } - // v1.2.0: Update conversation title + // v1.2.0: Update conversation title (with server sync) suspend fun updateConversationTitle(sessionId: String, newTitle: String) { val entity = database.conversationDao().getById(sessionId) ?: return database.conversationDao().update(entity.copy(title = newTitle)) + // Sync to server + val agentId = entity.agentId + if (agentId != null) { + try { + apiService.updateSession(agentId, sessionId, UpdateSessionRequest(title = newTitle)) + } catch (e: Exception) { + // Server sync is best-effort + } + } } suspend fun deleteConversation(sessionId: String) { + val entity = database.conversationDao().getById(sessionId) database.messageDao().deleteByConversation(sessionId) database.conversationDao().deleteById(sessionId) + // Sync to server + val agentId = entity?.agentId + if (agentId != null) { + try { + apiService.deleteSession(agentId, sessionId) + } catch (e: Exception) { + // Server sync is best-effort + } + } + } + + // v1.1.0: Pin/unpin conversation (with server sync) + suspend fun pinConversation(sessionId: String, isPinned: Boolean) { + database.conversationDao().updatePinStatus(sessionId, isPinned) + val entity = database.conversationDao().getById(sessionId) ?: return + val agentId = entity.agentId + if (agentId != null) { + try { + apiService.updateSession(agentId, sessionId, UpdateSessionRequest(isPinned = isPinned)) + } catch (e: Exception) { + // Server sync is best-effort + } + } + } + + // Sync pin/rename state from server sessions list to local Room + suspend fun syncSessionStateFromServer(agentId: String) { + try { + val result = apiService.getAgentSessions(agentId, limit = 200) + for (dto in result.sessions) { + val local = database.conversationDao().getById(dto.sessionId) + if (local != null) { + val needsUpdate = (dto.isPinned != local.isPinned) || + (dto.title != null && dto.title != local.title) + if (needsUpdate) { + database.conversationDao().insert( + local.copy( + title = dto.title ?: local.title, + isPinned = dto.isPinned + ) + ) + } + } + } + } catch (e: Exception) { + // Best-effort sync + } } // ─── Media operations (v1.2.0: moved from ChatViewModel to repository layer) ─── @@ -386,6 +450,15 @@ class ChatRepository @Inject constructor( ) } + suspend fun searchMessages(agentId: String, query: String): Result { + return try { + val response = apiService.searchMessages(agentId, query) + Result.success(response) + } catch (e: Exception) { + Result.failure(e) + } + } + companion object { fun parseIso8601ToEpoch(isoString: String): Long { return try { diff --git a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt index f3ef5b7..e4145a3 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt @@ -133,6 +133,12 @@ object AppModule { } } + private val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE conversations ADD COLUMN isPinned INTEGER NOT NULL DEFAULT 0") + } + } + @Provides @Singleton fun provideRoomDatabase(@ApplicationContext context: Context): AppDatabase { @@ -141,7 +147,7 @@ object AppModule { AppDatabase::class.java, "tiangong_db" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .build() } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt b/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt index a34e978..2795f13 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/domain/model/Conversation.kt @@ -7,5 +7,6 @@ data class Conversation( val title: String? = null, val lastMessage: String? = null, val lastMessageAt: Long = System.currentTimeMillis(), - val messageCount: Int = 0 + val messageCount: Int = 0, + val isPinned: Boolean = false ) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt index f749a3e..541a989 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Store import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -17,6 +18,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.tiangong.aiagent.ui.common.SkeletonAgentList +import com.tiangong.aiagent.util.AnalyticsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -24,10 +26,12 @@ fun AgentListScreen( onBack: () -> Unit, onAgentSelected: (com.tiangong.aiagent.domain.model.Agent) -> Unit, onManageMemory: (agentId: String, agentName: String) -> Unit = { _, _ -> }, + onNavigateToMarket: () -> Unit = {}, viewModel: AgentListViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() var showCreateDialog by remember { mutableStateOf(false) } + val analyticsVM: AnalyticsViewModel = hiltViewModel() Scaffold( topBar = { @@ -60,6 +64,18 @@ fun AgentListScreen( .fillMaxSize() .padding(paddingValues) ) { + // Market entry + OutlinedButton( + onClick = onNavigateToMarket, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Icon(Icons.Default.Store, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("探索 Agent 市场") + } + // Search bar OutlinedTextField( value = uiState.searchQuery, @@ -114,7 +130,10 @@ fun AgentListScreen( Box { AgentListItem( agent = agent, - onClick = { onAgentSelected(agent) }, + onClick = { + analyticsVM.tracker.trackEvent("agent", "agent_switch", agentId = agent.id) + onAgentSelected(agent) + }, onLongClick = { showMenu = true } ) DropdownMenu( diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt index 1c5cda1..560e5b1 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt @@ -3,6 +3,7 @@ package com.tiangong.aiagent.ui.chat import android.net.Uri import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -34,6 +35,7 @@ import com.tiangong.aiagent.ui.chat.components.ToolCallCard import com.tiangong.aiagent.ui.chat.components.VoiceInputButton import com.tiangong.aiagent.ui.common.SkeletonChat import kotlinx.coroutines.launch +import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -52,6 +54,19 @@ fun ChatScreen( var firstLoaded by remember { mutableStateOf(false) } var isOffline by remember { mutableStateOf(false) } + // Draft save per agent + val drafts = remember { mutableStateMapOf() } + var previousAgentId by remember { mutableStateOf(null) } + val currentAgentId = uiState.currentAgent?.id + // On agent switch: save previous draft, restore new + if (previousAgentId != currentAgentId) { + if (previousAgentId != null) { + drafts[previousAgentId!!] = inputText + } + inputText = drafts[currentAgentId] ?: "" + previousAgentId = currentAgentId + } + // Drawer state val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) @@ -112,29 +127,36 @@ fun ChatScreen( val context = androidx.compose.ui.platform.LocalContext.current - // Image picker + // Image picker (multi-select, max 9) val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { selectedUri -> + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) + ) { uris: List -> + if (uris.isNotEmpty()) { scope.launch { + val files = mutableListOf() try { - val inputStream = context.contentResolver.openInputStream(selectedUri) - val ext = context.contentResolver.getType(selectedUri)?.let { mime -> - when { - mime.contains("png") -> ".png" - mime.contains("gif") -> ".gif" - mime.contains("webp") -> ".webp" - else -> ".jpg" + for ((index, uri) in uris.withIndex()) { + val inputStream = context.contentResolver.openInputStream(uri) + val ext = context.contentResolver.getType(uri)?.let { mime -> + when { + mime.contains("png") -> ".png" + mime.contains("gif") -> ".gif" + mime.contains("webp") -> ".webp" + else -> ".jpg" + } + } ?: ".jpg" + val tempFile = File(context.cacheDir, "upload_${System.currentTimeMillis()}_$index$ext") + inputStream?.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } } - } ?: ".jpg" - val tempFile = java.io.File(context.cacheDir, "upload_${System.currentTimeMillis()}$ext") - inputStream?.use { input -> - tempFile.outputStream().use { output -> input.copyTo(output) } + files.add(tempFile) + } + if (files.isNotEmpty()) { + viewModel.uploadImages(files) } - viewModel.uploadImage(tempFile) } catch (e: Exception) { Log.e("ChatScreen", "Image selection failed", e) + files.forEach { it.delete() } } } } @@ -257,7 +279,7 @@ fun ChatScreen( onRecordingStopped = { viewModel.onRecordingStopped() } ) IconButton( - onClick = { imagePickerLauncher.launch("image/*") }, + onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, modifier = Modifier.size(40.dp) ) { Icon(Icons.Default.Add, contentDescription = "添加附件", @@ -266,18 +288,21 @@ fun ChatScreen( } OutlinedTextField( value = inputText, - onValueChange = { if (it.length <= 200) inputText = it }, + onValueChange = { if (it.length <= 2000) inputText = it }, placeholder = { - Text(if (uiState.editingMessageId != null) "编辑消息..." else "输入消息...") + Text( + if (uiState.editingMessageId != null) "编辑消息..." + else "给 ${uiState.currentAgent?.name ?: "AI"} 发送消息..." + ) }, modifier = Modifier.weight(1f), maxLines = 8, enabled = !uiState.isLoading && !uiState.isStreaming, supportingText = { Text( - "${inputText.length}/200", + "${inputText.length}/2000", style = MaterialTheme.typography.bodySmall, - color = if (inputText.length >= 200) + color = if (inputText.length >= 1800) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant @@ -533,7 +558,16 @@ fun ChatScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) - val suggestions = listOf("介绍一下你自己", "你能帮我做什么?", "给我讲个笑话") + val agent = uiState.currentAgent + val suggestions = if (agent != null) { + listOf( + "介绍一下你自己", + "你能帮我做什么?", + "用你的能力帮我完成一个任务" + ) + } else { + listOf("介绍一下你自己", "你能帮我做什么?", "给我讲个笑话") + } suggestions.forEach { suggestion -> OutlinedButton( onClick = { viewModel.sendMessage(suggestion) }, @@ -602,6 +636,27 @@ fun ChatScreen( tint = MaterialTheme.colorScheme.onSurfaceVariant ) } + // Share + val shareContext = androidx.compose.ui.platform.LocalContext.current + IconButton( + onClick = { + val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, message.content) + } + shareContext.startActivity( + android.content.Intent.createChooser(shareIntent, "分享消息") + ) + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "分享", + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } // Read aloud IconButton( onClick = { viewModel.speakText(message.content) }, @@ -673,7 +728,50 @@ fun ChatScreen( } } } else { - MessageBubbleFromUi(message = message) + // User message: show bubble with copy & share actions + val clipboardManager = LocalClipboardManager.current + Column { + MessageBubbleFromUi(message = message) + Row( + modifier = Modifier.padding(end = 16.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Copy + IconButton( + onClick = { clipboardManager.setText(AnnotatedString(message.content)) }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "复制", + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Share + val shareContext = androidx.compose.ui.platform.LocalContext.current + IconButton( + onClick = { + val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, message.content) + } + shareContext.startActivity( + android.content.Intent.createChooser(shareIntent, "分享消息") + ) + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "分享", + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt index 768abad..eade082 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt @@ -20,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import com.tiangong.aiagent.util.AnalyticsTracker import java.io.File import java.util.UUID import javax.inject.Inject @@ -114,6 +115,7 @@ class ChatViewModel @Inject constructor( private val audioPlayer: AudioPlayer, private val tokenDataStore: TokenDataStore, private val savedStateHandle: SavedStateHandle, + private val analyticsTracker: AnalyticsTracker, private val gson: Gson, private val networkMonitor: com.tiangong.aiagent.util.NetworkMonitor, private val offlineQueueManager: OfflineQueueManager @@ -429,6 +431,9 @@ class ChatViewModel @Inject constructor( val agentId = _uiState.value.currentAgent?.id val sessionId = _uiState.value.sessionId ?: UUID.randomUUID().toString() + // Analytics: track message send + analyticsTracker.trackEvent("chat", "message_send", agentId = agentId) + // v1.2.0: Queue message when offline if (_uiState.value.isOffline) { val userMsg = UiMessage( @@ -768,6 +773,27 @@ class ChatViewModel @Inject constructor( } } + fun uploadImages(files: List) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isTranscribing = true) + val results = mutableListOf() + for (file in files) { + chatRepository.uploadImage(file) + .onSuccess { markdownUrl -> results.add(markdownUrl) } + .onFailure { e -> + Log.e("ChatViewModel", "Image upload failed: ${file.name}", e) + } + file.delete() + } + if (results.isNotEmpty()) { + _uiState.value = _uiState.value.copy(transcribedText = results.joinToString(" ")) + } else { + _uiState.value = _uiState.value.copy(error = "图片上传失败") + } + _uiState.value = _uiState.value.copy(isTranscribing = false) + } + } + fun clearTranscribedText() { _uiState.value = _uiState.value.copy(transcribedText = null) } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt new file mode 100644 index 0000000..66f0b8b --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/common/ZoomableImage.kt @@ -0,0 +1,126 @@ +package com.tiangong.aiagent.ui.common + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage +import coil.request.ImageRequest +import androidx.compose.ui.platform.LocalContext + +/** + * A zoomable, pannable image viewer — Compose equivalent of the PhotoView + * pattern used in zhini_im (Chris Banes' PhotoView). + * + * Supports: + * - Pinch-to-zoom (1x ~ 5x) + * - Pan when zoomed in + * - Double-tap to toggle between 1x and 3x + * - Tap to dismiss callback + */ +@Composable +fun ZoomableImage( + imageUrl: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + onTap: (() -> Unit)? = null +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + fun resetZoom() { + scale = 1f + offsetX = 0f + offsetY = 0f + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = Modifier + .matchParentSize() + .graphicsLayer( + scaleX = scale.coerceIn(0.5f, 5f), + scaleY = scale.coerceIn(0.5f, 5f), + translationX = offsetX, + translationY = offsetY + ) + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(0.5f, 5f) + scale = newScale + + if (newScale > 1f) { + // Pan when zoomed in + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + } + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + if (scale > 1.5f) { + resetZoom() + } else { + scale = 3f + } + }, + onTap = { + if (scale <= 1.1f) { + onTap?.invoke() + } else { + resetZoom() + } + } + ) + } + ) + } +} + +/** + * Full-screen zoomable image viewer with dark background and tap-to-dismiss. + * Equivalent to zhini_im's ImagePreviewActivity. + */ +@Composable +fun FullScreenImageViewer( + imageUrl: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .graphicsLayer { alpha = 0.95f } + .pointerInput(Unit) { + // Simple tap on background to dismiss + detectTapGestures( + onTap = { onDismiss() } + ) + }, + contentAlignment = Alignment.Center + ) { + ZoomableImage( + imageUrl = imageUrl, + onTap = onDismiss + ) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt index 6a6c58a..8fad36a 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/history/ConversationListScreen.kt @@ -72,6 +72,10 @@ class ConversationListViewModel @Inject constructor( loadJob?.cancel() loadJob = viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true) + // Sync session state from server (pin/rename) + if (agentId != null) { + chatRepository.syncSessionStateFromServer(agentId) + } chatRepository.getConversations().collect { conversations -> val filtered = if (agentId != null) { conversations.filter { it.agentId == agentId } @@ -121,6 +125,13 @@ class ConversationListViewModel @Inject constructor( chatRepository.updateConversationTitle(sessionId, newTitle) } } + + // v1.1.0: Pin/unpin conversation + fun pinConversation(sessionId: String, isPinned: Boolean) { + viewModelScope.launch { + chatRepository.pinConversation(sessionId, isPinned) + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -232,6 +243,9 @@ fun ConversationListScreen( onDelete = { viewModel.deleteConversation(conversation.sessionId) }, onRename = { newTitle -> viewModel.renameConversation(conversation.sessionId, newTitle) + }, + onPin = { + viewModel.pinConversation(conversation.sessionId, !conversation.isPinned) } ) } @@ -247,7 +261,8 @@ fun ConversationListItem( conversation: Conversation, onClick: () -> Unit, onDelete: () -> Unit, - onRename: (String) -> Unit = {} + onRename: (String) -> Unit = {}, + onPin: () -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } @@ -286,13 +301,24 @@ fun ConversationListItem( Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = conversation.title ?: "新对话", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (conversation.isPinned) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = "已置顶", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = conversation.title ?: "新对话", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } if (conversation.lastMessage != null) { Spacer(modifier = Modifier.height(2.dp)) Text( @@ -342,6 +368,20 @@ fun ConversationListItem( Icon(Icons.Default.Create, contentDescription = null, modifier = Modifier.size(18.dp)) } ) + DropdownMenuItem( + text = { Text(if (conversation.isPinned) "取消置顶" else "置顶") }, + onClick = { + showMenu = false + onPin() + }, + leadingIcon = { + Icon( + Icons.Default.PushPin, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) DropdownMenuItem( text = { Text("删除", color = MaterialTheme.colorScheme.error) }, onClick = { diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt index 1efb9af..44199bc 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/LoginScreen.kt @@ -1,8 +1,12 @@ package com.tiangong.aiagent.ui.login +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Visibility @@ -11,6 +15,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight @@ -20,15 +25,23 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat.startActivity import android.content.Intent import android.net.Uri import androidx.hilt.navigation.compose.hiltViewModel +import com.tiangong.aiagent.util.AnalyticsViewModel + +// ── zhini_im inspired colors ────────────────────────────────────────────── +private val ZhiNiBg = Color(0xFFF5F7FA) +private val ZhiNiBlue = Color(0xFF3B62E0) +private val ZhiNiTextPrimary = Color(0xFF1A193E) +private val ZhiNiTextHint = Color(0xFFABB1C1) +private val ZhiNiDivider = Color(0xFFE8E8E8) @Composable fun LoginScreen( onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit = {}, + onNavigateToPhoneLogin: () -> Unit = {}, viewModel: LoginViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -36,44 +49,39 @@ fun LoginScreen( var passwordVisible by remember { mutableStateOf(false) } var isEditingUrl by remember { mutableStateOf(false) } var editedUrl by remember { mutableStateOf("") } + val analyticsVM: AnalyticsViewModel = hiltViewModel() LaunchedEffect(uiState.isLoggedIn) { if (uiState.isLoggedIn) { + analyticsVM.tracker.trackEvent("auth", "login_success") onLoginSuccess() } } Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = ZhiNiBg ) { Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.Start ) { + Spacer(modifier = Modifier.height(100.dp)) + + // ── Title ───────────────────────────────────────────────────── Text( - text = "天工智能体", - style = MaterialTheme.typography.headlineLarge.copy( - fontWeight = FontWeight.Bold, - fontSize = 28.sp - ), - color = MaterialTheme.colorScheme.primary + text = "密码登录", + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + color = ZhiNiTextPrimary ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(32.dp)) - Text( - text = "AI Agent Platform", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(36.dp)) - - // Server URL + // ── Server URL (compact, tap to edit) ───────────────────────── if (isEditingUrl) { // Preset quick-select buttons Row( @@ -82,42 +90,29 @@ fun LoginScreen( ) { val isLocalSelected = editedUrl == "http://192.168.31.135:8037" val isCloudSelected = editedUrl == "http://101.43.95.130:8037" - OutlinedButton( + FilterChip( + selected = isLocalSelected, onClick = { editedUrl = "http://192.168.31.135:8037" }, - colors = if (isLocalSelected) { - ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - } else { - ButtonDefaults.outlinedButtonColors() - } - ) { - Text("本地") - } - OutlinedButton( + label = { Text("本地") }, + enabled = !uiState.isLoading + ) + FilterChip( + selected = isCloudSelected, onClick = { editedUrl = "http://101.43.95.130:8037" }, - colors = if (isCloudSelected) { - ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - } else { - ButtonDefaults.outlinedButtonColors() - } - ) { - Text("云服务") - } + label = { Text("云服务") }, + enabled = !uiState.isLoading + ) } Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = editedUrl, onValueChange = { editedUrl = it }, - label = { Text("服务器地址") }, placeholder = { Text("http://101.43.95.130:8037") }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), enabled = !uiState.isLoading, - supportingText = { Text("点击上方快捷选择,或手动输入后保存") } + textStyle = MaterialTheme.typography.bodySmall ) Row( modifier = Modifier.fillMaxWidth(), @@ -126,9 +121,7 @@ fun LoginScreen( TextButton( onClick = { isEditingUrl = false }, enabled = !uiState.isLoading - ) { - Text("取消") - } + ) { Text("取消") } Spacer(modifier = Modifier.width(8.dp)) TextButton( onClick = { @@ -136,78 +129,112 @@ fun LoginScreen( isEditingUrl = false }, enabled = !uiState.isLoading - ) { - Text("保存") - } + ) { Text("保存") } } } else { - TextButton( - onClick = { - editedUrl = uiState.serverUrl - isEditingUrl = true - }, - enabled = !uiState.isLoading, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = if (uiState.serverUrl.isNotBlank()) uiState.serverUrl else "点击配置服务器地址", - style = MaterialTheme.typography.bodySmall, - maxLines = 1 + Text( + text = if (uiState.serverUrl.isNotBlank()) uiState.serverUrl else "点击配置服务器地址", + style = MaterialTheme.typography.bodySmall, + color = ZhiNiTextHint, + modifier = Modifier + .clickable(enabled = !uiState.isLoading) { + editedUrl = uiState.serverUrl + isEditingUrl = true + } + .padding(vertical = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // ── Username field ──────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "用户名", + fontSize = 16.sp, + color = ZhiNiTextPrimary + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.weight(1f)) { + if (uiState.username.isEmpty()) { + Text( + text = "请输入用户名", + fontSize = 15.sp, + color = ZhiNiTextHint, + modifier = Modifier.align(Alignment.CenterStart) + ) + } + TextField( + value = uiState.username, + onValueChange = viewModel::onUsernameChange, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = zhiniTextFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !uiState.isLoading ) } } + HorizontalDivider(color = if (uiState.username.isNotEmpty()) ZhiNiBlue else ZhiNiDivider, thickness = 1.dp) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // Username - OutlinedTextField( - value = uiState.username, - onValueChange = viewModel::onUsernameChange, - label = { Text("用户名") }, - singleLine = true, + // ── Password field ──────────────────────────────────────────── + Row( modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - enabled = !uiState.isLoading - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Password - OutlinedTextField( - value = uiState.password, - onValueChange = viewModel::onPasswordChange, - label = { Text("密码") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (passwordVisible) "隐藏密码" else "显示密码" + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "密 码", + fontSize = 16.sp, + color = ZhiNiTextPrimary + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.weight(1f)) { + if (uiState.password.isEmpty()) { + Text( + text = "请输入密码", + fontSize = 15.sp, + color = ZhiNiTextHint, + modifier = Modifier.align(Alignment.CenterStart) ) } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - viewModel.login() - } - ), - enabled = !uiState.isLoading - ) + TextField( + value = uiState.password, + onValueChange = viewModel::onPasswordChange, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = zhiniTextFieldColors(), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "隐藏密码" else "显示密码", + tint = ZhiNiTextHint + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + if (uiState.agreedTerms) viewModel.login() + } + ), + enabled = !uiState.isLoading + ) + } + } + HorizontalDivider(color = if (uiState.password.isNotEmpty()) ZhiNiBlue else ZhiNiDivider, thickness = 1.dp) - // Error + // ── Error message ───────────────────────────────────────────── uiState.error?.let { errorText -> Spacer(modifier = Modifier.height(12.dp)) Text( @@ -217,9 +244,30 @@ fun LoginScreen( ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) - // Agreement checkbox + // ── Links row: phone login + register ───────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "使用验证码登录", + color = ZhiNiBlue, + fontSize = 14.sp, + modifier = Modifier.clickable(enabled = !uiState.isLoading) { onNavigateToPhoneLogin() } + ) + Text( + text = "注册", + color = ZhiNiBlue, + fontSize = 14.sp, + modifier = Modifier.clickable(enabled = !uiState.isLoading) { onNavigateToRegister() } + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + + // ── Agreement checkbox ──────────────────────────────────────── val context = LocalContext.current Row( modifier = Modifier.fillMaxWidth(), @@ -228,33 +276,40 @@ fun LoginScreen( Checkbox( checked = uiState.agreedTerms, onCheckedChange = viewModel::onAgreedTermsChanged, - enabled = !uiState.isLoading + enabled = !uiState.isLoading, + colors = CheckboxDefaults.colors(checkedColor = ZhiNiBlue) + ) + Text( + text = "我已阅读并同意", + style = MaterialTheme.typography.bodySmall, + color = ZhiNiTextHint + ) + Text( + text = "《隐私政策》", + style = MaterialTheme.typography.bodySmall, + color = ZhiNiBlue, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy"))) + } + ) + Text( + text = "和", + style = MaterialTheme.typography.bodySmall, + color = ZhiNiTextHint + ) + Text( + text = "《用户协议》", + style = MaterialTheme.typography.bodySmall, + color = ZhiNiBlue, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms"))) + } ) - Text("我已阅读并同意", style = MaterialTheme.typography.bodySmall) - TextButton( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy")) - context.startActivity(intent) - }, - contentPadding = PaddingValues(horizontal = 2.dp) - ) { - Text("《隐私政策》", style = MaterialTheme.typography.bodySmall) - } - Text("和", style = MaterialTheme.typography.bodySmall) - TextButton( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms")) - context.startActivity(intent) - }, - contentPadding = PaddingValues(horizontal = 2.dp) - ) { - Text("《用户协议》", style = MaterialTheme.typography.bodySmall) - } } Spacer(modifier = Modifier.height(16.dp)) - // Login button + // ── Login button ────────────────────────────────────────────── Button( onClick = { focusManager.clearFocus() @@ -263,63 +318,70 @@ fun LoginScreen( modifier = Modifier .fillMaxWidth() .height(50.dp), - enabled = !uiState.isLoading && uiState.agreedTerms + enabled = !uiState.isLoading && uiState.agreedTerms, + colors = ButtonDefaults.buttonColors( + containerColor = ZhiNiBlue, + disabledContainerColor = ZhiNiBlue.copy(alpha = 0.4f), + disabledContentColor = Color.White.copy(alpha = 0.6f) + ), + shape = MaterialTheme.shapes.medium ) { if (uiState.isLoading) { CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(22.dp), + color = Color.White, strokeWidth = 2.dp ) } else { - Text("登录", style = MaterialTheme.typography.titleMedium) + Text("登录", fontSize = 16.sp, color = Color.White) } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(32.dp)) - // Register link - TextButton(onClick = onNavigateToRegister) { - Text("没有账户?立即注册") + // ── Bottom privacy policy ───────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "隐私政策", + style = MaterialTheme.typography.labelSmall, + color = ZhiNiTextHint, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy"))) + } + ) + Text( + text = " | ", + style = MaterialTheme.typography.labelSmall, + color = ZhiNiTextHint + ) + Text( + text = "用户协议", + style = MaterialTheme.typography.labelSmall, + color = ZhiNiTextHint, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms"))) + } + ) } - - Spacer(modifier = Modifier.height(24.dp)) - - // Privacy policy & user agreement - PrivacyPolicyRow( - onPrivacyPolicy = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/privacy")) - context.startActivity(intent) - }, - onUserAgreement = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://tiangong.ai/terms")) - context.startActivity(intent) - } - ) } } } +/** zhini_im-style TextField: transparent background, no border, compact. */ @Composable -fun PrivacyPolicyRow( - onPrivacyPolicy: () -> Unit, - onUserAgreement: () -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - TextButton(onClick = onPrivacyPolicy, contentPadding = PaddingValues(horizontal = 4.dp)) { - Text("隐私政策", style = MaterialTheme.typography.labelSmall) - } - Text( - text = "|", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp) - ) - TextButton(onClick = onUserAgreement, contentPadding = PaddingValues(horizontal = 4.dp)) { - Text("用户协议", style = MaterialTheme.typography.labelSmall) - } - } -} +private fun zhiniTextFieldColors() = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = ZhiNiTextPrimary, + unfocusedTextColor = ZhiNiTextPrimary, + cursorColor = ZhiNiBlue +) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt new file mode 100644 index 0000000..1c3fbcd --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PhoneLoginScreen.kt @@ -0,0 +1,294 @@ +package com.tiangong.aiagent.ui.login + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.PhoneLoginRequest +import com.tiangong.aiagent.data.remote.dto.PhoneSendCodeRequest +import com.tiangong.aiagent.data.remote.dto.PhoneLoginResponse +import com.tiangong.aiagent.data.local.TokenDataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class PhoneLoginState( + val phone: String = "", + val code: String = "", + val countdown: Int = 0, // 0 = ready, >0 = counting + val isSending: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, + val token: String? = null, + val refreshToken: String? = null +) + +@HiltViewModel +class PhoneLoginViewModel @Inject constructor( + private val apiService: ApiService, + private val tokenDataStore: TokenDataStore +) : ViewModel() { + + private val _uiState = MutableStateFlow(PhoneLoginState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updatePhone(phone: String) { + val digits = phone.filter { it.isDigit() }.take(11) + _uiState.value = _uiState.value.copy(phone = digits, error = null) + } + + fun updateCode(code: String) { + val digits = code.filter { it.isDigit() }.take(6) + _uiState.value = _uiState.value.copy(code = digits, error = null) + } + + fun sendCode() { + val phone = _uiState.value.phone + if (phone.length != 11) { + _uiState.value = _uiState.value.copy(error = "请输入正确的手机号") + return + } + if (_uiState.value.countdown > 0) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSending = true) + try { + apiService.sendPhoneCode(PhoneSendCodeRequest(phone = phone)) + _uiState.value = _uiState.value.copy(isSending = false, countdown = 60) + // Start countdown + launch { + for (i in 60 downTo 1) { + delay(1000) + _uiState.value = _uiState.value.copy(countdown = i) + } + } + } catch (e: Exception) { + val msg = if (e is retrofit2.HttpException) { + when (e.code()) { + 429 -> "操作过于频繁,请稍后再试" + else -> "发送失败,请重试" + } + } else { + "网络错误,请检查连接" + } + _uiState.value = _uiState.value.copy(isSending = false, error = msg) + } + } + } + + fun login() { + val state = _uiState.value + if (state.phone.length != 11) { + _uiState.value = _uiState.value.copy(error = "请输入正确的手机号") + return + } + if (state.code.length < 4) { + _uiState.value = _uiState.value.copy(error = "请输入验证码") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + try { + val response = apiService.phoneLogin( + PhoneLoginRequest(phone = state.phone, code = state.code) + ) + // Save tokens + tokenDataStore.saveToken(response.accessToken) + response.refreshToken?.let { tokenDataStore.saveRefreshToken(it) } + _uiState.value = _uiState.value.copy( + isLoading = false, + token = response.accessToken, + refreshToken = response.refreshToken + ) + } catch (e: Exception) { + val msg = if (e is retrofit2.HttpException) { + when (e.code()) { + 400 -> "验证码错误或已过期" + 403 -> "该账号已注销" + else -> "登录失败,请重试" + } + } else { + "网络错误,请检查连接" + } + _uiState.value = _uiState.value.copy(isLoading = false, error = msg) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PhoneLoginScreen( + onNavigateBack: () -> Unit, + onLoginSuccess: () -> Unit, + viewModel: PhoneLoginViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val focusManager = LocalFocusManager.current + + LaunchedEffect(uiState.token) { + if (uiState.token != null) { + onLoginSuccess() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("手机号登录") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(48.dp)) + + // Header + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "验证码登录", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + "未注册的手机号将自动创建账号", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Phone input + OutlinedTextField( + value = uiState.phone, + onValueChange = viewModel::updatePhone, + label = { Text("手机号") }, + placeholder = { Text("请输入手机号") }, + leadingIcon = { Icon(Icons.Default.Phone, contentDescription = null) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Code input + send button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + OutlinedTextField( + value = uiState.code, + onValueChange = viewModel::updateCode, + label = { Text("验证码") }, + placeholder = { Text("6位验证码") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + viewModel.login() + } + ), + modifier = Modifier.weight(1f) + ) + + OutlinedButton( + onClick = { viewModel.sendCode() }, + enabled = uiState.phone.length == 11 && uiState.countdown == 0 && !uiState.isSending, + modifier = Modifier + .height(56.dp) + .width(120.dp) + ) { + if (uiState.isSending) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + Text( + if (uiState.countdown > 0) "${uiState.countdown}s" else "发送验证码", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + // Error + if (uiState.error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Login button + Button( + onClick = { viewModel.login() }, + enabled = !uiState.isLoading && uiState.phone.length == 11 && uiState.code.length >= 4, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = MaterialTheme.shapes.large + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("登录", style = MaterialTheme.typography.titleSmall) + } + } + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt new file mode 100644 index 0000000..4025aba --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/login/PrivacyPolicyRow.kt @@ -0,0 +1,38 @@ +package com.tiangong.aiagent.ui.login + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PrivacyPolicyRow( + onPrivacyPolicy: () -> Unit, + onUserAgreement: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onPrivacyPolicy) { + Text("隐私政策", fontSize = 12.sp) + } + Text(" | ", fontSize = 12.sp, color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = onUserAgreement) { + Text("用户协议", fontSize = 12.sp) + } + } + Text( + text = "继续使用即表示您同意以上协议", + fontSize = 10.sp, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt new file mode 100644 index 0000000..72ee542 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt @@ -0,0 +1,555 @@ +package com.tiangong.aiagent.ui.market + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.tiangong.aiagent.data.remote.dto.AgentMarketItemDto +import com.tiangong.aiagent.data.remote.dto.SceneTemplateItemDto +import com.tiangong.aiagent.data.remote.dto.TemplateItemDto + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MarketScreen( + onNavigateBack: () -> Unit, + viewModel: MarketViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var searchText by remember { mutableStateOf("") } + + // Snackbar for success/error + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(uiState.installSuccessMessage) { + uiState.installSuccessMessage?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + LaunchedEffect(uiState.error) { + uiState.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Agent 市场") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + // Tab Row + TabRow(selectedTabIndex = uiState.selectedTab) { + Tab( + selected = uiState.selectedTab == 0, + onClick = { viewModel.selectTab(0) }, + text = { Text("推荐") } + ) + Tab( + selected = uiState.selectedTab == 1, + onClick = { viewModel.selectTab(1) }, + text = { Text("市场") } + ) + Tab( + selected = uiState.selectedTab == 2, + onClick = { viewModel.selectTab(2) }, + text = { Text("模板") } + ) + } + + when (uiState.selectedTab) { + 0 -> FeaturedTab(uiState, viewModel) + 1 -> AllAgentsTab(uiState, viewModel, searchText) { searchText = it } + 2 -> TemplatesTab(uiState, viewModel) + } + } + } +} + +// ─── Featured Tab ───────────────────────────────────────────────────── + +@Composable +private fun FeaturedTab(uiState: MarketUiState, viewModel: MarketViewModel) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Recommended section + item { + Text( + "精选推荐", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + if (uiState.isLoadingFeatured) { + item { LoadingIndicator() } + } else { + items(uiState.featuredAgents.chunked(2)) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + row.forEach { agent -> + AgentCard( + agent = agent, + isInstalling = agent.id in uiState.isInstalling, + onInstall = { viewModel.installAgent(agent) }, + modifier = Modifier.weight(1f) + ) + } + // Fill empty slot if odd count + if (row.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + + // Scene templates section + if (uiState.sceneTemplates.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "场景模板", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + items(uiState.sceneTemplates) { template -> + SceneTemplateCard(template, viewModel) + } + } + } +} + +// ─── All Agents Tab ──────────────────────────────────────────────────── + +@Composable +private fun AllAgentsTab( + uiState: MarketUiState, + viewModel: MarketViewModel, + searchText: String, + onSearchTextChange: (String) -> Unit +) { + Column(modifier = Modifier.fillMaxSize()) { + // Search bar + OutlinedTextField( + value = searchText, + onValueChange = onSearchTextChange, + placeholder = { Text("搜索智能体...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Trigger search + LaunchedEffect(searchText) { + viewModel.setSearchQuery(searchText) + viewModel.search() + } + + // Category chips + if (uiState.categories.isNotEmpty()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + FilterChip( + selected = uiState.selectedCategory == null, + onClick = { viewModel.selectCategory(null) }, + label = { Text("全部") } + ) + } + items(uiState.categories) { cat -> + FilterChip( + selected = uiState.selectedCategory == cat.category, + onClick = { viewModel.selectCategory(cat.category) }, + label = { Text("${cat.category} (${cat.count})") } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Agent list + if (uiState.isLoadingAll) { + LoadingIndicator() + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(uiState.allAgents) { agent -> + AgentListItem(agent, agent.id in uiState.isInstalling) { + viewModel.installAgent(agent) + } + } + } + } + } +} + +// ─── Templates Tab ───────────────────────────────────────────────────── + +@Composable +private fun TemplatesTab(uiState: MarketUiState, viewModel: MarketViewModel) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (uiState.isLoadingTemplates) { + item { LoadingIndicator() } + } else { + item { + Text( + "工作流模板", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + items(uiState.templates) { template -> + TemplateCard(template, template.id in uiState.isInstalling) { + viewModel.useTemplate(template) + } + } + } + } +} + +// ─── Components ──────────────────────────────────────────────────────── + +@Composable +private fun AgentCard( + agent: AgentMarketItemDto, + isInstalling: Boolean, + onInstall: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + agent.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + agent.description ?: "暂无描述", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 16.sp + ) + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFFFFB800) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + "${agent.ratingAvg}", + style = MaterialTheme.typography.labelSmall + ) + Text( + " (${agent.useCount}次)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + FilledTonalButton( + onClick = onInstall, + enabled = !isInstalling, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("安装", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } +} + +@Composable +private fun AgentListItem( + agent: AgentMarketItemDto, + isInstalling: Boolean, + onInstall: () -> Unit +) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + agent.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + agent.description ?: "暂无描述", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Star, contentDescription = null, + modifier = Modifier.size(14.dp), tint = Color(0xFFFFB800)) + Spacer(modifier = Modifier.width(2.dp)) + Text("${agent.ratingAvg} (${agent.ratingCount}评)", style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.width(12.dp)) + Text("${agent.useCount}次使用", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + if (agent.creatorUsername != null) { + Spacer(modifier = Modifier.width(12.dp)) + Text("@${agent.creatorUsername}", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary) + } + } + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onInstall, + enabled = !isInstalling, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("安装") + } + } + } + } +} + +@Composable +private fun TemplateCard( + template: TemplateItemDto, + isInstalling: Boolean, + onUse: () -> Unit +) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + template.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + if (!template.description.isNullOrBlank()) { + Text( + template.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${template.useCount}次使用", style = MaterialTheme.typography.labelSmall) + if (template.creatorUsername != null) { + Spacer(modifier = Modifier.width(8.dp)) + Text("@${template.creatorUsername}", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary) + } + } + } + Spacer(modifier = Modifier.width(12.dp)) + FilledTonalButton( + onClick = onUse, + enabled = !isInstalling + ) { + if (isInstalling) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("使用") + } + } + } + } +} + +@Composable +private fun SceneTemplateCard( + template: SceneTemplateItemDto, + viewModel: MarketViewModel +) { + var showNameDialog by remember { mutableStateOf(false) } + var agentName by remember { mutableStateOf(template.name) } + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + template.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + if (!template.description.isNullOrBlank()) { + Text( + template.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (template.category != null) { + Text( + template.category, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + FilledTonalButton(onClick = { showNameDialog = true }) { + Text("创建") + } + } + } + + if (showNameDialog) { + AlertDialog( + onDismissRequest = { showNameDialog = false }, + title = { Text("从模板创建智能体") }, + text = { + OutlinedTextField( + value = agentName, + onValueChange = { agentName = it }, + label = { Text("智能体名称") }, + singleLine = true + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.createFromSceneTemplate(template, agentName) + showNameDialog = false + }, + enabled = agentName.isNotBlank() + ) { + Text("创建") + } + }, + dismissButton = { + TextButton(onClick = { showNameDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +private fun LoadingIndicator() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt new file mode 100644 index 0000000..fab30cb --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketViewModel.kt @@ -0,0 +1,233 @@ +package com.tiangong.aiagent.ui.market + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tiangong.aiagent.data.remote.dto.AgentMarketItemDto +import com.tiangong.aiagent.data.remote.dto.MarketCategoryDto +import com.tiangong.aiagent.data.remote.dto.SceneTemplateItemDto +import com.tiangong.aiagent.data.remote.dto.TemplateItemDto +import com.tiangong.aiagent.data.repository.AgentMarketRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MarketUiState( + val selectedTab: Int = 0, // 0=Featured, 1=All, 2=Templates + val featuredAgents: List = emptyList(), + val allAgents: List = emptyList(), + val templates: List = emptyList(), + val sceneTemplates: List = emptyList(), + val categories: List = emptyList(), + val selectedCategory: String? = null, + val searchQuery: String = "", + val isLoadingFeatured: Boolean = false, + val isLoadingAll: Boolean = false, + val isLoadingTemplates: Boolean = false, + val isInstalling: Set = emptySet(), // agent IDs being installed + val installSuccessMessage: String? = null, + val error: String? = null +) + +@HiltViewModel +class MarketViewModel @Inject constructor( + private val marketRepository: AgentMarketRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(MarketUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadFeatured() + loadAllAgents() + loadTemplates() + loadCategories() + loadSceneTemplates() + } + + fun selectTab(tab: Int) { + _uiState.value = _uiState.value.copy(selectedTab = tab) + } + + fun selectCategory(category: String?) { + _uiState.value = _uiState.value.copy(selectedCategory = category) + loadAllAgents() + } + + fun setSearchQuery(query: String) { + _uiState.value = _uiState.value.copy(searchQuery = query) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null, installSuccessMessage = null) + } + + // ─── Load ─── + + fun loadFeatured() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingFeatured = true) + marketRepository.getFeaturedAgents(10).fold( + onSuccess = { agents -> + _uiState.value = _uiState.value.copy( + featuredAgents = agents, + isLoadingFeatured = false + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoadingFeatured = false, + error = "加载推荐失败: ${e.message}" + ) + } + ) + } + } + + fun loadAllAgents() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingAll = true) + val state = _uiState.value + marketRepository.browseAgents( + search = state.searchQuery.ifBlank { null }, + category = state.selectedCategory, + sortBy = "use_count" + ).fold( + onSuccess = { agents -> + _uiState.value = _uiState.value.copy( + allAgents = agents, + isLoadingAll = false + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoadingAll = false, + error = "加载市场失败: ${e.message}" + ) + } + ) + } + } + + fun loadTemplates() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingTemplates = true) + marketRepository.browseTemplates().fold( + onSuccess = { tmpl -> + _uiState.value = _uiState.value.copy( + templates = tmpl, + isLoadingTemplates = false + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoadingTemplates = false, + error = "加载模板失败: ${e.message}" + ) + } + ) + } + } + + private fun loadCategories() { + viewModelScope.launch { + marketRepository.getCategories().fold( + onSuccess = { cats -> + _uiState.value = _uiState.value.copy(categories = cats) + }, + onFailure = { /* silently ignore */ } + ) + } + } + + private fun loadSceneTemplates() { + viewModelScope.launch { + marketRepository.getSceneTemplates().fold( + onSuccess = { st -> + _uiState.value = _uiState.value.copy(sceneTemplates = st) + }, + onFailure = { /* silently ignore */ } + ) + } + } + + // ─── Actions ─── + + fun installAgent(agent: AgentMarketItemDto) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling + agent.id, + error = null + ) + marketRepository.installAgent(agent.id).fold( + onSuccess = { resp -> + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling - agent.id, + installSuccessMessage = "已安装: ${resp.agentName}" + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling - agent.id, + error = "安装失败: ${e.message}" + ) + } + ) + } + } + + fun useTemplate(template: TemplateItemDto) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling + template.id, + error = null + ) + marketRepository.useTemplate(template.id).fold( + onSuccess = { resp -> + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling - template.id, + installSuccessMessage = "已从模板创建: ${resp.name}" + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling - template.id, + error = "使用模板失败: ${e.message}" + ) + } + ) + } + } + + fun createFromSceneTemplate(template: SceneTemplateItemDto, agentName: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling + template.id, + error = null + ) + marketRepository.createFromSceneTemplate( + name = agentName, + sceneTemplateId = template.id + ).fold( + onSuccess = { resp -> + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling - template.id, + installSuccessMessage = "已创建: ${resp.name}" + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isInstalling = _uiState.value.isInstalling - template.id, + error = "创建失败: ${e.message}" + ) + } + ) + } + } + + fun search() { + loadAllAgents() + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt index 2c11833..f10de44 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/navigation/NavGraph.kt @@ -16,18 +16,26 @@ import com.tiangong.aiagent.ui.chat.ChatScreen import com.tiangong.aiagent.ui.chat.ChatViewModel import com.tiangong.aiagent.ui.history.ConversationListScreen import com.tiangong.aiagent.ui.login.LoginScreen +import com.tiangong.aiagent.ui.login.PhoneLoginScreen +import com.tiangong.aiagent.ui.market.MarketScreen +import com.tiangong.aiagent.ui.subscription.SubscriptionScreen import com.tiangong.aiagent.ui.memory.MemoryManageScreen import com.tiangong.aiagent.ui.notifications.NotificationDetailScreen import com.tiangong.aiagent.ui.notifications.NotificationsScreen +import com.tiangong.aiagent.ui.onboarding.OnboardingScreen import com.tiangong.aiagent.ui.register.RegisterScreen import com.tiangong.aiagent.ui.settings.AboutScreen import com.tiangong.aiagent.ui.settings.AccountSecurityScreen +import com.tiangong.aiagent.ui.settings.NetworkDiagnoseScreen import com.tiangong.aiagent.ui.settings.SettingsScreen +import com.tiangong.aiagent.ui.splash.SplashScreen +import com.tiangong.aiagent.util.AnalyticsViewModel private const val CS_AGENT_ID = "3490efa8-330d-458d-a26e-0b6c1d72e6f4" private const val CS_AGENT_NAME = "天工客服" object Routes { + const val SPLASH = "splash" const val LOGIN = "login" const val REGISTER = "register" const val CHAT = "chat" @@ -39,16 +47,29 @@ object Routes { const val NOTIFICATIONS = "notifications" const val NOTIFICATION_DETAIL = "notifications/{notificationId}" const val MEMORY_MANAGE = "memory_manage/{agentId}/{agentName}" + const val ONBOARDING = "onboarding" + const val PHONE_LOGIN = "phone_login" + const val MARKET = "market" + const val SUBSCRIPTION = "subscription" + const val NETWORK_DIAGNOSE = "network_diagnose" } @Composable fun NavGraph( token: String?, + onboardingCompleted: Boolean = false, + onOnboardingComplete: () -> Unit = {}, initialNotificationId: String? = null, navController: NavHostController = rememberNavController() ) { - val startDest = remember(token) { - if (token != null) Routes.CHAT else Routes.LOGIN + val startDest = Routes.SPLASH + + // Analytics: app open + val analyticsVM: AnalyticsViewModel = hiltViewModel() + LaunchedEffect(token) { + if (token != null) { + analyticsVM.tracker.trackEvent("app", "app_open") + } } LaunchedEffect(initialNotificationId) { @@ -61,6 +82,28 @@ fun NavGraph( navController = navController, startDestination = startDest ) { + composable(Routes.SPLASH) { + SplashScreen( + token = token, + onboardingCompleted = onboardingCompleted, + onNavigateToLogin = { + navController.navigate(Routes.LOGIN) { + popUpTo(Routes.SPLASH) { inclusive = true } + } + }, + onNavigateToOnboarding = { + navController.navigate(Routes.ONBOARDING) { + popUpTo(Routes.SPLASH) { inclusive = true } + } + }, + onNavigateToChat = { + navController.navigate(Routes.CHAT) { + popUpTo(Routes.SPLASH) { inclusive = true } + } + } + ) + } + composable(Routes.LOGIN) { LoginScreen( onLoginSuccess = { @@ -70,6 +113,9 @@ fun NavGraph( }, onNavigateToRegister = { navController.navigate(Routes.REGISTER) + }, + onNavigateToPhoneLogin = { + navController.navigate(Routes.PHONE_LOGIN) } ) } @@ -115,6 +161,9 @@ fun NavGraph( onManageMemory = { agentId, agentName -> val encoded = java.net.URLEncoder.encode(agentName, "UTF-8") navController.navigate("memory_manage/$agentId/$encoded") + }, + onNavigateToMarket = { + navController.navigate(Routes.MARKET) } ) } @@ -155,7 +204,9 @@ fun NavGraph( ) chatViewModel.switchAgent(csAgent) navController.popBackStack() - } + }, + onNavigateToSubscription = { navController.navigate(Routes.SUBSCRIPTION) }, + onNavigateToNetworkDiagnose = { navController.navigate(Routes.NETWORK_DIAGNOSE) } ) } @@ -202,5 +253,45 @@ fun NavGraph( onBack = { navController.popBackStack() } ) } + + composable(Routes.ONBOARDING) { + OnboardingScreen( + onComplete = { + onOnboardingComplete() + navController.navigate(Routes.CHAT) { + popUpTo(Routes.ONBOARDING) { inclusive = true } + } + } + ) + } + + composable(Routes.PHONE_LOGIN) { + PhoneLoginScreen( + onNavigateBack = { navController.popBackStack() }, + onLoginSuccess = { + navController.navigate(Routes.CHAT) { + popUpTo(0) { inclusive = true } + } + } + ) + } + + composable(Routes.MARKET) { + MarketScreen( + onNavigateBack = { navController.popBackStack() } + ) + } + + composable(Routes.SUBSCRIPTION) { + SubscriptionScreen( + onBack = { navController.popBackStack() } + ) + } + + composable(Routes.NETWORK_DIAGNOSE) { + NetworkDiagnoseScreen( + onBack = { navController.popBackStack() } + ) + } } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..8d37899 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,183 @@ +package com.tiangong.aiagent.ui.onboarding + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +private data class OnboardingPage( + val icon: ImageVector, + val title: String, + val description: String +) + +private val pages = listOf( + OnboardingPage( + icon = Icons.AutoMirrored.Filled.Chat, + title = "与智能体对话", + description = "选择你感兴趣的智能体,开始自然流畅的AI对话。每个智能体都有独特的能力和知识领域,随时为你提供帮助。" + ), + OnboardingPage( + icon = Icons.Default.SmartToy, + title = "切换不同智能体", + description = "从智能体市场选择适合你的AI助手。每个智能体擅长不同的任务——编程、写作、客服、数据分析,总有一个适合你。" + ), + OnboardingPage( + icon = Icons.Default.History, + title = "管理你的对话", + description = "所有对话自动保存,随时查看历史记录。支持重命名、搜索和置顶对话,让你的每一次交流都有迹可循。" + ) +) + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun OnboardingScreen( + onComplete: () -> Unit +) { + val pagerState = rememberPagerState(pageCount = { pages.size }) + val currentPage = pagerState.currentPage + + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + // Skip button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onComplete) { + Text("跳过") + } + } + + // Pager content + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { page -> + OnboardingPageContent(pages[page]) + } + + // Page indicator + button + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Page indicators + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 32.dp) + ) { + repeat(pages.size) { index -> + val color by animateColorAsState( + targetValue = if (index == currentPage) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + label = "indicatorColor" + ) + Box( + modifier = Modifier + .size(if (index == currentPage) 10.dp else 8.dp) + .clip(CircleShape) + .background(color) + ) + } + } + + // Start button + Button( + onClick = onComplete, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = MaterialTheme.shapes.large + ) { + Icon( + imageVector = Icons.Default.DoneAll, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (currentPage == pages.size - 1) "开始使用" else "下一步", + style = MaterialTheme.typography.titleSmall + ) + } + } + } +} + +@Composable +private fun OnboardingPageContent(page: OnboardingPage) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Icon + Surface( + modifier = Modifier.size(120.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = page.icon, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(48.dp)) + + // Title + Text( + text = page.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Description + Text( + text = page.description, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt new file mode 100644 index 0000000..87306e5 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/AppUpdateDialogs.kt @@ -0,0 +1,176 @@ +package com.tiangong.aiagent.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.tiangong.aiagent.util.AppUpdateManager +import com.tiangong.aiagent.util.UpdateState + +/** + * Compose dialogs for app update flow: notification, download progress, error. + */ +@Composable +fun AppUpdateDialogs( + updateManager: AppUpdateManager, + onDismiss: () -> Unit = {} +) { + val state by updateManager.updateState.collectAsState() + + // ── Update available notification dialog ── + if (state.updateAvailable && state.response != null && !state.downloading && !state.downloadComplete) { + val response = state.response!! + AlertDialog( + onDismissRequest = { + if (!state.forceUpdate) { + updateManager.reset() + onDismiss() + } + }, + icon = { + Icon(Icons.Default.SystemUpdate, contentDescription = null, + tint = MaterialTheme.colorScheme.primary) + }, + title = { + Text("发现新版本 ${response.latestVersion}") + }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text("当前版本: ${response.currentVersion}") + if (response.releaseNotes.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "更新内容:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = response.releaseNotes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (response.forceUpdate) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "此版本为强制更新,请立即升级。", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + Button(onClick = { updateManager.startDownload() }) { + Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("立即更新") + } + }, + dismissButton = { + if (!state.forceUpdate) { + TextButton(onClick = { + updateManager.reset() + onDismiss() + }) { + Text("稍后") + } + } + } + ) + } + + // ── Download progress dialog ── + if (state.downloading) { + AlertDialog( + onDismissRequest = { /* block dismiss during download */ }, + title = { Text("正在下载更新") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LinearProgressIndicator( + progress = { state.downloadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${state.downloadProgress}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + // No confirm - show progress only + }, + dismissButton = { + TextButton(onClick = { + updateManager.reset() + }) { + Text("取消") + } + } + ) + } + + // ── Download complete → install dialog ── + if (state.downloadComplete && !state.downloading && state.apkFilePath != null) { + AlertDialog( + onDismissRequest = {}, + title = { Text("下载完成") }, + text = { Text("更新包已下载完成,是否立即安装?") }, + confirmButton = { + Button(onClick = { updateManager.installApk() }) { + Text("立即安装") + } + }, + dismissButton = { + TextButton(onClick = { + updateManager.reset() + onDismiss() + }) { + Text("稍后安装") + } + } + ) + } + + // ── Download error dialog ── + state.downloadError?.let { error -> + AlertDialog( + onDismissRequest = { updateManager.reset() }, + icon = { + Icon(Icons.Default.Error, contentDescription = null, + tint = MaterialTheme.colorScheme.error) + }, + title = { Text("更新失败") }, + text = { Text(error) }, + confirmButton = { + Button(onClick = { + updateManager.reset() + // Fallback: open in browser + updateManager.openInBrowser() + }) { + Text("浏览器下载") + } + }, + dismissButton = { + TextButton(onClick = { updateManager.reset() }) { + Text("取消") + } + } + ) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt new file mode 100644 index 0000000..df05cc6 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/NetworkDiagnoseScreen.kt @@ -0,0 +1,281 @@ +package com.tiangong.aiagent.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URL + +data class DiagnoseItem( + val label: String, + val description: String, + val status: DiagnoseStatus = DiagnoseStatus.IDLE +) + +enum class DiagnoseStatus { + IDLE, RUNNING, SUCCESS, FAIL +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkDiagnoseScreen( + onBack: () -> Unit, + serverUrl: String = com.tiangong.aiagent.BuildConfig.BASE_URL +) { + val scope = rememberCoroutineScope() + + val items = remember { + mutableStateListOf( + DiagnoseItem("应用服务器", "HTTP 连接到 $serverUrl"), + DiagnoseItem("API 服务", "检查 /api/v1/health 端点"), + DiagnoseItem("网络连通性", "TCP 连接测试 baidu.com:80"), + ) + } + + var isRunning by remember { mutableStateOf(false) } + var allDone by remember { mutableStateOf(false) } + + fun runDiagnose() { + if (isRunning) return + isRunning = true + allDone = false + // Reset statuses + items.indices.forEach { i -> items[i] = items[i].copy(status = DiagnoseStatus.IDLE) } + + scope.launch { + // Step 1: TCP connectivity + updateItem(items, 2, DiagnoseStatus.RUNNING) + val tcpOk = withContext(Dispatchers.IO) { + try { + val socket = Socket() + socket.connect(InetSocketAddress("baidu.com", 80), 5000) + socket.close() + true + } catch (_: IOException) { false } + } + updateItem(items, 2, if (tcpOk) DiagnoseStatus.SUCCESS else DiagnoseStatus.FAIL) + + if (!tcpOk) { + // No network — skip HTTP tests + updateItem(items, 0, DiagnoseStatus.FAIL) + updateItem(items, 1, DiagnoseStatus.FAIL) + isRunning = false + allDone = true + return@launch + } + + // Step 2: App server HTTP + updateItem(items, 0, DiagnoseStatus.RUNNING) + val serverOk = withContext(Dispatchers.IO) { + try { + val conn = URL(serverUrl).openConnection() as HttpURLConnection + conn.connectTimeout = 5000 + conn.readTimeout = 5000 + conn.requestMethod = "GET" + conn.responseCode in 200..499 // any response (not a timeout) is OK + } catch (_: Exception) { false } + } + updateItem(items, 0, if (serverOk) DiagnoseStatus.SUCCESS else DiagnoseStatus.FAIL) + + // Step 3: API health endpoint + updateItem(items, 1, DiagnoseStatus.RUNNING) + val apiOk = withContext(Dispatchers.IO) { + try { + val url = serverUrl.trimEnd('/') + "/api/v1/health" + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 5000 + conn.readTimeout = 5000 + conn.requestMethod = "GET" + conn.responseCode == 200 + } catch (_: Exception) { false } + } + updateItem(items, 1, if (apiOk) DiagnoseStatus.SUCCESS else DiagnoseStatus.FAIL) + + isRunning = false + allDone = true + } + } + + LaunchedEffect(Unit) { runDiagnose() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("网络诊断") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + "正在检查网络连接状态...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(24.dp)) + + items.forEachIndexed { index, item -> + DiagnoseCard(item) + if (index < items.size - 1) { + Spacer(Modifier.height(12.dp)) + } + } + + Spacer(Modifier.height(24.dp)) + + // Re-test button + Button( + onClick = { runDiagnose() }, + enabled = !isRunning, + modifier = Modifier.fillMaxWidth() + ) { + if (isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + Text("诊断中...") + } else { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("重新检测") + } + } + + if (allDone) { + Spacer(Modifier.height(16.dp)) + val allOk = items.all { it.status == DiagnoseStatus.SUCCESS } + Card( + colors = CardDefaults.cardColors( + containerColor = if (allOk) Color(0xFFE8F5E9) else Color(0xFFFFF3E0) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (allOk) Icons.Default.CheckCircle else Icons.Default.Warning, + contentDescription = null, + tint = if (allOk) Color(0xFF4CAF50) else Color(0xFFFF9800) + ) + Spacer(Modifier.width(12.dp)) + Text( + if (allOk) "网络连接正常,所有服务均可访问" + else "部分服务连接异常,请检查网络或服务器状态", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } +} + +@Composable +private fun DiagnoseCard(item: DiagnoseItem) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status icon + when (item.status) { + DiagnoseStatus.IDLE -> Icon( + Icons.Default.FiberManualRecord, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(24.dp) + ) + DiagnoseStatus.RUNNING -> CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + DiagnoseStatus.SUCCESS -> Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(24.dp) + ) + DiagnoseStatus.FAIL -> Icon( + Icons.Default.Cancel, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + Text( + text = item.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(4.dp)) + Text( + text = when (item.status) { + DiagnoseStatus.IDLE -> "等待检测" + DiagnoseStatus.RUNNING -> "检测中..." + DiagnoseStatus.SUCCESS -> "连接正常" + DiagnoseStatus.FAIL -> "连接失败" + }, + fontSize = 12.sp, + color = when (item.status) { + DiagnoseStatus.SUCCESS -> Color(0xFF4CAF50) + DiagnoseStatus.FAIL -> Color(0xFFE53935) + else -> Color.Gray + } + ) + } + } + } +} + +private fun updateItem(items: MutableList, index: Int, status: DiagnoseStatus) { + items[index] = items[index].copy(status = status) +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt index add4489..cfbd802 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/settings/SettingsScreen.kt @@ -39,7 +39,7 @@ class SettingsViewModel @Inject constructor( private val tokenDataStore: TokenDataStore, private val dynamicUrlInterceptor: DynamicUrlInterceptor, private val apiService: ApiService, - private val appUpdateManager: AppUpdateManager + val appUpdateManager: AppUpdateManager ) : ViewModel() { private val _username = MutableStateFlow("admin") @@ -87,13 +87,12 @@ class SettingsViewModel @Inject constructor( fun checkUpdate() { _updateMessage.value = "正在检查..." - appUpdateManager.checkForUpdate { response -> - _updateMessage.value = if (response.hasUpdate) { - "发现新版本 ${response.latestVersion}" - } else if (response.releaseNotes.isNotBlank()) { - response.releaseNotes // error message - } else { - "已是最新版本 (${response.currentVersion})" + appUpdateManager.checkForUpdate(showNoUpdateToast = false) + viewModelScope.launch { + appUpdateManager.updateState.collect { state -> + if (!state.checking && !state.updateAvailable && state.downloadError == null) { + _updateMessage.value = "已是最新版本" + } } } } @@ -172,6 +171,8 @@ fun SettingsScreen( onNavigateToAbout: (() -> Unit)? = null, onNavigateToAccountSecurity: (() -> Unit)? = null, onContactSupport: (() -> Unit)? = null, + onNavigateToSubscription: (() -> Unit)? = null, + onNavigateToNetworkDiagnose: (() -> Unit)? = null, viewModel: SettingsViewModel = hiltViewModel() ) { var showLogoutDialog by remember { mutableStateOf(false) } @@ -410,6 +411,34 @@ fun SettingsScreen( HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + // Network diagnostics + if (onNavigateToNetworkDiagnose != null) { + ListItem( + headlineContent = { Text("网络诊断") }, + supportingContent = { Text("检测网络连接和服务状态") }, + leadingContent = { + Icon(Icons.Default.NetworkCheck, contentDescription = null) + }, + modifier = Modifier.clickable { onNavigateToNetworkDiagnose() } + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + + // Subscription (v1.2) + SettingsSectionHeader("订阅") + ListItem( + headlineContent = { Text("会员订阅") }, + supportingContent = { Text("管理您的订阅方案") }, + leadingContent = { + Icon(Icons.Default.WorkspacePremium, contentDescription = null) + }, + modifier = if (onNavigateToSubscription != null) { + Modifier.clickable { onNavigateToSubscription() } + } else Modifier + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + // About SettingsSectionHeader("关于") @@ -525,6 +554,9 @@ fun SettingsScreen( } ) } + + // App update dialogs (download progress, etc.) + AppUpdateDialogs(updateManager = viewModel.appUpdateManager) } @Composable diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt new file mode 100644 index 0000000..3efac55 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/splash/SplashScreen.kt @@ -0,0 +1,150 @@ +package com.tiangong.aiagent.ui.splash + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Splash screen with professional branding, token validation, and routing. + * Pattern adapted from zhni_im SplashActivity. + * + * Flow: + * 1. Show animated branding (logo + title + subtitle) for min 1.5s + * 2. Validate token in background (check presence from data store) + * 3. Route: no token → Login, token + not onboarded → Onboarding, token + onboarded → Chat + */ +@Composable +fun SplashScreen( + token: String?, + onboardingCompleted: Boolean, + onNavigateToLogin: () -> Unit, + onNavigateToOnboarding: () -> Unit, + onNavigateToChat: () -> Unit, + modifier: Modifier = Modifier +) { + // Animations + val logoAlpha = remember { Animatable(0f) } + val titleAlpha = remember { Animatable(0f) } + val subtitleAlpha = remember { Animatable(0f) } + var isRouting by remember { mutableStateOf(false) } + + // Gradient background colors + val gradient = Brush.verticalGradient( + colors = listOf( + Color(0xFF1A237E), // Deep indigo + Color(0xFF283593), + Color(0xFF3949AB) + ) + ) + + LaunchedEffect(Unit) { + // Fade in sequences (parallel launches within coroutine scope) + coroutineScope { + launch { logoAlpha.animateTo(1f, animationSpec = tween(600, easing = FastOutSlowInEasing)) } + launch { + delay(300) + titleAlpha.animateTo(1f, animationSpec = tween(500, easing = FastOutSlowInEasing)) + } + launch { + delay(600) + subtitleAlpha.animateTo(1f, animationSpec = tween(500, easing = FastOutSlowInEasing)) + } + } + } + + // Token validation + routing after minimum splash duration + LaunchedEffect(token, onboardingCompleted) { + if (isRouting) return@LaunchedEffect + + // Minimum splash display time for professional feel + delay(1800) + isRouting = true + + // Determine destination + when { + token == null -> onNavigateToLogin() + !onboardingCompleted -> onNavigateToOnboarding() + else -> onNavigateToChat() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(brush = gradient), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // App logo + Icon( + imageVector = Icons.Default.Android, + contentDescription = "天工智能体", + modifier = Modifier + .size(80.dp) + .alpha(logoAlpha.value), + tint = Color.White + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // App title + Text( + text = "天工智能体", + modifier = Modifier.alpha(titleAlpha.value), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Subtitle + Text( + text = "AI Agent Platform", + modifier = Modifier.alpha(subtitleAlpha.value), + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // Loading indicator + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .alpha(subtitleAlpha.value), + color = Color.White.copy(alpha = 0.6f), + strokeWidth = 2.dp + ) + } + + // Bottom version text + Text( + text = "v${com.tiangong.aiagent.BuildConfig.VERSION_NAME}", + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp), + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.4f) + ) + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt new file mode 100644 index 0000000..c82e48c --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionScreen.kt @@ -0,0 +1,329 @@ +package com.tiangong.aiagent.ui.subscription + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.tiangong.aiagent.data.remote.dto.BillingPlanDto + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubscriptionScreen( + onBack: () -> Unit, + viewModel: SubscriptionViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + // Handle purchase success + LaunchedEffect(uiState.purchaseSuccess) { + if (uiState.purchaseSuccess != null) { + viewModel.clearPurchaseSuccess() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("会员订阅") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + } + ) + }, + snackbarHost = { + if (uiState.error != null) { + Snackbar( + action = { + TextButton(onClick = { viewModel.clearError() }) { Text("关闭") } + } + ) { Text(uiState.error!!) } + } + } + ) { padding -> + if (uiState.isLoading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Current tier banner + val currentTier = uiState.subscription?.tier ?: "free" + CurrentTierBanner(currentTier) + + Spacer(Modifier.height(16.dp)) + + // Period toggle + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("月付", fontSize = 14.sp) + Switch( + checked = uiState.selectedPeriod == "yearly", + onCheckedChange = { + viewModel.selectPeriod(if (it) "yearly" else "monthly") + } + ) + Text("年付", fontSize = 14.sp) + Spacer(Modifier.weight(1f)) + if (uiState.selectedPeriod == "yearly") { + Text( + "省 43%", + color = Color(0xFF4CAF50), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Plan cards + uiState.plans.forEachIndexed { index, plan -> + PlanCard( + plan = plan, + isCurrentPlan = plan.tier == currentTier, + isRecommended = plan.tier == "pro", + selectedPeriod = uiState.selectedPeriod, + isPurchasing = uiState.isPurchasing, + onPurchase = { viewModel.purchase(plan.id) } + ) + if (index < uiState.plans.size - 1) { + Spacer(Modifier.height(12.dp)) + } + } + + Spacer(Modifier.height(24.dp)) + + // Features comparison + FeaturesComparison(uiState.plans) + + Spacer(Modifier.height(32.dp)) + } + } + } +} + +@Composable +private fun CurrentTierBanner(currentTier: String) { + val (label, color) = when (currentTier) { + "pro" -> "专业版" to Color(0xFF2196F3) + "enterprise" -> "企业版" to Color(0xFF9C27B0) + else -> "免费版" to Color(0xFF757575) + } + Surface( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = MaterialTheme.shapes.large, + color = color.copy(alpha = 0.1f) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.WorkspacePremium, + contentDescription = null, + tint = color, + modifier = Modifier.size(32.dp) + ) + Spacer(Modifier.width(12.dp)) + Column { + Text("当前套餐", fontSize = 12.sp, color = Color.Gray) + Text(label, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = color) + } + } + } +} + +@Composable +private fun PlanCard( + plan: BillingPlanDto, + isCurrentPlan: Boolean, + isRecommended: Boolean, + selectedPeriod: String, + isPurchasing: Boolean, + onPurchase: () -> Unit +) { + val price = if (selectedPeriod == "yearly") plan.priceYearly else plan.priceMonthly + val priceLabel = if (selectedPeriod == "yearly") "/年" else "/月" + val isFree = plan.tier == "free" + + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = if (isRecommended) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ), + border = if (isRecommended) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + else null + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isRecommended) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primary + ) { + Text( + "推荐", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Spacer(Modifier.height(8.dp)) + } + + Text(plan.name, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.Bottom) { + Text("¥", fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text("${price.toInt()}", fontSize = 36.sp, fontWeight = FontWeight.Bold) + Text(priceLabel, fontSize = 14.sp, color = Color.Gray) + } + + Spacer(Modifier.height(16.dp)) + + // Features + plan.features?.forEach { feature -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(feature, fontSize = 13.sp) + } + } + + Spacer(Modifier.height(16.dp)) + + // Action button + if (isCurrentPlan) { + OutlinedButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) { + Text("当前方案") + } + } else if (isFree) { + // Free plan with non-free current — should not happen for upgrade + OutlinedButton( + onClick = onPurchase, + modifier = Modifier.fillMaxWidth(), + enabled = !isPurchasing + ) { + Text("升级到 Pro") + } + } else { + Button( + onClick = onPurchase, + modifier = Modifier.fillMaxWidth(), + enabled = !isPurchasing + ) { + if (isPurchasing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text(if (isCurrentPlan) "当前方案" else "立即订阅") + } + } + } + } + } +} + +@Composable +private fun FeaturesComparison(plans: List) { + Text( + "权益对比", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(Modifier.height(12.dp)) + + val compareItems = listOf( + "每日对话" to { p: BillingPlanDto -> if (p.dailyQuota == 0) "无限" else "${p.dailyQuota}次" }, + "智能体数量" to { p: BillingPlanDto -> if (p.agentLimit == 0) "无限" else "${p.agentLimit}个" }, + "知识条目" to { p: BillingPlanDto -> if (p.knowledgeLimit == 0) "无限" else "${p.knowledgeLimit}条" }, + "文件上传" to { p: BillingPlanDto -> "${p.fileUploadMb} MB" }, + "高级模型" to { p: BillingPlanDto -> + (p.models?.size?.minus(1)?.let { "+${it}" } ?: "0") + } + ) + + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.large + ) { + Column(Modifier.padding(16.dp)) { + // Header + Row(Modifier.fillMaxWidth()) { + Spacer(Modifier.weight(1.2f)) + plans.forEach { plan -> + Text( + plan.name, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + } + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + compareItems.forEach { (label, extractor) -> + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, modifier = Modifier.weight(1.2f), fontSize = 13.sp) + plans.forEach { plan -> + Text( + extractor(plan), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 13.sp, + fontWeight = if (plan.tier == "pro") FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt new file mode 100644 index 0000000..4d9f548 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/SubscriptionViewModel.kt @@ -0,0 +1,124 @@ +package com.tiangong.aiagent.ui.subscription + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tiangong.aiagent.data.remote.dto.BillingPlanDto +import com.tiangong.aiagent.data.remote.dto.CreateOrderRequest +import com.tiangong.aiagent.data.remote.dto.OrderResponseDto +import com.tiangong.aiagent.data.remote.dto.UserSubscriptionDto +import com.tiangong.aiagent.data.remote.dto.UserUsageDto +import com.tiangong.aiagent.data.repository.BillingRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SubscriptionUiState( + val plans: List = emptyList(), + val subscription: UserSubscriptionDto? = null, + val usage: UserUsageDto? = null, + val isLoading: Boolean = false, + val isPurchasing: Boolean = false, + val selectedPeriod: String = "monthly", + val error: String? = null, + val purchaseSuccess: String? = null, + val showUpgradeDialog: Boolean = false +) + +@HiltViewModel +class SubscriptionViewModel @Inject constructor( + private val billingRepository: BillingRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SubscriptionUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + } + + fun loadData() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + // Load plans (public, no auth needed) + billingRepository.getPlans().fold( + onSuccess = { plans -> _uiState.update { it.copy(plans = plans) } }, + onFailure = { e -> Log.e("SubscriptionVM", "Failed to load plans", e) } + ) + // Load subscription + billingRepository.getUserSubscription().fold( + onSuccess = { sub -> _uiState.update { it.copy(subscription = sub) } }, + onFailure = { e -> Log.e("SubscriptionVM", "Failed to load subscription", e) } + ) + // Load usage + billingRepository.getUserUsage().fold( + onSuccess = { usage -> _uiState.update { it.copy(usage = usage) } }, + onFailure = { e -> Log.e("SubscriptionVM", "Failed to load usage", e) } + ) + _uiState.update { it.copy(isLoading = false) } + } + } + + fun loadUsage() { + viewModelScope.launch { + billingRepository.getUserUsage().fold( + onSuccess = { usage -> _uiState.update { it.copy(usage = usage) } }, + onFailure = { e -> Log.e("SubscriptionVM", "Failed to load usage", e) } + ) + } + } + + fun selectPeriod(period: String) { + _uiState.update { it.copy(selectedPeriod = period) } + } + + fun purchase(planId: String) { + viewModelScope.launch { + _uiState.update { it.copy(isPurchasing = true, error = null) } + val period = _uiState.value.selectedPeriod + billingRepository.createOrder(CreateOrderRequest(planId, period, "mock")) + .fold( + onSuccess = { order -> + // Pay the order + billingRepository.payOrder(order.id).fold( + onSuccess = { result -> + _uiState.update { + it.copy( + isPurchasing = false, + purchaseSuccess = result.message + ) + } + loadData() + }, + onFailure = { e -> + _uiState.update { + it.copy(isPurchasing = false, error = e.message ?: "支付失败") + } + } + ) + }, + onFailure = { e -> + _uiState.update { + it.copy(isPurchasing = false, error = e.message ?: "创建订单失败") + } + } + ) + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + fun clearPurchaseSuccess() { + _uiState.update { it.copy(purchaseSuccess = null) } + } + + fun dismissUpgradeDialog() { + _uiState.update { it.copy(showUpgradeDialog = false) } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt new file mode 100644 index 0000000..9f83a76 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UpgradeDialog.kt @@ -0,0 +1,34 @@ +package com.tiangong.aiagent.ui.subscription + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Composable +fun UpgradeDialog( + title: String = "升级会员", + message: String = "今日免费对话次数已用完,升级专业版享受每日 500 次对话和更多高级功能。", + onDismiss: () -> Unit, + onUpgrade: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(title, fontWeight = FontWeight.Bold) + }, + text = { + Text(message, fontSize = 14.sp) + }, + confirmButton = { + Button(onClick = onUpgrade) { + Text("立即升级") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("稍后再说") + } + } + ) +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt new file mode 100644 index 0000000..6f1c0ae --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/subscription/UsageIndicator.kt @@ -0,0 +1,100 @@ +package com.tiangong.aiagent.ui.subscription + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel + +@Composable +fun UsageIndicator( + onClick: () -> Unit, + viewModel: SubscriptionViewModel = hiltViewModel() +) { + val uiState = viewModel.uiState.collectAsState() + val usage = uiState.value.usage + val dailyUsed = usage?.dailyUsed ?: 0 + val dailyLimit = usage?.dailyLimit ?: 50 + val dailyRemaining = usage?.dailyRemaining ?: 50 + val isExhausted = usage?.isExhausted ?: false + + // Refresh usage periodically + LaunchedEffect(Unit) { + viewModel.loadUsage() + } + + val fraction = if (dailyLimit > 0) (dailyUsed.toFloat() / dailyLimit).coerceIn(0f, 1f) else 0f + val progressColor = when { + isExhausted -> Color(0xFFE53935) + fraction > 0.8f -> Color(0xFFFFA726) + else -> MaterialTheme.colorScheme.primary + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = if (isExhausted) Color(0xFFFFEBEE) else MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.WorkspacePremium, + contentDescription = null, + tint = if (isExhausted) Color(0xFFE53935) else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + + Column(Modifier.weight(1f)) { + Text( + if (isExhausted) "今日次数已用完" else "今日剩余 $dailyRemaining 次", + fontSize = 12.sp, + color = if (isExhausted) Color(0xFFE53935) else MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(2.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(progressColor.copy(alpha = 0.2f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(progressColor) + ) + } + } + + if (isExhausted) { + Text( + "升级", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt new file mode 100644 index 0000000..67754d2 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/util/AnalyticsTracker.kt @@ -0,0 +1,99 @@ +package com.tiangong.aiagent.util + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.AnalyticsEventItem +import com.tiangong.aiagent.data.remote.dto.AnalyticsEventsRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@HiltViewModel +class AnalyticsViewModel @Inject constructor( + val tracker: AnalyticsTracker +) : ViewModel() + +@Singleton +class AnalyticsTracker @Inject constructor( + private val apiService: ApiService +) { + companion object { + private const val TAG = "AnalyticsTracker" + private const val BATCH_SIZE = 10 + private const val FLUSH_INTERVAL_MS = 30_000L + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val eventQueue = mutableListOf() + private var flushJob: Job? = null + + fun trackScreen(screenName: String) { + enqueue(AnalyticsEventItem( + eventType = "screen_view", + screenName = screenName, + timestamp = Instant.now().toString() + )) + } + + fun trackEvent(category: String, action: String, label: String? = null, agentId: String? = null) { + enqueue(AnalyticsEventItem( + eventType = "user_action", + category = category, + action = action, + label = label, + agentId = agentId, + timestamp = Instant.now().toString() + )) + } + + private fun enqueue(event: AnalyticsEventItem) { + synchronized(eventQueue) { + eventQueue.add(event) + if (eventQueue.size >= BATCH_SIZE) { + flushInternal() + } else { + scheduleFlush() + } + } + } + + private fun scheduleFlush() { + flushJob?.cancel() + flushJob = scope.launch { + delay(FLUSH_INTERVAL_MS) + synchronized(eventQueue) { + if (eventQueue.isNotEmpty()) { + flushInternal() + } + } + } + } + + private fun flushInternal() { + if (eventQueue.isEmpty()) return + val batch = eventQueue.toList() + eventQueue.clear() + scope.launch { + try { + apiService.sendAnalyticsEvents(AnalyticsEventsRequest(events = batch)) + } catch (e: Exception) { + Log.w(TAG, "Failed to flush analytics events: ${e.message}") + // Re-queue on failure (up to max) + synchronized(eventQueue) { + if (eventQueue.size < 50) { + eventQueue.addAll(0, batch.take(10)) + } + } + } + } + } + + fun flush() { + synchronized(eventQueue) { + flushInternal() + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt index 825c16d..579c36a 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt @@ -1,107 +1,247 @@ package com.tiangong.aiagent.util +import android.app.DownloadManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.Uri +import android.os.Build +import android.os.Environment +import android.util.Log +import androidx.core.content.FileProvider import com.tiangong.aiagent.BuildConfig import com.tiangong.aiagent.data.remote.ApiService import com.tiangong.aiagent.data.remote.dto.AppVersionResponse import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL import javax.inject.Inject import javax.inject.Singleton +/** + * App update state for Compose UI observation. + */ +data class UpdateState( + val checking: Boolean = false, + val updateAvailable: Boolean = false, + val downloading: Boolean = false, + val downloadProgress: Int = 0, // 0-100 + val downloadError: String? = null, + val downloadComplete: Boolean = false, + val response: AppVersionResponse? = null, + val forceUpdate: Boolean = false, + val apkFilePath: String? = null +) + @Singleton class AppUpdateManager @Inject constructor( @ApplicationContext private val context: Context, private val apiService: ApiService ) { - /** - * Check for app update asynchronously. If update is found, shows a dialog. - * Called from MainActivity.onCreate(). - */ - fun checkForUpdate(onResult: ((AppVersionResponse) -> Unit)? = null) { - CoroutineScope(Dispatchers.IO).launch { + companion object { + private const val TAG = "AppUpdateManager" + private const val APK_FILE_NAME = "tiangong_update.apk" + } + + private val scope = CoroutineScope(Dispatchers.IO) + private val _updateState = MutableStateFlow(UpdateState()) + val updateState: StateFlow = _updateState.asStateFlow() + + /** Check for update from server. */ + fun checkForUpdate(showNoUpdateToast: Boolean = false) { + scope.launch { + _updateState.value = _updateState.value.copy(checking = true) try { val response = apiService.checkUpdate( platform = "android", version = BuildConfig.VERSION_NAME ) - withContext(Dispatchers.Main) { - if (response.hasUpdate) { - showUpdateDialog(response) - } - onResult?.invoke(response) - } + val hasUpdate = response.hasUpdate && + VersionUtils.compareVersion(BuildConfig.VERSION_NAME, response.latestVersion) < 0 + + _updateState.value = _updateState.value.copy( + checking = false, + updateAvailable = hasUpdate, + forceUpdate = response.forceUpdate, + response = response + ) } catch (e: Exception) { - withContext(Dispatchers.Main) { - onResult?.invoke( - AppVersionResponse( - latestVersion = "", currentVersion = BuildConfig.VERSION_NAME, - hasUpdate = false, forceUpdate = false, - releaseNotes = "检查失败: ${e.message}" - ) + Log.w(TAG, "Version check failed: ${e.message}") + _updateState.value = _updateState.value.copy( + checking = false, + updateAvailable = false, + downloadError = if (showNoUpdateToast) "版本检查失败" else null + ) + } + } + } + + /** Start downloading the APK in-app with progress tracking. */ + fun startDownload() { + val response = _updateState.value.response ?: return + val url = response.updateUrl + if (url.isBlank()) { + _updateState.value = _updateState.value.copy( + downloadError = "下载地址无效" + ) + return + } + + _updateState.value = _updateState.value.copy( + downloading = true, + downloadProgress = 0, + downloadError = null, + downloadComplete = false + ) + + scope.launch { + downloadApk(url) + } + } + + private suspend fun downloadApk(urlStr: String) { + withContext(Dispatchers.IO) { + var conn: HttpURLConnection? = null + var fos: FileOutputStream? = null + try { + val apkFile = getApkFile() + // Ensure parent directory exists + apkFile.parentFile?.mkdirs() + + val url = URL(urlStr) + conn = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 60_000 + setRequestProperty("Accept-Encoding", "identity") + connect() + } + + if (conn.responseCode != HttpURLConnection.HTTP_OK) { + _updateState.value = _updateState.value.copy( + downloading = false, + downloadError = "服务器错误: ${conn.responseCode}" ) + return@withContext + } + + val totalSize = conn.contentLength.toLong() + val input = conn.inputStream + fos = FileOutputStream(apkFile) + + val buffer = ByteArray(8192) + var downloaded = 0L + var lastProgress = -1 + + while (true) { + val read = input.read(buffer) + if (read == -1) break + fos.write(buffer, 0, read) + downloaded += read + + if (totalSize > 0) { + val progress = ((downloaded * 100) / totalSize).toInt() + if (progress != lastProgress) { + lastProgress = progress + _updateState.value = _updateState.value.copy(downloadProgress = progress) + } + } + } + + _updateState.value = _updateState.value.copy( + downloading = false, + downloadProgress = 100, + downloadComplete = true, + apkFilePath = apkFile.absolutePath + ) + + Log.d(TAG, "APK download complete: ${apkFile.absolutePath} (${apkFile.length()} bytes)") + } catch (e: Exception) { + Log.e(TAG, "APK download failed", e) + _updateState.value = _updateState.value.copy( + downloading = false, + downloadError = when { + e.message?.contains("timeout", ignoreCase = true) == true -> "下载超时,请检查网络" + e.message?.contains("Network", ignoreCase = true) == true -> "网络连接失败" + else -> "下载失败: ${e.message}" + } + ) + } finally { + try { fos?.close() } catch (_: Exception) {} + try { conn?.disconnect() } catch (_: Exception) {} + } + } + } + + /** Install the downloaded APK via FileProvider. */ + fun installApk() { + val path = _updateState.value.apkFilePath + if (path == null) { + _updateState.value = _updateState.value.copy(downloadError = "安装包不存在") + return + } + val apkFile = File(path) + if (!apkFile.exists()) { + _updateState.value = _updateState.value.copy(downloadError = "安装包文件不存在") + return + } + + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val authority = "${context.packageName}.fileprovider" + val apkUri = FileProvider.getUriForFile(context, authority, apkFile) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(apkUri, "application/vnd.android.package-archive") + } else { + @Suppress("DEPRECATION") + setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive") } } - } - } - - private fun showUpdateDialog(response: AppVersionResponse) { - try { - val builder = if (response.forceUpdate) { - android.app.AlertDialog.Builder(context) - .setTitle("发现新版本 ${response.latestVersion}") - .setMessage(buildDialogMessage(response)) - .setCancelable(false) - .setPositiveButton("立即更新") { _, _ -> - openDownloadUrl(response.updateUrl) - } - } else { - android.app.AlertDialog.Builder(context) - .setTitle("发现新版本 ${response.latestVersion}") - .setMessage(buildDialogMessage(response)) - .setCancelable(true) - .setPositiveButton("立即更新") { _, _ -> - openDownloadUrl(response.updateUrl) - } - .setNegativeButton("稍后", null) - } - - // Show dialog from a new activity context is safer but we use application context - // wrapped in a theme dialog - val dialog = builder.create() - // Ensure dialog is shown in an activity context if possible - dialog.show() - } catch (_: Exception) { - // Fallback: open browser - try { - openDownloadUrl(response.updateUrl) - } catch (_: Exception) { } - } - } - - private fun buildDialogMessage(response: AppVersionResponse): String { - val sb = StringBuilder() - sb.append("当前版本: ${BuildConfig.VERSION_NAME}\n") - sb.append("最新版本: ${response.latestVersion}\n") - if (response.releaseNotes.isNotBlank()) { - sb.append("\n更新内容:\n${response.releaseNotes}") - } - if (response.forceUpdate) { - sb.append("\n\n此版本为强制更新,请立即升级。") - } - return sb.toString() - } - - private fun openDownloadUrl(url: String) { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (_: Exception) { } + } catch (e: Exception) { + Log.e(TAG, "Install failed", e) + _updateState.value = _updateState.value.copy( + downloadError = "安装失败: ${e.message}" + ) + } + } + + /** Open download URL in browser (fallback). */ + fun openInBrowser() { + val url = _updateState.value.response?.updateUrl ?: return + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) {} + } + + /** Reset update state. */ + fun reset() { + _updateState.value = UpdateState() + } + + /** Get the target APK file path. */ + fun getApkFile(): File { + val dir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?: context.filesDir + } else { + @Suppress("DEPRECATION") + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + } + return File(dir, APK_FILE_NAME) } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt b/android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt new file mode 100644 index 0000000..88a7dfd --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/util/CrashHandler.kt @@ -0,0 +1,90 @@ +package com.tiangong.aiagent.util + +import android.os.Environment +import android.util.Log +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Crash log handler that writes uncaught exceptions to file before delegating + * to the previous handler (e.g. Bugly). + * + * Usage: CrashHandler.init() after Bugly.initCrashReport() in TiangongApp.onCreate() + */ +object CrashHandler : Thread.UncaughtExceptionHandler { + + private const val TAG = "CrashHandler" + private const val MAX_LOG_FILES = 5 + + private var previousHandler: Thread.UncaughtExceptionHandler? = null + private var crashDir: File? = null + + fun init() { + if (previousHandler != null) return // already initialized + + previousHandler = Thread.getDefaultUncaughtExceptionHandler() + + // Don't chain ourselves if we're already set (unlikely but defensive) + if (previousHandler is CrashHandler) return + + Thread.setDefaultUncaughtExceptionHandler(this) + } + + fun setCrashDir(dir: File) { + crashDir = dir + } + + override fun uncaughtException(thread: Thread, throwable: Throwable) { + try { + writeCrashLog(throwable) + } catch (e: Exception) { + Log.e(TAG, "Failed to write crash log", e) + } + + // Delegate to previous handler (Bugly or system default) + previousHandler?.uncaughtException(thread, throwable) + } + + private fun writeCrashLog(throwable: Throwable) { + val dir = crashDir ?: run { + val extDir = Environment.getExternalStorageDirectory() + ?: return + File(extDir, "Tiangong/crashes").also { + it.mkdirs() + } + } + + if (!dir.exists()) dir.mkdirs() + + // Rotate log files (keep MAX_LOG_FILES) + val logFiles = dir.listFiles { f -> f.extension == "log" } + ?.sortedByDescending { it.lastModified() } ?: emptyList() + logFiles.drop(MAX_LOG_FILES - 1).forEach { it.delete() } + + val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val crashFile = File(dir, "crash_$dateStr.log") + + val sw = StringWriter() + val pw = PrintWriter(sw) + pw.println("Crash Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())}") + pw.println() + throwable.printStackTrace(pw) + + // Also include cause chain + var cause = throwable.cause + while (cause != null) { + pw.println() + pw.println("Caused by:") + cause.printStackTrace(pw) + cause = cause.cause + } + + pw.flush() + crashFile.writeText(sw.toString()) + Log.i(TAG, "Crash log written to ${crashFile.absolutePath}") + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt b/android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt new file mode 100644 index 0000000..09c87f7 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/util/UmengHelper.kt @@ -0,0 +1,179 @@ +package com.tiangong.aiagent.util + +import android.content.Context +import android.util.Log +import com.umeng.analytics.MobclickAgent +import com.umeng.commonsdk.UMConfigure +import com.tiangong.aiagent.BuildConfig + +/** + * Umeng (友盟) statistics helper. + * Ported from zhini_im project's UmengAnalytics.java + UmengDebugHelper.java. + * + * Provides page tracking, custom event tracking, user profile tracking, + * and debug utilities for verifying SDK integration. + */ +object UmengHelper { + + private const val TAG = "UmengHelper" + private const val UMENG_APP_KEY = "69009244644c9e2c2067d39b" + private const val UMENG_CHANNEL = "default" + + @Volatile + private var initialized = false + + /** Initialize Umeng SDK. Call from Application.onCreate() BEFORE UI loads. */ + fun init(context: Context) { + if (initialized) return + + try { + // Pre-init: set log level, session timeout, etc. + UMConfigure.setLogEnabled(BuildConfig.DEBUG) + UMConfigure.preInit(context, UMENG_APP_KEY, UMENG_CHANNEL) + + // Main init: encrypt enabled, analytics + push configs + UMConfigure.init( + context, + UMENG_APP_KEY, + UMENG_CHANNEL, + UMConfigure.DEVICE_TYPE_PHONE, + null // push secret (not used for analytics-only integration) + ) + + // Enable auto page tracking (Activity lifecycle callbacks) + MobclickAgent.setPageCollectionMode(MobclickAgent.PageMode.AUTO) + + // Session timeout: 30 seconds (default) + MobclickAgent.setSessionContinueMillis(30_000) + + initialized = true + Log.d(TAG, "Umeng SDK initialized successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize Umeng SDK", e) + } + } + + fun isInitialized(): Boolean = initialized + + // ─── Page Tracking ────────────────────────────────────────────── + + fun onPageStart(pageName: String) { + try { + if (initialized) { + MobclickAgent.onPageStart(pageName) + } + } catch (e: Exception) { + Log.e(TAG, "onPageStart failed: $pageName", e) + } + } + + fun onPageEnd(pageName: String) { + try { + if (initialized) { + MobclickAgent.onPageEnd(pageName) + } + } catch (e: Exception) { + Log.e(TAG, "onPageEnd failed: $pageName", e) + } + } + + // ─── Event Tracking ───────────────────────────────────────────── + + /** Simple event without parameters. */ + fun onEvent(context: Context, eventId: String) { + try { + if (initialized) { + MobclickAgent.onEvent(context, eventId) + } + } catch (e: Exception) { + Log.e(TAG, "onEvent failed: $eventId", e) + } + } + + /** Event with a label. */ + fun onEvent(context: Context, eventId: String, label: String) { + try { + if (initialized) { + MobclickAgent.onEvent(context, eventId, label) + } + } catch (e: Exception) { + Log.e(TAG, "onEvent failed: $eventId", e) + } + } + + /** Event with custom key-value parameters. */ + fun onEvent(context: Context, eventId: String, params: Map) { + try { + if (initialized) { + MobclickAgent.onEvent(context, eventId, params) + } + } catch (e: Exception) { + Log.e(TAG, "onEvent failed: $eventId", e) + } + } + + // ─── User Profile ─────────────────────────────────────────────── + + /** Sign in user for analytics (call after login). */ + fun onProfileSignIn(userId: String) { + try { + if (initialized) { + MobclickAgent.onProfileSignIn(userId) + Log.d(TAG, "User signed in: $userId") + } + } catch (e: Exception) { + Log.e(TAG, "onProfileSignIn failed", e) + } + } + + /** Sign out user (call on logout). */ + fun onProfileSignOff() { + try { + if (initialized) { + MobclickAgent.onProfileSignOff() + } + } catch (e: Exception) { + Log.e(TAG, "onProfileSignOff failed", e) + } + } + + // ─── Debug Utilities ──────────────────────────────────────────── + + /** Send test events to verify Umeng integration. */ + fun sendTestEvents(context: Context) { + if (!initialized) { + Log.w(TAG, "SDK not initialized, skipping test events") + return + } + Log.d(TAG, "Sending Umeng test events...") + onEvent(context, "umeng_debug_test", "environment_check") + onEvent(context, "umeng_custom_debug", mapOf( + "test_type" to "debug", + "timestamp" to System.currentTimeMillis().toString(), + "device_model" to android.os.Build.MODEL, + "android_version" to android.os.Build.VERSION.RELEASE + )) + Log.d(TAG, "Test events sent") + } + + /** Force flush cached analytics data to server. */ + fun forceFlush(context: Context) { + try { + if (initialized) { + MobclickAgent.onKillProcess(context) + } + } catch (e: Exception) { + Log.e(TAG, "forceFlush failed", e) + } + } + + fun getDebugInfo(): String { + return buildString { + appendLine("=== Umeng Analytics Debug ===") + appendLine("SDK initialized: $initialized") + appendLine("Device: ${android.os.Build.MODEL}") + appendLine("Android: ${android.os.Build.VERSION.RELEASE}") + appendLine("SDK version: 9.8.8") + } + } +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt b/android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt new file mode 100644 index 0000000..91c29af --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/util/VersionUtils.kt @@ -0,0 +1,67 @@ +package com.tiangong.aiagent.util + +import android.content.Context +import android.content.pm.PackageManager + +/** + * Version comparison and info utilities. + * Ported from zhini_im project's VersionUtils.java. + */ +object VersionUtils { + + /** + * Compare two semantic version strings. + * @return -1 if v1 < v2, 0 if equal, 1 if v1 > v2 + */ + fun compareVersion(v1: String, v2: String): Int { + if (v1 == v2) return 0 + val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 } + val parts2 = v2.split(".").map { it.toIntOrNull() ?: 0 } + val minLen = minOf(parts1.size, parts2.size) + for (i in 0 until minLen) { + val diff = parts1[i] - parts2[i] + if (diff != 0) return if (diff > 0) 1 else -1 + } + // If common prefix matches, longer version with non-zero parts is newer + for (i in minLen until parts1.size) { + if (parts1[i] > 0) return 1 + } + for (i in minLen until parts2.size) { + if (parts2[i] > 0) return -1 + } + return 0 + } + + fun getVersionName(context: Context): String { + return try { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + info.versionName ?: "1.0.0" + } catch (_: PackageManager.NameNotFoundException) { + "1.0.0" + } + } + + fun getVersionCode(context: Context): Long { + return try { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + info.longVersionCode + } else { + @Suppress("DEPRECATION") + info.versionCode.toLong() + } + } catch (_: PackageManager.NameNotFoundException) { + 1L + } + } + + fun getAppName(context: Context): String { + return try { + val pm = context.packageManager + val info = pm.getApplicationInfo(context.packageName, 0) + pm.getApplicationLabel(info).toString() + } catch (_: PackageManager.NameNotFoundException) { + "天工智能体" + } + } +} diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..32e28fc --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a527516..04416d4 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -87,6 +87,12 @@ markwon-core = { group = "io.noties.markwon", name = "core", version.ref = "mark markwon-image = { group = "io.noties.markwon", name = "image-coil", version.ref = "markwon" } markwon-linkify = { group = "io.noties.markwon", name = "linkify", version.ref = "markwon" } +# Umeng Analytics (from zhini_im) +umeng-common = { group = "com.umeng.umsdk", name = "common", version = "9.8.8" } +umeng-asms = { group = "com.umeng.umsdk", name = "asms", version = "1.8.7" } +umeng-uyumao = { group = "com.umeng.umsdk", name = "uyumao", version = "1.1.4" } +umeng-abtest = { group = "com.umeng.umsdk", name = "abtest", version = "1.0.1" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/backend/alembic/versions/025_billing.py b/backend/alembic/versions/025_billing.py new file mode 100644 index 0000000..990e808 --- /dev/null +++ b/backend/alembic/versions/025_billing.py @@ -0,0 +1,98 @@ +"""Add billing tables and user subscription fields + +Revision ID: 025_billing +Revises: 104e05fc9cf2 +Create Date: 2026-07-03 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.mysql import CHAR + +revision = '025_billing' +down_revision = '104e05fc9cf2' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # billing_plans + op.create_table( + 'billing_plans', + sa.Column('id', CHAR(36), primary_key=True, comment='套餐ID'), + sa.Column('name', sa.String(100), nullable=False, comment='套餐名称'), + sa.Column('tier', sa.String(20), nullable=False, comment='套餐等级: free/pro/enterprise'), + sa.Column('price_monthly', sa.Float, nullable=False, server_default='0', comment='月付价格(CNY)'), + sa.Column('price_yearly', sa.Float, nullable=False, server_default='0', comment='年付价格(CNY)'), + sa.Column('daily_quota', sa.Integer, nullable=False, server_default='0', comment='每日对话次数限制'), + sa.Column('agent_limit', sa.Integer, nullable=False, server_default='0', comment='智能体数量限制'), + sa.Column('knowledge_limit', sa.Integer, nullable=False, server_default='0', comment='知识条目限制'), + sa.Column('file_upload_mb', sa.Integer, nullable=False, server_default='5', comment='文件上传大小限制(MB)'), + sa.Column('models', sa.JSON, nullable=True, comment='可用模型列表'), + sa.Column('features', sa.JSON, nullable=True, comment='权益特性列表'), + sa.Column('is_active', sa.Boolean, server_default='1', comment='是否启用'), + sa.Column('sort_order', sa.Integer, server_default='0', comment='排序'), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), comment='更新时间'), + ) + + # billing_orders + op.create_table( + 'billing_orders', + sa.Column('id', CHAR(36), primary_key=True, comment='订单ID'), + sa.Column('user_id', CHAR(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, comment='用户ID'), + sa.Column('plan_id', CHAR(36), sa.ForeignKey('billing_plans.id', ondelete='SET NULL'), nullable=True, comment='套餐ID'), + sa.Column('amount', sa.Float, nullable=False, comment='支付金额(CNY)'), + sa.Column('period', sa.String(10), nullable=False, server_default="'monthly'", comment='周期: monthly/yearly'), + sa.Column('status', sa.String(20), nullable=False, server_default="'pending'", comment='状态: pending/paid/cancelled/expired'), + sa.Column('payment_method', sa.String(20), nullable=True, comment='支付方式: mock/wechat/alipay'), + sa.Column('payment_ref', sa.String(255), nullable=True, comment='第三方支付流水号'), + sa.Column('paid_at', sa.DateTime, nullable=True, comment='支付时间'), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), comment='更新时间'), + ) + op.create_index('ix_billing_orders_user_id', 'billing_orders', ['user_id']) + + # user_subscriptions + op.create_table( + 'user_subscriptions', + sa.Column('id', CHAR(36), primary_key=True, comment='订阅ID'), + sa.Column('user_id', CHAR(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, comment='用户ID'), + sa.Column('plan_id', CHAR(36), sa.ForeignKey('billing_plans.id', ondelete='SET NULL'), nullable=True, comment='当前套餐ID'), + sa.Column('tier', sa.String(20), nullable=False, server_default="'free'", comment='当前等级'), + sa.Column('status', sa.String(20), nullable=False, server_default="'active'", comment='状态: active/cancelled/expired'), + sa.Column('started_at', sa.DateTime, nullable=True, comment='订阅开始时间'), + sa.Column('expires_at', sa.DateTime, nullable=True, comment='订阅到期时间'), + sa.Column('auto_renew', sa.Boolean, server_default='0', comment='是否自动续费'), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), comment='更新时间'), + ) + op.create_index('ix_user_subscriptions_user_id', 'user_subscriptions', ['user_id'], unique=True) + + # usage_records + op.create_table( + 'usage_records', + sa.Column('id', CHAR(36), primary_key=True, comment='记录ID'), + sa.Column('user_id', CHAR(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, comment='用户ID'), + sa.Column('date', sa.Date, nullable=False, comment='日期'), + sa.Column('count', sa.Integer, nullable=False, server_default='0', comment='当日对话次数'), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), comment='创建时间'), + ) + op.create_index('ix_usage_user_date', 'usage_records', ['user_id', 'date'], unique=True) + + # Add columns to users + op.add_column('users', sa.Column('subscription_tier', sa.String(20), server_default="'free'", comment='订阅等级')) + op.add_column('users', sa.Column('subscription_expires_at', sa.DateTime, nullable=True, comment='订阅到期时间')) + op.add_column('users', sa.Column('daily_usage_count', sa.Integer, server_default='0', comment='今日对话次数')) + op.add_column('users', sa.Column('daily_usage_date', sa.Date, nullable=True, comment='用量统计日期')) + + +def downgrade() -> None: + op.drop_column('users', 'daily_usage_date') + op.drop_column('users', 'daily_usage_count') + op.drop_column('users', 'subscription_expires_at') + op.drop_column('users', 'subscription_tier') + op.drop_table('usage_records') + op.drop_table('user_subscriptions') + op.drop_index('ix_billing_orders_user_id', table_name='billing_orders') + op.drop_table('billing_orders') + op.drop_table('billing_plans') diff --git a/backend/app/api/agent_chat.py b/backend/app/api/agent_chat.py index 3c576e0..747fc12 100644 --- a/backend/app/api/agent_chat.py +++ b/backend/app/api/agent_chat.py @@ -10,13 +10,14 @@ from __future__ import annotations import logging import json from typing import Any, AsyncGenerator, Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.core.database import get_db from sqlalchemy.orm import Session from app.api.auth import get_current_user +from app.api.deps import get_current_workspace_id, get_workspace_membership, WorkspaceContext from app.models.user import User from app.models.agent import Agent from app.models.agent_llm_log import AgentLLMLog @@ -43,6 +44,7 @@ def _make_llm_logger( db: Session, agent_id: Optional[str] = None, user_id: Optional[str] = None, + workspace_id: Optional[str] = None, ): """创建 LLM 调用日志回调,写入 AgentLLMLog 表。""" def _log(metrics: dict): @@ -51,6 +53,7 @@ def _make_llm_logger( agent_id=agent_id, session_id=metrics.get("session_id"), user_id=user_id, + workspace_id=workspace_id, model=metrics.get("model", ""), provider=metrics.get("provider"), prompt_tokens=metrics.get("prompt_tokens", 0), @@ -74,6 +77,7 @@ def _make_message_saver( db: Session, agent_id: Optional[str] = None, user_id: Optional[str] = None, + workspace_id: Optional[str] = None, ): """创建消息持久化回调,将每条消息写入 chat_messages 表。""" def _save(msg: dict): @@ -82,6 +86,7 @@ def _make_message_saver( session_id=msg.get("session_id"), agent_id=agent_id, user_id=user_id, + workspace_id=workspace_id, role=msg.get("role", "user"), content=msg.get("content"), tool_name=msg.get("tool_name"), @@ -90,6 +95,29 @@ def _make_message_saver( iteration=msg.get("iteration", 0), ) db.add(record) + + # 自动创建 / 更新 AgentSession 记录 + session_id = msg.get("session_id") + if session_id and agent_id: + from app.models.agent_session import AgentSession + existing = db.query(AgentSession).filter( + AgentSession.id == session_id + ).first() + if not existing: + title = None + if msg.get("role") == "user" and msg.get("content"): + title = msg["content"][:100] + session = AgentSession( + id=session_id, + user_id=user_id, + agent_id=agent_id, + workspace_id=workspace_id, + title=title, + ) + db.add(session) + elif existing.title is None and msg.get("role") == "user" and msg.get("content"): + existing.title = msg["content"][:100] + db.commit() except Exception as e: logger.warning("写入 ChatMessage 失败: %s", e) @@ -155,6 +183,7 @@ class SessionItem(BaseModel): title: Optional[str] = None last_message: Optional[str] = None message_count: int = 0 + is_pinned: bool = False created_at: Optional[str] = None updated_at: Optional[str] = None @@ -164,6 +193,23 @@ class SessionListResponse(BaseModel): sessions: List[SessionItem] +class SearchMessageItem(BaseModel): + """跨会话搜索消息条目""" + id: str + session_id: str + agent_id: Optional[str] = None + role: str + content: Optional[str] = None + session_title: Optional[str] = None + created_at: Optional[str] = None + + +class SearchMessagesResponse(BaseModel): + """消息搜索响应""" + messages: List[SearchMessageItem] + total: int + + class OrchestrateAgentItem(BaseModel): """编排中单个 Agent 的定义""" id: str @@ -208,6 +254,7 @@ class OrchestrateResponse(BaseModel): async def orchestrate_agents( req: OrchestrateRequest, current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), db: Session = Depends(get_db), ): """多 Agent 编排:支持 route / sequential / debate 三种模式。""" @@ -225,7 +272,7 @@ async def orchestrate_agents( for a in req.agents ] - on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id) + on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id) orchestrator = AgentOrchestrator( default_llm_config=AgentLLMConfig( model=req.model or "deepseek-v4-flash", @@ -265,10 +312,11 @@ class GraphOrchestrateRequest(BaseModel): async def orchestrate_graph( req: GraphOrchestrateRequest, current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), db: Session = Depends(get_db), ): """图编排模式:按 DAG 拓扑顺序执行 Agent 和条件节点。""" - on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id) + on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id) orchestrator = AgentOrchestrator( default_llm_config=AgentLLMConfig( model=req.model or "deepseek-v4-flash", @@ -301,6 +349,7 @@ async def orchestrate_graph( async def chat_bare( req: ChatRequest, current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), db: Session = Depends(get_db), ): """无需 Agent 配置,使用默认设置直接对话。""" @@ -334,8 +383,8 @@ async def chat_bare( config.prompt_sections.enabled = False if req.system_prompt_override: config.system_prompt = req.system_prompt_override - on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id) - on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id) + on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id) + on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id) context = AgentContext(session_id=req.session_id) runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined) result = await runtime.run(req.message) @@ -366,6 +415,7 @@ async def chat_bare( async def chat_bare_stream( req: ChatRequest, current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), db: Session = Depends(get_db), ): """无需 Agent 配置,使用默认设置直接对话(流式 SSE)。""" @@ -399,8 +449,8 @@ async def chat_bare_stream( config.prompt_sections.enabled = False if req.system_prompt_override: config.system_prompt = req.system_prompt_override - on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id) - on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id) + on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id) + on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id) context = AgentContext(session_id=req.session_id) runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined) return StreamingResponse( @@ -419,6 +469,7 @@ async def chat_with_agent( agent_id: str, req: ChatRequest, current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), db: Session = Depends(get_db), ): """与指定的 Agent 对话。Agent 的工作流配置会用于构建 Runtime。""" @@ -485,8 +536,8 @@ async def chat_with_agent( if req.system_prompt_override: config.system_prompt = req.system_prompt_override - on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id) - on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id) + on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id) + on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id) context = AgentContext(session_id=req.session_id) runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined) result = await runtime.run(req.message) @@ -519,6 +570,7 @@ async def chat_with_agent_stream( agent_id: str, req: ChatRequest, current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), db: Session = Depends(get_db), ): """与指定的 Agent 对话(流式 SSE)。""" @@ -581,8 +633,8 @@ async def chat_with_agent_stream( if req.system_prompt_override: config.system_prompt = req.system_prompt_override - on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id) - on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id) + on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id) + on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id) context = AgentContext(session_id=req.session_id) runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined) return StreamingResponse( @@ -600,18 +652,20 @@ async def chat_with_agent_stream( async def list_agent_sessions( agent_id: str, limit: int = 50, + search: Optional[str] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """获取 Agent 的会话列表,按最近活跃时间排序。""" + """获取 Agent 的会话列表,置顶优先,再按最近活跃时间排序。支持 search 参数按标题/内容搜索。""" from sqlalchemy import func as sa_func, desc, or_ + from app.models.agent_session import AgentSession # 验证 agent 存在或有权限 agent = db.query(Agent).filter(Agent.id == agent_id).first() if not agent: raise HTTPException(status_code=404, detail="Agent 不存在") - rows = ( + rows_query = ( db.query( ChatMessage.session_id, sa_func.min(ChatMessage.created_at).label("created_at"), @@ -620,23 +674,65 @@ async def list_agent_sessions( ) .filter(ChatMessage.agent_id == agent_id) .group_by(ChatMessage.session_id) - .order_by(desc("updated_at")) - .limit(limit) - .all() ) + # 搜索过滤:按会话标题(AgentSession.title)或消息内容 + if search and search.strip(): + pattern = f"%{search.strip()}%" + # 子查询:在标题或消息内容中匹配的 session_id + matching_session_ids = set() + + # 匹配 AgentSession.title + title_matches = db.query(AgentSession.id).filter( + AgentSession.title.like(pattern) + ).all() + matching_session_ids.update(r[0] for r in title_matches) + + # 匹配消息内容 + content_matches = db.query(ChatMessage.session_id).filter( + ChatMessage.agent_id == agent_id, + ChatMessage.content.like(pattern), + ).distinct().all() + matching_session_ids.update(r[0] for r in content_matches) + + if matching_session_ids: + rows_query = rows_query.filter( + ChatMessage.session_id.in_(list(matching_session_ids)) + ) + else: + # 无匹配,返回空 + return SessionListResponse(sessions=[]) + + rows = rows_query.order_by(desc("updated_at")).limit(limit).all() + + # 批量查询 AgentSession 记录 + session_ids = [row.session_id for row in rows] + agent_sessions_map = {} + if session_ids: + records = db.query(AgentSession).filter( + AgentSession.id.in_(session_ids) + ).all() + agent_sessions_map = {s.id: s for s in records} + sessions = [] for row in rows: - # 取第一条 user 消息作为标题 - first_user_msg = ( - db.query(ChatMessage) - .filter( - ChatMessage.session_id == row.session_id, - ChatMessage.role == "user", + asession = agent_sessions_map.get(row.session_id) + + # 标题优先级: AgentSession.title > 第一条 user 消息 + title = asession.title if asession and asession.title else None + if not title: + first_user_msg = ( + db.query(ChatMessage) + .filter( + ChatMessage.session_id == row.session_id, + ChatMessage.role == "user", + ) + .order_by(ChatMessage.created_at.asc()) + .first() ) - .order_by(ChatMessage.created_at.asc()) - .first() - ) + if first_user_msg and first_user_msg.content: + title = first_user_msg.content[:100] + # 取最后一条消息作为预览 last_msg = ( db.query(ChatMessage) @@ -646,13 +742,21 @@ async def list_agent_sessions( ) sessions.append(SessionItem( session_id=row.session_id, - title=first_user_msg.content[:100] if first_user_msg and first_user_msg.content else None, + title=title, last_message=last_msg.content[:200] if last_msg and last_msg.content else None, message_count=row.message_count, + is_pinned=asession.is_pinned if asession else False, created_at=row.created_at.isoformat() if row.created_at else None, updated_at=row.updated_at.isoformat() if row.updated_at else None, )) + # 排序:置顶优先,再按更新时间降序 + pinned = [s for s in sessions if s.is_pinned] + unpinned = [s for s in sessions if not s.is_pinned] + pinned.sort(key=lambda s: s.updated_at or "", reverse=True) + unpinned.sort(key=lambda s: s.updated_at or "", reverse=True) + sessions = pinned + unpinned + return SessionListResponse(sessions=sessions) @@ -662,10 +766,11 @@ async def get_session_messages( session_id: str, before_id: Optional[str] = None, limit: int = 50, + search: Optional[str] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """获取会话的消息历史(分页),从旧到新排序。""" + """获取会话的消息历史(分页),从旧到新排序。支持 search 参数按内容搜索。""" from sqlalchemy import or_ # limit 限制 @@ -676,6 +781,11 @@ async def get_session_messages( ChatMessage.session_id == session_id, ) + # 消息内容搜索 + if search and search.strip(): + pattern = f"%{search.strip()}%" + base_q = base_q.filter(ChatMessage.content.like(pattern)) + # 游标分页:before_id 之前的老消息(使用复合键避免 created_at 相同导致遗漏) if before_id: cursor_msg = db.query(ChatMessage).filter(ChatMessage.id == before_id).first() @@ -725,6 +835,72 @@ async def get_session_messages( return MessageHistoryResponse(messages=messages, has_more=has_more, total=total) +@router.get("/{agent_id}/search-messages", response_model=SearchMessagesResponse) +async def search_messages( + agent_id: str, + q: str = Query(..., min_length=1, description="搜索关键词"), + limit: int = Query(50, ge=1, le=100), + skip: int = Query(0, ge=0), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """跨会话搜索消息内容,返回匹配的消息及所属会话信息。""" + from app.models.agent_session import AgentSession + + # 验证 agent 存在 + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + pattern = f"%{q.strip()}%" + + # 搜索匹配的消息 + total = ( + db.query(ChatMessage) + .filter( + ChatMessage.agent_id == agent_id, + ChatMessage.content.like(pattern), + ) + .count() + ) + + messages = ( + db.query(ChatMessage) + .filter( + ChatMessage.agent_id == agent_id, + ChatMessage.content.like(pattern), + ) + .order_by(ChatMessage.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + # 批量获取会话标题 + session_ids = list(set(m.session_id for m in messages)) + session_titles = {} + if session_ids: + records = db.query(AgentSession).filter( + AgentSession.id.in_(session_ids) + ).all() + session_titles = {s.id: s.title for s in records if s.title} + + result = [ + SearchMessageItem( + id=m.id, + session_id=m.session_id, + agent_id=m.agent_id, + role=m.role, + content=m.content, + session_title=session_titles.get(m.session_id), + created_at=m.created_at.isoformat() if m.created_at else None, + ) + for m in messages + ] + + return SearchMessagesResponse(messages=result, total=total) + + def _find_agent_node_config(nodes: list) -> Dict[str, Any]: """从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。""" if not nodes: diff --git a/backend/app/api/agent_schedules.py b/backend/app/api/agent_schedules.py index 228931f..74e055e 100644 --- a/backend/app/api/agent_schedules.py +++ b/backend/app/api/agent_schedules.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.auth import get_current_user +from app.api.deps import require_workspace_admin, WorkspaceContext from app.core.database import get_db from app.models.agent import Agent from app.models.agent_schedule import AgentSchedule @@ -162,8 +163,9 @@ async def delete_schedule( schedule_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): - """删除定时任务。""" + """删除定时任务(仅工作区管理员和平台管理员可操作)。""" schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first() if not schedule: raise HTTPException(status_code=404, detail="定时任务不存在") @@ -180,8 +182,9 @@ async def trigger_schedule( schedule_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): - """手动触发一次定时任务。""" + """手动触发一次定时任务(仅工作区管理员和平台管理员可操作)。""" schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first() if not schedule: raise HTTPException(status_code=404, detail="定时任务不存在") diff --git a/backend/app/api/agent_sessions.py b/backend/app/api/agent_sessions.py new file mode 100644 index 0000000..e6aa89d --- /dev/null +++ b/backend/app/api/agent_sessions.py @@ -0,0 +1,180 @@ +""" +Agent 会话管理 API +提供会话重命名、置顶、删除、导出功能 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel, Field +from typing import Optional +from sqlalchemy.orm import Session +import json + +from app.core.database import get_db +from app.api.auth import get_current_user +from app.api.deps import get_current_workspace_id +from app.models.user import User +from app.models.agent import Agent +from app.models.agent_session import AgentSession +from app.models.chat_message import ChatMessage + +router = APIRouter(prefix="/api/v1/agent-chat", tags=["agent-sessions"]) + + +class UpdateSessionRequest(BaseModel): + title: Optional[str] = Field(default=None, description="会话标题") + is_pinned: Optional[bool] = Field(default=None, description="是否置顶") + + +class SessionResponse(BaseModel): + id: str + title: Optional[str] = None + is_pinned: bool = False + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +@router.patch("/{agent_id}/sessions/{session_id}", response_model=SessionResponse) +async def update_session( + agent_id: str, + session_id: str, + req: UpdateSessionRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + workspace_id: str = Depends(get_current_workspace_id), +): + """更新会话信息(重命名 / 置顶)""" + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + session = db.query(AgentSession).filter( + AgentSession.id == session_id, + AgentSession.agent_id == agent_id, + ).first() + + if not session: + session = AgentSession( + id=session_id, + user_id=current_user.id, + agent_id=agent_id, + workspace_id=workspace_id or None, + ) + db.add(session) + + if req.title is not None: + session.title = req.title + if req.is_pinned is not None: + session.is_pinned = req.is_pinned + + db.commit() + db.refresh(session) + + return SessionResponse( + id=session.id, + title=session.title, + is_pinned=session.is_pinned, + created_at=session.created_at.isoformat() if session.created_at else None, + updated_at=session.updated_at.isoformat() if session.updated_at else None, + ) + + +@router.delete("/{agent_id}/sessions/{session_id}") +async def delete_session( + agent_id: str, + session_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + workspace_id: str = Depends(get_current_workspace_id), +): + """删除会话及所有关联消息""" + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + session_filter = [ + AgentSession.id == session_id, + AgentSession.agent_id == agent_id, + ] + if workspace_id: + session_filter.append(AgentSession.workspace_id == workspace_id) + + db.query(AgentSession).filter(*session_filter).delete() + + msg_filter = [ + ChatMessage.agent_id == agent_id, + ChatMessage.session_id == session_id, + ] + if workspace_id: + msg_filter.append(ChatMessage.workspace_id == workspace_id) + + deleted_count = db.query(ChatMessage).filter(*msg_filter).delete() + + db.commit() + + return {"deleted": True, "messages_deleted": deleted_count} + + +@router.get("/{agent_id}/sessions/{session_id}/export") +async def export_session( + agent_id: str, + session_id: str, + format: str = Query("json", pattern="^(json|markdown)$"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + workspace_id: str = Depends(get_current_workspace_id), +): + """导出会话为 JSON 或 Markdown 格式""" + agent = db.query(Agent).filter(Agent.id == agent_id).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + msg_query = db.query(ChatMessage).filter( + ChatMessage.agent_id == agent_id, + ChatMessage.session_id == session_id, + ) + if workspace_id: + msg_query = msg_query.filter(ChatMessage.workspace_id == workspace_id) + + messages = msg_query.order_by(ChatMessage.created_at.asc()).all() + + if not messages: + raise HTTPException(status_code=404, detail="会话无消息") + + # 获取会话标题 + session = db.query(AgentSession).filter(AgentSession.id == session_id).first() + title = session.title if session and session.title else "对话" + + if format == "markdown": + lines = [f"# {title}", "", f"**Agent**: {agent.name}", f"**时间**: {messages[0].created_at.isoformat() if messages[0].created_at else 'N/A'}", "", "---", ""] + for m in messages: + role_label = "用户" if m.role == "user" else "AI 助手" if m.role == "assistant" else m.role + lines.append(f"### {role_label}") + if m.content: + lines.append(m.content) + if m.tool_name: + lines.append(f"\n> 工具调用: `{m.tool_name}`") + if m.tool_output: + lines.append(f"\n
工具输出\n\n```\n{m.tool_output[:500]}\n```\n
") + lines.append("") + return PlainTextResponse("\n".join(lines), media_type="text/markdown") + else: + # JSON + export_data = { + "title": title, + "agent_name": agent.name, + "agent_id": agent_id, + "session_id": session_id, + "exported_at": messages[0].created_at.isoformat() if messages[0].created_at else None, + "messages": [ + { + "role": m.role, + "content": m.content, + "tool_name": m.tool_name, + "tool_input": m.tool_input, + "tool_output": m.tool_output, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + for m in messages + ], + } + return export_data diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 3c8e114..aa2a87f 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -11,6 +11,7 @@ import logging from app.core.database import get_db from app.models.agent import Agent from app.api.auth import get_current_user +from app.api.deps import require_workspace_admin, WorkspaceContext from app.models.user import User from app.core.exceptions import NotFoundError, ValidationError, ConflictError from app.services.permission_service import check_agent_permission @@ -385,10 +386,11 @@ async def update_agent( async def delete_agent( agent_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): """ - 删除Agent(只有所有者可以删除) + 删除Agent(仅工作区管理员和平台管理员可操作) """ agent = db.query(Agent).filter(Agent.id == agent_id).first() @@ -435,11 +437,12 @@ async def delete_agent( async def deploy_agent( agent_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): """ - 部署Agent - + 部署Agent(仅工作区管理员和平台管理员可操作) + 将Agent状态设置为published """ agent = db.query(Agent).filter(Agent.id == agent_id).first() @@ -463,11 +466,12 @@ async def deploy_agent( async def stop_agent( agent_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): """ - 停止Agent - + 停止Agent(仅工作区管理员和平台管理员可操作) + 将Agent状态设置为stopped """ agent = db.query(Agent).filter(Agent.id == agent_id).first() @@ -601,8 +605,9 @@ async def create_main_agent( agent_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): - """一键将当前 Agent 升级为 Main Agent(预置专用工具 + 系统提示词)""" + """一键将当前 Agent 升级为 Main Agent(仅工作区管理员和平台管理员可操作)""" agent = db.query(Agent).filter(Agent.id == agent_id).first() if not agent: raise NotFoundError(f"Agent不存在: {agent_id}") diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py index 61e80ee..1faa6f4 100644 --- a/backend/app/api/alert_rules.py +++ b/backend/app/api/alert_rules.py @@ -10,6 +10,7 @@ import logging from app.core.database import get_db from app.models.alert_rule import AlertRule, AlertLog from app.api.auth import get_current_user +from app.api.deps import get_current_workspace_id from app.models.user import User from app.core.exceptions import NotFoundError, ValidationError, ConflictError from app.services.alert_service import AlertService @@ -102,14 +103,17 @@ async def get_alert_rules( alert_type: Optional[str] = Query(None, description="告警类型筛选"), enabled: Optional[bool] = Query(None, description="是否启用筛选"), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), ): """ 获取告警规则列表 - + 支持分页和筛选 """ query = db.query(AlertRule).filter(AlertRule.user_id == current_user.id) + if workspace_id: + query = query.filter(AlertRule.workspace_id == workspace_id) # 筛选 if alert_type: @@ -126,7 +130,8 @@ async def get_alert_rules( async def create_alert_rule( rule_data: AlertRuleCreate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), ): """ 创建告警规则 @@ -165,7 +170,8 @@ async def create_alert_rule( notification_type=rule_data.notification_type, notification_config=rule_data.notification_config, enabled=rule_data.enabled, - user_id=current_user.id + user_id=current_user.id, + workspace_id=workspace_id or None, ) db.add(alert_rule) db.commit() diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py new file mode 100644 index 0000000..34fc7a1 --- /dev/null +++ b/backend/app/api/analytics.py @@ -0,0 +1,229 @@ +""" +数据分析 API — 从 user_behavior_logs 表聚合 +提供概览、日活趋势、Agent 排行榜 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from typing import Optional, List, Any, Dict +from sqlalchemy.orm import Session +from sqlalchemy import func as sa_func, text, desc +from datetime import datetime, timedelta + +from app.core.database import get_db +from app.api.auth import get_current_user +from app.models.user import User +from app.models.user_behavior import UserBehaviorLog +from app.models.chat_message import ChatMessage + +router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"]) + + +# ─────────── Response Models ─────────── + +class OverviewResponse(BaseModel): + total_users: int = 0 + active_users_today: int = 0 + messages_today: int = 0 + sessions_today: int = 0 + + +class DailyStatItem(BaseModel): + date: str + active_users: int = 0 + messages: int = 0 + new_registrations: int = 0 + + +class DailyStatsResponse(BaseModel): + stats: List[DailyStatItem] + + +class TopAgentItem(BaseModel): + agent_id: str + message_count: int = 0 + session_count: int = 0 + + +class TopAgentsResponse(BaseModel): + agents: List[TopAgentItem] + + +# ─────────── Endpoints ─────────── + +@router.get("/overview", response_model=OverviewResponse) +async def get_overview( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """运营概览:总用户数、今日活跃、今日消息数/会话数""" + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + total_users = db.query(sa_func.count(User.id)).scalar() or 0 + + active_users_today = db.query(sa_func.count(sa_func.distinct(UserBehaviorLog.user_id))).filter( + UserBehaviorLog.created_at >= today + ).scalar() or 0 + + messages_today = db.query(sa_func.count(ChatMessage.id)).filter( + ChatMessage.created_at >= today + ).scalar() or 0 + + sessions_today = db.query(sa_func.count(sa_func.distinct(ChatMessage.session_id))).filter( + ChatMessage.created_at >= today + ).scalar() or 0 + + return OverviewResponse( + total_users=total_users, + active_users_today=active_users_today, + messages_today=messages_today, + sessions_today=sessions_today, + ) + + +@router.get("/daily-stats", response_model=DailyStatsResponse) +async def get_daily_stats( + days: int = Query(default=30, ge=1, le=365), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """每日趋势:UV、消息数、新注册数""" + since = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days - 1) + + # 每日活跃用户 + uv_rows = ( + db.query( + sa_func.date(UserBehaviorLog.created_at).label("date"), + sa_func.count(sa_func.distinct(UserBehaviorLog.user_id)).label("uv"), + ) + .filter(UserBehaviorLog.created_at >= since) + .group_by(text("date")) + .order_by("date") + .all() + ) + uv_map = {str(row.date): row.uv for row in uv_rows} + + # 每日消息 + msg_rows = ( + db.query( + sa_func.date(ChatMessage.created_at).label("date"), + sa_func.count(ChatMessage.id).label("cnt"), + ) + .filter(ChatMessage.created_at >= since) + .group_by(text("date")) + .order_by("date") + .all() + ) + msg_map = {str(row.date): row.cnt for row in msg_rows} + + # 每日新注册 + reg_rows = ( + db.query( + sa_func.date(User.created_at).label("date"), + sa_func.count(User.id).label("cnt"), + ) + .filter(User.created_at >= since) + .group_by(text("date")) + .order_by("date") + .all() + ) + reg_map = {str(row.date): row.cnt for row in reg_rows} + + # 构建日期序列 + stats = [] + for i in range(days): + d = (since + timedelta(days=i)).strftime("%Y-%m-%d") + stats.append(DailyStatItem( + date=d, + active_users=uv_map.get(d, 0), + messages=msg_map.get(d, 0), + new_registrations=reg_map.get(d, 0), + )) + + return DailyStatsResponse(stats=stats) + + +@router.get("/top-agents", response_model=TopAgentsResponse) +async def get_top_agents( + days: int = Query(default=30, ge=1, le=365), + limit: int = Query(default=10, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """最活跃 Agent 排行""" + since = datetime.now() - timedelta(days=days) + + rows = ( + db.query( + ChatMessage.agent_id, + sa_func.count(ChatMessage.id).label("message_count"), + sa_func.count(sa_func.distinct(ChatMessage.session_id)).label("session_count"), + ) + .filter( + ChatMessage.created_at >= since, + ChatMessage.agent_id.isnot(None), + ) + .group_by(ChatMessage.agent_id) + .order_by(desc("message_count")) + .limit(limit) + .all() + ) + + agents = [ + TopAgentItem( + agent_id=row.agent_id, + message_count=row.message_count, + session_count=row.session_count, + ) + for row in rows + ] + + return TopAgentsResponse(agents=agents) + + +# ─────────── Mobile Events (v1.1.0) ─────────── + +class AnalyticsEventItem(BaseModel): + event_type: str = Field(..., description="事件类型: screen_view / user_action") + screen_name: Optional[str] = None + category: Optional[str] = None + action: Optional[str] = None + label: Optional[str] = None + agent_id: Optional[str] = None + timestamp: Optional[str] = None + + +class AnalyticsEventsRequest(BaseModel): + events: List[AnalyticsEventItem] + + +@router.post("/events") +async def receive_events( + req: AnalyticsEventsRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """接收移动端批量埋点事件""" + count = 0 + for evt in req.events: + try: + t = datetime.fromisoformat(evt.timestamp.replace("Z", "+00:00")) if evt.timestamp else datetime.now() + except Exception: + t = datetime.now() + + log = UserBehaviorLog( + user_id=current_user.id, + category=evt.category or "mobile", + action=evt.action or evt.event_type, + context={ + "screen": evt.screen_name, + "label": evt.label, + "agent_id": evt.agent_id, + }, + source="android", + created_at=t, + ) + db.add(log) + count += 1 + + db.commit() + return {"received": count} diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 35eefe7..c8c5f75 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -585,19 +585,256 @@ async def bind_phone( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """绑定或修改手机号。""" + """绑定或修改手机号(需验证码校验)。""" + redis = get_redis_client() + + # 必须提供验证码 + if not body.code: + raise HTTPException(status_code=400, detail="请提供短信验证码") + + # 校验验证码 + if not _verify_sms_code(redis, body.phone, body.code): + raise HTTPException(status_code=400, detail="验证码错误或已过期") + # 检查手机号是否已被其他用户绑定 existing = db.query(User).filter(User.phone == body.phone, User.id != current_user.id).first() if existing: raise ConflictError("该手机号已被其他用户绑定") current_user.phone = body.phone + current_user.phone_verified = True + current_user.phone_verified_at = datetime.utcnow() db.commit() logger.info("用户 %s 绑定手机号成功", current_user.username) return {"message": "手机号绑定成功", "phone": body.phone} +# ─── 手机验证码(v1.1.0)──────────────────────────────────── + +SMS_CODE_TTL_SEC = 600 # 验证码 10 分钟有效 +SMS_RATE_LIMIT_SEC = 60 # 同一手机号 60 秒内只能发一次 + + +class SendSmsCodeRequest(BaseModel): + phone: str + + @field_validator("phone") + @classmethod + def phone_format(cls, v: str) -> str: + if not re.match(r"^1[3-9]\d{9}$", v): + raise ValueError("手机号格式无效") + return v + + +class VerifyPhoneRequest(BaseModel): + phone: str + code: str + + @field_validator("phone") + @classmethod + def phone_format(cls, v: str) -> str: + if not re.match(r"^1[3-9]\d{9}$", v): + raise ValueError("手机号格式无效") + return v + + +class PhoneLoginRequest(BaseModel): + phone: str + code: str + + @field_validator("phone") + @classmethod + def phone_format(cls, v: str) -> str: + if not re.match(r"^1[3-9]\d{9}$", v): + raise ValueError("手机号格式无效") + return v + + +def _gen_sms_code() -> str: + """生成 6 位数字验证码""" + return str(secrets.randbelow(900000) + 100000) + + +async def _send_sms_code(phone: str, code: str) -> bool: + """发送短信验证码。返回 True 表示发送成功。""" + from app.core.sms_service import get_sms_provider + provider = get_sms_provider() + try: + return await provider.send(phone, code) + except Exception as e: + logger.warning("SMS 发送异常: %s", e) + return False + + +@router.post("/phone/send-code") +async def send_phone_code(body: SendSmsCodeRequest): + """发送手机验证码。""" + redis = get_redis_client() + + # 频率限制 + rate_key = f"sms_rate:{body.phone}" + if redis: + if redis.exists(rate_key): + ttl = redis.ttl(rate_key) + raise HTTPException( + status_code=429, + detail=f"操作过于频繁,请 {ttl} 秒后重试" + ) + + code = _gen_sms_code() + + # 存储到 Redis + code_key = f"sms_code:{body.phone}" + if redis: + redis.setex(code_key, SMS_CODE_TTL_SEC, code) + redis.setex(rate_key, SMS_RATE_LIMIT_SEC, "1") + else: + # 无 Redis 时回退内存存储 + if not hasattr(send_phone_code, "_memory_store"): + send_phone_code._memory_store = {} + send_phone_code._memory_rate = {} + send_phone_code._memory_store[body.phone] = { + "code": code, + "expires_at": datetime.utcnow() + timedelta(seconds=SMS_CODE_TTL_SEC), + } + send_phone_code._memory_rate[body.phone] = \ + datetime.utcnow() + timedelta(seconds=SMS_RATE_LIMIT_SEC) + + # 发送验证码 + sent = await _send_sms_code(body.phone, code) + + if not sent: + # Mock 模式或发送失败时返回验证码(仅开发环境) + logger.info("开发模式:%s 的短信验证码为 %s", body.phone, code) + return { + "message": "验证码已生成(Mock 模式)", + "dev_code": code, + } + + return {"message": "验证码已发送"} + + +def _verify_sms_code(redis, phone: str, code: str) -> bool: + """校验短信验证码(从 Redis 或内存存储中读取)。""" + code_key = f"sms_code:{phone}" + + stored_code = None + if redis: + stored_code_raw = redis.get(code_key) + stored_code = stored_code_raw.decode() if isinstance(stored_code_raw, bytes) else stored_code_raw + elif hasattr(send_phone_code, "_memory_store"): + entry = send_phone_code._memory_store.get(phone, {}) + if entry and entry.get("expires_at", datetime.min) > datetime.utcnow(): + stored_code = entry.get("code") + + if not stored_code: + return False + + if stored_code != code.strip(): + return False + + # 验证通过后清除 + if redis: + redis.delete(code_key) + elif hasattr(send_phone_code, "_memory_store"): + send_phone_code._memory_store.pop(phone, None) + + return True + + +@router.post("/phone/verify") +async def verify_phone( + body: VerifyPhoneRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """验证手机号(绑定后验证)。""" + redis = get_redis_client() + + if not _verify_sms_code(redis, body.phone, body.code): + raise HTTPException(status_code=400, detail="验证码错误或已过期") + + # 检查手机号是否已被其他用户绑定 + existing = db.query(User).filter( + User.phone == body.phone, User.id != current_user.id + ).first() + if existing: + raise HTTPException(status_code=400, detail="该手机号已被其他用户绑定") + + current_user.phone = body.phone + current_user.phone_verified = True + current_user.phone_verified_at = datetime.utcnow() + db.commit() + + logger.info("用户 %s 手机号 %s 验证成功", current_user.username, body.phone) + return {"message": "手机号验证成功", "phone": body.phone} + + +@router.post("/login/phone", response_model=Token) +async def phone_login( + body: PhoneLoginRequest, + db: Session = Depends(get_db), +): + """手机号+验证码登录。用户不存在则自动注册。""" + redis = get_redis_client() + + if not _verify_sms_code(redis, body.phone, body.code): + raise HTTPException(status_code=400, detail="验证码错误或已过期") + + user = db.query(User).filter(User.phone == body.phone).first() + + if not user: + # 自动注册:用手机号生成用户名 + username_base = f"user_{body.phone[-6:]}" + username = username_base + counter = 1 + while db.query(User).filter(User.username == username).first(): + username = f"{username_base}_{counter}" + counter += 1 + + user = User( + username=username, + email=f"{body.phone}@phone.local", + password_hash=get_password_hash(secrets.token_urlsafe(16)), + phone=body.phone, + phone_verified=True, + phone_verified_at=datetime.utcnow(), + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("手机号自动注册: %s → %s", body.phone, username) + else: + # 如果手机号还没标记为已验证,更新 + if not user.phone_verified: + user.phone_verified = True + user.phone_verified_at = datetime.utcnow() + db.commit() + + if user.status == "deleted": + raise HTTPException(status_code=403, detail="该账号已注销") + + ws_id = _get_user_default_workspace_id(db, user) + + access_token = create_access_token( + data={"sub": user.id, "username": user.username, "ws": ws_id or ""}, + expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES), + ) + + refresh_token = create_refresh_token( + user_id=user.id, + username=user.username, + workspace_id=ws_id or "", + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "refresh_token": refresh_token, + } + + async def get_optional_user( token: str | None = Depends(oauth2_scheme_optional), db: Session = Depends(get_db) diff --git a/backend/app/api/billing.py b/backend/app/api/billing.py new file mode 100644 index 0000000..57854b6 --- /dev/null +++ b/backend/app/api/billing.py @@ -0,0 +1,303 @@ +""" +付费体系 API:套餐、订阅、订单、用量 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from typing import Optional, List +from sqlalchemy.orm import Session +from datetime import date, datetime, timedelta + +from app.core.database import get_db +from app.api.auth import get_current_user +from app.models.user import User +from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord + +router = APIRouter(prefix="/api/v1/billing", tags=["billing"]) + + +# ─── Pydantic Models ─── + +class PlanResponse(BaseModel): + id: str + name: str + tier: str + price_monthly: float + price_yearly: float + daily_quota: int + agent_limit: int + knowledge_limit: int + file_upload_mb: int = 5 + models: Optional[list] = None + features: Optional[list] = None + is_active: bool = True + sort_order: int = 0 + + class Config: + from_attributes = True + + +class SubscriptionResponse(BaseModel): + tier: str = "free" + status: str = "active" + plan_name: Optional[str] = None + started_at: Optional[str] = None + expires_at: Optional[str] = None + auto_renew: bool = False + daily_quota: int = 50 + agent_limit: int = 3 + knowledge_limit: int = 10 + file_upload_mb: int = 5 + models: Optional[list] = None + features: Optional[list] = None + + +class UsageResponse(BaseModel): + daily_used: int = 0 + daily_limit: int = 50 + daily_remaining: int = 50 + is_exhausted: bool = False + + +class CreateOrderRequest(BaseModel): + plan_id: str = Field(..., description="套餐ID") + period: str = Field(default="monthly", description="周期: monthly/yearly") + payment_method: str = Field(default="mock", description="支付方式: mock/wechat/alipay") + + +class OrderResponse(BaseModel): + id: str + plan_id: Optional[str] = None + amount: float + period: str + status: str + payment_method: Optional[str] = None + payment_ref: Optional[str] = None + created_at: Optional[str] = None + paid_at: Optional[str] = None + + class Config: + from_attributes = True + + +# ─── Helpers ─── + +QUOTA_DEFAULTS = { + "free": {"daily_quota": 50, "agent_limit": 3, "knowledge_limit": 10, "file_upload_mb": 5}, + "pro": {"daily_quota": 500, "agent_limit": 20, "knowledge_limit": 500, "file_upload_mb": 50}, + "enterprise": {"daily_quota": 0, "agent_limit": 0, "knowledge_limit": 0, "file_upload_mb": 200}, +} + + +def _get_user_subscription_info(db: Session, user: User) -> SubscriptionResponse: + """获取用户当前订阅信息(套餐+权益)""" + sub = db.query(UserSubscription).filter(UserSubscription.user_id == user.id).first() + tier = sub.tier if sub else (user.subscription_tier or "free") + plan = db.query(BillingPlan).filter(BillingPlan.id == sub.plan_id).first() if (sub and sub.plan_id) else None + + quota = QUOTA_DEFAULTS.get(tier, QUOTA_DEFAULTS["free"]) + return SubscriptionResponse( + tier=tier, + status=sub.status if sub else "active", + plan_name=plan.name if plan else (sub.tier if sub else "免费版"), + started_at=sub.started_at.isoformat() if (sub and sub.started_at) else None, + expires_at=sub.expires_at.isoformat() if (sub and sub.expires_at) else None, + auto_renew=sub.auto_renew if sub else False, + daily_quota=plan.daily_quota if plan else quota["daily_quota"], + agent_limit=plan.agent_limit if plan else quota["agent_limit"], + knowledge_limit=plan.knowledge_limit if plan else quota["knowledge_limit"], + file_upload_mb=plan.file_upload_mb if plan else quota["file_upload_mb"], + models=plan.models if plan else None, + features=plan.features if plan else None, + ) + + +def _get_daily_limit(tier: str) -> int: + plan = QUOTA_DEFAULTS.get(tier) + return plan["daily_quota"] if plan else 50 + + +# ─── Endpoints ─── + +@router.get("/plans", response_model=List[PlanResponse]) +async def list_plans( + db: Session = Depends(get_db), +): + """获取所有可用套餐""" + return db.query(BillingPlan).filter(BillingPlan.is_active == True).order_by(BillingPlan.sort_order.asc()).all() + + +@router.get("/user/subscription", response_model=SubscriptionResponse) +async def get_user_subscription( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取当前用户订阅状态与权益""" + return _get_user_subscription_info(db, current_user) + + +@router.get("/user/usage", response_model=UsageResponse) +async def get_user_usage( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """获取当前用户今日用量""" + today = date.today() + sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first() + tier = sub.tier if sub else (current_user.subscription_tier or "free") + daily_limit = _get_daily_limit(tier) + + # 如果日期变了,重置计数 + if current_user.daily_usage_date and current_user.daily_usage_date < today: + current_user.daily_usage_count = 0 + current_user.daily_usage_date = today + db.commit() + + used = current_user.daily_usage_count or 0 + remaining = max(0, daily_limit - used) if daily_limit > 0 else 999999 + + return UsageResponse( + daily_used=used, + daily_limit=daily_limit, + daily_remaining=remaining, + is_exhausted=daily_limit > 0 and used >= daily_limit, + ) + + +@router.post("/orders", response_model=OrderResponse) +async def create_order( + req: CreateOrderRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """创建订单""" + plan = db.query(BillingPlan).filter(BillingPlan.id == req.plan_id, BillingPlan.is_active == True).first() + if not plan: + raise HTTPException(status_code=404, detail="套餐不存在") + + if plan.tier == "free": + raise HTTPException(status_code=400, detail="免费套餐无需购买") + + amount = plan.price_yearly if req.period == "yearly" else plan.price_monthly + if amount <= 0: + raise HTTPException(status_code=400, detail="套餐价格无效") + + order = BillingOrder( + user_id=current_user.id, + plan_id=plan.id, + amount=amount, + period=req.period, + status="pending", + payment_method=req.payment_method, + ) + db.add(order) + db.commit() + db.refresh(order) + + return OrderResponse( + id=order.id, + plan_id=order.plan_id, + amount=order.amount, + period=order.period, + status=order.status, + payment_method=order.payment_method, + payment_ref=order.payment_ref, + created_at=order.created_at.isoformat() if order.created_at else None, + paid_at=order.paid_at.isoformat() if order.paid_at else None, + ) + + +@router.get("/orders/{order_id}", response_model=OrderResponse) +async def get_order( + order_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """查询订单状态""" + order = db.query(BillingOrder).filter( + BillingOrder.id == order_id, + BillingOrder.user_id == current_user.id, + ).first() + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + + return OrderResponse( + id=order.id, + plan_id=order.plan_id, + amount=order.amount, + period=order.period, + status=order.status, + payment_method=order.payment_method, + payment_ref=order.payment_ref, + created_at=order.created_at.isoformat() if order.created_at else None, + paid_at=order.paid_at.isoformat() if order.paid_at else None, + ) + + +@router.post("/orders/{order_id}/pay") +async def pay_order( + order_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """模拟支付(Phase 4 替换为真实支付)""" + order = db.query(BillingOrder).filter( + BillingOrder.id == order_id, + BillingOrder.user_id == current_user.id, + ).first() + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + if order.status != "pending": + raise HTTPException(status_code=400, detail="订单状态不允许支付") + + plan = db.query(BillingPlan).filter(BillingPlan.id == order.plan_id).first() + if not plan: + raise HTTPException(status_code=404, detail="套餐不存在") + + # 计算到期时间 + days = 365 if order.period == "yearly" else 30 + now = datetime.utcnow() + expires_at = now + timedelta(days=days) + + # 更新订单状态 + order.status = "paid" + order.paid_at = now + order.payment_ref = f"mock_{order.id}" + + # 创建或更新订阅 + sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first() + if not sub: + sub = UserSubscription(user_id=current_user.id) + db.add(sub) + + sub.plan_id = plan.id + sub.tier = plan.tier + sub.status = "active" + sub.started_at = now + sub.expires_at = expires_at + + # 更新用户模型 + current_user.subscription_tier = plan.tier + current_user.subscription_expires_at = expires_at + current_user.daily_usage_count = 0 + current_user.daily_usage_date = date.today() + + db.commit() + + return { + "success": True, + "tier": plan.tier, + "expires_at": expires_at.isoformat(), + "message": f"已升级到 {plan.name},有效期至 {expires_at.strftime('%Y-%m-%d')}", + } + + +@router.post("/callback/wechat") +async def wechat_callback(): + """微信支付回调(Phase 4 实现)""" + return {"code": "SUCCESS", "message": "not implemented yet"} + + +@router.post("/callback/alipay") +async def alipay_callback(): + """支付宝回调(Phase 4 实现)""" + return {"code": "SUCCESS", "message": "not implemented yet"} diff --git a/backend/app/api/collaboration.py b/backend/app/api/collaboration.py index f3112f9..2d5b6d0 100644 --- a/backend/app/api/collaboration.py +++ b/backend/app/api/collaboration.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPExce from sqlalchemy.orm import Session from app.core.database import get_db, SessionLocal from app.models.workflow import Workflow +from app.models.workspace import WorkspaceMembership from app.api.auth import get_current_user from app.models.user import User from app.websocket.collaboration_manager import collaboration_manager @@ -79,6 +80,25 @@ async def websocket_collaboration( await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="工作流不存在") return + # 检查 workspace 成员资格 + ws_id = payload.get("ws", "") + if ws_id and user.role != "admin": + membership = ( + db.query(WorkspaceMembership) + .filter( + WorkspaceMembership.workspace_id == ws_id, + WorkspaceMembership.user_id == user.id, + ) + .first() + ) + if not membership: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权访问此工作区") + return + # 验证 workflow 属于该 workspace + if workflow.workspace_id and workflow.workspace_id != ws_id: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权编辑此工作流") + return + # 检查权限(只有工作流所有者可以协作编辑,或者未来可以扩展权限) # 暂时只允许所有者编辑 if workflow.user_id != user.id: diff --git a/backend/app/api/data_sources.py b/backend/app/api/data_sources.py index 7cfdf8c..778b374 100644 --- a/backend/app/api/data_sources.py +++ b/backend/app/api/data_sources.py @@ -10,6 +10,7 @@ import logging from app.core.database import get_db from app.models.data_source import DataSource from app.api.auth import get_current_user +from app.api.deps import require_workspace_admin, WorkspaceContext from app.models.user import User from app.core.exceptions import NotFoundError, ValidationError from app.services.data_source_connector import DataSourceConnector @@ -163,9 +164,10 @@ async def update_data_source( async def delete_data_source( data_source_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): - """删除数据源""" + """删除数据源(仅工作区管理员和平台管理员可操作)""" data_source = db.query(DataSource).filter( DataSource.id == data_source_id, DataSource.user_id == current_user.id diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6d8e681..49988a2 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,7 +1,8 @@ """ -FastAPI 依赖注入 — Workspace 上下文、权限检查 +FastAPI 依赖注入 — Workspace 上下文、RBAC 权限检查 """ from dataclasses import dataclass +import logging from typing import Optional from fastapi import Depends, HTTPException, Request @@ -10,6 +11,9 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.core.security import decode_access_token from app.models.user import User +from app.models.workspace import WorkspaceMembership + +logger = logging.getLogger(__name__) @dataclass @@ -41,7 +45,12 @@ def get_workspace_context( request: Request, db: Session = Depends(get_db), ) -> WorkspaceContext: - """获取完整的 Workspace 上下文(用户 + 工作区),用于需要 workspace 过滤的接口。""" + """获取完整的 Workspace 上下文(用户 + 工作区)。 + + 注意:此函数仅认证用户并提取 workspace_id,不验证 workspace 成员资格。 + 用于跨 workspace 或 admin 端点。需要强制成员资格验证的端点请使用 + get_workspace_membership()。 + """ auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="未提供有效的认证令牌") @@ -61,4 +70,128 @@ def get_workspace_context( if not user: raise HTTPException(status_code=401, detail="用户不存在") + if user.status == "deleted": + raise HTTPException(status_code=403, detail="账号已注销") + return WorkspaceContext(user=user, workspace_id=ws_id or "") + + +# ─── 多租户 RBAC 依赖 (v1.3) ─── + +def get_workspace_membership( + request: Request, + db: Session = Depends(get_db), +) -> WorkspaceContext: + """认证用户 + 验证 workspace 成员资格。 + + 平台管理员(user.role == 'admin')绕过 workspace 成员资格检查。 + 普通用户必须属于 JWT 中 `ws` 指定的 workspace。 + """ + ctx = get_workspace_context(request, db) + + # 平台管理员可访问所有 workspace + if ctx.user.role == "admin": + return ctx + + if not ctx.workspace_id: + raise HTTPException(status_code=400, detail="未选择工作区") + + membership = ( + db.query(WorkspaceMembership) + .filter( + WorkspaceMembership.workspace_id == ctx.workspace_id, + WorkspaceMembership.user_id == ctx.user.id, + ) + .first() + ) + if not membership: + logger.warning("用户 %s 尝试访问未加入的工作区 %s", ctx.user.id, ctx.workspace_id) + raise HTTPException(status_code=403, detail="无权访问此工作区") + + return ctx + + +async def require_workspace_admin( + ctx: WorkspaceContext = Depends(get_workspace_membership), + db: Session = Depends(get_db), +) -> WorkspaceContext: + """要求 workspace 管理员或平台管理员权限。 + + 用于删除/发布/修改工作区设置等敏感操作。 + """ + # 平台管理员 + if ctx.user.role == "admin": + return ctx + + # workspace 管理员 + membership = ( + db.query(WorkspaceMembership) + .filter( + WorkspaceMembership.workspace_id == ctx.workspace_id, + WorkspaceMembership.user_id == ctx.user.id, + ) + .first() + ) + if not membership or membership.role != "admin": + raise HTTPException(status_code=403, detail="需要工作区管理员权限") + + return ctx + + +class WorkspacePermission: + """细粒度权限检查依赖 — 桥接系统 RBAC 与 workspace 上下文。 + + 用法: + @router.delete("/agents/{id}") + async def delete_agent( + ..., + ctx: WorkspaceContext = Depends(WorkspacePermission("agent:delete")), + ): + """ + + def __init__(self, permission_code: str): + self.permission_code = permission_code + + async def __call__( + self, + ctx: WorkspaceContext = Depends(get_workspace_membership), + db: Session = Depends(get_db), + ) -> WorkspaceContext: + # 平台管理员总是通过 + if ctx.user.role == "admin": + return ctx + + # workspace 管理员拥有该 workspace 内所有权限 + membership = ( + db.query(WorkspaceMembership) + .filter( + WorkspaceMembership.workspace_id == ctx.workspace_id, + WorkspaceMembership.user_id == ctx.user.id, + ) + .first() + ) + if membership and membership.role == "admin": + return ctx + + # 检查系统级 RBAC 权限 + if _user_has_permission(db, ctx.user.id, self.permission_code): + return ctx + + raise HTTPException(status_code=403, detail=f"缺少权限: {self.permission_code}") + + +def _user_has_permission(db: Session, user_id: str, permission_code: str) -> bool: + """检查用户是否拥有指定权限(通过系统级 RBAC)。""" + from app.models.permission import Role, Permission + from app.models.user import User + result = ( + db.query(Permission) + .join(Permission.roles) + .join(Role.users) + .filter( + Permission.code == permission_code, + User.id == user_id, + ) + .first() + ) + return result is not None diff --git a/backend/app/api/knowledge_dashboard.py b/backend/app/api/knowledge_dashboard.py index 52bf772..a164c03 100644 --- a/backend/app/api/knowledge_dashboard.py +++ b/backend/app/api/knowledge_dashboard.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.api.auth import get_current_user +from app.api.deps import get_current_workspace_id from app.models.user import User from app.models.knowledge_entry import KnowledgeEntry from app.services.bottleneck_detector import bottleneck_detector @@ -53,19 +54,17 @@ def get_knowledge_entries( limit: int = Query(50, ge=1, le=200, description="返回条数"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), ): - """获取知识条目列表,按创建时间倒序。""" + """获取当前工作区知识条目列表,按创建时间倒序。""" since = datetime.now() - timedelta(days=days) - entries = ( - db.query(KnowledgeEntry) - .filter( - KnowledgeEntry.created_at >= since, - KnowledgeEntry.is_active == True, - ) - .order_by(KnowledgeEntry.created_at.desc()) - .limit(limit) - .all() + q = db.query(KnowledgeEntry).filter( + KnowledgeEntry.created_at >= since, + KnowledgeEntry.is_active == True, ) + if workspace_id: + q = q.filter(KnowledgeEntry.workspace_id == workspace_id) + entries = q.order_by(KnowledgeEntry.created_at.desc()).limit(limit).all() return [e.to_dict() for e in entries] @@ -74,24 +73,24 @@ def get_knowledge_trend( days: int = Query(7, ge=1, le=365, description="统计天数"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), + workspace_id: str = Depends(get_current_workspace_id), ): - """知识条目增长趋势 — 按天统计新增数量。""" + """知识条目增长趋势(当前工作区)— 按天统计新增数量。""" since = datetime.now() - timedelta(days=days) - # GROUP BY date(created_at) - rows = ( - db.query( - func.date(KnowledgeEntry.created_at).label("date"), - func.count(KnowledgeEntry.id).label("count"), - ) - .filter( - KnowledgeEntry.created_at >= since, - KnowledgeEntry.is_active == True, - ) - .group_by(func.date(KnowledgeEntry.created_at)) - .order_by(func.date(KnowledgeEntry.created_at).asc()) - .all() + q = db.query( + func.date(KnowledgeEntry.created_at).label("date"), + func.count(KnowledgeEntry.id).label("count"), + ).filter( + KnowledgeEntry.created_at >= since, + KnowledgeEntry.is_active == True, ) + if workspace_id: + q = q.filter(KnowledgeEntry.workspace_id == workspace_id) + + rows = q.group_by(func.date(KnowledgeEntry.created_at)).order_by( + func.date(KnowledgeEntry.created_at).asc() + ).all() # Fill in missing dates with 0 count trend = [] diff --git a/backend/app/api/model_configs.py b/backend/app/api/model_configs.py index 23090a9..8d01450 100644 --- a/backend/app/api/model_configs.py +++ b/backend/app/api/model_configs.py @@ -10,6 +10,7 @@ import logging from app.core.database import get_db from app.models.model_config import ModelConfig from app.api.auth import get_current_user +from app.api.deps import require_workspace_admin, WorkspaceContext from app.models.user import User from app.core.exceptions import NotFoundError, ValidationError, ConflictError from app.services.encryption_service import EncryptionService @@ -207,10 +208,11 @@ async def update_model_config( async def delete_model_config( config_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): """ - 删除模型配置 + 删除模型配置(仅工作区管理员和平台管理员可操作) """ config = db.query(ModelConfig).filter( ModelConfig.id == config_id, diff --git a/backend/app/api/plugins.py b/backend/app/api/plugins.py index c5a7529..a171951 100644 --- a/backend/app/api/plugins.py +++ b/backend/app/api/plugins.py @@ -11,6 +11,7 @@ import logging from app.core.database import get_db from app.api.auth import get_current_user +from app.api.deps import get_current_workspace_id from app.models.user import User from app.models.plugin import NodePlugin from app.services.plugin_loader import ( @@ -202,11 +203,14 @@ async def create_plugin( async def get_plugin( plugin_id: str, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - """获取插件详情。""" + """获取插件详情(仅所有者或公开插件可查看)。""" plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") + if not plugin.is_public and plugin.user_id and plugin.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权查看此插件") return plugin @@ -222,7 +226,7 @@ async def update_plugin( plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") - if plugin.user_id and plugin.user_id != current_user.id: + if plugin.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权修改此插件") for field, value in body.dict(exclude_unset=True).items(): @@ -251,7 +255,7 @@ async def delete_plugin( plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") - if plugin.user_id and plugin.user_id != current_user.id: + if plugin.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权删除此插件") unload_plugin_code(plugin.id) @@ -271,6 +275,8 @@ async def toggle_plugin( plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") + if plugin.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权操作此插件") plugin.enabled = not plugin.enabled if plugin.enabled: @@ -310,11 +316,14 @@ async def test_plugin( plugin_id: str, body: PluginExecuteRequest, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - """在沙箱中测试执行插件。""" + """在沙箱中测试执行插件(仅所有者可测试)。""" plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") + if plugin.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权测试此插件") if not plugin.code: raise HTTPException(status_code=400, detail="插件无代码") diff --git a/backend/app/api/system_logs.py b/backend/app/api/system_logs.py index 3e537e3..904e8d0 100644 --- a/backend/app/api/system_logs.py +++ b/backend/app/api/system_logs.py @@ -110,26 +110,25 @@ def _build_union_query( AgentExecutionLog.id.label("id"), text("'agent'").label("source"), case( - (AgentExecutionLog.status == "failed", "ERROR"), - (AgentExecutionLog.status == "completed", "INFO"), + (AgentExecutionLog.success == False, "ERROR"), else_="INFO" ).label("level"), - AgentExecutionLog.user_message.label("message"), + AgentExecutionLog.input_text.label("message"), AgentExecutionLog.created_at.label("timestamp"), text("'agent_chat'").label("resource_type"), AgentExecutionLog.agent_id.label("resource_id"), - AgentExecutionLog.total_latency_ms.label("duration_ms"), + AgentExecutionLog.latency_ms.label("duration_ms"), text("NULL").label("username"), ) if level: if level.upper() == "ERROR": - q = q.filter(AgentExecutionLog.status == "failed") + q = q.filter(AgentExecutionLog.success == False) elif level.upper() == "WARN": - q = q.filter(AgentExecutionLog.status == "failed") + q = q.filter(AgentExecutionLog.success == False) else: - q = q.filter(AgentExecutionLog.status != "failed") + q = q.filter(AgentExecutionLog.success == True) if keyword: - q = q.filter(AgentExecutionLog.user_message.contains(keyword)) + q = q.filter(AgentExecutionLog.input_text.contains(keyword)) if start_date: q = q.filter(AgentExecutionLog.created_at >= start_date) if end_date: @@ -195,7 +194,6 @@ async def get_system_logs( ): """ 统一日志查询:跨 execution_logs / agent_execution_logs / agent_llm_logs 联合查询。 - 管理员可查全部,普通用户只能查看自己相关的 Agent 执行日志和 LLM 日志。 """ _check_admin(current_user) @@ -209,22 +207,91 @@ async def get_system_logs( if getattr(current_user, "role", None) != "admin": user_id = current_user.id - source_val = source if source else "all" - queries = _build_union_query(db, source_val, level, keyword, sd, ed, user_id) + results: list[UnifiedLogItem] = [] - if not queries: - return [] + # 1) 工作流执行日志 + if source in (None, "all", "execution"): + q = db.query(ExecutionLog) + if level: + q = q.filter(ExecutionLog.level == level.upper()) + if keyword: + q = q.filter(ExecutionLog.message.contains(keyword)) + if sd: + q = q.filter(ExecutionLog.timestamp >= sd) + if ed: + q = q.filter(ExecutionLog.timestamp <= ed) + for row in q.order_by(ExecutionLog.timestamp.desc()).offset(skip).limit(limit).all(): + results.append(UnifiedLogItem( + id=row.id, + source="execution", + level=row.level, + message=row.message or "", + timestamp=row.timestamp, + resource_type=row.node_type, + resource_id=row.execution_id, + duration_ms=row.duration, + )) - # UNION ALL - union = queries[0] - for q in queries[1:]: - union = union.union_all(q) + # 2) Agent 执行日志 + if source in (None, "all", "agent"): + q = db.query(AgentExecutionLog) + if keyword: + q = q.filter(AgentExecutionLog.input_text.contains(keyword)) + if sd: + q = q.filter(AgentExecutionLog.created_at >= sd) + if ed: + q = q.filter(AgentExecutionLog.created_at <= ed) + if user_id: + q = q.filter(AgentExecutionLog.user_id == user_id) + if level: + if level.upper() == "ERROR": + q = q.filter(AgentExecutionLog.success == False) + elif level.upper() != "WARN": + q = q.filter(AgentExecutionLog.success == True) + for row in q.order_by(AgentExecutionLog.created_at.desc()).offset(skip).limit(limit).all(): + results.append(UnifiedLogItem( + id=row.id, + source="agent", + level="ERROR" if not row.success else "INFO", + message=row.input_text or "", + timestamp=row.created_at, + resource_type="agent_chat", + resource_id=row.agent_id, + duration_ms=row.latency_ms, + )) - # 排序 + 分页 - total = union.count() if hasattr(union, 'count') else 0 - rows = union.order_by(text("timestamp DESC")).offset(skip).limit(limit).all() + # 3) LLM 调用日志 + if source in (None, "all", "llm"): + q = db.query(AgentLLMLog) + if keyword: + q = q.filter(AgentLLMLog.error_message.contains(keyword)) + if sd: + q = q.filter(AgentLLMLog.created_at >= sd) + if ed: + q = q.filter(AgentLLMLog.created_at <= ed) + if level: + if level.upper() == "ERROR": + q = q.filter(AgentLLMLog.status == "error") + elif level.upper() == "WARN": + q = q.filter(AgentLLMLog.status == "rate_limited") + else: + q = q.filter(AgentLLMLog.status == "success") + for row in q.order_by(AgentLLMLog.created_at.desc()).offset(skip).limit(limit).all(): + msg = row.error_message or f"LLM call: {row.model or 'unknown'} ({row.step_type or '?'})" + results.append(UnifiedLogItem( + id=row.id, + source="llm", + level="ERROR" if row.status == "error" else ("WARN" if row.status == "rate_limited" else "INFO"), + message=msg, + timestamp=row.created_at, + resource_type="llm_call", + resource_id=row.agent_id, + duration_ms=row.latency_ms, + )) - return rows + # 按时间排序 + 分页 + results.sort(key=lambda x: x.timestamp or datetime.min, reverse=True) + return results[skip:skip + limit] @router.get("/stats", response_model=LogStatsResponse) @@ -258,7 +325,7 @@ async def get_system_logs_stats( ).scalar() or 0 agent_errors = db.query(func.count(AgentExecutionLog.id)).filter( AgentExecutionLog.created_at >= today_start, - AgentExecutionLog.status == "failed" + AgentExecutionLog.success == False ).scalar() or 0 # LLM 调用日志统计 diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 219e852..ec6e4c4 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.auth import get_current_user +from app.api.deps import require_workspace_admin, WorkspaceContext from app.core.database import get_db from app.models.tool import Tool from app.models.user import User @@ -355,8 +356,12 @@ async def delete_tool( @router.post("/reload") -async def reload_tools(db: Session = Depends(get_db)): - """从数据库重新加载所有自定义工具到注册表(热更新)。""" +async def reload_tools( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), +): + """从数据库重新加载所有自定义工具到注册表(热更新,仅工作区管理员可操作)。""" # 清除旧的自定义工具 for name in list(tool_registry._custom_tool_configs.keys()): if name not in tool_registry._builtin_tools: diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..8eba819 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,264 @@ +""" +Admin 用户管理 API(仅平台管理员可访问) +""" +from __future__ import annotations + +import logging +import uuid +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field, EmailStr +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.api.auth import get_current_user +from app.core.database import get_db +from app.core.security import get_password_hash +from app.models.user import User +from app.models.workspace import WorkspaceMembership + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/admin/users", tags=["admin-users"]) + + +def _require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="仅平台管理员可访问") + return current_user + + +# ── Request / Response schemas ── + +class UserCreateRequest(BaseModel): + username: str = Field(..., min_length=2, max_length=50) + email: EmailStr + password: str = Field(..., min_length=6, max_length=128) + role: str = Field(default="user") + phone: Optional[str] = None + status: str = Field(default="active") + + +class UserUpdateRequest(BaseModel): + username: Optional[str] = Field(None, min_length=2, max_length=50) + email: Optional[EmailStr] = None + role: Optional[str] = None + phone: Optional[str] = None + status: Optional[str] = None + subscription_tier: Optional[str] = None + + +class ResetPasswordRequest(BaseModel): + new_password: str = Field(..., min_length=6, max_length=128) + + +class UserItem(BaseModel): + id: str + username: str + email: str + role: str + phone: Optional[str] = None + phone_verified: bool = False + status: str + is_email_verified: bool = False + subscription_tier: str = "free" + daily_usage_count: int = 0 + workspace_count: int = 0 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class UserListResponse(BaseModel): + items: List[UserItem] + total: int + page: int + page_size: int + + +# ── Endpoints ── + +@router.get("", response_model=UserListResponse) +def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None, description="搜索用户名或邮箱"), + status: Optional[str] = Query(None, description="过滤状态: active/disabled/deleted"), + role: Optional[str] = Query(None, description="过滤角色"), + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """获取用户列表(分页、搜索、过滤)""" + q = db.query(User) + + if search: + like = f"%{search}%" + q = q.filter((User.username.ilike(like)) | (User.email.ilike(like))) + if status: + q = q.filter(User.status == status) + if role: + q = q.filter(User.role == role) + + total = q.count() + items = q.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + # 获取每个用户的 workspace 数量 + user_ids = [u.id for u in items] + ws_counts = {} + if user_ids: + rows = ( + db.query( + WorkspaceMembership.user_id, + func.count(WorkspaceMembership.workspace_id).label("cnt"), + ) + .filter(WorkspaceMembership.user_id.in_(user_ids)) + .group_by(WorkspaceMembership.user_id) + .all() + ) + ws_counts = {r.user_id: r.cnt for r in rows} + + return { + "items": [ + {**{c.name: getattr(u, c.name) for c in User.__table__.columns}, + "workspace_count": ws_counts.get(u.id, 0)} + for u in items + ], + "total": total, + "page": page, + "page_size": page_size, + } + + +@router.post("", response_model=UserItem) +def create_user( + data: UserCreateRequest, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """管理员创建新用户""" + if db.query(User).filter(User.username == data.username).first(): + raise HTTPException(status_code=409, detail="用户名已存在") + if db.query(User).filter(User.email == data.email).first(): + raise HTTPException(status_code=409, detail="邮箱已存在") + + user = User( + id=str(uuid.uuid4()), + username=data.username, + email=data.email, + password_hash=get_password_hash(data.password), + role=data.role, + phone=data.phone, + status=data.status, + ) + db.add(user) + db.commit() + db.refresh(user) + + logger.info("管理员创建了用户: %s (%s)", user.username, user.id) + return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": 0} + + +@router.get("/{user_id}", response_model=UserItem) +def get_user( + user_id: str, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """获取用户详情""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + ws_count = ( + db.query(func.count(WorkspaceMembership.workspace_id)) + .filter(WorkspaceMembership.user_id == user_id) + .scalar() + ) or 0 + + return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": ws_count} + + +@router.put("/{user_id}", response_model=UserItem) +def update_user( + user_id: str, + data: UserUpdateRequest, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """编辑用户""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + if data.username is not None and data.username != user.username: + if db.query(User).filter(User.username == data.username, User.id != user_id).first(): + raise HTTPException(status_code=409, detail="用户名已存在") + user.username = data.username + if data.email is not None and data.email != user.email: + if db.query(User).filter(User.email == data.email, User.id != user_id).first(): + raise HTTPException(status_code=409, detail="邮箱已存在") + user.email = data.email + if data.role is not None: + user.role = data.role + if data.phone is not None: + user.phone = data.phone + if data.status is not None: + if data.status not in ("active", "disabled", "deleted"): + raise HTTPException(status_code=400, detail="无效的状态值") + user.status = data.status + if data.subscription_tier is not None: + user.subscription_tier = data.subscription_tier + + db.commit() + db.refresh(user) + + logger.info("管理员更新了用户: %s (%s)", user.username, user.id) + ws_count = ( + db.query(func.count(WorkspaceMembership.workspace_id)) + .filter(WorkspaceMembership.user_id == user_id) + .scalar() + ) or 0 + return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": ws_count} + + +@router.delete("/{user_id}") +def delete_user( + user_id: str, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """软删除用户(标记为 deleted)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.role == "admin": + raise HTTPException(status_code=400, detail="不能删除平台管理员") + + user.status = "deleted" + db.commit() + + logger.info("管理员软删除了用户: %s (%s)", user.username, user.id) + return {"message": "用户已删除"} + + +@router.post("/{user_id}/reset-password") +def reset_password( + user_id: str, + data: ResetPasswordRequest, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """重置用户密码""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + user.password_hash = get_password_hash(data.new_password) + db.commit() + + logger.info("管理员重置了用户 %s 的密码", user.username) + return {"message": "密码已重置"} diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index 71debfd..0bc689c 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -1,14 +1,19 @@ """ -WebSocket API +WebSocket API — 实时推送执行状态(workspace 隔离) """ -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from app.websocket.manager import websocket_manager from app.core.database import SessionLocal +from app.core.security import decode_access_token from app.models.execution import Execution +from app.models.user import User +from app.models.workspace import WorkspaceMembership from typing import Optional import json import asyncio +import logging +logger = logging.getLogger(__name__) router = APIRouter() @@ -27,21 +32,83 @@ def _get_progress_from_redis(execution_id: str) -> Optional[dict]: return None +def _validate_ws_token(token: Optional[str], execution_id: str) -> dict: + """验证 WebSocket JWT token,返回 {user_id, workspace_id}。 + + 失败时返回包含 error/code 的字典,调用方应据此拒绝连接。 + """ + if not token: + return {"error": "缺少认证令牌", "code": 4001} + + payload = decode_access_token(token) + if payload is None: + return {"error": "无效的访问令牌", "code": 4001} + + user_id = payload.get("sub") + ws_id = payload.get("ws", "") + if not user_id: + return {"error": "无效的令牌载荷", "code": 4001} + + # 验证用户存在且未被删除 + db = SessionLocal() + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + return {"error": "用户不存在", "code": 4001} + if user.status == "deleted": + return {"error": "账号已注销", "code": 4003} + + # 平台管理员绕过 workspace 检查 + if user.role == "admin": + return {"user_id": user_id, "workspace_id": ws_id, "is_admin": True} + + # 验证 workspace 成员资格 + if ws_id: + membership = ( + db.query(WorkspaceMembership) + .filter( + WorkspaceMembership.workspace_id == ws_id, + WorkspaceMembership.user_id == user_id, + ) + .first() + ) + if not membership: + return {"error": "无权访问此工作区", "code": 4003} + + # 验证 execution 属于该 workspace + execution = db.query(Execution).filter(Execution.id == execution_id).first() + if execution and ws_id and execution.workspace_id and execution.workspace_id != ws_id: + return {"error": "无权查看此执行记录", "code": 4003} + + return {"user_id": user_id, "workspace_id": ws_id} + finally: + db.close() + + @router.websocket("/api/v1/ws/executions/{execution_id}") async def websocket_execution_status( websocket: WebSocket, execution_id: str, - token: Optional[str] = None + token: Optional[str] = Query(None), ): """ - WebSocket实时推送执行状态 + WebSocket 实时推送执行状态(workspace 隔离)。 - Args: - websocket: WebSocket连接 - execution_id: 执行记录ID - token: JWT Token(可选,通过query参数传递) + 需要 JWT token 通过 query 参数传递: ?token=xxx + 连接建立前验证:JWT 有效性 → workspace 成员资格 → execution workspace 归属。 """ - await websocket_manager.connect(websocket, execution_id) + # ── 认证 + 鉴权 ── + auth = _validate_ws_token(token, execution_id) + if "error" in auth: + await websocket.accept() + await websocket.send_json({"type": "error", "message": auth["error"], "code": auth["code"]}) + await websocket.close(code=auth["code"]) + return + + user_id = auth["user_id"] + ws_id = auth.get("workspace_id", "") + + await websocket_manager.connect(websocket, execution_id, workspace_id=ws_id) db = SessionLocal() @@ -139,7 +206,7 @@ async def websocket_execution_status( except WebSocketDisconnect: pass except Exception as e: - print(f"WebSocket错误: {e}") + logger.error("WebSocket 错误: %s", e) try: await websocket_manager.send_personal_message({ "type": "error", diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index b097fd3..89ae749 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -11,6 +11,7 @@ from app.core.database import get_db from app.models.workflow import Workflow from app.models.workflow_version import WorkflowVersion from app.api.auth import get_current_user, UserResponse +from app.api.deps import require_workspace_admin, WorkspaceContext from app.models.user import User from app.core.exceptions import NotFoundError, ValidationError, ConflictError from app.services.workflow_validator import validate_workflow @@ -340,9 +341,10 @@ async def update_workflow( async def delete_workflow( workflow_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): - """删除工作流(只有所有者可以删除)""" + """删除工作流(仅工作区管理员和平台管理员可操作)""" workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() if not workflow: @@ -610,9 +612,10 @@ async def rollback_workflow_version( version: int, rollback_data: Optional[WorkflowVersionRollback] = None, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + _ws_admin: WorkspaceContext = Depends(require_workspace_admin), ): - """回滚工作流到指定版本""" + """回滚工作流到指定版本(仅工作区管理员和平台管理员可操作)""" # 验证工作流是否存在且属于当前用户 workflow = db.query(Workflow).filter( Workflow.id == workflow_id, diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py index 755371c..9dd3407 100644 --- a/backend/app/api/workspaces.py +++ b/backend/app/api/workspaces.py @@ -11,6 +11,7 @@ import logging from app.core.database import get_db from app.api.auth import get_current_user +from app.api.users import _require_admin from app.models.user import User from app.models.workspace import Workspace, WorkspaceMembership from app.services.workspace_service import check_workspace_access, get_user_workspaces @@ -391,3 +392,165 @@ def remove_member( db.commit() return {"message": "成员已移除"} + + +# ── 平台管理员端点 ── + +admin_router = APIRouter( + prefix="/api/v1/admin/workspaces", + tags=["admin-workspaces"], + responses={ + 401: {"description": "未授权"}, + 403: {"description": "仅平台管理员可访问"}, + 404: {"description": "资源不存在"}, + } +) + + +class AdminWorkspaceResponse(BaseModel): + id: str + name: str + description: Optional[str] + is_default: bool + owner_id: str + owner_name: str = "" + max_members: int + member_count: int = 0 + status: str + created_at: Optional[str] + updated_at: Optional[str] + + +class AdminWorkspaceListResponse(BaseModel): + items: List[AdminWorkspaceResponse] + total: int + page: int + page_size: int + + +@admin_router.get("", response_model=AdminWorkspaceListResponse) +def admin_list_workspaces( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None, description="搜索工作区名称"), + status: Optional[str] = Query(None, description="过滤状态: active/disabled/deleted"), + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """平台管理员查看所有工作区(分页)。""" + q = db.query(Workspace) + if search: + q = q.filter(Workspace.name.ilike(f"%{search}%")) + if status: + q = q.filter(Workspace.status == status) + + total = q.count() + workspaces = q.order_by(Workspace.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + # 批量查 owner 和 member_count + owner_ids = [w.owner_id for w in workspaces] + owners = {} + if owner_ids: + for u in db.query(User).filter(User.id.in_(owner_ids)).all(): + owners[u.id] = u.username + + items = [] + for w in workspaces: + mc = ( + db.query(WorkspaceMembership) + .filter(WorkspaceMembership.workspace_id == w.id) + .count() + ) + items.append(AdminWorkspaceResponse( + id=w.id, + name=w.name, + description=w.description, + is_default=bool(w.is_default), + owner_id=w.owner_id, + owner_name=owners.get(w.owner_id, ""), + max_members=w.max_members, + member_count=mc, + status=w.status, + created_at=w.created_at.isoformat() if w.created_at else None, + updated_at=w.updated_at.isoformat() if w.updated_at else None, + )) + + return AdminWorkspaceListResponse(items=items, total=total, page=page, page_size=page_size) + + +@admin_router.get("/{workspace_id}", response_model=AdminWorkspaceResponse) +def admin_get_workspace( + workspace_id: str, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """平台管理员查看任意工作区详情。""" + ws = db.query(Workspace).filter(Workspace.id == workspace_id).first() + if not ws: + raise NotFoundError("工作区", workspace_id) + + owner = db.query(User).filter(User.id == ws.owner_id).first() + mc = ( + db.query(WorkspaceMembership) + .filter(WorkspaceMembership.workspace_id == workspace_id) + .count() + ) + + return AdminWorkspaceResponse( + id=ws.id, + name=ws.name, + description=ws.description, + is_default=bool(ws.is_default), + owner_id=ws.owner_id, + owner_name=owner.username if owner else "", + max_members=ws.max_members, + member_count=mc, + status=ws.status, + created_at=ws.created_at.isoformat() if ws.created_at else None, + updated_at=ws.updated_at.isoformat() if ws.updated_at else None, + ) + + +@admin_router.put("/{workspace_id}") +def admin_update_workspace( + workspace_id: str, + data: WorkspaceUpdate, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """平台管理员编辑任意工作区。""" + ws = db.query(Workspace).filter(Workspace.id == workspace_id).first() + if not ws: + raise NotFoundError("工作区", workspace_id) + + if data.name is not None: + ws.name = data.name + if data.description is not None: + ws.description = data.description + if data.max_members is not None: + ws.max_members = data.max_members + if data.status is not None: + ws.status = data.status + ws.updated_at = datetime.utcnow() + + db.commit() + return {"message": "工作区已更新"} + + +@admin_router.delete("/{workspace_id}") +def admin_delete_workspace( + workspace_id: str, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + """平台管理员强制删除工作区(软删除)。""" + ws = db.query(Workspace).filter(Workspace.id == workspace_id).first() + if not ws: + raise NotFoundError("工作区", workspace_id) + + ws.status = "deleted" + ws.updated_at = datetime.utcnow() + db.commit() + + logger.info("平台管理员删除了工作区: %s (%s)", ws.name, ws.id) + return {"message": "工作区已删除"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ed6397e..900a431 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -105,6 +105,9 @@ class Settings(BaseSettings): # 外部访问地址(用于飞书通知中的详情链接等) EXTERNAL_URL: str = "" + # SMS 短信服务配置(mock / tencent / aliyun) + SMS_PROVIDER: str = "mock" + # 飞书应用配置(用于发送消息通知到用户飞书) FEISHU_APP_ID: str = "" FEISHU_APP_SECRET: str = "" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3ee95bd..17d3f72 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -77,4 +77,245 @@ def init_db(): import app.models.scene_contract import app.models.team import app.models.chat_message + import app.models.agent_session + import app.models.billing Base.metadata.create_all(bind=engine) + + # v1.1.0: 手机验证字段迁移(safe ALTER) + _run_safe_migrations() + + # v1.3: Auto-seed RBAC roles & permissions + _seed_rbac() + + +def _run_safe_migrations(): + """安全迁移:对可能缺失的列执行 ALTER TABLE(幂等,失败不中断)。""" + import logging + from sqlalchemy import text + logger = logging.getLogger(__name__) + migrations = [ + # v1.0: 手机验证 + "ALTER TABLE users ADD COLUMN phone_verified TINYINT(1) DEFAULT 0", + "ALTER TABLE users ADD COLUMN phone_verified_at DATETIME NULL", + # v1.2: 订阅与用量 + "ALTER TABLE users ADD COLUMN subscription_tier VARCHAR(20) DEFAULT 'free'", + "ALTER TABLE users ADD COLUMN subscription_expires_at DATETIME NULL", + "ALTER TABLE users ADD COLUMN daily_usage_count INT DEFAULT 0", + "ALTER TABLE users ADD COLUMN daily_usage_date DATE NULL", + # v1.3: 多租户 workspace_id + "ALTER TABLE agent_sessions ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE agent_sessions ADD INDEX idx_session_workspace (workspace_id)", + "ALTER TABLE chat_messages ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE chat_messages ADD INDEX idx_msg_workspace (workspace_id)", + "ALTER TABLE agent_execution_logs ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE agent_execution_logs ADD INDEX idx_exec_workspace (workspace_id)", + "ALTER TABLE agent_llm_logs ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE agent_llm_logs ADD INDEX idx_llm_workspace (workspace_id)", + "ALTER TABLE conversation_branches ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE conversation_branches ADD INDEX idx_branch_workspace (workspace_id)", + "ALTER TABLE node_templates ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE node_templates ADD INDEX idx_nt_workspace (workspace_id)", + "ALTER TABLE node_plugins ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE node_plugins ADD INDEX idx_plugin_workspace (workspace_id)", + "ALTER TABLE knowledge_entries ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE knowledge_entries ADD INDEX idx_ke_workspace (workspace_id)", + "ALTER TABLE alert_rules ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE alert_rules ADD INDEX idx_ar_workspace (workspace_id)", + "ALTER TABLE alert_logs ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE alert_logs ADD INDEX idx_al_workspace (workspace_id)", + "ALTER TABLE agent_ratings ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE agent_ratings ADD INDEX idx_arating_workspace (workspace_id)", + "ALTER TABLE agent_favorites ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE agent_favorites ADD INDEX idx_afav_workspace (workspace_id)", + "ALTER TABLE template_ratings ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE template_ratings ADD INDEX idx_trating_workspace (workspace_id)", + "ALTER TABLE template_favorites ADD COLUMN workspace_id CHAR(36) NULL", + "ALTER TABLE template_favorites ADD INDEX idx_tfav_workspace (workspace_id)", + ] + for raw_sql in migrations: + try: + with engine.begin() as conn: + conn.execute(text(raw_sql)) + logger.info("迁移执行: %s", raw_sql) + except Exception: + # 列已存在或其他错误,跳过 + pass + + # 回填已有数据的 workspace_id + _backfill_workspace_ids(logger) + + +def _backfill_workspace_ids(logger): + """回填已有数据的 workspace_id(通过父表 FK 链推导)。""" + from sqlalchemy import text + + backfills = [ + # (table, fk_column, parent_table) — 通过父表的 workspace_id 回填 + ("agent_sessions", "agent_id", "agents"), + ("agent_execution_logs", "agent_id", "agents"), + ("agent_llm_logs", "agent_id", "agents"), + ("agent_ratings", "agent_id", "agents"), + ("agent_favorites", "agent_id", "agents"), + ("template_ratings", "template_id", "workflow_templates"), + ("template_favorites", "template_id", "workflow_templates"), + ] + + for table, fk_col, parent_table in backfills: + try: + with engine.begin() as conn: + result = conn.execute(text( + f"UPDATE {table} t " + f"JOIN {parent_table} p ON t.{fk_col} = p.id " + f"SET t.workspace_id = p.workspace_id " + f"WHERE t.workspace_id IS NULL AND p.workspace_id IS NOT NULL" + )) + if result.rowcount: + logger.info("回填 %s.workspace_id: %s 行", table, result.rowcount) + except Exception: + pass # 父表可能没有 workspace_id 列 + + # chat_messages: 通过 agent_sessions 回填 + try: + with engine.begin() as conn: + result = conn.execute(text( + "UPDATE chat_messages cm " + "JOIN agent_sessions s ON cm.session_id = s.id " + "SET cm.workspace_id = s.workspace_id " + "WHERE cm.workspace_id IS NULL AND s.workspace_id IS NOT NULL" + )) + if result.rowcount: + logger.info("回填 chat_messages.workspace_id: %s 行", result.rowcount) + except Exception: + pass + + # conversation_branches: 通过 agent_sessions 回填 + try: + with engine.begin() as conn: + result = conn.execute(text( + "UPDATE conversation_branches cb " + "JOIN agent_sessions s ON cb.parent_session_id = s.id " + "SET cb.workspace_id = s.workspace_id " + "WHERE cb.workspace_id IS NULL AND s.workspace_id IS NOT NULL" + )) + if result.rowcount: + logger.info("回填 conversation_branches.workspace_id: %s 行", result.rowcount) + except Exception: + pass + + +def _seed_rbac(): + """Auto-seed system roles & permissions on startup (idempotent).""" + import logging + import uuid + from sqlalchemy import text + + logger = logging.getLogger(__name__) + + # Check if RBAC tables exist (may fail on fresh DB before create_all runs) + try: + from app.models.permission import Role, Permission + except Exception: + logger.warning("RBAC model not available, skip seeding") + return + + db = SessionLocal() + try: + # ── Permissions ── + PERMISSIONS = [ + {"name": "工作流-创建", "code": "workflow:create", "resource": "workflow", "action": "create"}, + {"name": "工作流-查看", "code": "workflow:read", "resource": "workflow", "action": "read"}, + {"name": "工作流-更新", "code": "workflow:update", "resource": "workflow", "action": "update"}, + {"name": "工作流-删除", "code": "workflow:delete", "resource": "workflow", "action": "delete"}, + {"name": "工作流-执行", "code": "workflow:execute", "resource": "workflow", "action": "execute"}, + {"name": "工作流-分享", "code": "workflow:share", "resource": "workflow", "action": "share"}, + {"name": "Agent-创建", "code": "agent:create", "resource": "agent", "action": "create"}, + {"name": "Agent-查看", "code": "agent:read", "resource": "agent", "action": "read"}, + {"name": "Agent-更新", "code": "agent:update", "resource": "agent", "action": "update"}, + {"name": "Agent-删除", "code": "agent:delete", "resource": "agent", "action": "delete"}, + {"name": "Agent-执行", "code": "agent:execute", "resource": "agent", "action": "execute"}, + {"name": "Agent-部署", "code": "agent:deploy", "resource": "agent", "action": "deploy"}, + {"name": "执行-查看", "code": "execution:read", "resource": "execution", "action": "read"}, + {"name": "执行-取消", "code": "execution:cancel", "resource": "execution", "action": "cancel"}, + {"name": "数据源-创建", "code": "data_source:create", "resource": "data_source", "action": "create"}, + {"name": "数据源-查看", "code": "data_source:read", "resource": "data_source", "action": "read"}, + {"name": "数据源-更新", "code": "data_source:update", "resource": "data_source", "action": "update"}, + {"name": "数据源-删除", "code": "data_source:delete", "resource": "data_source", "action": "delete"}, + {"name": "模型配置-创建", "code": "model_config:create", "resource": "model_config", "action": "create"}, + {"name": "模型配置-查看", "code": "model_config:read", "resource": "model_config", "action": "read"}, + {"name": "模型配置-更新", "code": "model_config:update", "resource": "model_config", "action": "update"}, + {"name": "模型配置-删除", "code": "model_config:delete", "resource": "model_config", "action": "delete"}, + {"name": "权限-管理", "code": "permission:manage", "resource": "permission", "action": "manage"}, + ] + + permission_map = {} + for perm_data in PERMISSIONS: + existing = db.query(Permission).filter(Permission.code == perm_data["code"]).first() + if existing: + permission_map[perm_data["code"]] = existing + else: + perm = Permission( + id=str(uuid.uuid4()), + name=perm_data["name"], + code=perm_data["code"], + resource=perm_data["resource"], + action=perm_data["action"], + ) + db.add(perm) + permission_map[perm_data["code"]] = perm + db.commit() + + # ── Roles ── + SYSTEM_ROLES = [ + {"name": "admin", "description": "系统管理员,拥有所有权限", "is_system": True}, + {"name": "developer", "description": "开发者,可以创建和管理工作流、Agent", "is_system": True}, + {"name": "viewer", "description": "查看者,只能查看工作流和执行记录", "is_system": True}, + {"name": "operator", "description": "操作员,可以执行工作流,但不能修改", "is_system": True}, + ] + + role_map = {} + for role_data in SYSTEM_ROLES: + existing = db.query(Role).filter(Role.name == role_data["name"]).first() + if existing: + role_map[role_data["name"]] = existing + else: + role = Role( + id=str(uuid.uuid4()), + name=role_data["name"], + description=role_data["description"], + is_system=role_data["is_system"], + ) + db.add(role) + role_map[role_data["name"]] = role + db.commit() + + # ── Role-Permission mappings ── + ROLE_PERMISSIONS = { + "admin": ["*"], + "developer": [ + "workflow:create", "workflow:read", "workflow:update", "workflow:delete", "workflow:execute", "workflow:share", + "agent:create", "agent:read", "agent:update", "agent:delete", "agent:execute", "agent:deploy", + "execution:read", "execution:cancel", + "data_source:create", "data_source:read", "data_source:update", "data_source:delete", + "model_config:create", "model_config:read", "model_config:update", "model_config:delete", + ], + "viewer": ["workflow:read", "agent:read", "execution:read", "data_source:read", "model_config:read"], + "operator": ["workflow:read", "workflow:execute", "agent:read", "agent:execute", "execution:read", "execution:cancel"], + } + + for role_name, perm_codes in ROLE_PERMISSIONS.items(): + role = role_map.get(role_name) + if not role: + continue + if perm_codes == ["*"]: + role.permissions = list(permission_map.values()) + else: + role.permissions = [permission_map[c] for c in perm_codes if c in permission_map] + db.commit() + + logger.info("RBAC seeded: %d roles, %d permissions", len(role_map), len(permission_map)) + + except Exception: + db.rollback() + logger.warning("RBAC seed skipped (tables may not exist yet)", exc_info=True) + finally: + db.close() diff --git a/backend/app/core/sms_service.py b/backend/app/core/sms_service.py new file mode 100644 index 0000000..1b56819 --- /dev/null +++ b/backend/app/core/sms_service.py @@ -0,0 +1,83 @@ +""" +SMS 服务抽象层 +Mock 模式:验证码打印到后端日志 +后续可接入阿里云短信 / 腾讯云 SMS,只需替换 Provider 实现 +""" +import logging +from abc import ABC, abstractmethod +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class SmsProvider(ABC): + """SMS 发送提供者基类""" + + @abstractmethod + async def send(self, phone: str, code: str) -> bool: + """发送验证码短信,成功返回 True""" + ... + + +class MockSmsProvider(SmsProvider): + """Mock 提供者 —— 验证码打印到后端日志(开发/测试环境用)""" + + async def send(self, phone: str, code: str) -> bool: + logger.info("=" * 50) + logger.info("📱 [MockSMS] 验证码: %s → %s", code, phone) + logger.info("=" * 50) + return True + + +class TencentSmsProvider(SmsProvider): + """腾讯云 SMS 提供者(待接入)""" + + def __init__(self, secret_id: str, secret_key: str, app_id: str, sign_name: str, template_id: str): + self.secret_id = secret_id + self.secret_key = secret_key + self.app_id = app_id + self.sign_name = sign_name + self.template_id = template_id + + async def send(self, phone: str, code: str) -> bool: + # TODO: 接入腾讯云 SMS SDK + logger.warning("TencentSmsProvider 尚未实现,验证码: %s → %s", code, phone) + return False + + +class AliyunSmsProvider(SmsProvider): + """阿里云短信提供者(待接入)""" + + def __init__(self, access_key_id: str, access_key_secret: str, sign_name: str, template_code: str): + self.access_key_id = access_key_id + self.access_key_secret = access_key_secret + self.sign_name = sign_name + self.template_code = template_code + + async def send(self, phone: str, code: str) -> bool: + # TODO: 接入阿里云短信 SDK + logger.warning("AliyunSmsProvider 尚未实现,验证码: %s → %s", code, phone) + return False + + +def get_sms_provider() -> SmsProvider: + """根据配置返回 SMS 提供者实例""" + provider_name = getattr(settings, "SMS_PROVIDER", "mock") or "mock" + + if provider_name == "tencent": + return TencentSmsProvider( + secret_id=getattr(settings, "TENCENT_SMS_SECRET_ID", ""), + secret_key=getattr(settings, "TENCENT_SMS_SECRET_KEY", ""), + app_id=getattr(settings, "TENCENT_SMS_APP_ID", ""), + sign_name=getattr(settings, "TENCENT_SMS_SIGN_NAME", ""), + template_id=getattr(settings, "TENCENT_SMS_TEMPLATE_ID", ""), + ) + elif provider_name == "aliyun": + return AliyunSmsProvider( + access_key_id=getattr(settings, "ALIYUN_SMS_ACCESS_KEY_ID", ""), + access_key_secret=getattr(settings, "ALIYUN_SMS_ACCESS_KEY_SECRET", ""), + sign_name=getattr(settings, "ALIYUN_SMS_SIGN_NAME", ""), + template_code=getattr(settings, "ALIYUN_SMS_TEMPLATE_CODE", ""), + ) + else: + return MockSmsProvider() diff --git a/backend/app/core/usage_middleware.py b/backend/app/core/usage_middleware.py new file mode 100644 index 0000000..cdc16fc --- /dev/null +++ b/backend/app/core/usage_middleware.py @@ -0,0 +1,119 @@ +""" +用量追踪中间件:每日对话次数限制与强制执行 +""" +import logging +from datetime import date +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.core.security import decode_access_token +from app.models.user import User + +logger = logging.getLogger(__name__) + +# 套餐每日限额 +TIER_DAILY_LIMITS = { + "free": 50, + "pro": 500, + "enterprise": 0, # 0 = 无限 +} + +# 需要计数的路径前缀 +COUNTED_PATH_PREFIXES = [ + "/api/v1/agent-chat/", +] + +# 不计数路径(导出、会话管理等非对话操作) +EXCLUDED_PATH_PATTERNS = [ + "/sessions/", + "/search-messages", + "/export", +] + + +class UsageMiddleware(BaseHTTPMiddleware): + """用量追踪中间件:检查每日限额 → 放行 → 递增计数""" + + async def dispatch(self, request: Request, call_next): + path = request.url.path + should_count = ( + request.method == "POST" + and any(path.startswith(p) for p in COUNTED_PATH_PREFIXES) + and not any(p in path for p in EXCLUDED_PATH_PATTERNS) + ) + + if not should_count: + return await call_next(request) + + # 从 Authorization header 解码 user_id + user_id = None + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + try: + payload = decode_access_token(token) + user_id = payload.get("sub") + except Exception: + pass + + if not user_id: + return await call_next(request) + + db: Session = SessionLocal() + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + return await call_next(request) + + today = date.today() + + # 日期重置 + if user.daily_usage_date and user.daily_usage_date < today: + user.daily_usage_count = 0 + user.daily_usage_date = today + if not user.daily_usage_date: + user.daily_usage_date = today + + tier = user.subscription_tier or "free" + limit = TIER_DAILY_LIMITS.get(tier, 50) + current = user.daily_usage_count or 0 + + # 检查是否已达限额 + if limit > 0 and current >= limit: + db.close() + logger.warning("用户 %s 已达每日限额 %s/%s,拒绝请求", user_id, current, limit) + return JSONResponse( + status_code=429, + content={ + "detail": f"今日对话次数已用完({current}/{limit})。升级 Pro 版享受每日 {TIER_DAILY_LIMITS.get('pro', 500)} 次对话。", + "daily_used": current, + "daily_limit": limit, + "upgrade_required": True, + }, + headers={"X-Upgrade-Required": "true"}, + ) + + # 未达限额:先放行,再递增 + response = await call_next(request) + + if response.status_code < 400: + # 递增计数(需要重新查询,因为 request 可能耗时较长) + db_user = db.query(User).filter(User.id == user_id).first() + if db_user: + db_user.daily_usage_count = (db_user.daily_usage_count or 0) + 1 + db.commit() + + new_count = db_user.daily_usage_count + if limit > 0 and new_count >= limit: + logger.info("用户 %s 已达每日限额 %s/%s", user_id, new_count, limit) + + return response + except Exception as e: + db.rollback() + logger.error("用量追踪失败: %s", e) + return await call_next(request) + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py index 413d1ee..ea0cdcb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.core.database import init_db from app.core.rate_limiter import RateLimiterMiddleware from app.core.behavior_middleware import BehaviorCollectionMiddleware from app.core.security_headers import SecurityHeadersMiddleware +from app.core.usage_middleware import UsageMiddleware from app.core.metrics import setup_metrics _SECURITY_WARNED = False @@ -198,6 +199,9 @@ app.add_middleware(SecurityHeadersMiddleware) # 用户行为自动采集中间件 app.add_middleware(BehaviorCollectionMiddleware) +# v1.2: 用量追踪中间件 +app.add_middleware(UsageMiddleware) + # 注册全局异常处理器 app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(BaseAPIException, api_exception_handler) @@ -510,10 +514,11 @@ async def startup_event(): logger.error(f"定时任务调度器启动失败: {e}") # 注册路由 -from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update +from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users app.include_router(auth.router) app.include_router(workspaces.router) +app.include_router(workspaces.admin_router) app.include_router(uploads.router) app.include_router(workflows.router) app.include_router(executions.router) @@ -535,6 +540,7 @@ app.include_router(node_templates.router) app.include_router(tools.router) app.include_router(agent_branches.router) app.include_router(agent_chat.router) +app.include_router(agent_sessions.router) app.include_router(agent_monitoring.router) app.include_router(knowledge_base.router) app.include_router(agent_schedules.router) @@ -559,6 +565,9 @@ app.include_router(teams.router) app.include_router(agent_memory.router) app.include_router(legal.router) app.include_router(app_update.router) +app.include_router(analytics.router) +app.include_router(billing.router) +app.include_router(users.router) if __name__ == "__main__": import uvicorn diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7dbae02..861b84d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -34,5 +34,7 @@ from app.models.workspace import Workspace, WorkspaceMembership from app.models.scene_contract import SceneContract from app.models.team import Team, TeamMember from app.models.chat_message import ChatMessage +from app.models.agent_session import AgentSession +from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord -__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "ChatMessage"] \ No newline at end of file +__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "ChatMessage", "AgentSession", "BillingPlan", "BillingOrder", "UserSubscription", "UsageRecord"] \ No newline at end of file diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index bb4db8c..d653f70 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -137,6 +137,7 @@ class AgentRating(Base): id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="评分ID") agent_id = Column(CHAR(36), nullable=False, index=True, comment="Agent ID") user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="评分用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") rating = Column(Integer, nullable=False, comment="评分 1-5") comment = Column(Text, nullable=True, comment="评论内容") created_at = Column(DateTime, default=func.now(), comment="评分时间") @@ -155,6 +156,7 @@ class AgentFavorite(Base): id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="收藏ID") agent_id = Column(CHAR(36), nullable=False, index=True, comment="Agent ID") user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="收藏用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") created_at = Column(DateTime, default=func.now(), comment="收藏时间") # 关系 diff --git a/backend/app/models/agent_execution_log.py b/backend/app/models/agent_execution_log.py index 97a923e..24f6224 100644 --- a/backend/app/models/agent_execution_log.py +++ b/backend/app/models/agent_execution_log.py @@ -2,7 +2,7 @@ Agent 执行日志模型 — 结构化记录每次 Agent 执行的完整信息 用于知识自进化系统的数据基础 """ -from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Float, Boolean +from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Float, Boolean, ForeignKey from sqlalchemy.dialects.mysql import CHAR from app.core.database import Base import uuid @@ -20,6 +20,7 @@ class AgentExecutionLog(Base): task_id = Column(String(36), nullable=True, index=True, comment="关联 Task ID") user_id = Column(String(36), nullable=True, index=True, comment="用户 ID") session_id = Column(String(100), nullable=True, comment="会话标识") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") # 输入/输出 input_text = Column(Text, nullable=True, comment="用户输入文本") diff --git a/backend/app/models/agent_llm_log.py b/backend/app/models/agent_llm_log.py index 161b3e4..d0f5185 100644 --- a/backend/app/models/agent_llm_log.py +++ b/backend/app/models/agent_llm_log.py @@ -15,6 +15,7 @@ class AgentLLMLog(Base): agent_id = Column(CHAR(36), ForeignKey("agents.id"), nullable=True, comment="Agent ID") session_id = Column(String(100), nullable=True, comment="会话ID") user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=True, comment="用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") model = Column(String(100), nullable=False, comment="模型名称") provider = Column(String(50), nullable=True, comment="提供商") prompt_tokens = Column(Integer, default=0, comment="提示 tokens") diff --git a/backend/app/models/agent_session.py b/backend/app/models/agent_session.py new file mode 100644 index 0000000..bdf56f2 --- /dev/null +++ b/backend/app/models/agent_session.py @@ -0,0 +1,24 @@ +""" +会话表 — 独立于 chat_messages 管理会话元数据 +""" +from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, ForeignKey, func +from sqlalchemy.dialects.mysql import CHAR +from app.core.database import Base +import uuid + + +class AgentSession(Base): + """Agent 会话表""" + __tablename__ = "agent_sessions" + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="会话ID") + user_id = Column(CHAR(36), nullable=True, index=True, comment="用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") + agent_id = Column(CHAR(36), nullable=True, index=True, comment="Agent ID") + title = Column(String(200), nullable=True, comment="会话标题") + is_pinned = Column(Boolean, default=False, comment="是否置顶") + created_at = Column(DateTime, default=func.now(), comment="创建时间") + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" diff --git a/backend/app/models/alert_rule.py b/backend/app/models/alert_rule.py index a70104b..bbb7289 100644 --- a/backend/app/models/alert_rule.py +++ b/backend/app/models/alert_rule.py @@ -40,7 +40,8 @@ class AlertRule(Base): # 用户关联 user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="创建者ID") - + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") + # 时间戳 created_at = Column(DateTime, default=func.now(), comment="创建时间") updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") @@ -82,6 +83,7 @@ class AlertLog(Base): triggered_at = Column(DateTime, default=func.now(), comment="触发时间") acknowledged_at = Column(DateTime, comment="确认时间") acknowledged_by = Column(CHAR(36), ForeignKey("users.id"), nullable=True, comment="确认人ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") __table_args__ = ( Index("idx_al_rule_id", "rule_id"), diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py new file mode 100644 index 0000000..a52e929 --- /dev/null +++ b/backend/app/models/billing.py @@ -0,0 +1,93 @@ +""" +付费体系模型:套餐、订单、订阅、用量记录 +""" +from sqlalchemy import Boolean, Column, String, Integer, Float, DateTime, Date, Text, JSON, ForeignKey, func, Index +from sqlalchemy.dialects.mysql import CHAR +from sqlalchemy.orm import relationship +from app.core.database import Base +import uuid + + +class BillingPlan(Base): + """套餐定义表""" + __tablename__ = "billing_plans" + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="套餐ID") + name = Column(String(100), nullable=False, comment="套餐名称") + tier = Column(String(20), nullable=False, comment="套餐等级: free/pro/enterprise") + price_monthly = Column(Float, nullable=False, default=0, comment="月付价格(CNY)") + price_yearly = Column(Float, nullable=False, default=0, comment="年付价格(CNY)") + daily_quota = Column(Integer, nullable=False, default=0, comment="每日对话次数限制, 0=无限") + agent_limit = Column(Integer, nullable=False, default=0, comment="智能体数量限制, 0=无限") + knowledge_limit = Column(Integer, nullable=False, default=0, comment="知识条目限制, 0=无限") + file_upload_mb = Column(Integer, nullable=False, default=5, comment="文件上传大小限制(MB)") + models = Column(JSON, nullable=True, comment="可用模型列表") + features = Column(JSON, nullable=True, comment="权益特性列表") + is_active = Column(Boolean, default=True, comment="是否启用") + sort_order = Column(Integer, default=0, comment="排序") + created_at = Column(DateTime, default=func.now(), comment="创建时间") + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" + + +class BillingOrder(Base): + """订单记录表""" + __tablename__ = "billing_orders" + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="订单ID") + user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID") + plan_id = Column(CHAR(36), ForeignKey("billing_plans.id", ondelete="SET NULL"), nullable=True, comment="套餐ID") + amount = Column(Float, nullable=False, comment="支付金额(CNY)") + period = Column(String(10), nullable=False, default="monthly", comment="周期: monthly/yearly") + status = Column(String(20), nullable=False, default="pending", comment="状态: pending/paid/cancelled/expired") + payment_method = Column(String(20), nullable=True, comment="支付方式: mock/wechat/alipay") + payment_ref = Column(String(255), nullable=True, comment="第三方支付流水号") + paid_at = Column(DateTime, nullable=True, comment="支付时间") + created_at = Column(DateTime, default=func.now(), comment="创建时间") + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") + + user = relationship("User", backref="orders") + + def __repr__(self): + return f"" + + +class UserSubscription(Base): + """用户订阅状态表""" + __tablename__ = "user_subscriptions" + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="订阅ID") + user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, comment="用户ID") + plan_id = Column(CHAR(36), ForeignKey("billing_plans.id", ondelete="SET NULL"), nullable=True, comment="当前套餐ID") + tier = Column(String(20), nullable=False, default="free", comment="当前等级") + status = Column(String(20), nullable=False, default="active", comment="状态: active/cancelled/expired") + started_at = Column(DateTime, nullable=True, comment="订阅开始时间") + expires_at = Column(DateTime, nullable=True, comment="订阅到期时间") + auto_renew = Column(Boolean, default=False, comment="是否自动续费") + created_at = Column(DateTime, default=func.now(), comment="创建时间") + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") + + user = relationship("User", backref="subscription") + plan = relationship("BillingPlan") + + def __repr__(self): + return f"" + + +class UsageRecord(Base): + """每日用量记录表""" + __tablename__ = "usage_records" + __table_args__ = ( + Index("ix_usage_user_date", "user_id", "date", unique=True), + ) + + id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="记录ID") + user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID") + date = Column(Date, nullable=False, comment="日期") + count = Column(Integer, nullable=False, default=0, comment="当日对话次数") + created_at = Column(DateTime, default=func.now(), comment="创建时间") + + def __repr__(self): + return f"" diff --git a/backend/app/models/chat_message.py b/backend/app/models/chat_message.py index d04f5d4..260a069 100644 --- a/backend/app/models/chat_message.py +++ b/backend/app/models/chat_message.py @@ -15,6 +15,7 @@ class ChatMessage(Base): session_id = Column(CHAR(36), nullable=False, comment="会话ID") agent_id = Column(CHAR(36), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, comment="智能体ID") user_id = Column(CHAR(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") role = Column(String(20), nullable=False, comment="角色: user/assistant/tool/system") content = Column(Text, comment="消息内容") tool_name = Column(String(100), nullable=True, comment="工具名称(仅tool消息)") @@ -33,6 +34,7 @@ class ChatMessage(Base): "session_id": self.session_id, "agent_id": self.agent_id, "user_id": self.user_id, + "workspace_id": self.workspace_id, "role": self.role, "content": self.content, "tool_name": self.tool_name, diff --git a/backend/app/models/conversation_branch.py b/backend/app/models/conversation_branch.py index f30c0bd..33d0ce2 100644 --- a/backend/app/models/conversation_branch.py +++ b/backend/app/models/conversation_branch.py @@ -7,7 +7,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Boolean +from sqlalchemy import Column, String, Text, Integer, DateTime, JSON, Boolean, ForeignKey from sqlalchemy.dialects.mysql import CHAR from app.core.database import Base @@ -19,6 +19,7 @@ class ConversationBranch(Base): id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="分支ID") user_id = Column(String(36), nullable=False, index=True, comment="用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") agent_id = Column(String(36), nullable=True, comment="关联Agent ID") agent_name = Column(String(200), nullable=True, comment="Agent 名称(冗余便于展示)") diff --git a/backend/app/models/knowledge_entry.py b/backend/app/models/knowledge_entry.py index ba96bd6..fe382b1 100644 --- a/backend/app/models/knowledge_entry.py +++ b/backend/app/models/knowledge_entry.py @@ -3,7 +3,8 @@ """ import uuid from datetime import datetime -from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON, Float, Index +from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON, Float, Index, ForeignKey +from sqlalchemy.dialects.mysql import CHAR from app.core.database import Base @@ -13,6 +14,7 @@ class KnowledgeEntry(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) agent_id = Column(String(36), nullable=True, index=True, comment="所属 Agent ID(NULL=全Agent共享)") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") title = Column(String(500), nullable=False, comment="知识标题(一句话概括)") category = Column(String(30), nullable=False, index=True, comment="类别: bug_fix/best_practice/workaround/optimization/insight") @@ -55,6 +57,7 @@ class KnowledgeEntry(Base): return { "id": self.id, "agent_id": self.agent_id, + "workspace_id": self.workspace_id, "title": self.title, "category": self.category, "tags": self.tags or [], diff --git a/backend/app/models/node_template.py b/backend/app/models/node_template.py index 973cf42..bbc233e 100644 --- a/backend/app/models/node_template.py +++ b/backend/app/models/node_template.py @@ -34,6 +34,7 @@ class NodeTemplate(Base): is_featured = Column(Boolean, default=False, comment="是否精选") use_count = Column(Integer, default=0, comment="使用次数") user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="创建者ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") created_at = Column(DateTime, default=func.now(), comment="创建时间") updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") @@ -67,6 +68,7 @@ class NodeTemplate(Base): "is_featured": self.is_featured, "use_count": self.use_count, "user_id": self.user_id, + "workspace_id": self.workspace_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None } diff --git a/backend/app/models/plugin.py b/backend/app/models/plugin.py index 7a8e16a..49d653e 100644 --- a/backend/app/models/plugin.py +++ b/backend/app/models/plugin.py @@ -1,7 +1,7 @@ """ 插件模型 — 第三方节点扩展 """ -from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, func +from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, ForeignKey, func from sqlalchemy.dialects.mysql import CHAR from app.core.database import Base import uuid @@ -40,6 +40,7 @@ class NodePlugin(Base): icon = Column(String(256), comment="图标URL") tags = Column(JSON, comment="标签列表") user_id = Column(CHAR(36), nullable=True, comment="上传者ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") created_at = Column(DateTime, default=func.now(), comment="创建时间") updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 769e30b..73440cc 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,7 @@ """ 用户模型 """ -from sqlalchemy import Boolean, Column, String, DateTime, func +from sqlalchemy import Boolean, Column, String, Integer, DateTime, Date, func from sqlalchemy.dialects.mysql import CHAR from sqlalchemy.orm import relationship from app.core.database import Base @@ -20,11 +20,18 @@ class User(Base): feishu_open_id = Column(String(64), nullable=True, comment="飞书用户 open_id,用于推送通知") feishu_default_agent_id = Column(CHAR(36), nullable=True, comment="飞书对话默认 Agent ID") phone = Column(String(20), nullable=True, comment="手机号") + phone_verified = Column(Boolean, default=False, comment="手机号是否已验证") + phone_verified_at = Column(DateTime, nullable=True, comment="手机号验证时间") status = Column(String(20), default="active", comment="账号状态: active/disabled/deleted") is_email_verified = Column(Boolean, default=False, comment="邮箱是否已验证") agreed_terms = Column(Boolean, default=False, comment="是否同意用户协议") agreed_terms_version = Column(String(20), nullable=True, comment="同意的协议版本") agreed_terms_at = Column(DateTime, nullable=True, comment="同意协议的时间") + # v1.2: 订阅与用量 + subscription_tier = Column(String(20), default="free", comment="订阅等级: free/pro/enterprise") + subscription_expires_at = Column(DateTime, nullable=True, comment="订阅到期时间") + daily_usage_count = Column(Integer, default=0, comment="今日对话次数") + daily_usage_date = Column(Date, nullable=True, comment="用量统计日期") created_at = Column(DateTime, default=func.now(), comment="创建时间") updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间") diff --git a/backend/app/models/workflow_template.py b/backend/app/models/workflow_template.py index 48e08aa..abb5688 100644 --- a/backend/app/models/workflow_template.py +++ b/backend/app/models/workflow_template.py @@ -51,6 +51,7 @@ class TemplateRating(Base): id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="评分ID") template_id = Column(CHAR(36), ForeignKey("workflow_templates.id"), nullable=False, comment="模板ID") user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") rating = Column(Integer, nullable=False, comment="评分: 1-5") comment = Column(Text, comment="评论") created_at = Column(DateTime, default=func.now(), comment="创建时间") @@ -71,6 +72,7 @@ class TemplateFavorite(Base): id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="收藏ID") template_id = Column(CHAR(36), ForeignKey("workflow_templates.id"), nullable=False, comment="模板ID") user_id = Column(CHAR(36), ForeignKey("users.id"), nullable=False, comment="用户ID") + workspace_id = Column(CHAR(36), ForeignKey("workspaces.id"), nullable=True, index=True, comment="工作区ID") created_at = Column(DateTime, default=func.now(), comment="创建时间") # 关系 diff --git a/backend/app/websocket/manager.py b/backend/app/websocket/manager.py index 2e092e0..1fed788 100644 --- a/backend/app/websocket/manager.py +++ b/backend/app/websocket/manager.py @@ -1,49 +1,47 @@ """ -WebSocket连接管理器 +WebSocket连接管理器 — 支持 workspace 隔离 """ -from typing import Dict, Set +from typing import Dict, Set, Optional from fastapi import WebSocket import json -import asyncio class WebSocketManager: - """WebSocket连接管理器""" - + """WebSocket连接管理器(workspace 感知)""" + def __init__(self): - """初始化管理器""" # execution_id -> Set[WebSocket] self.active_connections: Dict[str, Set[WebSocket]] = {} - - async def connect(self, websocket: WebSocket, execution_id: str): - """ - 建立WebSocket连接 - - Args: - websocket: WebSocket连接 - execution_id: 执行记录ID - """ + # websocket -> workspace_id (用于 workspace 隔离广播) + self._ws_workspace: Dict[int, str] = {} + + async def connect( + self, websocket: WebSocket, execution_id: str, workspace_id: Optional[str] = None + ): + """建立 WebSocket 连接,标记 workspace。""" await websocket.accept() - + if execution_id not in self.active_connections: self.active_connections[execution_id] = set() - + self.active_connections[execution_id].add(websocket) - + + if workspace_id: + self._ws_workspace[id(websocket)] = workspace_id + def disconnect(self, websocket: WebSocket, execution_id: str): - """ - 断开WebSocket连接 - - Args: - websocket: WebSocket连接 - execution_id: 执行记录ID - """ + """断开 WebSocket 连接,清理 workspace 标记。""" + self._ws_workspace.pop(id(websocket), None) + if execution_id in self.active_connections: self.active_connections[execution_id].discard(websocket) - - # 如果没有连接了,删除该execution_id + if not self.active_connections[execution_id]: del self.active_connections[execution_id] + + def get_workspace_id(self, websocket: WebSocket) -> Optional[str]: + """获取连接关联的 workspace_id。""" + return self._ws_workspace.get(id(websocket)) async def send_personal_message(self, message: dict, websocket: WebSocket): """ diff --git a/backend/scripts/seed_billing_plans.py b/backend/scripts/seed_billing_plans.py new file mode 100644 index 0000000..be88e86 --- /dev/null +++ b/backend/scripts/seed_billing_plans.py @@ -0,0 +1,112 @@ +""" +种子数据:初始化 3 个默认付费套餐 +用法: cd backend && ./venv/Scripts/python.exe scripts/seed_billing_plans.py +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core.database import SessionLocal, init_db +from app.models.billing import BillingPlan +import uuid + +PLANS = [ + { + "id": "a0000000-0000-0000-0000-000000000001", + "name": "免费版", + "tier": "free", + "price_monthly": 0, + "price_yearly": 0, + "daily_quota": 50, + "agent_limit": 3, + "knowledge_limit": 10, + "file_upload_mb": 5, + "models": ["deepseek-v4-pro"], + "features": [ + "每日 50 次对话", + "3 个自建智能体", + "基础模型 DeepSeek-V3", + "10 条全局知识", + "5 MB 文件上传", + "社区支持", + ], + "is_active": True, + "sort_order": 0, + }, + { + "id": "a0000000-0000-0000-0000-000000000002", + "name": "专业版", + "tier": "pro", + "price_monthly": 29, + "price_yearly": 198, + "daily_quota": 500, + "agent_limit": 20, + "knowledge_limit": 500, + "file_upload_mb": 50, + "models": ["deepseek-v4-pro", "gpt-4o", "claude-sonnet-4-6", "gemini-2.5-pro"], + "features": [ + "每日 500 次对话", + "20 个自建智能体", + "全部高级模型", + "500 条全局知识", + "50 MB 文件上传", + "优先响应队列", + "对话历史云端同步", + "对话导出 Markdown/PDF", + "专属客服通道", + ], + "is_active": True, + "sort_order": 1, + }, + { + "id": "a0000000-0000-0000-0000-000000000003", + "name": "企业版", + "tier": "enterprise", + "price_monthly": 99, + "price_yearly": 899, + "daily_quota": 0, + "agent_limit": 0, + "knowledge_limit": 0, + "file_upload_mb": 200, + "models": ["deepseek-v4-pro", "gpt-4o", "claude-sonnet-4-6", "gemini-2.5-pro"], + "features": [ + "无限对话次数", + "无限智能体", + "团队协作(10 人)", + "全部知识库功能", + "200 MB 文件上传", + "最高优先响应", + "API 调用额度 ¥50/月", + "管理员面板", + "专属成功经理 + 电话支持", + ], + "is_active": True, + "sort_order": 2, + }, +] + + +def seed(): + init_db() + db = SessionLocal() + try: + for plan_data in PLANS: + existing = db.query(BillingPlan).filter(BillingPlan.id == plan_data["id"]).first() + if existing: + print(f"跳过已存在: {plan_data['name']}") + continue + plan = BillingPlan(**plan_data) + db.add(plan) + print(f"创建套餐: {plan_data['name']} (tier={plan_data['tier']})") + db.commit() + print("种子数据初始化完成") + except Exception as e: + db.rollback() + print(f"错误: {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + seed() diff --git a/docs/index.md b/docs/index.md index edd41e6..48e2d95 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,6 +19,7 @@ | [🛠️ 开发指南](./development-guide.md) | 开发环境搭建、编码规范、测试指南 | 开发者 | | [🚀 部署与运维指南](./deployment-guide.md) | 环境要求、Docker 部署、配置说明 | DevOps、运维 | | [🔌 API 参考](./api-reference.md) | RESTful API 端点说明、请求/响应格式(含场景契约) | 前端开发者、集成方 | +| [🔐 多租户与 RBAC 权限指南](./multi-tenant-rbac-guide.md) | 工作区隔离、角色权限、用户管理、管理员功能 | 管理员、高级用户 | | [🔧 插件开发指南](./plugin-development-guide.md) | 自定义工作流节点插件开发(Code/HTTP 类型) | 开发者 | | [🤖 虚拟团队](./virtual-team.md) | 虚拟软件公司团队:PM+设计师+开发+QA+DevOps 自动化协作开发 | 所有用户、开发者 | | [🤝 贡献指南](./contributing.md) | 如何参与贡献、代码审查流程 | 贡献者 | diff --git a/docs/multi-tenant-rbac-guide.md b/docs/multi-tenant-rbac-guide.md new file mode 100644 index 0000000..25af751 --- /dev/null +++ b/docs/multi-tenant-rbac-guide.md @@ -0,0 +1,570 @@ +# 多租户与 RBAC 权限管理指南 + +> **Multi-Tenant & RBAC Guide** — 工作区隔离、角色权限、用户管理完整说明 + +本文档涵盖天工智能体平台 v1.2+ 的多租户架构、RBAC 权限体系、工作区管理和平台管理员功能。 + +--- + +## 一、多租户架构概览 + +### 1.1 什么是多租户 + +天工平台采用 **工作区 (Workspace)** 作为租户隔离的基本单元: + +``` +┌────────────────────────────────────────────────────┐ +│ 天工平台 │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ 工作区 A │ │ 工作区 B │ │ 工作区 C │ │ +│ │ (研发团队) │ │ (市场团队) │ │ (个人空间) │ │ +│ │ │ │ │ │ │ │ +│ │ Agent: 5 个 │ │ Agent: 3 个 │ │ Agent: 2 │ │ +│ │ 工作流: 8 个 │ │ 工作流: 4 个 │ │ 工作流: 3 │ │ +│ │ 知识库: 3 个 │ │ 知识库: 1 个 │ │ 知识库: 1 │ │ +│ │ 成员: 12 人 │ │ 成员: 5 人 │ │ 成员: 1 │ │ +│ └──────────────┘ └──────────────┘ └───────────┘ │ +└────────────────────────────────────────────────────┘ +``` + +### 1.2 核心隔离原则 + +| 隔离维度 | 说明 | +|----------|------| +| **数据隔离** | 每个工作区的 Agent、工作流、知识库、执行记录等数据完全隔离 | +| **成员隔离** | 工作区 A 的成员默认无法访问工作区 B 的任何资源 | +| **API 隔离** | 所有资源 API 自动按工作区过滤,返回结果仅限当前工作区 | +| **WebSocket 隔离** | 实时推送按工作区频道隔离,不会跨工作区串消息 | + +### 1.3 JWT Token 中的工作区标识 + +登录或切换工作区后,JWT Token 中会携带 `ws` 字段: + +```json +{ + "sub": "user-uuid-xxx", + "ws": "workspace-uuid-xxx", + "exp": 1712345678 +} +``` + +所有后续 API 请求自动按此工作区范围过滤数据。 + +--- + +## 二、工作区管理 + +### 2.1 工作区列表 + +进入「工作区管理」页面,查看你有权限访问的所有工作区: + +``` +┌──────────────────────────────────────────────────────┐ +│ 工作区管理 [+ 创建工作区] │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 研发团队 │ │ 个人空间 │ │ AI实验室 │ │ +│ │ [默认] [管理员]│ │ [成员] │ │ [管理员] │ │ +│ │ 描述... │ │ 描述... │ │ 描述... │ │ +│ │ 状态: 启用 │ │ 状态: 启用 │ │ 状态: 停用 │ │ +│ │ [进入] [管理]│ │ [进入] │ │ [进入] [管理]│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +### 2.2 创建工作区 + +1. 点击「创建工作区」按钮 +2. 填写工作区信息: + +| 字段 | 说明 | 限制 | +|------|------|------| +| 名称 | 工作区唯一标识 | 1-100 字符 | +| 描述 | 可选说明信息 | 最多 1000 字符 | +| 最大成员数 | 工作区成员上限 | 1-500,默认 50 | + +3. 创建者自动成为该工作区的 **管理员 (admin)** + +### 2.3 切换工作区 + +- 在工作区列表中点击「进入」自动切换 +- 或通过顶部导航栏的工作区下拉选择器切换 +- 切换后 JWT Token 刷新,后续所有操作在新工作区上下文中执行 + +### 2.4 管理工作区 + +点击「管理」进入工作区详情页,包含三个标签页: + +**基本信息** +- 修改名称、描述、最大成员数 +- 启用/停用工作区(停用后成员无法访问) + +**成员管理** +- 查看当前成员列表(用户名、邮箱、角色) +- 添加新成员(按用户名查找) +- 修改成员角色(管理员 / 成员) +- 移除成员 + +**危险操作** +- 删除工作区(软删除,状态标记为 deleted) + +### 2.5 角色说明 + +| 角色 | 权限范围 | +|------|----------| +| **admin** (工作区管理员) | 管理成员、修改设置、删除工作区、创建/编辑/删除所有资源 | +| **member** (成员) | 查看和创建资源,编辑自己创建的资源 | + +> **注意**: 工作区管理员 ≠ 平台管理员。工作区管理员仅管理当前工作区,平台管理员拥有全局权限。 + +--- + +## 三、RBAC 权限体系 + +### 3.1 系统角色 + +天工平台定义了 4 个系统级角色: + +| 角色 | 说明 | 典型用户 | +|------|------|----------| +| **admin** (平台管理员) | 全局管理权限,绕过所有工作区限制 | 平台运维 | +| **developer** (开发者) | 创建和管理 Agent、工作流、知识库 | 核心开发 | +| **viewer** (只读用户) | 仅查看,无创建/编辑/删除权限 | 审计、管理层 | +| **operator** (操作员) | 管理执行、监控告警,但不创建新 Agent | 运维 | + +### 3.2 权限列表 (23 项) + +所有权限按资源类型分组: + +| 权限码 | 说明 | developer | viewer | operator | +|--------|------|:---------:|:------:|:--------:| +| **workflow:create** | 创建工作流 | ✅ | ❌ | ❌ | +| **workflow:read** | 查看工作流 | ✅ | ✅ | ✅ | +| **workflow:update** | 编辑工作流 | ✅ | ❌ | ❌ | +| **workflow:delete** | 删除工作流 | ✅ | ❌ | ❌ | +| **workflow:execute** | 执行工作流 | ✅ | ❌ | ✅ | +| **workflow:publish** | 发布工作流 | ✅ | ❌ | ❌ | +| **agent:create** | 创建 Agent | ✅ | ❌ | ❌ | +| **agent:read** | 查看 Agent | ✅ | ✅ | ✅ | +| **agent:update** | 编辑 Agent | ✅ | ❌ | ❌ | +| **agent:delete** | 删除 Agent | ✅ | ❌ | ❌ | +| **agent:deploy** | 部署 Agent | ✅ | ❌ | ✅ | +| **agent:stop** | 停止 Agent | ✅ | ❌ | ✅ | +| **execution:read** | 查看执行记录 | ✅ | ✅ | ✅ | +| **execution:stop** | 中止执行 | ✅ | ❌ | ✅ | +| **data_source:create** | 创建数据源 | ✅ | ❌ | ❌ | +| **data_source:read** | 查看数据源 | ✅ | ✅ | ✅ | +| **data_source:update** | 编辑数据源 | ✅ | ❌ | ❌ | +| **data_source:delete** | 删除数据源 | ✅ | ❌ | ❌ | +| **model_config:create** | 创建模型配置 | ✅ | ❌ | ❌ | +| **model_config:read** | 查看模型配置 | ✅ | ✅ | ✅ | +| **model_config:update** | 编辑模型配置 | ✅ | ❌ | ❌ | +| **model_config:delete** | 删除模型配置 | ✅ | ❌ | ❌ | +| **permission:manage** | 管理权限 | ❌ | ❌ | ❌ | + +> **admin 角色拥有所有 23 项权限(通配符 `*`),且绕过所有工作区成员资格检查。** + +### 3.3 权限检查流程 + +``` +API 请求 + │ + ├─ 1. JWT 认证 (get_current_user) + │ └─ 失败 → 401 Unauthorized + │ + ├─ 2. 工作区成员资格检查 (get_workspace_membership) + │ ├─ 平台管理员 → 直接放行 + │ ├─ 非成员 → 403 Forbidden + │ └─ 成员 → 继续 + │ + ├─ 3. 细粒度权限检查 (WorkspacePermission) + │ ├─ 无该权限 → 403 Forbidden + │ └─ 有权限 → 继续 + │ + └─ 4. 执行请求 +``` + +### 3.4 敏感操作保护 + +以下操作需要 **工作区管理员** 权限: + +| 操作 | 端点 | 要求 | +|------|------|------| +| 删除 Agent | DELETE /agents/{id} | 工作区管理员 | +| 删除工作流 | DELETE /workflows/{id} | 工作区管理员 | +| 删除数据源 | DELETE /data-sources/{id} | 工作区管理员 | +| 删除模型配置 | DELETE /model-configs/{id} | 工作区管理员 | +| 删除定时任务 | DELETE /agent-schedules/{id} | 工作区管理员 | +| 部署 Agent | POST /agents/{id}/deploy | 工作区管理员 | +| 停止 Agent | POST /agents/{id}/stop | 工作区管理员 | +| 发布工作流 | POST /workflows/{id}/publish | 工作区管理员 | +| 重载工具 | POST /tools/reload | 工作区管理员 | + +--- + +## 四、平台管理员功能 + +> 仅 `role=admin` 的用户可访问以下功能。 + +### 4.1 用户管理 + +进入「运维管理 → 用户管理」: + +**用户列表** +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 搜索: [________] 状态: [全部▾] 角色: [全部▾] │ +├──────┬──────────┬──────┬──────┬──────┬────────┬──────┬──────────┤ +│ 用户名 │ 邮箱 │ 角色 │ 状态 │ 订阅 │ 工作区数 │ 创建 │ 操作 │ +├──────┼──────────┼──────┼──────┼──────┼────────┼──────┼──────────┤ +│ admin │admin@.. │ 管理员│ 启用 │ 免费 │ 2 │06-01 │编辑 重置 │ +│ 张三 │zhang@.. │ 开发者│ 启用 │ 专业 │ 1 │06-15 │编辑 重置 │ +│ 李四 │li@.. │ 观察者│ 停用 │ 免费 │ 0 │06-20 │编辑 重置 │ +└──────┴──────────┴──────┴──────┴──────┴────────┴──────┴──────────┘ +``` + +**功能操作:** +- **创建用户**: 填写用户名、邮箱、密码(6 位以上)、角色 +- **编辑用户**: 修改用户名、邮箱、角色、手机号、状态、订阅等级 +- **重置密码**: 为用户设置新密码 +- **禁用/删除**: 软删除用户(设置 status=deleted),平台管理员不可被删除 + +### 4.2 工作区全局管理 + +平台管理员在工作区管理页面看到全局视图: + +``` +┌── 所有工作区(平台管理员视图)──────────────────────────────────────┐ +│ 搜索: [________] 状态: [全部▾] │ +├──────────┬────────┬────────┬──────┬──────┬──────────┬────────────┤ +│ 名称 │ 所有者 │ 成员数 │ 上限 │ 状态 │ 创建时间 │ 操作 │ +├──────────┼────────┼────────┼──────┼──────┼──────────┼────────────┤ +│ 研发团队 │ admin │ 12 │ 50 │ 启用 │ 2026-06 │ [进入][管理]│ +│ AI实验室 │ 张三 │ 3 │ 20 │ 停用 │ 2026-07 │ [进入][管理]│ +└──────────┴────────┴────────┴──────┴──────┴──────────┴────────────┘ +``` + +**管理操作:** +- 点击「管理」可编辑任意工作区的名称和状态 +- 可强制删除任意工作区(包括默认工作区) +- 所有操作记录在审计日志中 + +### 4.3 审计日志 + +进入「系统日志 → 审计日志」查看所有管理员操作记录: +- 操作人、操作类型(CREATE/UPDATE/DELETE/LOGIN) +- 资源类型、IP 地址 +- 操作时间、操作结果 + +--- + +## 五、API 使用指南 + +### 5.1 认证与工作区切换 + +```bash +# 1. 登录获取 Token +curl -X POST http://localhost:8037/api/v1/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=123456" + +# 响应: { "access_token": "eyJ...", "token_type": "bearer" } + +# 2. 查看当前用户信息(含工作区列表) +curl http://localhost:8037/api/v1/auth/me \ + -H "Authorization: Bearer $TOKEN" + +# 响应包含: +# { +# "id": "...", "username": "admin", "role": "admin", +# "workspaces": [ +# {"id": "ws-1", "name": "研发团队", "role": "admin"}, +# {"id": "ws-2", "name": "个人空间", "role": "member"} +# ], +# "current_workspace_id": "ws-1" +# } + +# 3. 切换工作区 +curl -X POST http://localhost:8037/api/v1/auth/switch-workspace/ws-2 \ + -H "Authorization: Bearer $TOKEN" + +# 响应包含新的 Token(携带新工作区 ID) +``` + +### 5.2 工作区管理 API + +```bash +# 创建工作区 +curl -X POST http://localhost:8037/api/v1/workspaces \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "新项目", "description": "项目协作空间", "max_members": 50}' + +# 查看成员 +curl http://localhost:8037/api/v1/workspaces/{ws_id}/members \ + -H "Authorization: Bearer $TOKEN" + +# 添加成员 +curl -X POST http://localhost:8037/api/v1/workspaces/{ws_id}/members \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"username": "zhangsan", "role": "member"}' + +# 修改成员角色 +curl -X PUT http://localhost:8037/api/v1/workspaces/{ws_id}/members/{user_id} \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"role": "admin"}' + +# 移除成员 +curl -X DELETE http://localhost:8037/api/v1/workspaces/{ws_id}/members/{user_id} \ + -H "Authorization: Bearer $TOKEN" +``` + +### 5.3 管理员 API + +```bash +# 用户列表(分页、搜索、过滤) +curl "http://localhost:8037/api/v1/admin/users?page=1&page_size=20&search=admin&role=admin" \ + -H "Authorization: Bearer $ADMIN_TOKEN" + +# 创建用户 +curl -X POST http://localhost:8037/api/v1/admin/users \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"username":"newuser","email":"new@test.com","password":"123456","role":"developer"}' + +# 编辑用户 +curl -X PUT http://localhost:8037/api/v1/admin/users/{user_id} \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"role":"viewer","status":"disabled"}' + +# 重置密码 +curl -X POST http://localhost:8037/api/v1/admin/users/{user_id}/reset-password \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"new_password":"newpass123"}' + +# 删除用户(软删除) +curl -X DELETE http://localhost:8037/api/v1/admin/users/{user_id} \ + -H "Authorization: Bearer $ADMIN_TOKEN" + +# 查看所有工作区 +curl "http://localhost:8037/api/v1/admin/workspaces?page=1&page_size=20" \ + -H "Authorization: Bearer $ADMIN_TOKEN" + +# 查看任意工作区详情 +curl http://localhost:8037/api/v1/admin/workspaces/{ws_id} \ + -H "Authorization: Bearer $ADMIN_TOKEN" + +# 编辑任意工作区 +curl -X PUT http://localhost:8037/api/v1/admin/workspaces/{ws_id} \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status":"disabled"}' + +# 强制删除工作区 +curl -X DELETE http://localhost:8037/api/v1/admin/workspaces/{ws_id} \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +### 5.4 WebSocket 连接 + +WebSocket 连接需要传递 Token 作为 query 参数: + +```javascript +// 执行状态实时推送 +const ws = new WebSocket( + `ws://localhost:8037/api/v1/ws/executions/${executionId}?token=${jwtToken}` +); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + // data.type: "status" | "progress" | "error" | "pong" + console.log(data); +}; + +// 协作编辑 +const collabWs = new WebSocket( + `ws://localhost:8037/api/v1/collaboration/ws/workflows/${workflowId}?token=${jwtToken}` +); + +// 发送操作 +collabWs.send(JSON.stringify({ + type: "operation", + operation: { + type: "node_add", + node: { id: "...", type: "llm", position: { x: 100, y: 200 } } + } +})); +``` + +WebSocket 认证验证: +- JWT Token 有效性 +- 用户存在且未被删除 +- 工作区成员资格 +- 资源归属当前工作区 + +### 5.5 错误码说明 + +| HTTP 状态码 | WebSocket 关闭码 | 说明 | +|:-----------:|:----------------:|------| +| 401 | 4001 | Token 无效或已过期 | +| 403 | 4003 | 无权访问(非工作区成员 / 无所需权限) | +| 403 | 4008 | 违反策略(如缺少 Token) | + +--- + +## 六、数据隔离详解 + +### 6.1 已隔离的资源 + +以下资源通过 `workspace_id` 字段实现完全隔离: + +| 资源 | 表 | 隔离方式 | +|------|-----|----------| +| Agent | agents | 查询时自动按 workspace_id 过滤 | +| 工作流 | workflows | 查询时自动按 workspace_id 过滤 | +| 执行记录 | executions | 查询时自动按 workspace_id 过滤 | +| 数据源 | data_sources | 查询时自动按 workspace_id 过滤 | +| 模型配置 | model_configs | 查询时自动按 workspace_id 过滤 | +| 定时任务 | agent_schedules | 查询时自动按 workspace_id 过滤 | +| 知识库 | knowledge_bases | 查询时自动按 workspace_id 过滤 | +| 聊天消息 | chat_messages | 查询时自动按 workspace_id 过滤 | +| Agent 会话 | agent_sessions | 查询时自动按 workspace_id 过滤 | +| 告警规则 | alert_rules | 查询时自动按 workspace_id 过滤 | +| 节点模板 | node_templates | 查询时自动按 workspace_id 过滤 | +| 插件 | plugins | 查询时自动按 workspace_id 过滤 | +| 目标/任务 | goals / tasks | 查询时自动按 workspace_id 过滤 | +| 编排模板 | orchestration_templates | 查询时自动按 workspace_id 过滤 | +| 场景契约 | scene_contracts | 查询时自动按 workspace_id 过滤 | +| 团队 | teams | 查询时自动按 workspace_id 过滤 | +| 对话分支 | conversation_branches | 查询时自动按 workspace_id 过滤 | +| LLM 日志 | agent_llm_logs | 查询时自动按 workspace_id 过滤 | +| 知识条目 | knowledge_entries | 查询时自动按 workspace_id 过滤 | +| 告警日志 | alert_logs | 查询时自动按 workspace_id 过滤 | + +### 6.2 创建时自动标记 + +所有新建资源自动 stamp 当前工作区 ID,确保数据归属正确: + +```python +# 后端自动处理(以 Agent 为例) +agent = Agent( + ... + user_id=current_user.id, + workspace_id=workspace_id, # 从 JWT ws 字段自动提取 +) +``` + +--- + +## 七、常见场景 + +### 7.1 企业新员工入职 + +1. **管理员创建用户账号** — 进入「用户管理」→ 创建用户,设置角色为 `developer` +2. **将用户加入工作区** — 进入目标工作区「成员管理」→ 添加成员 +3. **用户登录** — 使用分配的账号密码登录 +4. **用户切换工作区** — 在工作区列表选择目标工作区进入 +5. **开始工作** — 创建 Agent、工作流等 + +### 7.2 创建新团队协作空间 + +1. **创建工作区** — 点击「创建工作区」,命名为"XX 项目" +2. **添加团队成员** — 逐个添加成员账号 +3. **设置成员角色** — 核心成员设为 `admin`(可管理成员),普通成员设为 `member` +4. **团队开始协作** — 所有成员在该工作区中创建的 Agent、工作流自动共享 + +### 7.3 权限审查 + +1. **平台管理员**进入「用户管理」查看所有用户及其角色 +2. 检查是否有用户拥有不当权限(如普通用户被设为 `admin`) +3. 通过「编辑用户」调整角色 +4. 停用离职员工的账号(设置 status=disabled) + +### 7.4 审计追溯 + +1. 进入「系统日志 → 审计日志」 +2. 按时间、用户、操作类型筛选 +3. 查看关键操作:用户创建/删除、权限变更、工作区删除等 +4. 所有敏感操作有完整审计轨迹 + +--- + +## 八、安全注意事项 + +### 8.1 密码安全 + +- 默认管理员密码 `123456` 应在首次部署后立即修改 +- 通过个人设置或管理员强制重置修改密码 +- 密码至少 6 位,建议使用强密码策略 + +### 8.2 权限最小化 + +- 给用户分配其工作需要的最低权限角色 +- 默认使用 `developer` 角色,而非 `admin` +- 定期审查工作区成员列表,移除不再需要访问的用户 + +### 8.3 工作区删除 + +- 删除工作区是软删除(status=deleted),数据不会物理删除 +- 需要彻底清理数据请联系平台管理员 +- 默认工作区建议保留不删除 + +### 8.4 Token 安全 + +- JWT Token 包含工作区上下文,请妥善保管 +- Token 过期后需重新登录 +- WebSocket 连接的 Token 通过 query 参数传递,注意日志中不要泄露 + +--- + +## 附录 A: 角色权限矩阵速查 + +| 操作 | platform admin | workspace admin | developer | viewer | operator | +|------|:---:|:---:|:---:|:---:|:---:| +| 查看所有工作区 | ✅ | ❌ | ❌ | ❌ | ❌ | +| 管理所有工作区 | ✅ | ❌ | ❌ | ❌ | ❌ | +| 管理所有用户 | ✅ | ❌ | ❌ | ❌ | ❌ | +| 管理本工作区成员 | ✅ | ✅ | ❌ | ❌ | ❌ | +| 修改工作区设置 | ✅ | ✅ | ❌ | ❌ | ❌ | +| 创建 Agent/工作流 | ✅ | ✅ | ✅ | ❌ | ❌ | +| 编辑自己的资源 | ✅ | ✅ | ✅ | ❌ | ❌ | +| 删除自己的资源 | ✅ | ✅ | ✅ | ❌ | ❌ | +| 编辑他人的资源 | ✅ | ✅ | ❌ | ❌ | ❌ | +| 删除他人的资源 | ✅ | ✅ | ❌ | ❌ | ❌ | +| 部署/停止 Agent | ✅ | ✅ | ✅ | ❌ | ✅ | +| 执行工作流 | ✅ | ✅ | ✅ | ❌ | ✅ | +| 查看资源 | ✅ | ✅ | ✅ | ✅ | ✅ | +| 查看执行记录 | ✅ | ✅ | ✅ | ✅ | ✅ | +| 查看审计日志 | ✅ | ❌ | ❌ | ❌ | ❌ | + +## 附录 B: 数据库表 workspace_id 标记 + +所有支持多租户隔离的数据表均包含 `workspace_id` 列: + +``` +agents agent_schedules alert_rules +alert_logs agent_execution_logs agent_llm_logs +agent_sessions chat_messages conversation_branches +data_sources executions goals +knowledge_bases knowledge_entries model_configs +node_templates plugins scene_contracts +orchestration_templates tasks teams +templates workflows tools +``` + +## 附录 C: 技术架构要点 + +- **后端框架**: FastAPI + SQLAlchemy ORM +- **认证**: JWT (python-jose) + bcrypt 密码哈希 +- **依赖注入**: FastAPI `Depends()` 链式调用实现认证→授权→执行 +- **数据隔离**: 所有查询通过 SQLAlchemy `filter(workspace_id=...)` 实现 +- **WebSocket**: 连接注册表按 workspace_id 标记,防止跨工作区消息泄露 +- **平台管理员绕过**: `user.role == "admin"` 跳过所有 workspace 成员资格检查 + +--- + +> 最后更新:2026-07-04 | 适用版本 v1.2+ diff --git a/frontend/src/components/MainLayout.vue b/frontend/src/components/MainLayout.vue index 9860a8a..3f512b5 100644 --- a/frontend/src/components/MainLayout.vue +++ b/frontend/src/components/MainLayout.vue @@ -1,96 +1,125 @@ @@ -104,7 +133,7 @@ import { Grid, Document, User, ChatLineSquare, Share, UserFilled, Clock, Monitor, List, Connection, Setting, Star, Shop, Tools, Bell, Tickets, OfficeBuilding, Lock, ArrowDown, DataAnalysis, - Sunny, Moon, Iphone + Sunny, Moon, Iphone, Fold, Expand, SwitchButton } from '@element-plus/icons-vue' import GlobalSearch from '@/components/GlobalSearch.vue' import BreadcrumbNav from '@/components/BreadcrumbNav.vue' @@ -117,7 +146,9 @@ const userStore = useUserStore() const themeStore = useThemeStore() const notifyCenterRef = ref | null>(null) -// ──────────── 菜单分组定义 ──────────── +const sidebarCollapsed = ref(false) + +// ──────────── 菜单定义 ──────────── interface MenuItem { index: string label: string @@ -142,7 +173,6 @@ const menuGroups = computed(() => { label: '核心功能', icon: Grid, children: [ - { index: 'console', label: '主控台', description: '平台概览仪表盘', path: '/console', icon: Grid }, { index: 'workflows', label: '工作流管理', description: '创建和管理工作流', path: '/', pathPattern: '/workflow', icon: Document }, { index: 'agents', label: 'Agent管理', description: '管理所有智能体', path: '/agents', pathPattern: '/agents', icon: User }, ], @@ -186,37 +216,62 @@ const menuGroups = computed(() => { icon: Setting, children: [ { index: 'data-sources', label: '数据源管理', description: '配置数据源', path: '/data-sources', icon: Connection }, - { index: 'model-configs', label: '模型配置管理', description: '管理LLM模型', path: '/model-configs', icon: Setting }, + { index: 'model-configs', label: '模型配置', description: '管理LLM模型', path: '/model-configs', icon: Setting }, { index: 'workspaces', label: '工作区', description: '管理多工作区', path: '/workspaces', pathPattern: '/workspace', icon: OfficeBuilding }, { index: 'permissions', label: '权限管理', description: '用户权限管理', path: '/permissions', icon: Lock, adminOnly: true }, { index: 'app-versions', label: 'App版本管理', description: '发布App新版本', path: '/app-versions', icon: Iphone, adminOnly: true }, + { index: 'users', label: '用户管理', description: '用户列表与权限管理', path: '/users', icon: User, adminOnly: true }, ], }, ] - // Filter admin-only items + // 过滤管理员专属项 return groups.map(g => ({ ...g, children: g.children.filter(item => !item.adminOnly || userStore.user?.role === 'admin'), })).filter(g => g.children.length > 0) }) -// ──────────── 菜单激活状态 ──────────── -function isActiveMenu(item: MenuItem): boolean { - if (item.pathPattern) { - return route.path.startsWith(item.pathPattern) - } - if (route.path === item.path) return true - // 根路径特殊处理 - if (item.index === 'workflows' && (route.path === '/' || route.path.startsWith('/workflow/'))) return true - return false -} +// ──────────── 活动菜单计算 ──────────── +const activeMenu = computed(() => { + const path = route.path -// ──────────── 路由导航 ──────────── -function navigateTo(item: MenuItem) { - if (item.path === route.path) return // already there - router.push(item.path) -} + // 遍历所有菜单项找匹配 + for (const group of menuGroups.value) { + for (const item of group.children) { + if (item.pathPattern) { + if (path.startsWith(item.pathPattern)) return item.path + } + if (path === item.path) return item.path + // 根路径匹配工作流 + if (item.index === 'workflows' && (path === '/' || path.startsWith('/workflow/'))) { + return item.path + } + } + } + + // 主控台 + if (path === '/console') return '/console' + + // 默认返回当前路径 + return path +}) + +// ──────────── 默认展开的菜单组 ──────────── +const defaultOpeneds = computed(() => { + // 找到当前活动菜单所属的 group + for (const group of menuGroups.value) { + for (const item of group.children) { + if (activeMenu.value === item.path) return [group.name] + // 路径匹配 + if (item.pathPattern && route.path.startsWith(item.pathPattern)) return [group.name] + if (item.index === 'workflows' && (route.path === '/' || route.path.startsWith('/workflow/'))) { + return [group.name] + } + } + } + return ['core'] +}) // ──────────── 工作区切换 ──────────── const handleSwitchWorkspace = async (workspaceId: string) => { @@ -241,21 +296,45 @@ const handleLogout = () => { height: 100vh; } -.header-content { +/* ══════════ Header ══════════ */ +:deep(.el-header) { + --el-header-padding: 0; + height: 52px; display: flex; - justify-content: space-between; align-items: center; - height: 100%; - padding: 0 20px; - background: #409eff; - color: white; - gap: 24px; + background: #fff; + border-bottom: 1px solid var(--el-border-color-lighter); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + z-index: 100; + position: relative; } -.header-left h1 { - margin: 0; - font-size: 18px; - font-weight: 500; +html.dark :deep(.el-header) { + background: #1d1e1f; + border-color: var(--el-border-color-darker); +} + +.header-left { + display: flex; + align-items: center; + gap: 8px; + padding-left: 12px; + flex-shrink: 0; +} + +.collapse-btn { + color: var(--el-text-color-regular); + padding: 6px; +} + +.collapse-btn:hover { + background: var(--el-fill-color-light); +} + +.brand-name { + font-size: 16px; + font-weight: 600; + color: var(--el-text-color-primary); white-space: nowrap; } @@ -263,122 +342,103 @@ const handleLogout = () => { flex: 1; display: flex; justify-content: center; - max-width: 480px; + max-width: 420px; + margin: 0 auto; } -.header-actions { +.header-right { display: flex; align-items: center; - gap: 15px; - white-space: nowrap; + gap: 10px; + padding-right: 16px; + flex-shrink: 0; } .workspace-selector { - width: 180px; + width: 170px; } .theme-toggle-btn { - color: white; + color: var(--el-text-color-regular); padding: 4px; } .theme-toggle-btn:hover { - background: rgba(255, 255, 255, 0.15); + background: var(--el-fill-color-light); } -/* ───── 菜单栏 ───── */ -.menu-bar { - display: flex; - align-items: center; - gap: 2px; - padding: 4px 0 2px; - margin-bottom: 2px; - border-bottom: 1px solid var(--el-border-color-lighter); - overflow-x: auto; - flex-shrink: 0; -} - -.menu-dropdown { - flex-shrink: 0; -} - -.menu-group-btn { - font-size: 14px; - font-weight: 500; - color: var(--el-text-color-regular); - padding: 8px 14px; - border-radius: 6px; - transition: all 0.2s; +.user-info { display: flex; align-items: center; gap: 6px; + cursor: pointer; + color: var(--el-text-color-regular); + font-size: 14px; + padding: 4px 8px; + border-radius: 6px; + transition: background 0.2s; } -.menu-group-btn:hover { +.user-info:hover { background: var(--el-fill-color-light); - color: var(--el-color-primary); +} + +.username { + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .arrow-icon { font-size: 12px; - transition: transform 0.2s; } -/* ───── 下拉项 ───── */ -:deep(.menu-dropdown-popper) { - min-width: 240px; +/* ══════════ Body ══════════ */ +.body-container { + height: calc(100vh - 52px); } -:deep(.menu-dropdown-popper .el-dropdown-menu__item) { - padding: 8px 12px; +/* ══════════ Sidebar ══════════ */ +.sidebar-aside { + transition: width 0.2s ease; + overflow: hidden; + border-right: 1px solid var(--el-border-color-lighter); + background: var(--el-bg-color); } -.dropdown-item-content { - display: flex; - align-items: flex-start; - gap: 10px; - width: 100%; -} - -.dropdown-item-content > .el-icon { - margin-top: 2px; - font-size: 18px; - color: var(--el-color-primary); - flex-shrink: 0; -} - -.item-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.item-label { - font-size: 14px; - font-weight: 500; - color: var(--el-text-color-primary); -} - -.item-desc { - font-size: 12px; - color: var(--el-text-color-secondary); - line-height: 1.3; -} - -:deep(.el-dropdown-menu__item.is-active) { - background: var(--el-color-primary-light-9); -} - -:deep(.el-dropdown-menu__item.is-active .item-label) { - color: var(--el-color-primary); -} - -/* ───── 适配 ───── */ -:deep(.el-main) { - display: flex; - flex-direction: column; - padding: 12px 20px 20px; +.sidebar-menu { + border-right: none; + height: 100%; overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-menu:not(.el-menu--collapse) { + width: 219px; +} + +/* 子菜单缩进微调 */ +.sidebar-menu :deep(.el-sub-menu .el-menu-item) { + padding-left: 56px !important; +} + +.sidebar-menu.el-menu--collapse :deep(.el-sub-menu .el-menu-item) { + padding-left: 24px !important; +} + +/* 子菜单标题图标间距 */ +.sidebar-menu :deep(.el-sub-menu__title .el-icon) { + margin-right: 8px; +} + +/* ══════════ Main Content ══════════ */ +:deep(.el-main) { + --el-main-padding: 12px 20px 20px; + display: flex; + flex-direction: column; + overflow-y: auto; + background: var(--el-bg-color-page); } .page-content { @@ -388,18 +448,30 @@ const handleLogout = () => { min-height: 0; } -/* 搜索框暗色适配 */ +/* ══════════ Dark mode adjustments ══════════ */ +html.dark .sidebar-aside { + background: #1d1e1f; + border-color: var(--el-border-color-darker); +} + +html.dark :deep(.el-main) { + background: #141414; +} + +/* ══════════ Header search adapt ══════════ */ .header-center :deep(.search-trigger) { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.85); + background: var(--el-fill-color-light); + border-color: var(--el-border-color-light); + color: var(--el-text-color-regular); } + .header-center :deep(.search-trigger):hover { - background: rgba(255, 255, 255, 0.25); - border-color: rgba(255, 255, 255, 0.4); + background: var(--el-fill-color); + border-color: var(--el-border-color); } + .header-center :deep(.search-trigger kbd) { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.6); + background: var(--el-fill-color); + color: var(--el-text-color-secondary); } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2ee803b..48f5d29 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -213,6 +213,12 @@ const router = createRouter({ name: 'app-versions', component: () => import('@/views/AppVersionManage.vue'), meta: { requiresAuth: true, requiresAdmin: true } + }, + { + path: '/users', + name: 'users', + component: () => import('@/views/Users.vue'), + meta: { requiresAuth: true, requiresAdmin: true } } ] }) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index f97e8b5..6de5676 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -18,6 +18,19 @@ export const useUserStore = defineStore('user', () => { return workspaces.value.find(w => w.id === currentWorkspaceId.value) || null }) + // 当前工作区角色 + const currentWorkspaceRole = computed(() => { + if (!currentWorkspaceId.value) return null + const ws = workspaces.value.find(w => w.id === currentWorkspaceId.value) + return ws?.role || null + }) + + // 是否为工作区管理员(平台管理员或工作区admin) + const isWorkspaceAdmin = computed(() => { + if (user.value?.role === 'admin') return true + return currentWorkspaceRole.value === 'admin' + }) + // 设置token const setToken = (newToken: string) => { token.value = newToken @@ -136,6 +149,8 @@ export const useUserStore = defineStore('user', () => { workspaces, currentWorkspaceId, currentWorkspace, + currentWorkspaceRole, + isWorkspaceAdmin, login, register, logout, diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue new file mode 100644 index 0000000..343e717 --- /dev/null +++ b/frontend/src/views/Users.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/frontend/src/views/Workspaces.vue b/frontend/src/views/Workspaces.vue index d26651d..ffb14a4 100644 --- a/frontend/src/views/Workspaces.vue +++ b/frontend/src/views/Workspaces.vue @@ -8,7 +8,60 @@ - + +
+

所有工作区(平台管理员视图)

+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ +
+
+ + +

我的工作区

@@ -55,21 +108,45 @@ 确定 + + + + + + + + {{ adminEditWs.owner_name }} + {{ adminEditWs.member_count }} / {{ adminEditWs.max_members }} + + + + + + + + + @@ -146,6 +296,19 @@ onMounted(() => { margin: 0; } +.admin-section { + margin-bottom: 8px; +} + +.admin-section h3 { + margin: 0 0 8px 0; +} + +.admin-toolbar { + display: flex; + align-items: center; +} + .workspace-card { margin-bottom: 20px; cursor: pointer;