From 0edaa855db61d0cf875b0ec26a0e1d4fed450c06 Mon Sep 17 00:00:00 2001 From: amos wong Date: Sun, 14 Dec 2025 20:33:33 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E9=A1=B9=E7=9B=AE=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 33 ++ README.md | 92 +++ android/app/build.gradle.kts | 85 +++ android/app/proguard-rules.pro | 32 + android/app/src/main/AndroidManifest.xml | 28 + .../main/java/com/memory/app/MainActivity.kt | 130 +++++ .../java/com/memory/app/data/api/ApiClient.kt | 59 ++ .../com/memory/app/data/api/ApiService.kt | 104 ++++ .../java/com/memory/app/data/model/Models.kt | 131 +++++ .../app/data/repository/AuthRepository.kt | 96 +++ .../com/memory/app/ui/components/MediaGrid.kt | 131 +++++ .../com/memory/app/ui/components/PostCard.kt | 266 +++++++++ .../memory/app/ui/navigation/Navigation.kt | 220 +++++++ .../com/memory/app/ui/screen/ArchiveScreen.kt | 538 +++++++++++++++++ .../memory/app/ui/screen/CreatePostScreen.kt | 201 +++++++ .../com/memory/app/ui/screen/HomeScreen.kt | 144 +++++ .../com/memory/app/ui/screen/LoginScreen.kt | 131 +++++ .../memory/app/ui/screen/PostDetailScreen.kt | 549 ++++++++++++++++++ .../com/memory/app/ui/screen/ProfileScreen.kt | 257 ++++++++ .../com/memory/app/ui/screen/SearchScreen.kt | 269 +++++++++ .../java/com/memory/app/ui/theme/Theme.kt | 55 ++ .../app/ui/viewmodel/ArchiveViewModel.kt | 150 +++++ .../memory/app/ui/viewmodel/HomeViewModel.kt | 164 ++++++ .../app/ui/viewmodel/PostDetailViewModel.kt | 149 +++++ .../app/ui/viewmodel/ProfileViewModel.kt | 94 +++ .../app/ui/viewmodel/SearchViewModel.kt | 67 +++ .../java/com/memory/app/util/TimeUtils.kt | 49 ++ .../res/drawable/ic_launcher_foreground.xml | 15 + .../src/main/res/mipmap-hdpi/ic_launcher.xml | 5 + .../res/mipmap-hdpi/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-mdpi/ic_launcher.xml | 5 + .../res/mipmap-mdpi/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-xhdpi/ic_launcher.xml | 5 + .../res/mipmap-xhdpi/ic_launcher_round.xml | 5 + .../main/res/mipmap-xxhdpi/ic_launcher.xml | 5 + .../res/mipmap-xxhdpi/ic_launcher_round.xml | 5 + .../main/res/mipmap-xxxhdpi/ic_launcher.xml | 5 + .../res/mipmap-xxxhdpi/ic_launcher_round.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + android/app/src/main/res/values/themes.xml | 8 + android/build.gradle.kts | 6 + android/gradle.properties | 12 + .../gradle/wrapper/gradle-wrapper.properties | 7 + android/settings.gradle.kts | 24 + docker-compose.yml | 20 + memory-ui-preview.html | 509 ++++++++++++++++ server/.env.example | 15 + server/Dockerfile | 17 + server/go.mod | 55 ++ server/go.sum | 128 ++++ server/internal/config/config.go | 44 ++ server/internal/database/database.go | 115 ++++ server/internal/handler/auth.go | 147 +++++ server/internal/handler/comment.go | 119 ++++ server/internal/handler/post.go | 291 ++++++++++ server/internal/handler/search.go | 135 +++++ server/internal/handler/upload.go | 171 ++++++ server/internal/handler/user.go | 126 ++++ server/internal/middleware/auth.go | 71 +++ server/internal/model/model.go | 114 ++++ server/internal/router/router.go | 88 +++ server/main.go | 27 + 62 files changed, 6542 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/memory/app/MainActivity.kt create mode 100644 android/app/src/main/java/com/memory/app/data/api/ApiClient.kt create mode 100644 android/app/src/main/java/com/memory/app/data/api/ApiService.kt create mode 100644 android/app/src/main/java/com/memory/app/data/model/Models.kt create mode 100644 android/app/src/main/java/com/memory/app/data/repository/AuthRepository.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/components/PostCard.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/ArchiveScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/HomeScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/LoginScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/ProfileScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/screen/SearchScreen.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/viewmodel/ArchiveViewModel.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/viewmodel/PostDetailViewModel.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/viewmodel/ProfileViewModel.kt create mode 100644 android/app/src/main/java/com/memory/app/ui/viewmodel/SearchViewModel.kt create mode 100644 android/app/src/main/java/com/memory/app/util/TimeUtils.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 docker-compose.yml create mode 100644 memory-ui-preview.html create mode 100644 server/.env.example create mode 100644 server/Dockerfile create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/config/config.go create mode 100644 server/internal/database/database.go create mode 100644 server/internal/handler/auth.go create mode 100644 server/internal/handler/comment.go create mode 100644 server/internal/handler/post.go create mode 100644 server/internal/handler/search.go create mode 100644 server/internal/handler/upload.go create mode 100644 server/internal/handler/user.go create mode 100644 server/internal/middleware/auth.go create mode 100644 server/internal/model/model.go create mode 100644 server/internal/router/router.go create mode 100644 server/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2d7a31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# macOS +.DS_Store +*.swp +*.swo + +# IDE +.idea/ +.vscode/ +*.iml + +# Android +android/.gradle/ +android/.kotlin/ +android/build/ +android/app/build/ +android/local.properties +android/captures/ +android/*.apk +android/*.aab + +# Go +server/memory-server +server/*.exe +server/data/ +server/uploads/ +server/.env + +# Logs +*.log + +# Temp +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d0b805 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Memory + +一个类似 X (Twitter) 的个人生活记录应用。 + +## 功能 + +- 📝 发帖:支持文字 + 图片/视频 +- 💬 评论:帖子评论功能 +- ❤️ 点赞:帖子点赞 +- 😊 表情反应:给帖子添加表情 +- 🔍 搜索:按内容和时间范围搜索 +- 📊 归档:GitHub 风格热力图展示发帖记录 +- 👤 个人中心:修改头像、昵称、简介 + +## 技术栈 + +### 服务端 +- Go + Gin +- SQLite +- Cloudflare R2 (图片存储) + +### Android +- Kotlin + Jetpack Compose +- Retrofit + OkHttp +- Coil (图片加载) + +## 部署 + +### 服务端 + +1. 复制配置文件: +```bash +cd server +cp .env.example .env +``` + +2. 编辑 `.env` 配置 R2 存储和 JWT 密钥 + +3. 使用 Docker 部署: +```bash +docker-compose up -d +``` + +或直接运行: +```bash +go build -o memory . +./memory +``` + +### Android + +1. 修改 `app/build.gradle.kts` 中的 `API_BASE_URL` 为你的服务器地址 + +2. 使用 Android Studio 构建 APK + +## API 接口 + +### 认证 +- `POST /api/auth/register` - 注册 +- `POST /api/auth/login` - 登录 + +### 帖子 +- `GET /api/posts` - 获取帖子列表 +- `POST /api/posts` - 创建帖子 +- `GET /api/posts/:id` - 获取帖子详情 +- `DELETE /api/posts/:id` - 删除帖子 +- `POST /api/posts/:id/like` - 点赞 +- `DELETE /api/posts/:id/like` - 取消点赞 +- `POST /api/posts/:id/reactions` - 添加表情 +- `DELETE /api/posts/:id/reactions` - 移除表情 + +### 评论 +- `GET /api/posts/:id/comments` - 获取评论 +- `POST /api/posts/:id/comments` - 发表评论 +- `DELETE /api/posts/:id/comments/:comment_id` - 删除评论 + +### 用户 +- `GET /api/user/profile` - 获取个人信息 +- `PUT /api/user/profile` - 更新个人信息 +- `PUT /api/user/avatar` - 更新头像 + +### 搜索 +- `GET /api/search` - 搜索帖子 +- `GET /api/heatmap` - 获取热力图数据 + +### 管理员 +- `GET /api/admin/settings` - 获取系统设置 +- `PUT /api/admin/settings` - 更新系统设置 + +## License + +MIT diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..16c37e5 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,85 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.memory.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.memory.app" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + buildConfigField("String", "API_BASE_URL", "\"http://192.168.0.100:8080/api/\"") + } + + signingConfigs { + create("release") { + // 使用环境变量或 local.properties 配置 + storeFile = file(System.getenv("KEYSTORE_FILE") ?: "release.keystore") + storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "" + keyAlias = System.getenv("KEY_ALIAS") ?: "memory" + keyPassword = System.getenv("KEY_PASSWORD") ?: "" + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + // Compose + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.navigation:navigation-compose:2.8.5") + + // Lifecycle + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + + // Network + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + + // Image loading + implementation("io.coil-kt:coil-compose:2.7.0") + + // DataStore + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Debug + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..7cfc93f --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,32 @@ +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } + +# Kotlinx Serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class com.memory.app.data.model.**$$serializer { *; } +-keepclassmembers class com.memory.app.data.model.** { + *** Companion; +} +-keepclasseswithmembers class com.memory.app.data.model.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Coil +-keep class coil.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f6c70c8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/memory/app/MainActivity.kt b/android/app/src/main/java/com/memory/app/MainActivity.kt new file mode 100644 index 0000000..d2105ea --- /dev/null +++ b/android/app/src/main/java/com/memory/app/MainActivity.kt @@ -0,0 +1,130 @@ +package com.memory.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import coil.Coil +import coil.ImageLoader +import coil.util.DebugLogger +import com.memory.app.data.model.User +import com.memory.app.data.repository.AuthRepository +import com.memory.app.ui.navigation.MainNavigation +import com.memory.app.ui.screen.LoginScreen +import com.memory.app.ui.theme.MemoryTheme +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient + +class MainActivity : ComponentActivity() { + private lateinit var authRepository: AuthRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // 配置 Coil 图片加载器,允许 HTTP + val imageLoader = ImageLoader.Builder(this) + .okHttpClient { + OkHttpClient.Builder() + .build() + } + .crossfade(true) + .build() + Coil.setImageLoader(imageLoader) + + authRepository = AuthRepository(this) + + setContent { + MemoryTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + var isLoggedIn by remember { mutableStateOf(null) } + var currentUser by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + // Check login state + LaunchedEffect(Unit) { + val restored = authRepository.restoreSession() + if (restored) { + currentUser = authRepository.currentUser.first() + } + isLoggedIn = restored + } + + // Observe user changes + LaunchedEffect(Unit) { + authRepository.currentUser.collect { user -> + currentUser = user + } + } + + when (isLoggedIn) { + null -> { + // Loading + } + false -> { + LoginScreen( + onLogin = { username, password -> + isLoading = true + error = null + lifecycleScope.launch { + authRepository.login(username, password) + .onSuccess { + isLoggedIn = true + currentUser = it.user + } + .onFailure { + error = it.message ?: "登录失败" + } + isLoading = false + } + }, + onRegister = { username, password, nickname -> + isLoading = true + error = null + lifecycleScope.launch { + authRepository.register(username, password, nickname) + .onSuccess { + isLoggedIn = true + currentUser = it.user + } + .onFailure { + error = it.message ?: "注册失败" + } + isLoading = false + } + }, + isLoading = isLoading, + error = error + ) + } + true -> { + MainNavigation( + user = currentUser, + postCount = 0, + likeCount = 0, + onLogout = { + lifecycleScope.launch { + authRepository.logout() + isLoggedIn = false + currentUser = null + } + } + ) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/data/api/ApiClient.kt b/android/app/src/main/java/com/memory/app/data/api/ApiClient.kt new file mode 100644 index 0000000..14e4ee6 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/data/api/ApiClient.kt @@ -0,0 +1,59 @@ +package com.memory.app.data.api + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.memory.app.BuildConfig +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +object ApiClient { + private var token: String? = null + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + private val okHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor { chain -> + val request = chain.request().newBuilder() + token?.let { + request.addHeader("Authorization", "Bearer $it") + } + chain.proceed(request.build()) + } + .addInterceptor(HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + }) + .build() + } + + private val retrofit by lazy { + Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + val api: ApiService by lazy { + retrofit.create(ApiService::class.java) + } + + fun setToken(newToken: String?) { + token = newToken + } + + fun getToken(): String? = token +} diff --git a/android/app/src/main/java/com/memory/app/data/api/ApiService.kt b/android/app/src/main/java/com/memory/app/data/api/ApiService.kt new file mode 100644 index 0000000..a88dccb --- /dev/null +++ b/android/app/src/main/java/com/memory/app/data/api/ApiService.kt @@ -0,0 +1,104 @@ +package com.memory.app.data.api + +import com.memory.app.data.model.* +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.* + +interface ApiService { + // Auth + @POST("auth/register") + suspend fun register(@Body request: RegisterRequest): Response + + @POST("auth/login") + suspend fun login(@Body request: LoginRequest): Response + + // Posts + @GET("posts") + suspend fun getPosts( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20 + ): Response> + + @POST("posts") + suspend fun createPost(@Body request: CreatePostRequest): Response + + @GET("posts/{id}") + suspend fun getPost(@Path("id") id: Long): Response + + @DELETE("posts/{id}") + suspend fun deletePost(@Path("id") id: Long): Response + + @POST("posts/{id}/like") + suspend fun likePost(@Path("id") id: Long): Response + + @DELETE("posts/{id}/like") + suspend fun unlikePost(@Path("id") id: Long): Response + + @POST("posts/{id}/reactions") + suspend fun addReaction( + @Path("id") id: Long, + @Body request: AddReactionRequest + ): Response + + @DELETE("posts/{id}/reactions") + suspend fun removeReaction( + @Path("id") id: Long, + @Query("emoji") emoji: String + ): Response + + // Comments + @GET("posts/{id}/comments") + suspend fun getComments( + @Path("id") postId: Long, + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20 + ): Response> + + @POST("posts/{id}/comments") + suspend fun createComment( + @Path("id") postId: Long, + @Body request: CreateCommentRequest + ): Response + + @DELETE("posts/{postId}/comments/{commentId}") + suspend fun deleteComment( + @Path("postId") postId: Long, + @Path("commentId") commentId: Long + ): Response + + // User + @GET("user/profile") + suspend fun getProfile(): Response + + @PUT("user/profile") + suspend fun updateProfile(@Body request: UpdateProfileRequest): Response + + @PUT("user/avatar") + suspend fun updateAvatar(@Body request: Map): Response + + // Search + @GET("search") + suspend fun search( + @Query("q") query: String? = null, + @Query("start_date") startDate: String? = null, + @Query("end_date") endDate: String? = null, + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20 + ): Response> + + @GET("heatmap") + suspend fun getHeatmap(@Query("year") year: String? = null): Response> + + // Upload + @Multipart + @POST("upload") + suspend fun upload(@Part file: MultipartBody.Part): Response + + // Admin + @GET("admin/settings") + suspend fun getSettings(): Response> + + @PUT("admin/settings") + suspend fun updateSettings(@Body settings: Map): Response +} diff --git a/android/app/src/main/java/com/memory/app/data/model/Models.kt b/android/app/src/main/java/com/memory/app/data/model/Models.kt new file mode 100644 index 0000000..6f37c2e --- /dev/null +++ b/android/app/src/main/java/com/memory/app/data/model/Models.kt @@ -0,0 +1,131 @@ +package com.memory.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: Long, + val username: String, + val nickname: String, + @SerialName("avatar_url") val avatarUrl: String = "", + val bio: String = "", + @SerialName("is_admin") val isAdmin: Boolean = false, + @SerialName("created_at") val createdAt: String = "" +) + +@Serializable +data class Post( + val id: Long, + @SerialName("user_id") val userId: Long, + val content: String, + @SerialName("created_at") val createdAt: String, + val user: User? = null, + val media: List = emptyList(), + val reactions: List = emptyList(), + @SerialName("like_count") val likeCount: Int = 0, + val liked: Boolean = false, + @SerialName("comment_count") val commentCount: Int = 0 +) + +@Serializable +data class Media( + val id: Long, + @SerialName("post_id") val postId: Long, + @SerialName("media_url") val mediaUrl: String, + @SerialName("media_type") val mediaType: String, + @SerialName("sort_order") val sortOrder: Int = 0 +) + +@Serializable +data class Comment( + val id: Long, + @SerialName("post_id") val postId: Long, + @SerialName("user_id") val userId: Long, + val content: String, + @SerialName("created_at") val createdAt: String, + val user: User? = null +) + +@Serializable +data class ReactionGroup( + val emoji: String, + val count: Int, + val reacted: Boolean = false +) + +@Serializable +data class HeatmapData( + val date: String, + val count: Int +) + +// Request/Response +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +@Serializable +data class RegisterRequest( + val username: String, + val password: String, + val nickname: String +) + +@Serializable +data class LoginResponse( + val token: String, + val user: User +) + +@Serializable +data class CreatePostRequest( + val content: String, + @SerialName("media_ids") val mediaIds: List = emptyList() +) + +@Serializable +data class CreateCommentRequest( + val content: String +) + +@Serializable +data class AddReactionRequest( + val emoji: String +) + +@Serializable +data class UpdateProfileRequest( + val nickname: String? = null, + val bio: String? = null +) + +@Serializable +data class UploadResponse( + val url: String, + val filename: String +) + +@Serializable +data class ProfileResponse( + val user: User, + @SerialName("post_count") val postCount: Int, + @SerialName("like_count") val likeCount: Int +) + +@Serializable +data class IdResponse( + val id: Long +) + +@Serializable +data class MessageResponse( + val message: String +) + +@Serializable +data class ErrorResponse( + val error: String +) diff --git a/android/app/src/main/java/com/memory/app/data/repository/AuthRepository.kt b/android/app/src/main/java/com/memory/app/data/repository/AuthRepository.kt new file mode 100644 index 0000000..5dab689 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/data/repository/AuthRepository.kt @@ -0,0 +1,96 @@ +package com.memory.app.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.memory.app.data.api.ApiClient +import com.memory.app.data.model.LoginRequest +import com.memory.app.data.model.LoginResponse +import com.memory.app.data.model.RegisterRequest +import com.memory.app.data.model.User +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val Context.dataStore: DataStore by preferencesDataStore(name = "auth") + +class AuthRepository(private val context: Context) { + private val tokenKey = stringPreferencesKey("token") + private val userKey = stringPreferencesKey("user") + + val isLoggedIn: Flow = context.dataStore.data.map { prefs -> + prefs[tokenKey] != null + } + + val currentUser: Flow = context.dataStore.data.map { prefs -> + prefs[userKey]?.let { Json.decodeFromString(it) } + } + + suspend fun login(username: String, password: String): Result { + return try { + val response = ApiClient.api.login(LoginRequest(username, password)) + if (response.isSuccessful && response.body() != null) { + val loginResponse = response.body()!! + saveAuth(loginResponse) + Result.success(loginResponse) + } else { + Result.failure(Exception(response.errorBody()?.string() ?: "Login failed")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun register(username: String, password: String, nickname: String): Result { + return try { + val response = ApiClient.api.register(RegisterRequest(username, password, nickname)) + if (response.isSuccessful && response.body() != null) { + val loginResponse = response.body()!! + saveAuth(loginResponse) + Result.success(loginResponse) + } else { + Result.failure(Exception(response.errorBody()?.string() ?: "Registration failed")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logout() { + context.dataStore.edit { prefs -> + prefs.remove(tokenKey) + prefs.remove(userKey) + } + ApiClient.setToken(null) + } + + suspend fun restoreSession(): Boolean { + val prefs = context.dataStore.data.first() + val token = prefs[tokenKey] + return if (token != null) { + ApiClient.setToken(token) + true + } else { + false + } + } + + private suspend fun saveAuth(response: LoginResponse) { + context.dataStore.edit { prefs -> + prefs[tokenKey] = response.token + prefs[userKey] = Json.encodeToString(response.user) + } + ApiClient.setToken(response.token) + } + + suspend fun updateUser(user: User) { + context.dataStore.edit { prefs -> + prefs[userKey] = Json.encodeToString(user) + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt b/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt new file mode 100644 index 0000000..ec1b0b7 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt @@ -0,0 +1,131 @@ +package com.memory.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.memory.app.data.model.Media + +@Composable +fun MediaGrid( + media: List, + onMediaClick: ((Int) -> Unit)? = null, + modifier: Modifier = Modifier +) { + val shape = RoundedCornerShape(16.dp) + + when (media.size) { + 1 -> { + AsyncImage( + model = media[0].mediaUrl, + contentDescription = null, + modifier = modifier + .fillMaxWidth() + .aspectRatio(16f / 10f) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }, + contentScale = ContentScale.Crop + ) + } + 2 -> { + Row( + modifier = modifier + .fillMaxWidth() + .aspectRatio(16f / 10f) + .clip(shape), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + media.forEachIndexed { index, item -> + AsyncImage( + model = item.mediaUrl, + contentDescription = null, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }, + contentScale = ContentScale.Crop + ) + } + } + } + 3 -> { + Row( + modifier = modifier + .fillMaxWidth() + .aspectRatio(16f / 10f) + .clip(shape), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + AsyncImage( + model = media[0].mediaUrl, + contentDescription = null, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }, + contentScale = ContentScale.Crop + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + for (i in 1..2) { + AsyncImage( + model = media[i].mediaUrl, + contentDescription = null, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(i) }, + contentScale = ContentScale.Crop + ) + } + } + } + } + else -> { + // 4+ images: 2x2 grid + Column( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(shape), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + for (row in 0..1) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + for (col in 0..1) { + val index = row * 2 + col + if (index < media.size) { + AsyncImage( + model = media[index].mediaUrl, + contentDescription = null, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }, + contentScale = ContentScale.Crop + ) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt b/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt new file mode 100644 index 0000000..cdac513 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt @@ -0,0 +1,266 @@ +package com.memory.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.filled.Favorite +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.memory.app.data.model.Post +import com.memory.app.util.TimeUtils + +@Composable +fun PostCard( + post: Post, + onPostClick: () -> Unit, + onLikeClick: () -> Unit, + onCommentClick: () -> Unit, + onReactionClick: (String) -> Unit, + onMediaClick: ((String) -> Unit)? = null, + modifier: Modifier = Modifier +) { + var showEmojiPicker by remember { mutableStateOf(false) } + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onPostClick) + .padding(12.dp) + ) { + Row(modifier = Modifier.fillMaxWidth()) { + // Avatar + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + if (post.user?.avatarUrl?.isNotEmpty() == true) { + AsyncImage( + model = post.user.avatarUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = post.user?.nickname?.firstOrNull()?.toString() ?: "?", + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = post.user?.nickname ?: "", + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = " @${post.user?.username ?: ""} · ${TimeUtils.formatRelative(post.createdAt)}", + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Content + Text( + text = post.content, + fontSize = 15.sp, + lineHeight = 20.sp, + color = MaterialTheme.colorScheme.onBackground + ) + + // Media + if (post.media.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + MediaGrid(media = post.media) + } + + // Reactions + if (post.reactions.isNotEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(post.reactions) { reaction -> + ReactionChip( + emoji = reaction.emoji, + count = reaction.count, + isSelected = reaction.reacted, + onClick = { onReactionClick(reaction.emoji) } + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Actions + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ActionButton( + icon = Icons.Outlined.ChatBubbleOutline, + count = post.commentCount, + onClick = onCommentClick + ) + ActionButton( + icon = if (post.liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + count = post.likeCount, + tint = if (post.liked) Color(0xFFF91880) else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = onLikeClick + ) + EmojiButton(onClick = { showEmojiPicker = true }) + } + } + } + } + + // 表情选择器 + if (showEmojiPicker) { + EmojiPickerDialog( + onDismiss = { showEmojiPicker = false }, + onEmojiSelected = { emoji -> + onReactionClick(emoji) + showEmojiPicker = false + } + ) + } +} + +@Composable +private fun EmojiPickerDialog( + onDismiss: () -> Unit, + onEmojiSelected: (String) -> Unit +) { + val emojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉", "🔥", "👏", "🤔", "💯", "✨") + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "选择表情", + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + modifier = Modifier.padding(bottom = 16.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + emojis.take(6).forEach { emoji -> + Text( + text = emoji, + fontSize = 28.sp, + modifier = Modifier + .clickable { onEmojiSelected(emoji) } + .padding(8.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + emojis.drop(6).forEach { emoji -> + Text( + text = emoji, + fontSize = 28.sp, + modifier = Modifier + .clickable { onEmojiSelected(emoji) } + .padding(8.dp) + ) + } + } + } + } + } +} + +@Composable +private fun ReactionChip( + emoji: String, + count: Int, + isSelected: Boolean, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(50), + color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else MaterialTheme.colorScheme.surfaceVariant, + border = if (isSelected) ButtonDefaults.outlinedButtonBorder + else null + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = emoji, fontSize = 14.sp) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = count.toString(), + fontSize = 13.sp, + color = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ActionButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + count: Int, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + onClick: () -> Unit +) { + TextButton( + onClick = onClick, + colors = ButtonDefaults.textButtonColors(contentColor = tint) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + if (count > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Text(text = count.toString(), fontSize = 13.sp) + } + } +} + +@Composable +private fun EmojiButton(onClick: () -> Unit) { + TextButton( + onClick = onClick, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text(text = "😊", fontSize = 18.sp) + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt b/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt new file mode 100644 index 0000000..fb5148e --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt @@ -0,0 +1,220 @@ +package com.memory.app.ui.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.memory.app.data.model.User +import com.memory.app.ui.screen.* +import com.memory.app.ui.viewmodel.HomeViewModel +import com.memory.app.ui.viewmodel.PostDetailViewModel +import com.memory.app.ui.viewmodel.ProfileViewModel + +sealed class Screen( + val route: String, + val title: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector +) { + data object Home : Screen("home", "首页", Icons.Filled.Home, Icons.Outlined.Home) + data object Search : Screen("search", "搜索", Icons.Filled.Search, Icons.Outlined.Search) + data object Archive : Screen("archive", "归档", Icons.Filled.Folder, Icons.Outlined.Folder) + data object Profile : Screen("profile", "我的", Icons.Filled.Person, Icons.Outlined.Person) +} + +val bottomNavItems = listOf(Screen.Home, Screen.Search, Screen.Archive, Screen.Profile) + +@Composable +fun MainNavigation( + user: User?, + postCount: Int, + likeCount: Int, + onLogout: () -> Unit +) { + val navController = rememberNavController() + var showCreatePost by remember { mutableStateOf(false) } + val homeViewModel: HomeViewModel = viewModel() + val profileViewModel: ProfileViewModel = viewModel() + val context = LocalContext.current + val isPosting by homeViewModel.isPosting.collectAsState() + val postSuccess by homeViewModel.postSuccess.collectAsState() + + // Profile data + val profileUser by profileViewModel.user.collectAsState() + val profilePostCount by profileViewModel.postCount.collectAsState() + val profileLikeCount by profileViewModel.likeCount.collectAsState() + + // 初始化用户数据 + LaunchedEffect(user) { + profileViewModel.setUser(user) + profileViewModel.loadProfile() + } + + // 发帖成功后关闭弹窗 + LaunchedEffect(postSuccess) { + if (postSuccess) { + showCreatePost = false + homeViewModel.resetPostSuccess() + } + } + + Scaffold( + bottomBar = { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + // 在详情页隐藏底部导航 + if (currentRoute?.startsWith("post/") != true) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.background + ) { + val currentDestination = navBackStackEntry?.destination + + bottomNavItems.forEach { screen -> + val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true + NavigationBarItem( + icon = { + Icon( + if (selected) screen.selectedIcon else screen.unselectedIcon, + contentDescription = screen.title + ) + }, + selected = selected, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor = MaterialTheme.colorScheme.background + ) + ) + } + } + } + } + ) { padding -> + NavHost( + navController = navController, + startDestination = Screen.Home.route, + modifier = Modifier.padding(padding) + ) { + composable(Screen.Home.route) { + HomeScreen( + viewModel = homeViewModel, + onPostClick = { postId -> + navController.navigate("post/$postId") + }, + onCreatePost = { showCreatePost = true } + ) + } + + composable(Screen.Search.route) { + SearchScreen( + onPostClick = { postId -> + navController.navigate("post/$postId") + } + ) + } + + composable(Screen.Archive.route) { + ArchiveScreen( + onPostClick = { postId -> + navController.navigate("post/$postId") + } + ) + } + + composable(Screen.Profile.route) { + ProfileScreen( + user = profileUser ?: user, + postCount = profilePostCount.takeIf { it > 0 } ?: postCount, + likeCount = profileLikeCount.takeIf { it > 0 } ?: likeCount, + onEditProfile = { }, + onLogout = onLogout, + onUpdateNickname = { nickname -> + profileViewModel.updateNickname(nickname) + }, + onUpdateAvatar = { uri -> + profileViewModel.updateAvatar(context, uri) + } + ) + } + + // 帖子详情页 + composable( + route = "post/{postId}", + arguments = listOf(navArgument("postId") { type = NavType.LongType }) + ) { backStackEntry -> + val postId = backStackEntry.arguments?.getLong("postId") ?: return@composable + val detailViewModel: PostDetailViewModel = viewModel() + val post by detailViewModel.post.collectAsState() + val comments by detailViewModel.comments.collectAsState() + val isLoading by detailViewModel.isLoading.collectAsState() + val isDeleted by detailViewModel.isDeleted.collectAsState() + + LaunchedEffect(postId) { + detailViewModel.loadPost(postId) + } + + LaunchedEffect(isDeleted) { + if (isDeleted) { + homeViewModel.refresh() + navController.popBackStack() + } + } + + PostDetailScreen( + post = post, + comments = comments, + isLoading = isLoading, + currentUserId = user?.id ?: 0, + onBack = { + homeViewModel.refresh() + navController.popBackStack() + }, + onLike = { detailViewModel.toggleLike() }, + onComment = { detailViewModel.addComment(it) }, + onDeletePost = { detailViewModel.deletePost() }, + onDeleteComment = { detailViewModel.deleteComment(it) }, + onReaction = { detailViewModel.toggleReaction(it) }, + onImageClick = { } + ) + } + } + } + + // Create post modal + if (showCreatePost) { + CreatePostScreen( + user = user, + onClose = { showCreatePost = false }, + onPost = { content, images -> + homeViewModel.createPost(context, content, images) + }, + isLoading = isPosting + ) + } +} + + diff --git a/android/app/src/main/java/com/memory/app/ui/screen/ArchiveScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/ArchiveScreen.kt new file mode 100644 index 0000000..2301fd6 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/ArchiveScreen.kt @@ -0,0 +1,538 @@ +package com.memory.app.ui.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.TrendingUp +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.graphics.vector.ImageVector +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.lifecycle.viewmodel.compose.viewModel +import com.memory.app.data.model.Post +import com.memory.app.ui.components.PostCard +import com.memory.app.ui.viewmodel.ArchiveViewModel +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.WeekFields +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArchiveScreen( + onPostClick: (Long) -> Unit = {}, + viewModel: ArchiveViewModel = viewModel() +) { + val heatmap by viewModel.heatmapData.collectAsState() + val selectedYear by viewModel.selectedYear.collectAsState() + val selectedQuarter by viewModel.selectedQuarter.collectAsState() + val selectedDate by viewModel.selectedDate.collectAsState() + val selectedDatePosts by viewModel.selectedDatePosts.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val totalPosts by viewModel.totalPosts.collectAsState() + val maxDay by viewModel.maxDay.collectAsState() + val currentStreak by viewModel.currentStreak.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadHeatmap() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("归档", fontWeight = FontWeight.SemiBold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // 季度选择器 + QuarterSelector( + year = selectedYear, + quarter = selectedQuarter, + canGoNext = viewModel.canGoNext(), + onPrevious = { viewModel.previousQuarter() }, + onNext = { viewModel.nextQuarter() } + ) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + // 季度热力图 + QuarterHeatmapGrid( + year = selectedYear, + quarter = selectedQuarter, + data = heatmap, + onDateClick = { viewModel.selectDate(it) } + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // 统计信息 + StatsSection( + totalPosts = totalPosts, + maxDay = maxDay, + currentStreak = currentStreak + ) + } + } + } + + // 日期帖子弹窗 + selectedDate?.let { date -> + DatePostsSheet( + date = date, + posts = selectedDatePosts, + onDismiss = { viewModel.clearSelectedDate() }, + onPostClick = onPostClick + ) + } +} + +@Composable +private fun QuarterSelector( + year: Int, + quarter: Int, + canGoNext: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit +) { + val quarterNames = listOf("1-3月", "4-6月", "7-9月", "10-12月") + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onPrevious, + enabled = year > 2020 || quarter > 1 + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上一季度") + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = "${year}年", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = quarterNames[quarter - 1], + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } + + IconButton( + onClick = onNext, + enabled = canGoNext + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下一季度") + } + } +} + +@Composable +private fun QuarterHeatmapGrid( + year: Int, + quarter: Int, + data: Map, + onDateClick: (String) -> Unit +) { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val today = LocalDate.now() + + // 计算季度的起止日期 + val startMonth = (quarter - 1) * 3 + 1 + val endMonth = quarter * 3 + val quarterStart = LocalDate.of(year, startMonth, 1) + val quarterEndDate = LocalDate.of(year, endMonth, 1).plusMonths(1).minusDays(1) + val effectiveEnd = if (quarterEndDate > today) today else quarterEndDate + + // 调整到周一开始 + val adjustedStart = quarterStart.with(WeekFields.of(Locale.getDefault()).dayOfWeek(), 1) + .let { if (it > quarterStart) it.minusWeeks(1) else it } + + // 构建周数据 - 遍历到季度末尾 + val weeks = mutableListOf>() + var currentWeekStart = adjustedStart + + // 循环直到当前周的周日超过季度末尾 + while (currentWeekStart.plusDays(6) >= quarterStart && currentWeekStart <= quarterEndDate.plusDays(6)) { + val week = mutableListOf() + for (i in 0..6) { + val date = currentWeekStart.plusDays(i.toLong()) + // 只要在季度范围内就显示 + if (date.monthValue in startMonth..endMonth && date.year == year) { + week.add(date) + } else { + week.add(null) + } + } + if (week.any { it != null }) { + weeks.add(week) + } + currentWeekStart = currentWeekStart.plusWeeks(1) + // 如果已经超过季度末尾太多,退出 + if (currentWeekStart.isAfter(quarterEndDate.plusWeeks(1))) break + } + + // 月份标签位置 - 检查每周中是否有新月份开始 + val monthLabels = mutableListOf>() + val seenMonths = mutableSetOf() + weeks.forEachIndexed { index, week -> + // 检查这一周中所有日期,找到第一个新月份 + week.filterNotNull().forEach { date -> + if (date.monthValue !in seenMonths) { + seenMonths.add(date.monthValue) + monthLabels.add(index to "${date.monthValue}月") + } + } + } + + // 计算格子大小 + val cellSize = 20.dp + val spacing = 4.dp + val weekWidth = cellSize + spacing // 每周的宽度 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + // 月份标签 - 使用 Box 绝对定位 + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, bottom = 8.dp) + ) { + monthLabels.forEach { (weekIndex, label) -> + Text( + text = label, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.offset(x = (weekIndex * weekWidth.value).dp) + ) + } + } + + // 星期标签 + 热力图 + Row(modifier = Modifier.fillMaxWidth()) { + // 星期标签 + Column( + modifier = Modifier.padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(spacing) + ) { + listOf("一", "二", "三", "四", "五", "六", "日").forEach { day -> + Box( + modifier = Modifier.size(cellSize), + contentAlignment = Alignment.Center + ) { + Text( + text = day, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // 热力图格子 + Row( + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + weeks.forEach { week -> + Column(verticalArrangement = Arrangement.spacedBy(spacing)) { + week.forEach { date -> + if (date != null) { + val dateStr = date.format(dateFormatter) + val count = data[dateStr] ?: 0 + val isFuture = date.isAfter(today) + HeatmapCell( + count = count, + size = cellSize, + isToday = date == today, + isFuture = isFuture, + onClick = { onDateClick(dateStr) } + ) + } else { + Spacer(modifier = Modifier.size(cellSize)) + } + } + } + } + } + } + + // 图例 + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "少", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + listOf(0, 1, 2, 3, 4).forEach { level -> + Box( + modifier = Modifier + .size(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(getHeatmapColor(level)) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "多", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun HeatmapCell( + count: Int, + size: androidx.compose.ui.unit.Dp, + isToday: Boolean = false, + isFuture: Boolean = false, + onClick: () -> Unit +) { + val level = when { + isFuture -> -1 // 未来日期 + count == 0 -> 0 + count <= 1 -> 1 + count <= 3 -> 2 + count <= 5 -> 3 + else -> 4 + } + + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(4.dp)) + .background(getHeatmapColor(level)) + .then( + if (isToday) Modifier.background( + color = Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) else Modifier + ) + .clickable(enabled = count > 0 && !isFuture) { onClick() } + ) +} + +@Composable +private fun getHeatmapColor(level: Int): Color { + return when (level) { + -1 -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) // 未来日期更淡 + 0 -> MaterialTheme.colorScheme.surfaceVariant + 1 -> Color(0xFFB6E3FF) + 2 -> Color(0xFF54AEFF) + 3 -> Color(0xFF0969DA) + 4 -> Color(0xFF0A3069) + else -> MaterialTheme.colorScheme.surfaceVariant + } +} + +@Composable +private fun StatsSection( + totalPosts: Int, + maxDay: Pair?, + currentStreak: Int +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + icon = Icons.Default.TrendingUp, + iconColor = Color(0xFF1D9BF0), + value = totalPosts.toString(), + label = "今年记录", + modifier = Modifier.weight(1f) + ) + StatCard( + icon = Icons.Default.LocalFireDepartment, + iconColor = Color(0xFFFF6B35), + value = if (maxDay != null) "${maxDay.second}" else "-", + label = "单日最多", + modifier = Modifier.weight(1f) + ) + StatCard( + icon = Icons.Default.CalendarMonth, + iconColor = Color(0xFF22C55E), + value = "$currentStreak", + label = "连续天数", + modifier = Modifier.weight(1f) + ) + } + + if (maxDay != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "最活跃日:${maxDay.first}", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun StatCard( + icon: ImageVector, + iconColor: Color, + value: String, + label: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 16.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = value, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePostsSheet( + date: String, + posts: List, + onDismiss: () -> Unit, + onPostClick: (Long) -> Unit +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = date, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "${posts.size}条记录", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + HorizontalDivider() + + if (posts.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "这一天没有记录", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.heightIn(max = 400.dp) + ) { + items(posts, key = { it.id }) { post -> + PostCard( + post = post, + onPostClick = { onPostClick(post.id) }, + onLikeClick = { }, + onCommentClick = { onPostClick(post.id) }, + onReactionClick = { } + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + thickness = 0.5.dp + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt new file mode 100644 index 0000000..dba1940 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt @@ -0,0 +1,201 @@ +package com.memory.app.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.LocationOn +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.memory.app.data.model.User + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatePostScreen( + user: User?, + onClose: () -> Unit, + onPost: (String, List) -> Unit, + isLoading: Boolean +) { + var content by remember { mutableStateOf("") } + var selectedImages by remember { mutableStateOf>(emptyList()) } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + selectedImages = (selectedImages + uris).take(4) + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "关闭") + } + }, + title = { }, + actions = { + Button( + onClick = { onPost(content, selectedImages) }, + enabled = content.isNotBlank() && !isLoading, + modifier = Modifier.padding(end = 8.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("发布", fontWeight = FontWeight.Bold) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Avatar + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + if (user?.avatarUrl?.isNotEmpty() == true) { + AsyncImage( + model = user.avatarUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = user?.nickname?.firstOrNull()?.toString() ?: "?", + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + TextField( + value = content, + onValueChange = { content = it }, + placeholder = { + Text( + "有什么新鲜事?", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + textStyle = LocalTextStyle.current.copy(fontSize = 18.sp) + ) + + // Selected images + if (selectedImages.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(selectedImages) { uri -> + Box { + AsyncImage( + model = uri, + contentDescription = null, + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + IconButton( + onClick = { + selectedImages = selectedImages - uri + }, + modifier = Modifier + .align(Alignment.TopEnd) + .size(24.dp) + .background( + Color.Black.copy(alpha = 0.6f), + CircleShape + ) + ) { + Icon( + Icons.Default.Close, + contentDescription = "移除", + modifier = Modifier.size(16.dp), + tint = Color.White + ) + } + } + } + } + } + + // Toolbar + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + Row( + modifier = Modifier.padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + onClick = { imagePicker.launch("image/*") }, + enabled = selectedImages.size < 4 + ) { + Icon( + Icons.Default.Image, + contentDescription = "添加图片", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton(onClick = { }) { + Icon( + Icons.Default.LocationOn, + contentDescription = "添加位置", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/screen/HomeScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/HomeScreen.kt new file mode 100644 index 0000000..d726bfd --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/HomeScreen.kt @@ -0,0 +1,144 @@ +package com.memory.app.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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 androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.memory.app.ui.components.PostCard +import com.memory.app.ui.viewmodel.HomeViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onPostClick: (Long) -> Unit, + onCreatePost: () -> Unit, + viewModel: HomeViewModel = viewModel() +) { + val posts by viewModel.posts.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + viewModel.loadPosts() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Memory", + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + color = MaterialTheme.colorScheme.onBackground + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onCreatePost, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Default.Add, contentDescription = "发帖") + } + } + ) { padding -> + PullToRefreshBox( + isRefreshing = isLoading && posts.isNotEmpty(), + onRefresh = { viewModel.refresh() }, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (isLoading && posts.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (posts.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "📝", + fontSize = 48.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "还没有帖子", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "点击右下角按钮发布第一条", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items(posts, key = { it.id }) { post -> + PostCard( + post = post, + onPostClick = { onPostClick(post.id) }, + onLikeClick = { viewModel.toggleLike(post) }, + onCommentClick = { onPostClick(post.id) }, + onReactionClick = { emoji -> + if (emoji.isNotEmpty()) { + viewModel.toggleReaction(post, emoji) + } + } + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + thickness = 0.5.dp + ) + } + + // 加载更多 + item { + LaunchedEffect(Unit) { + viewModel.loadMore() + } + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/screen/LoginScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/LoginScreen.kt new file mode 100644 index 0000000..f778bda --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/LoginScreen.kt @@ -0,0 +1,131 @@ +package com.memory.app.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +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.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun LoginScreen( + onLogin: (String, String) -> Unit, + onRegister: (String, String, String) -> Unit, + isLoading: Boolean, + error: String? +) { + var isLoginMode by remember { mutableStateOf(true) } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var nickname by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Memory", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "记录生活的点点滴滴", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(48.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("用户名") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("密码") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + + if (!isLoginMode) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it }, + label = { Text("昵称") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + + if (error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (isLoginMode) { + onLogin(username, password) + } else { + onRegister(username, password, nickname) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + enabled = !isLoading && username.isNotBlank() && password.isNotBlank() && + (isLoginMode || nickname.isNotBlank()) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text( + text = if (isLoginMode) "登录" else "注册", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = { isLoginMode = !isLoginMode }) { + Text( + text = if (isLoginMode) "没有账号?立即注册" else "已有账号?立即登录", + color = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt new file mode 100644 index 0000000..8f222e5 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt @@ -0,0 +1,549 @@ +package com.memory.app.ui.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +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.automirrored.filled.Send +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.FavoriteBorder +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.memory.app.data.model.Comment +import com.memory.app.data.model.Post +import com.memory.app.util.TimeUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostDetailScreen( + post: Post?, + comments: List, + isLoading: Boolean, + currentUserId: Long, + onBack: () -> Unit, + onLike: () -> Unit, + onComment: (String) -> Unit, + onDeletePost: () -> Unit, + onDeleteComment: (Long) -> Unit, + onReaction: (String) -> Unit, + onImageClick: (String) -> Unit +) { + var commentText by remember { mutableStateOf("") } + var showDeleteDialog by remember { mutableStateOf(false) } + var showEmojiPicker by remember { mutableStateOf(false) } + var showImageViewer by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") + } + }, + title = { Text("帖子详情") }, + actions = { + if (post?.userId == currentUserId) { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, "删除", tint = Color(0xFFE53935)) + } + } + } + ) + }, + bottomBar = { + Surface( + tonalElevation = 3.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = commentText, + onValueChange = { commentText = it }, + placeholder = { Text("写评论...") }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(24.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ), + maxLines = 3 + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + if (commentText.isNotBlank()) { + onComment(commentText) + commentText = "" + } + }, + enabled = commentText.isNotBlank() + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + "发送", + tint = if (commentText.isNotBlank()) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + ) { padding -> + if (isLoading && post == null) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (post != null) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // 帖子内容 + item { + PostContent( + post = post, + onLike = onLike, + onReaction = onReaction, + onEmojiPicker = { showEmojiPicker = true }, + onImageClick = { showImageViewer = it } + ) + HorizontalDivider(thickness = 8.dp, color = MaterialTheme.colorScheme.surfaceVariant) + } + + // 评论标题 + item { + Text( + text = "评论 (${comments.size})", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp) + ) + } + + // 评论列表 + items(comments, key = { it.id }) { comment -> + CommentItem( + comment = comment, + canDelete = comment.userId == currentUserId, + onDelete = { onDeleteComment(comment.id) } + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + } + + if (comments.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + "还没有评论,快来抢沙发吧!", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + // 删除确认对话框 + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("删除帖子") }, + text = { Text("确定要删除这条帖子吗?此操作不可撤销。") }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + onDeletePost() + }, + colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935)) + ) { + Text("删除") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("取消") + } + } + ) + } + + // 表情选择器 + if (showEmojiPicker) { + EmojiPickerDialog( + onDismiss = { showEmojiPicker = false }, + onEmojiSelected = { emoji -> + onReaction(emoji) + showEmojiPicker = false + } + ) + } + + // 图片查看器 + showImageViewer?.let { imageUrl -> + ImageViewerDialog( + imageUrl = imageUrl, + onDismiss = { showImageViewer = null } + ) + } +} + +@Composable +private fun PostContent( + post: Post, + onLike: () -> Unit, + onReaction: (String) -> Unit, + onEmojiPicker: () -> Unit, + onImageClick: (String) -> Unit +) { + Column(modifier = Modifier.padding(16.dp)) { + // 用户信息 + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + if (post.user?.avatarUrl?.isNotEmpty() == true) { + AsyncImage( + model = post.user.avatarUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = post.user?.nickname?.firstOrNull()?.toString() ?: "?", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = post.user?.nickname ?: "", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + text = "@${post.user?.username} · ${TimeUtils.formatRelative(post.createdAt)}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 内容 + Text( + text = post.content, + fontSize = 16.sp, + lineHeight = 24.sp + ) + + // 图片 + if (post.media.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + post.media.forEach { media -> + AsyncImage( + model = media.mediaUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onImageClick(media.mediaUrl) }, + contentScale = ContentScale.FillWidth + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // 表情反应 + if (post.reactions.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + post.reactions.forEach { reaction -> + Surface( + onClick = { onReaction(reaction.emoji) }, + shape = RoundedCornerShape(50), + color = if (reaction.reacted) + MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else + MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = reaction.emoji, fontSize = 16.sp) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = reaction.count.toString(), fontSize = 14.sp) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 操作按钮 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { } + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = "评论", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = post.commentCount.toString(), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onLike() } + ) { + Icon( + if (post.liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = "点赞", + modifier = Modifier.size(20.dp), + tint = if (post.liked) Color(0xFFF91880) else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = post.likeCount.toString(), + color = if (post.liked) Color(0xFFF91880) else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onEmojiPicker() } + ) { + Text(text = "😊", fontSize = 20.sp) + } + } + } +} + +@Composable +private fun CommentItem( + comment: Comment, + canDelete: Boolean, + onDelete: () -> Unit +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + if (comment.user?.avatarUrl?.isNotEmpty() == true) { + AsyncImage( + model = comment.user.avatarUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = comment.user?.nickname?.firstOrNull()?.toString() ?: "?", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = comment.user?.nickname ?: "", + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = " · ${TimeUtils.formatRelative(comment.createdAt)}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = comment.content, + fontSize = 14.sp, + lineHeight = 20.sp + ) + } + + if (canDelete) { + IconButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "删除", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("删除评论") }, + text = { Text("确定要删除这条评论吗?") }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + onDelete() + }, + colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935)) + ) { + Text("删除") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +fun EmojiPickerDialog( + onDismiss: () -> Unit, + onEmojiSelected: (String) -> Unit +) { + val emojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉", "🔥", "👏", "🤔", "💯", "✨") + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "选择表情", + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + modifier = Modifier.padding(bottom = 16.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + emojis.take(6).forEach { emoji -> + Text( + text = emoji, + fontSize = 28.sp, + modifier = Modifier + .clickable { onEmojiSelected(emoji) } + .padding(8.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + emojis.drop(6).forEach { emoji -> + Text( + text = emoji, + fontSize = 28.sp, + modifier = Modifier + .clickable { onEmojiSelected(emoji) } + .padding(8.dp) + ) + } + } + } + } + } +} + +@Composable +fun ImageViewerDialog( + imageUrl: String, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit + ) + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/screen/ProfileScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/ProfileScreen.kt new file mode 100644 index 0000000..b0ee86d --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/ProfileScreen.kt @@ -0,0 +1,257 @@ +package com.memory.app.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +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.layout.ContentScale +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 coil.compose.AsyncImage +import com.memory.app.data.model.User + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + user: User?, + postCount: Int, + likeCount: Int, + onEditProfile: () -> Unit, + onLogout: () -> Unit, + onUpdateNickname: ((String) -> Unit)? = null, + onUpdateAvatar: ((Uri) -> Unit)? = null +) { + var isEditing by remember { mutableStateOf(false) } + var editedNickname by remember(user) { mutableStateOf(user?.nickname ?: "") } + var selectedAvatarUri by remember { mutableStateOf(null) } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { + selectedAvatarUri = it + onUpdateAvatar?.invoke(it) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("我的", fontWeight = FontWeight.SemiBold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ), + actions = { + if (isEditing) { + IconButton(onClick = { + isEditing = false + editedNickname = user?.nickname ?: "" + }) { + Icon(Icons.Default.Close, "取消") + } + IconButton(onClick = { + onUpdateNickname?.invoke(editedNickname) + isEditing = false + }) { + Icon( + Icons.Default.Check, + "保存", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // 头像 + Box(contentAlignment = Alignment.BottomEnd) { + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .border(3.dp, MaterialTheme.colorScheme.outline, CircleShape) + .clickable { imagePicker.launch("image/*") }, + contentAlignment = Alignment.Center + ) { + when { + selectedAvatarUri != null -> { + AsyncImage( + model = selectedAvatarUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + user?.avatarUrl?.isNotEmpty() == true -> { + AsyncImage( + model = user.avatarUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + else -> { + Text( + text = user?.nickname?.firstOrNull()?.toString() ?: "?", + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + // 相机图标 + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .clickable { imagePicker.launch("image/*") }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.CameraAlt, + contentDescription = "更换头像", + modifier = Modifier.size(18.dp), + tint = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 昵称 + if (isEditing) { + OutlinedTextField( + value = editedNickname, + onValueChange = { editedNickname = it }, + modifier = Modifier.width(200.dp), + textStyle = LocalTextStyle.current.copy( + textAlign = TextAlign.Center, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold + ), + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { isEditing = true } + ) { + Text( + text = user?.nickname ?: "", + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + Icons.Default.Edit, + contentDescription = "编辑昵称", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "@${user?.username ?: ""}", + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // 统计数据 + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatColumn(count = postCount, label = "帖子") + Box( + modifier = Modifier + .width(1.dp) + .height(40.dp) + .background(MaterialTheme.colorScheme.outline) + ) + StatColumn(count = likeCount, label = "获赞") + } + + Spacer(modifier = Modifier.weight(1f)) + + // 退出登录按钮 + OutlinedButton( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + border = ButtonDefaults.outlinedButtonBorder(enabled = true) + ) { + Text( + "退出登录", + modifier = Modifier.padding(vertical = 4.dp), + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun StatColumn(count: Int, label: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = count.toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/screen/SearchScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/SearchScreen.kt new file mode 100644 index 0000000..ef25f6a --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/screen/SearchScreen.kt @@ -0,0 +1,269 @@ +package com.memory.app.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +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 androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.memory.app.ui.components.PostCard +import com.memory.app.ui.viewmodel.SearchViewModel +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + onPostClick: (Long) -> Unit = {}, + viewModel: SearchViewModel = viewModel() +) { + val searchQuery by viewModel.searchQuery.collectAsState() + val posts by viewModel.posts.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val startDate by viewModel.startDate.collectAsState() + val endDate by viewModel.endDate.collectAsState() + + var showStartDatePicker by remember { mutableStateOf(false) } + var showEndDatePicker by remember { mutableStateOf(false) } + + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + Scaffold( + topBar = { + TopAppBar( + title = { Text("搜索", fontWeight = FontWeight.SemiBold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // 搜索框 + OutlinedTextField( + value = searchQuery, + onValueChange = { viewModel.updateQuery(it) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("搜索内容...") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.updateQuery("") }) { + Icon(Icons.Default.Close, contentDescription = "清除") + } + } + }, + shape = RoundedCornerShape(12.dp), + singleLine = true + ) + + // 日期筛选 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 开始日期 + OutlinedCard( + onClick = { showStartDatePicker = true }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CalendarMonth, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = startDate?.format(dateFormatter) ?: "开始日期", + fontSize = 14.sp, + color = if (startDate != null) + MaterialTheme.colorScheme.onBackground + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 结束日期 + OutlinedCard( + onClick = { showEndDatePicker = true }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CalendarMonth, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = endDate?.format(dateFormatter) ?: "结束日期", + fontSize = 14.sp, + color = if (endDate != null) + MaterialTheme.colorScheme.onBackground + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // 清除日期筛选 + if (startDate != null || endDate != null) { + TextButton( + onClick = { viewModel.clearDateFilter() }, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text("清除日期筛选") + } + } + + // 搜索按钮 + Button( + onClick = { viewModel.search() }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text("搜索", modifier = Modifier.padding(vertical = 4.dp)) + } + + HorizontalDivider( + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.outline + ) + + // 搜索结果 + Box(modifier = Modifier.fillMaxSize()) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + posts.isEmpty() -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "🔍", + fontSize = 48.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "输入关键词或选择日期范围", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + LazyColumn { + items(posts, key = { it.id }) { post -> + PostCard( + post = post, + onPostClick = { onPostClick(post.id) }, + onLikeClick = { }, + onCommentClick = { onPostClick(post.id) }, + onReactionClick = { } + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + thickness = 0.5.dp + ) + } + } + } + } + } + } + } + + // 日期选择器 + if (showStartDatePicker) { + DatePickerDialog( + onDismiss = { showStartDatePicker = false }, + onDateSelected = { date -> + viewModel.updateStartDate(date) + showStartDatePicker = false + } + ) + } + + if (showEndDatePicker) { + DatePickerDialog( + onDismiss = { showEndDatePicker = false }, + onDateSelected = { date -> + viewModel.updateEndDate(date) + showEndDatePicker = false + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerDialog( + onDismiss: () -> Unit, + onDateSelected: (LocalDate) -> Unit +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val date = java.time.Instant.ofEpochMilli(millis) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate() + onDateSelected(date) + } + } + ) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) { + DatePicker(state = datePickerState) + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/theme/Theme.kt b/android/app/src/main/java/com/memory/app/ui/theme/Theme.kt new file mode 100644 index 0000000..21f32bb --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/theme/Theme.kt @@ -0,0 +1,55 @@ +package com.memory.app.ui.theme + +import android.app.Activity +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +// X/Twitter 风格浅色主题 +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF1D9BF0), + onPrimary = Color.White, + primaryContainer = Color(0xFFE8F5FD), + secondary = Color(0xFFF91880), + onSecondary = Color.White, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF0F1419), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF0F1419), + surfaceVariant = Color(0xFFF7F9F9), + onSurfaceVariant = Color(0xFF536471), + outline = Color(0xFFEFF3F4), + outlineVariant = Color(0xFFCFD9DE), + error = Color(0xFFF4212E), + onError = Color.White +) + +@Composable +fun MemoryTheme( + content: @Composable () -> Unit +) { + val colorScheme = LightColorScheme + val view = LocalView.current + + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = true + isAppearanceLightNavigationBars = true + } + } + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/ArchiveViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/ArchiveViewModel.kt new file mode 100644 index 0000000..c8d033c --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/ArchiveViewModel.kt @@ -0,0 +1,150 @@ +package com.memory.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.memory.app.data.api.ApiClient +import com.memory.app.data.model.HeatmapData +import com.memory.app.data.model.Post +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class ArchiveViewModel : ViewModel() { + private val _heatmapData = MutableStateFlow>(emptyMap()) + val heatmapData: StateFlow> = _heatmapData + + private val _selectedYear = MutableStateFlow(LocalDate.now().year) + val selectedYear: StateFlow = _selectedYear + + // 季度:1-4 + private val _selectedQuarter = MutableStateFlow((LocalDate.now().monthValue - 1) / 3 + 1) + val selectedQuarter: StateFlow = _selectedQuarter + + private val _selectedDatePosts = MutableStateFlow>(emptyList()) + val selectedDatePosts: StateFlow> = _selectedDatePosts + + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _totalPosts = MutableStateFlow(0) + val totalPosts: StateFlow = _totalPosts + + private val _maxDay = MutableStateFlow?>(null) + val maxDay: StateFlow?> = _maxDay + + private val _currentStreak = MutableStateFlow(0) + val currentStreak: StateFlow = _currentStreak + + fun loadHeatmap() { + viewModelScope.launch { + _isLoading.value = true + try { + val response = ApiClient.api.getHeatmap(_selectedYear.value.toString()) + if (response.isSuccessful) { + val data = response.body() ?: emptyList() + _heatmapData.value = data.associate { it.date to it.count } + + // 计算统计数据 + _totalPosts.value = data.sumOf { it.count } + _maxDay.value = data.maxByOrNull { it.count }?.let { it.date to it.count } + _currentStreak.value = calculateStreak(data) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } + + fun selectYear(year: Int) { + _selectedYear.value = year + loadHeatmap() + } + + fun selectQuarter(quarter: Int) { + _selectedQuarter.value = quarter + } + + fun previousQuarter() { + if (_selectedQuarter.value > 1) { + _selectedQuarter.value-- + } else if (_selectedYear.value > 2020) { + _selectedYear.value-- + _selectedQuarter.value = 4 + loadHeatmap() + } + } + + fun nextQuarter() { + val now = LocalDate.now() + val currentQuarter = (now.monthValue - 1) / 3 + 1 + + if (_selectedYear.value < now.year || + (_selectedYear.value == now.year && _selectedQuarter.value < currentQuarter)) { + if (_selectedQuarter.value < 4) { + _selectedQuarter.value++ + } else { + _selectedYear.value++ + _selectedQuarter.value = 1 + loadHeatmap() + } + } + } + + fun canGoNext(): Boolean { + val now = LocalDate.now() + val currentQuarter = (now.monthValue - 1) / 3 + 1 + return _selectedYear.value < now.year || + (_selectedYear.value == now.year && _selectedQuarter.value < currentQuarter) + } + + fun selectDate(date: String) { + _selectedDate.value = date + loadPostsForDate(date) + } + + fun clearSelectedDate() { + _selectedDate.value = null + _selectedDatePosts.value = emptyList() + } + + private fun loadPostsForDate(date: String) { + viewModelScope.launch { + try { + val response = ApiClient.api.search( + startDate = date, + endDate = date + ) + if (response.isSuccessful) { + _selectedDatePosts.value = response.body() ?: emptyList() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun calculateStreak(data: List): Int { + if (data.isEmpty()) return 0 + + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val today = LocalDate.now() + var streak = 0 + var currentDate = today + + val dateSet = data.filter { it.count > 0 }.map { it.date }.toSet() + + while (dateSet.contains(currentDate.format(dateFormatter))) { + streak++ + currentDate = currentDate.minusDays(1) + } + + return streak + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..c227648 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt @@ -0,0 +1,164 @@ +package com.memory.app.ui.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.memory.app.data.api.ApiClient +import com.memory.app.data.model.AddReactionRequest +import com.memory.app.data.model.CreatePostRequest +import com.memory.app.data.model.Post +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +class HomeViewModel : ViewModel() { + private val _posts = MutableStateFlow>(emptyList()) + val posts: StateFlow> = _posts + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _isPosting = MutableStateFlow(false) + val isPosting: StateFlow = _isPosting + + private val _postSuccess = MutableStateFlow(false) + val postSuccess: StateFlow = _postSuccess + + private var currentPage = 1 + private var hasMore = true + + fun loadPosts() { + if (_isLoading.value) return + viewModelScope.launch { + _isLoading.value = true + try { + val response = ApiClient.api.getPosts(page = 1) + if (response.isSuccessful) { + _posts.value = response.body() ?: emptyList() + currentPage = 1 + hasMore = (response.body()?.size ?: 0) >= 20 + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } + + fun loadMore() { + if (_isLoading.value || !hasMore) return + viewModelScope.launch { + _isLoading.value = true + try { + val response = ApiClient.api.getPosts(page = currentPage + 1) + if (response.isSuccessful) { + val newPosts = response.body() ?: emptyList() + _posts.value = _posts.value + newPosts + currentPage++ + hasMore = newPosts.size >= 20 + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } + + fun toggleLike(post: Post) { + viewModelScope.launch { + try { + val response = if (post.liked) { + ApiClient.api.unlikePost(post.id) + } else { + ApiClient.api.likePost(post.id) + } + if (response.isSuccessful) { + _posts.value = _posts.value.map { + if (it.id == post.id) { + it.copy( + liked = !post.liked, + likeCount = if (post.liked) post.likeCount - 1 else post.likeCount + 1 + ) + } else it + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun toggleReaction(post: Post, emoji: String) { + viewModelScope.launch { + try { + val existingReaction = post.reactions.find { it.emoji == emoji && it.reacted } + val response = if (existingReaction != null) { + ApiClient.api.removeReaction(post.id, emoji) + } else { + ApiClient.api.addReaction(post.id, AddReactionRequest(emoji)) + } + if (response.isSuccessful) { + // 刷新帖子 + loadPosts() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun refresh() { + loadPosts() + } + + fun createPost(context: Context, content: String, images: List) { + if (_isPosting.value) return + viewModelScope.launch { + _isPosting.value = true + _postSuccess.value = false + try { + // 先上传图片 + val imageUrls = mutableListOf() + for (uri in images) { + val inputStream = context.contentResolver.openInputStream(uri) + val bytes = inputStream?.readBytes() ?: continue + inputStream.close() + + val fileName = "image_${System.currentTimeMillis()}.jpg" + val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", fileName, requestBody) + + val uploadResponse = ApiClient.api.upload(part) + if (uploadResponse.isSuccessful) { + uploadResponse.body()?.url?.let { imageUrls.add(it) } + } + } + + // 创建帖子 + val request = CreatePostRequest( + content = content, + mediaIds = imageUrls + ) + val response = ApiClient.api.createPost(request) + if (response.isSuccessful) { + _postSuccess.value = true + loadPosts() // 刷新列表 + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isPosting.value = false + } + } + } + + fun resetPostSuccess() { + _postSuccess.value = false + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/PostDetailViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/PostDetailViewModel.kt new file mode 100644 index 0000000..bf3fc60 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/PostDetailViewModel.kt @@ -0,0 +1,149 @@ +package com.memory.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.memory.app.data.api.ApiClient +import com.memory.app.data.model.AddReactionRequest +import com.memory.app.data.model.Comment +import com.memory.app.data.model.CreateCommentRequest +import com.memory.app.data.model.Post +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class PostDetailViewModel : ViewModel() { + private val _post = MutableStateFlow(null) + val post: StateFlow = _post + + private val _comments = MutableStateFlow>(emptyList()) + val comments: StateFlow> = _comments + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _isDeleted = MutableStateFlow(false) + val isDeleted: StateFlow = _isDeleted + + private var postId: Long = 0 + + fun loadPost(id: Long) { + postId = id + viewModelScope.launch { + _isLoading.value = true + try { + val response = ApiClient.api.getPost(id) + if (response.isSuccessful) { + _post.value = response.body() + } + loadComments() + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } + + private fun loadComments() { + viewModelScope.launch { + try { + val response = ApiClient.api.getComments(postId) + if (response.isSuccessful) { + _comments.value = response.body() ?: emptyList() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun toggleLike() { + val currentPost = _post.value ?: return + viewModelScope.launch { + try { + val response = if (currentPost.liked) { + ApiClient.api.unlikePost(postId) + } else { + ApiClient.api.likePost(postId) + } + if (response.isSuccessful) { + _post.value = currentPost.copy( + liked = !currentPost.liked, + likeCount = if (currentPost.liked) currentPost.likeCount - 1 else currentPost.likeCount + 1 + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun addComment(content: String) { + viewModelScope.launch { + try { + val response = ApiClient.api.createComment(postId, CreateCommentRequest(content)) + if (response.isSuccessful) { + loadComments() + // 更新评论数 + _post.value = _post.value?.copy( + commentCount = _post.value!!.commentCount + 1 + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun deleteComment(commentId: Long) { + viewModelScope.launch { + try { + val response = ApiClient.api.deleteComment(postId, commentId) + if (response.isSuccessful) { + _comments.value = _comments.value.filter { it.id != commentId } + _post.value = _post.value?.copy( + commentCount = _post.value!!.commentCount - 1 + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun deletePost() { + viewModelScope.launch { + try { + val response = ApiClient.api.deletePost(postId) + if (response.isSuccessful) { + _isDeleted.value = true + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun toggleReaction(emoji: String) { + val currentPost = _post.value ?: return + viewModelScope.launch { + try { + val existingReaction = currentPost.reactions.find { it.emoji == emoji && it.reacted } + val response = if (existingReaction != null) { + ApiClient.api.removeReaction(postId, emoji) + } else { + ApiClient.api.addReaction(postId, AddReactionRequest(emoji)) + } + if (response.isSuccessful) { + // 重新加载帖子获取最新反应 + val postResponse = ApiClient.api.getPost(postId) + if (postResponse.isSuccessful) { + _post.value = postResponse.body() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/ProfileViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/ProfileViewModel.kt new file mode 100644 index 0000000..5e6d3be --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/ProfileViewModel.kt @@ -0,0 +1,94 @@ +package com.memory.app.ui.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.memory.app.data.api.ApiClient +import com.memory.app.data.model.UpdateProfileRequest +import com.memory.app.data.model.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +class ProfileViewModel : ViewModel() { + private val _user = MutableStateFlow(null) + val user: StateFlow = _user + + private val _postCount = MutableStateFlow(0) + val postCount: StateFlow = _postCount + + private val _likeCount = MutableStateFlow(0) + val likeCount: StateFlow = _likeCount + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + fun loadProfile() { + viewModelScope.launch { + _isLoading.value = true + try { + val response = ApiClient.api.getProfile() + if (response.isSuccessful) { + response.body()?.let { + _user.value = it.user + _postCount.value = it.postCount + _likeCount.value = it.likeCount + } + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } + + fun updateNickname(nickname: String) { + viewModelScope.launch { + try { + val response = ApiClient.api.updateProfile(UpdateProfileRequest(nickname = nickname)) + if (response.isSuccessful) { + _user.value = _user.value?.copy(nickname = nickname) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun updateAvatar(context: Context, uri: Uri) { + viewModelScope.launch { + try { + // 上传图片 + val inputStream = context.contentResolver.openInputStream(uri) + val bytes = inputStream?.readBytes() ?: return@launch + inputStream.close() + + val fileName = "avatar_${System.currentTimeMillis()}.jpg" + val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", fileName, requestBody) + + val uploadResponse = ApiClient.api.upload(part) + if (uploadResponse.isSuccessful) { + val avatarUrl = uploadResponse.body()?.url ?: return@launch + + // 更新头像 + val updateResponse = ApiClient.api.updateAvatar(mapOf("avatar_url" to avatarUrl)) + if (updateResponse.isSuccessful) { + _user.value = _user.value?.copy(avatarUrl = avatarUrl) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun setUser(user: User?) { + _user.value = user + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/SearchViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/SearchViewModel.kt new file mode 100644 index 0000000..c1e8b12 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/SearchViewModel.kt @@ -0,0 +1,67 @@ +package com.memory.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.memory.app.data.api.ApiClient +import com.memory.app.data.model.Post +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class SearchViewModel : ViewModel() { + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery + + private val _startDate = MutableStateFlow(null) + val startDate: StateFlow = _startDate + + private val _endDate = MutableStateFlow(null) + val endDate: StateFlow = _endDate + + private val _posts = MutableStateFlow>(emptyList()) + val posts: StateFlow> = _posts + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + fun updateQuery(query: String) { + _searchQuery.value = query + } + + fun updateStartDate(date: LocalDate?) { + _startDate.value = date + } + + fun updateEndDate(date: LocalDate?) { + _endDate.value = date + } + + fun clearDateFilter() { + _startDate.value = null + _endDate.value = null + } + + fun search() { + viewModelScope.launch { + _isLoading.value = true + try { + val response = ApiClient.api.search( + query = _searchQuery.value.takeIf { it.isNotBlank() }, + startDate = _startDate.value?.format(dateFormatter), + endDate = _endDate.value?.format(dateFormatter) + ) + if (response.isSuccessful) { + _posts.value = response.body() ?: emptyList() + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/util/TimeUtils.kt b/android/app/src/main/java/com/memory/app/util/TimeUtils.kt new file mode 100644 index 0000000..3e438fd --- /dev/null +++ b/android/app/src/main/java/com/memory/app/util/TimeUtils.kt @@ -0,0 +1,49 @@ +package com.memory.app.util + +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +object TimeUtils { + private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME + + fun formatRelative(dateString: String): String { + return try { + val date = ZonedDateTime.parse(dateString, isoFormatter) + val now = ZonedDateTime.now() + val minutes = ChronoUnit.MINUTES.between(date, now) + val hours = ChronoUnit.HOURS.between(date, now) + val days = ChronoUnit.DAYS.between(date, now) + + when { + minutes < 1 -> "刚刚" + minutes < 60 -> "${minutes}分钟" + hours < 24 -> "${hours}小时" + days < 7 -> "${days}天" + date.year == now.year -> date.format(DateTimeFormatter.ofPattern("M月d日")) + else -> date.format(DateTimeFormatter.ofPattern("yyyy年M月d日")) + } + } catch (e: Exception) { + dateString + } + } + + fun formatDate(dateString: String): String { + return try { + val date = ZonedDateTime.parse(dateString, isoFormatter) + date.format(DateTimeFormatter.ofPattern("yyyy年M月d日 HH:mm")) + } catch (e: Exception) { + dateString + } + } + + fun formatMonth(dateString: String): String { + return try { + val date = LocalDateTime.parse(dateString.replace("Z", "")) + date.format(DateTimeFormatter.ofPattern("yyyy年M月")) + } catch (e: Exception) { + dateString + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..f219884 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-mdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..cdde88f --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #000000 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..09e0fcf --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..da0c673 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..7b73b87 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,12 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# AndroidX +android.useAndroidX=true +android.enableJetifier=true + +# Kotlin +kotlin.code.style=official + +# Enable build cache +org.gradle.caching=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ff01ed7 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..54b8d65 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/central") } + maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/central") } + maven { url = uri("https://maven.aliyun.com/repository/public") } + google() + mavenCentral() + } +} + +rootProject.name = "Memory" +include(":app") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1091dd4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + memory: + build: ./server + container_name: memory-server + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/app/data + environment: + - SERVER_ADDR=:8080 + - DB_PATH=/app/data/memory.db + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - R2_ACCOUNT_ID=${R2_ACCOUNT_ID} + - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID} + - R2_ACCESS_KEY_SECRET=${R2_ACCESS_KEY_SECRET} + - R2_BUCKET_NAME=${R2_BUCKET_NAME:-memory} + - R2_PUBLIC_URL=${R2_PUBLIC_URL} diff --git a/memory-ui-preview.html b/memory-ui-preview.html new file mode 100644 index 0000000..c8ad051 --- /dev/null +++ b/memory-ui-preview.html @@ -0,0 +1,509 @@ + + + + + + Memory - UI Preview + + + + + + + +
+
首页 - 时间线
+
+
9:41
+
Memory
+
+ +
+
M
+
+
+ Memory + @memory_user + · + 5分钟 +
+
今天天气真好,出门散步拍了几张照片 📸
+
+
+
+
+
+
❤️12
+
👍5
+
😊3
+
+
+
8
+ +
+
+
+
+ +
+
M
+
+
+ Memory + @memory_user + · + 11月12日 +
+
记录一下今天的午餐,自己做的意面 🍝
+
+
+
+
+
😋8
+
👏4
+
+
+
3
+ +
+
+
+
+ +
+
M
+
+
+ Memory + @memory_user + · + 11月10日 +
+
新的一周开始了,给自己加油 💪
+
+
💪15
+
🔥6
+
+
+
5
+ +
+
+
+
+
+
+
+ + + + +
+
+
+ + +
+
帖子详情 - 评论区
+
+
9:41
+
+
+ 帖子 +
+
+
+
+
M
+
+
+ Memory + @memory_user + · + 5分钟 +
+
今天天气真好,出门散步拍了几张照片 📸
+
+
+
+
+
+
❤️12
+
👍5
+
😊3
+
+
+
8
+ +
+
+
+
+ +
+
+
A
+
+
+ Alice + @alice + · + 2分钟 +
+
照片拍得真好看!是在哪里拍的呀?
+
+
回复
+
2
+
+
+
+
+
B
+
+
+ Bob + @bob_dev + · + 1分钟 +
+
天气确实不错,适合出门走走 ☀️
+
+
回复
+
5
+
+
+
+
+
C
+
+
+ Charlie + @charlie + · + 刚刚 +
+
羡慕!我这边下雨了 🌧️
+
+
回复
+
1
+
+
+
+
+
+
+
M
+ +
+
+
+
+ + +
+
发帖页面
+
+
9:41
+
+
+ +
+
+
M
+
+ +
+
+
+
+
+
+
+ +
点击上传图片或视频
+
+
+
+ + + +
+
我的页面
+
+
9:41
+
+ 我的 +
+
+
+
+
+ M +
+ +
+
+
+
+
+
+ Memory 用户 +
+ +
+
+
@memory_user
+
记录生活的点点滴滴 ✨ 用 Memory 留住美好时光
+
+
128帖子
+
1,024获赞
+
+
+ + 2024年11月 加入 +
+
+
+
设置
+
+
+
+ 编辑个人资料 +
+
+
+
+
+
+ 隐私设置 +
+
+
+
+
+
+ 数据备份 +
+
+
+
+
+
+ 帮助与反馈 +
+
+
+
+
退出登录
+
+
+ + + + +
+
+
+ + diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..1e45dd2 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,15 @@ +# Server +SERVER_ADDR=:8080 +DB_PATH=./data/memory.db +JWT_SECRET=your-secret-key-change-in-production +BASE_URL=http://192.168.0.100:8080 + +# 本地存储 (优先使用,留空则使用R2) +LOCAL_UPLOAD_PATH=./uploads + +# Cloudflare R2 (可选,LOCAL_UPLOAD_PATH为空时使用) +R2_ACCOUNT_ID=your-account-id +R2_ACCESS_KEY_ID=your-access-key-id +R2_ACCESS_KEY_SECRET=your-access-key-secret +R2_BUCKET_NAME=memory +R2_PUBLIC_URL=https://your-bucket.r2.dev diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..24b7feb --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o memory . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates sqlite + +WORKDIR /app +COPY --from=builder /app/memory . + +EXPOSE 8080 +CMD ["./memory"] diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..985ec99 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,55 @@ +module memory + +go 1.21 + +require ( + github.com/aws/aws-sdk-go-v2 v1.24.0 + github.com/aws/aws-sdk-go-v2/config v1.26.1 + github.com/aws/aws-sdk-go-v2/credentials v1.16.12 + github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/google/uuid v1.5.0 + github.com/mattn/go-sqlite3 v1.14.19 + golang.org/x/crypto v0.17.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect + github.com/aws/smithy-go v1.19.0 // indirect + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..576e857 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,128 @@ +github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= +github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= +github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= +github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..6813332 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" +) + +type Config struct { + ServerAddr string + DBPath string + JWTSecret string + BaseURL string + + // 本地存储 + LocalUploadPath string + + // Cloudflare R2 + R2AccountID string + R2AccessKeyID string + R2AccessKeySecret string + R2BucketName string + R2PublicURL string +} + +func Load() *Config { + return &Config{ + ServerAddr: getEnv("SERVER_ADDR", ":8080"), + DBPath: getEnv("DB_PATH", "./data/memory.db"), + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + BaseURL: getEnv("BASE_URL", "http://localhost:8080"), + LocalUploadPath: getEnv("LOCAL_UPLOAD_PATH", "./uploads"), + R2AccountID: getEnv("R2_ACCOUNT_ID", ""), + R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""), + R2AccessKeySecret: getEnv("R2_ACCESS_KEY_SECRET", ""), + R2BucketName: getEnv("R2_BUCKET_NAME", "memory"), + R2PublicURL: getEnv("R2_PUBLIC_URL", ""), + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/server/internal/database/database.go b/server/internal/database/database.go new file mode 100644 index 0000000..9176a74 --- /dev/null +++ b/server/internal/database/database.go @@ -0,0 +1,115 @@ +package database + +import ( + "database/sql" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) + +func Init(dbPath string) (*sql.DB, error) { + // 确保目录存在 + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, err + } + + if err := migrate(db); err != nil { + return nil, err + } + + return db, nil +} + +func migrate(db *sql.DB) error { + schema := ` + -- 用户表 + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + nickname TEXT NOT NULL, + avatar_url TEXT DEFAULT '', + bio TEXT DEFAULT '', + is_admin INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- 系统配置表 + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + -- 帖子表 + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + -- 帖子媒体表 + CREATE TABLE IF NOT EXISTS post_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + media_url TEXT NOT NULL, + media_type TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE + ); + + -- 评论表 + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + -- 点赞表 + CREATE TABLE IF NOT EXISTS likes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, user_id), + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + -- 表情反应表 + CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(post_id, user_id, emoji), + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + -- 索引 + CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); + CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at); + CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments(post_id); + CREATE INDEX IF NOT EXISTS idx_likes_post_id ON likes(post_id); + CREATE INDEX IF NOT EXISTS idx_reactions_post_id ON reactions(post_id); + + -- 初始化默认设置 + INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_register', 'true'); + ` + _, err := db.Exec(schema) + return err +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go new file mode 100644 index 0000000..9bf2a91 --- /dev/null +++ b/server/internal/handler/auth.go @@ -0,0 +1,147 @@ +package handler + +import ( + "database/sql" + "net/http" + "time" + + "memory/internal/config" + "memory/internal/middleware" + "memory/internal/model" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type AuthHandler struct { + db *sql.DB + cfg *config.Config +} + +func NewAuthHandler(db *sql.DB, cfg *config.Config) *AuthHandler { + return &AuthHandler{db: db, cfg: cfg} +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req model.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查是否允许注册 + var allowRegister string + err := h.db.QueryRow("SELECT value FROM settings WHERE key = 'allow_register'").Scan(&allowRegister) + if err == nil && allowRegister == "false" { + // 检查是否有用户存在(第一个用户可以注册) + var count int + h.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"}) + return + } + } + + // 检查用户名是否已存在 + var exists int + h.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", req.Username).Scan(&exists) + if exists > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "username already exists"}) + return + } + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + + // 检查是否是第一个用户(设为管理员) + var userCount int + h.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) + isAdmin := userCount == 0 + + // 创建用户 + result, err := h.db.Exec( + "INSERT INTO users (username, password_hash, nickname, is_admin) VALUES (?, ?, ?, ?)", + req.Username, string(hashedPassword), req.Nickname, isAdmin, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + + userID, _ := result.LastInsertId() + + // 生成 token + token, err := h.generateToken(userID, isAdmin) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + + c.JSON(http.StatusCreated, model.LoginResponse{ + Token: token, + User: &model.User{ + ID: userID, + Username: req.Username, + Nickname: req.Nickname, + IsAdmin: isAdmin, + }, + }) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req model.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user model.User + err := h.db.QueryRow( + "SELECT id, username, password_hash, nickname, avatar_url, bio, is_admin, created_at FROM users WHERE username = ?", + req.Username, + ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Nickname, &user.AvatarURL, &user.Bio, &user.IsAdmin, &user.CreatedAt) + + if err == sql.ErrNoRows { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + + token, err := h.generateToken(user.ID, user.IsAdmin) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + + c.JSON(http.StatusOK, model.LoginResponse{ + Token: token, + User: &user, + }) +} + +func (h *AuthHandler) generateToken(userID int64, isAdmin bool) (string, error) { + claims := &middleware.Claims{ + UserID: userID, + IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(h.cfg.JWTSecret)) +} diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go new file mode 100644 index 0000000..8526abb --- /dev/null +++ b/server/internal/handler/comment.go @@ -0,0 +1,119 @@ +package handler + +import ( + "database/sql" + "net/http" + "strconv" + + "memory/internal/config" + "memory/internal/middleware" + "memory/internal/model" + + "github.com/gin-gonic/gin" +) + +type CommentHandler struct { + db *sql.DB + cfg *config.Config +} + +func NewCommentHandler(db *sql.DB, cfg *config.Config) *CommentHandler { + return &CommentHandler{db: db, cfg: cfg} +} + +func (h *CommentHandler) Create(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + + var req model.CreateCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查帖子是否存在 + var exists int + err := h.db.QueryRow("SELECT COUNT(*) FROM posts WHERE id = ?", postID).Scan(&exists) + if err != nil || exists == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + + result, err := h.db.Exec( + "INSERT INTO comments (post_id, user_id, content) VALUES (?, ?, ?)", + postID, userID, req.Content, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create comment"}) + return + } + + commentID, _ := result.LastInsertId() + c.JSON(http.StatusCreated, gin.H{"id": commentID}) +} + +func (h *CommentHandler) List(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + offset := (page - 1) * pageSize + + rows, err := h.db.Query(` + SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, + u.id, u.username, u.nickname, u.avatar_url + FROM comments c + JOIN users u ON c.user_id = u.id + WHERE c.post_id = ? + ORDER BY c.created_at ASC + LIMIT ? OFFSET ? + `, postID, pageSize, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer rows.Close() + + comments := []model.Comment{} + for rows.Next() { + var comment model.Comment + var user model.User + err := rows.Scan( + &comment.ID, &comment.PostID, &comment.UserID, &comment.Content, &comment.CreatedAt, + &user.ID, &user.Username, &user.Nickname, &user.AvatarURL, + ) + if err != nil { + continue + } + comment.User = &user + comments = append(comments, comment) + } + + c.JSON(http.StatusOK, comments) +} + +func (h *CommentHandler) Delete(c *gin.Context) { + commentID, _ := strconv.ParseInt(c.Param("comment_id"), 10, 64) + userID := middleware.GetUserID(c) + isAdmin, _ := c.Get("is_admin") + + // 检查权限 + var commentUserID int64 + err := h.db.QueryRow("SELECT user_id FROM comments WHERE id = ?", commentID).Scan(&commentUserID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) + return + } + + if commentUserID != userID && !isAdmin.(bool) { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + _, err = h.db.Exec("DELETE FROM comments WHERE id = ?", commentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete comment"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/server/internal/handler/post.go b/server/internal/handler/post.go new file mode 100644 index 0000000..61cc0eb --- /dev/null +++ b/server/internal/handler/post.go @@ -0,0 +1,291 @@ +package handler + +import ( + "database/sql" + "net/http" + "strconv" + + "memory/internal/config" + "memory/internal/middleware" + "memory/internal/model" + + "github.com/gin-gonic/gin" +) + +type PostHandler struct { + db *sql.DB + cfg *config.Config +} + +func NewPostHandler(db *sql.DB, cfg *config.Config) *PostHandler { + return &PostHandler{db: db, cfg: cfg} +} + +func (h *PostHandler) Create(c *gin.Context) { + var req model.CreatePostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := middleware.GetUserID(c) + + tx, err := h.db.Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer tx.Rollback() + + result, err := tx.Exec("INSERT INTO posts (user_id, content) VALUES (?, ?)", userID, req.Content) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create post"}) + return + } + + postID, _ := result.LastInsertId() + + // 关联媒体文件 + for i, mediaURL := range req.MediaIDs { + _, err := tx.Exec( + "INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, 'image', ?)", + postID, mediaURL, i, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"}) + return + } + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"id": postID}) +} + +func (h *PostHandler) List(c *gin.Context) { + userID := middleware.GetUserID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + offset := (page - 1) * pageSize + + rows, err := h.db.Query(` + SELECT p.id, p.user_id, p.content, p.created_at, + u.id, u.username, u.nickname, u.avatar_url, + (SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count, + (SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as liked, + (SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count + FROM posts p + JOIN users u ON p.user_id = u.id + ORDER BY p.created_at DESC + LIMIT ? OFFSET ? + `, userID, pageSize, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer rows.Close() + + posts := []model.Post{} + for rows.Next() { + var post model.Post + var user model.User + var liked int + err := rows.Scan( + &post.ID, &post.UserID, &post.Content, &post.CreatedAt, + &user.ID, &user.Username, &user.Nickname, &user.AvatarURL, + &post.LikeCount, &liked, &post.CommentCount, + ) + if err != nil { + continue + } + post.User = &user + post.Liked = liked > 0 + + // 获取媒体 + post.Media = h.getPostMedia(post.ID) + // 获取表情反应 + post.Reactions = h.getPostReactions(post.ID, userID) + + posts = append(posts, post) + } + + c.JSON(http.StatusOK, posts) +} + +func (h *PostHandler) Get(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + + var post model.Post + var user model.User + var liked int + + err := h.db.QueryRow(` + SELECT p.id, p.user_id, p.content, p.created_at, + u.id, u.username, u.nickname, u.avatar_url, + (SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count, + (SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as liked, + (SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count + FROM posts p + JOIN users u ON p.user_id = u.id + WHERE p.id = ? + `, userID, postID).Scan( + &post.ID, &post.UserID, &post.Content, &post.CreatedAt, + &user.ID, &user.Username, &user.Nickname, &user.AvatarURL, + &post.LikeCount, &liked, &post.CommentCount, + ) + + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + post.User = &user + post.Liked = liked > 0 + post.Media = h.getPostMedia(post.ID) + post.Reactions = h.getPostReactions(post.ID, userID) + + c.JSON(http.StatusOK, post) +} + +func (h *PostHandler) Delete(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + isAdmin, _ := c.Get("is_admin") + + // 检查权限 + var postUserID int64 + err := h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postUserID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + + if postUserID != userID && !isAdmin.(bool) { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + _, err = h.db.Exec("DELETE FROM posts WHERE id = ?", postID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete post"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +func (h *PostHandler) Like(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + + _, err := h.db.Exec("INSERT OR IGNORE INTO likes (post_id, user_id) VALUES (?, ?)", postID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to like"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "liked"}) +} + +func (h *PostHandler) Unlike(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + + _, err := h.db.Exec("DELETE FROM likes WHERE post_id = ? AND user_id = ?", postID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unlike"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "unliked"}) +} + +func (h *PostHandler) AddReaction(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + + var req model.AddReactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + _, err := h.db.Exec( + "INSERT OR IGNORE INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)", + postID, userID, req.Emoji, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add reaction"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "reaction added"}) +} + +func (h *PostHandler) RemoveReaction(c *gin.Context) { + postID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + userID := middleware.GetUserID(c) + emoji := c.Query("emoji") + + _, err := h.db.Exec( + "DELETE FROM reactions WHERE post_id = ? AND user_id = ? AND emoji = ?", + postID, userID, emoji, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove reaction"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "reaction removed"}) +} + +func (h *PostHandler) getPostMedia(postID int64) []model.Media { + rows, err := h.db.Query( + "SELECT id, post_id, media_url, media_type, sort_order FROM post_media WHERE post_id = ? ORDER BY sort_order", + postID, + ) + if err != nil { + return nil + } + defer rows.Close() + + var media []model.Media + for rows.Next() { + var m model.Media + rows.Scan(&m.ID, &m.PostID, &m.MediaURL, &m.MediaType, &m.SortOrder) + media = append(media, m) + } + return media +} + +func (h *PostHandler) getPostReactions(postID, userID int64) []model.ReactionGroup { + rows, err := h.db.Query(` + SELECT emoji, COUNT(*) as count, + SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) as reacted + FROM reactions WHERE post_id = ? + GROUP BY emoji + ORDER BY count DESC + `, userID, postID) + if err != nil { + return nil + } + defer rows.Close() + + var reactions []model.ReactionGroup + for rows.Next() { + var r model.ReactionGroup + var reacted int + rows.Scan(&r.Emoji, &r.Count, &reacted) + r.Reacted = reacted > 0 + reactions = append(reactions, r) + } + return reactions +} diff --git a/server/internal/handler/search.go b/server/internal/handler/search.go new file mode 100644 index 0000000..79d34d9 --- /dev/null +++ b/server/internal/handler/search.go @@ -0,0 +1,135 @@ +package handler + +import ( + "database/sql" + "net/http" + + "memory/internal/config" + "memory/internal/middleware" + "memory/internal/model" + + "github.com/gin-gonic/gin" +) + +type SearchHandler struct { + db *sql.DB + cfg *config.Config +} + +func NewSearchHandler(db *sql.DB, cfg *config.Config) *SearchHandler { + return &SearchHandler{db: db, cfg: cfg} +} + +func (h *SearchHandler) Search(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req model.SearchRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 50 { + req.PageSize = 20 + } + offset := (req.Page - 1) * req.PageSize + + // 构建查询 + query := ` + SELECT p.id, p.user_id, p.content, p.created_at, + u.id, u.username, u.nickname, u.avatar_url, + (SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count, + (SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as liked, + (SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count + FROM posts p + JOIN users u ON p.user_id = u.id + WHERE 1=1 + ` + args := []interface{}{userID} + + if req.Query != "" { + query += " AND p.content LIKE ?" + args = append(args, "%"+req.Query+"%") + } + + if req.StartDate != "" { + query += " AND DATE(p.created_at) >= ?" + args = append(args, req.StartDate) + } + + if req.EndDate != "" { + query += " AND DATE(p.created_at) <= ?" + args = append(args, req.EndDate) + } + + query += " ORDER BY p.created_at DESC LIMIT ? OFFSET ?" + args = append(args, req.PageSize, offset) + + rows, err := h.db.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer rows.Close() + + posts := []model.Post{} + for rows.Next() { + var post model.Post + var user model.User + var liked int + err := rows.Scan( + &post.ID, &post.UserID, &post.Content, &post.CreatedAt, + &user.ID, &user.Username, &user.Nickname, &user.AvatarURL, + &post.LikeCount, &liked, &post.CommentCount, + ) + if err != nil { + continue + } + post.User = &user + post.Liked = liked > 0 + posts = append(posts, post) + } + + c.JSON(http.StatusOK, posts) +} + +// 获取热力图数据(GitHub 风格) +func (h *SearchHandler) Heatmap(c *gin.Context) { + year := c.DefaultQuery("year", "") + + query := ` + SELECT DATE(created_at) as date, COUNT(*) as count + FROM posts + WHERE 1=1 + ` + args := []interface{}{} + + if year != "" { + query += " AND strftime('%Y', created_at) = ?" + args = append(args, year) + } else { + // 默认最近一年 + query += " AND created_at >= DATE('now', '-1 year')" + } + + query += " GROUP BY DATE(created_at) ORDER BY date" + + rows, err := h.db.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer rows.Close() + + data := []model.HeatmapData{} + for rows.Next() { + var item model.HeatmapData + rows.Scan(&item.Date, &item.Count) + data = append(data, item) + } + + c.JSON(http.StatusOK, data) +} diff --git a/server/internal/handler/upload.go b/server/internal/handler/upload.go new file mode 100644 index 0000000..80ffd5b --- /dev/null +++ b/server/internal/handler/upload.go @@ -0,0 +1,171 @@ +package handler + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "memory/internal/config" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UploadHandler struct { + cfg *config.Config + s3Client *s3.Client +} + +func NewUploadHandler(cfg *config.Config) *UploadHandler { + h := &UploadHandler{cfg: cfg} + + if cfg.R2AccountID != "" && cfg.R2AccessKeyID != "" { + // 初始化 R2 客户端 + r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.R2AccountID), + }, nil + }) + + awsCfg, err := awsconfig.LoadDefaultConfig(context.TODO(), + awsconfig.WithEndpointResolverWithOptions(r2Resolver), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.R2AccessKeyID, + cfg.R2AccessKeySecret, + "", + )), + awsconfig.WithRegion("auto"), + ) + if err == nil { + h.s3Client = s3.NewFromConfig(awsCfg) + } + } + + return h +} + +func (h *UploadHandler) Upload(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) + return + } + + // 检查文件类型 + ext := strings.ToLower(filepath.Ext(file.Filename)) + allowedExts := map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + } + + contentType, ok := allowedExts[ext] + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"}) + return + } + + // 检查文件大小 (最大 50MB) + if file.Size > 50*1024*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"}) + return + } + + // 生成唯一文件名 + filename := fmt.Sprintf("%s/%s%s", + time.Now().Format("2006/01"), + uuid.New().String(), + ext, + ) + + // 打开文件 + src, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) + return + } + defer src.Close() + + // 优先使用本地存储 + if h.cfg.LocalUploadPath != "" { + url, err := h.saveLocal(src, filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "url": url, + "filename": filename, + }) + return + } + + // 使用 R2 存储 + if h.s3Client != nil { + url, err := h.uploadToR2(src, filename, contentType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "url": url, + "filename": filename, + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{"error": "storage not configured"}) +} + +func (h *UploadHandler) saveLocal(src io.Reader, filename string) (string, error) { + // 创建目录 + fullPath := filepath.Join(h.cfg.LocalUploadPath, filename) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + // 创建文件 + dst, err := os.Create(fullPath) + if err != nil { + return "", err + } + defer dst.Close() + + // 复制内容 + if _, err := io.Copy(dst, src); err != nil { + return "", err + } + + // 返回 URL + url := fmt.Sprintf("%s/uploads/%s", strings.TrimSuffix(h.cfg.BaseURL, "/"), filename) + return url, nil +} + +func (h *UploadHandler) uploadToR2(src io.Reader, filename, contentType string) (string, error) { + _, err := h.s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(h.cfg.R2BucketName), + Key: aws.String(filename), + Body: src, + ContentType: aws.String(contentType), + }) + if err != nil { + return "", err + } + + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(h.cfg.R2PublicURL, "/"), filename) + return url, nil +} diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go new file mode 100644 index 0000000..eabc953 --- /dev/null +++ b/server/internal/handler/user.go @@ -0,0 +1,126 @@ +package handler + +import ( + "database/sql" + "net/http" + + "memory/internal/config" + "memory/internal/middleware" + "memory/internal/model" + + "github.com/gin-gonic/gin" +) + +type UserHandler struct { + db *sql.DB + cfg *config.Config +} + +func NewUserHandler(db *sql.DB, cfg *config.Config) *UserHandler { + return &UserHandler{db: db, cfg: cfg} +} + +func (h *UserHandler) GetProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + + var user model.User + err := h.db.QueryRow(` + SELECT id, username, nickname, avatar_url, bio, is_admin, created_at + FROM users WHERE id = ? + `, userID).Scan(&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.Bio, &user.IsAdmin, &user.CreatedAt) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + // 获取统计数据 + var postCount, likeCount int + h.db.QueryRow("SELECT COUNT(*) FROM posts WHERE user_id = ?", userID).Scan(&postCount) + h.db.QueryRow("SELECT COUNT(*) FROM likes l JOIN posts p ON l.post_id = p.id WHERE p.user_id = ?", userID).Scan(&likeCount) + + c.JSON(http.StatusOK, gin.H{ + "user": user, + "post_count": postCount, + "like_count": likeCount, + }) +} + +func (h *UserHandler) UpdateProfile(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req model.UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + _, err := h.db.Exec(` + UPDATE users SET nickname = COALESCE(NULLIF(?, ''), nickname), bio = ? + WHERE id = ? + `, req.Nickname, req.Bio, userID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "profile updated"}) +} + +func (h *UserHandler) UpdateAvatar(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req struct { + AvatarURL string `json:"avatar_url" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + _, err := h.db.Exec("UPDATE users SET avatar_url = ? WHERE id = ?", req.AvatarURL, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update avatar"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "avatar updated"}) +} + +// 管理员接口 +func (h *UserHandler) GetSettings(c *gin.Context) { + rows, err := h.db.Query("SELECT key, value FROM settings") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + defer rows.Close() + + settings := make(map[string]string) + for rows.Next() { + var key, value string + rows.Scan(&key, &value) + settings[key] = value + } + + c.JSON(http.StatusOK, settings) +} + +func (h *UserHandler) UpdateSettings(c *gin.Context) { + var req map[string]string + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for key, value := range req { + _, err := h.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "settings updated"}) +} diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go new file mode 100644 index 0000000..1d00752 --- /dev/null +++ b/server/internal/middleware/auth.go @@ -0,0 +1,71 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID int64 `json:"user_id"` + IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims +} + +func AuthMiddleware(jwtSecret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + c.Abort() + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"}) + c.Abort() + return + } + + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + c.Abort() + return + } + + claims, ok := token.Claims.(*Claims) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"}) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("is_admin", claims.IsAdmin) + c.Next() + } +} + +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + isAdmin, exists := c.Get("is_admin") + if !exists || !isAdmin.(bool) { + c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"}) + c.Abort() + return + } + c.Next() + } +} + +func GetUserID(c *gin.Context) int64 { + userID, _ := c.Get("user_id") + return userID.(int64) +} diff --git a/server/internal/model/model.go b/server/internal/model/model.go new file mode 100644 index 0000000..77106ad --- /dev/null +++ b/server/internal/model/model.go @@ -0,0 +1,114 @@ +package model + +import "time" + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Nickname string `json:"nickname"` + AvatarURL string `json:"avatar_url"` + Bio string `json:"bio"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` +} + +type Post struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + User *User `json:"user,omitempty"` + Media []Media `json:"media,omitempty"` + Reactions []ReactionGroup `json:"reactions,omitempty"` + LikeCount int `json:"like_count"` + Liked bool `json:"liked"` + CommentCount int `json:"comment_count"` +} + +type Media struct { + ID int64 `json:"id"` + PostID int64 `json:"post_id"` + MediaURL string `json:"media_url"` + MediaType string `json:"media_type"` // image, video + SortOrder int `json:"sort_order"` +} + +type Comment struct { + ID int64 `json:"id"` + PostID int64 `json:"post_id"` + UserID int64 `json:"user_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + User *User `json:"user,omitempty"` + LikeCount int `json:"like_count"` +} + +type Like struct { + ID int64 `json:"id"` + PostID int64 `json:"post_id"` + UserID int64 `json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + +type Reaction struct { + ID int64 `json:"id"` + PostID int64 `json:"post_id"` + UserID int64 `json:"user_id"` + Emoji string `json:"emoji"` + CreatedAt time.Time `json:"created_at"` +} + +type ReactionGroup struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Reacted bool `json:"reacted"` +} + +// 请求/响应结构 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=20"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname" binding:"required,min=1,max=50"` +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` + User *User `json:"user"` +} + +type CreatePostRequest struct { + Content string `json:"content" binding:"required,max=1000"` + MediaIDs []string `json:"media_ids"` +} + +type CreateCommentRequest struct { + Content string `json:"content" binding:"required,max=500"` +} + +type AddReactionRequest struct { + Emoji string `json:"emoji" binding:"required"` +} + +type UpdateProfileRequest struct { + Nickname string `json:"nickname" binding:"max=50"` + Bio string `json:"bio" binding:"max=200"` +} + +type HeatmapData struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type SearchRequest struct { + Query string `form:"q"` + StartDate string `form:"start_date"` + EndDate string `form:"end_date"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go new file mode 100644 index 0000000..1cd6cd0 --- /dev/null +++ b/server/internal/router/router.go @@ -0,0 +1,88 @@ +package router + +import ( + "database/sql" + + "memory/internal/config" + "memory/internal/handler" + "memory/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func Setup(db *sql.DB, cfg *config.Config) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + + // CORS + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + // 静态文件服务 (本地上传的文件) + if cfg.LocalUploadPath != "" { + r.Static("/uploads", cfg.LocalUploadPath) + } + + // Handlers + authHandler := handler.NewAuthHandler(db, cfg) + postHandler := handler.NewPostHandler(db, cfg) + commentHandler := handler.NewCommentHandler(db, cfg) + userHandler := handler.NewUserHandler(db, cfg) + searchHandler := handler.NewSearchHandler(db, cfg) + uploadHandler := handler.NewUploadHandler(cfg) + + // 公开接口 + r.POST("/api/auth/register", authHandler.Register) + r.POST("/api/auth/login", authHandler.Login) + + // 需要认证的接口 + auth := r.Group("/api") + auth.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + { + // 帖子 + auth.GET("/posts", postHandler.List) + auth.POST("/posts", postHandler.Create) + auth.GET("/posts/:id", postHandler.Get) + auth.DELETE("/posts/:id", postHandler.Delete) + auth.POST("/posts/:id/like", postHandler.Like) + auth.DELETE("/posts/:id/like", postHandler.Unlike) + auth.POST("/posts/:id/reactions", postHandler.AddReaction) + auth.DELETE("/posts/:id/reactions", postHandler.RemoveReaction) + + // 评论 + auth.GET("/posts/:id/comments", commentHandler.List) + auth.POST("/posts/:id/comments", commentHandler.Create) + auth.DELETE("/posts/:id/comments/:comment_id", commentHandler.Delete) + + // 用户 + auth.GET("/user/profile", userHandler.GetProfile) + auth.PUT("/user/profile", userHandler.UpdateProfile) + auth.PUT("/user/avatar", userHandler.UpdateAvatar) + + // 搜索 + auth.GET("/search", searchHandler.Search) + auth.GET("/heatmap", searchHandler.Heatmap) + + // 上传 + auth.POST("/upload", uploadHandler.Upload) + + // 管理员接口 + admin := auth.Group("/admin") + admin.Use(middleware.AdminMiddleware()) + { + admin.GET("/settings", userHandler.GetSettings) + admin.PUT("/settings", userHandler.UpdateSettings) + } + } + + return r +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..5c86210 --- /dev/null +++ b/server/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "memory/internal/config" + "memory/internal/database" + "memory/internal/router" +) + +func main() { + // 加载配置 + cfg := config.Load() + + // 初始化数据库 + db, err := database.Init(cfg.DBPath) + if err != nil { + log.Fatalf("Failed to init database: %v", err) + } + defer db.Close() + + // 启动服务器 + r := router.Setup(db, cfg) + log.Printf("Memory server starting on %s", cfg.ServerAddr) + if err := r.Run(cfg.ServerAddr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +}