feat:项目完成
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -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
|
||||
92
README.md
Normal file
92
README.md
Normal file
@@ -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
|
||||
85
android/app/build.gradle.kts
Normal file
85
android/app/build.gradle.kts
Normal file
@@ -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")
|
||||
}
|
||||
32
android/app/proguard-rules.pro
vendored
Normal file
32
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Retrofit
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepclasseswithmembers class * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# 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.** { *; }
|
||||
28
android/app/src/main/AndroidManifest.xml
Normal file
28
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Memory"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Memory"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Memory">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
130
android/app/src/main/java/com/memory/app/MainActivity.kt
Normal file
130
android/app/src/main/java/com/memory/app/MainActivity.kt
Normal file
@@ -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<Boolean?>(null) }
|
||||
var currentUser by remember { mutableStateOf<User?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
104
android/app/src/main/java/com/memory/app/data/api/ApiService.kt
Normal file
104
android/app/src/main/java/com/memory/app/data/api/ApiService.kt
Normal file
@@ -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<LoginResponse>
|
||||
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
// Posts
|
||||
@GET("posts")
|
||||
suspend fun getPosts(
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("page_size") pageSize: Int = 20
|
||||
): Response<List<Post>>
|
||||
|
||||
@POST("posts")
|
||||
suspend fun createPost(@Body request: CreatePostRequest): Response<IdResponse>
|
||||
|
||||
@GET("posts/{id}")
|
||||
suspend fun getPost(@Path("id") id: Long): Response<Post>
|
||||
|
||||
@DELETE("posts/{id}")
|
||||
suspend fun deletePost(@Path("id") id: Long): Response<MessageResponse>
|
||||
|
||||
@POST("posts/{id}/like")
|
||||
suspend fun likePost(@Path("id") id: Long): Response<MessageResponse>
|
||||
|
||||
@DELETE("posts/{id}/like")
|
||||
suspend fun unlikePost(@Path("id") id: Long): Response<MessageResponse>
|
||||
|
||||
@POST("posts/{id}/reactions")
|
||||
suspend fun addReaction(
|
||||
@Path("id") id: Long,
|
||||
@Body request: AddReactionRequest
|
||||
): Response<MessageResponse>
|
||||
|
||||
@DELETE("posts/{id}/reactions")
|
||||
suspend fun removeReaction(
|
||||
@Path("id") id: Long,
|
||||
@Query("emoji") emoji: String
|
||||
): Response<MessageResponse>
|
||||
|
||||
// Comments
|
||||
@GET("posts/{id}/comments")
|
||||
suspend fun getComments(
|
||||
@Path("id") postId: Long,
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("page_size") pageSize: Int = 20
|
||||
): Response<List<Comment>>
|
||||
|
||||
@POST("posts/{id}/comments")
|
||||
suspend fun createComment(
|
||||
@Path("id") postId: Long,
|
||||
@Body request: CreateCommentRequest
|
||||
): Response<IdResponse>
|
||||
|
||||
@DELETE("posts/{postId}/comments/{commentId}")
|
||||
suspend fun deleteComment(
|
||||
@Path("postId") postId: Long,
|
||||
@Path("commentId") commentId: Long
|
||||
): Response<MessageResponse>
|
||||
|
||||
// User
|
||||
@GET("user/profile")
|
||||
suspend fun getProfile(): Response<ProfileResponse>
|
||||
|
||||
@PUT("user/profile")
|
||||
suspend fun updateProfile(@Body request: UpdateProfileRequest): Response<MessageResponse>
|
||||
|
||||
@PUT("user/avatar")
|
||||
suspend fun updateAvatar(@Body request: Map<String, String>): Response<MessageResponse>
|
||||
|
||||
// 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<List<Post>>
|
||||
|
||||
@GET("heatmap")
|
||||
suspend fun getHeatmap(@Query("year") year: String? = null): Response<List<HeatmapData>>
|
||||
|
||||
// Upload
|
||||
@Multipart
|
||||
@POST("upload")
|
||||
suspend fun upload(@Part file: MultipartBody.Part): Response<UploadResponse>
|
||||
|
||||
// Admin
|
||||
@GET("admin/settings")
|
||||
suspend fun getSettings(): Response<Map<String, String>>
|
||||
|
||||
@PUT("admin/settings")
|
||||
suspend fun updateSettings(@Body settings: Map<String, String>): Response<MessageResponse>
|
||||
}
|
||||
131
android/app/src/main/java/com/memory/app/data/model/Models.kt
Normal file
131
android/app/src/main/java/com/memory/app/data/model/Models.kt
Normal file
@@ -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<Media> = emptyList(),
|
||||
val reactions: List<ReactionGroup> = 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<String> = 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
|
||||
)
|
||||
@@ -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<Preferences> by preferencesDataStore(name = "auth")
|
||||
|
||||
class AuthRepository(private val context: Context) {
|
||||
private val tokenKey = stringPreferencesKey("token")
|
||||
private val userKey = stringPreferencesKey("user")
|
||||
|
||||
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[tokenKey] != null
|
||||
}
|
||||
|
||||
val currentUser: Flow<User?> = context.dataStore.data.map { prefs ->
|
||||
prefs[userKey]?.let { Json.decodeFromString<User>(it) }
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String): Result<LoginResponse> {
|
||||
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<LoginResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Media>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Int>,
|
||||
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<List<LocalDate?>>()
|
||||
var currentWeekStart = adjustedStart
|
||||
|
||||
// 循环直到当前周的周日超过季度末尾
|
||||
while (currentWeekStart.plusDays(6) >= quarterStart && currentWeekStart <= quarterEndDate.plusDays(6)) {
|
||||
val week = mutableListOf<LocalDate?>()
|
||||
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<Pair<Int, String>>()
|
||||
val seenMonths = mutableSetOf<Int>()
|
||||
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<String, Int>?,
|
||||
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<Post>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Uri>) -> Unit,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
var content by remember { mutableStateOf("") }
|
||||
var selectedImages by remember { mutableStateOf<List<Uri>>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
144
android/app/src/main/java/com/memory/app/ui/screen/HomeScreen.kt
Normal file
144
android/app/src/main/java/com/memory/app/ui/screen/HomeScreen.kt
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Comment>,
|
||||
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<String?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Uri?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
55
android/app/src/main/java/com/memory/app/ui/theme/Theme.kt
Normal file
55
android/app/src/main/java/com/memory/app/ui/theme/Theme.kt
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<Map<String, Int>>(emptyMap())
|
||||
val heatmapData: StateFlow<Map<String, Int>> = _heatmapData
|
||||
|
||||
private val _selectedYear = MutableStateFlow(LocalDate.now().year)
|
||||
val selectedYear: StateFlow<Int> = _selectedYear
|
||||
|
||||
// 季度:1-4
|
||||
private val _selectedQuarter = MutableStateFlow((LocalDate.now().monthValue - 1) / 3 + 1)
|
||||
val selectedQuarter: StateFlow<Int> = _selectedQuarter
|
||||
|
||||
private val _selectedDatePosts = MutableStateFlow<List<Post>>(emptyList())
|
||||
val selectedDatePosts: StateFlow<List<Post>> = _selectedDatePosts
|
||||
|
||||
private val _selectedDate = MutableStateFlow<String?>(null)
|
||||
val selectedDate: StateFlow<String?> = _selectedDate
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
private val _totalPosts = MutableStateFlow(0)
|
||||
val totalPosts: StateFlow<Int> = _totalPosts
|
||||
|
||||
private val _maxDay = MutableStateFlow<Pair<String, Int>?>(null)
|
||||
val maxDay: StateFlow<Pair<String, Int>?> = _maxDay
|
||||
|
||||
private val _currentStreak = MutableStateFlow(0)
|
||||
val currentStreak: StateFlow<Int> = _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<HeatmapData>): 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
|
||||
}
|
||||
}
|
||||
@@ -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<List<Post>>(emptyList())
|
||||
val posts: StateFlow<List<Post>> = _posts
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
private val _isPosting = MutableStateFlow(false)
|
||||
val isPosting: StateFlow<Boolean> = _isPosting
|
||||
|
||||
private val _postSuccess = MutableStateFlow(false)
|
||||
val postSuccess: StateFlow<Boolean> = _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<Uri>) {
|
||||
if (_isPosting.value) return
|
||||
viewModelScope.launch {
|
||||
_isPosting.value = true
|
||||
_postSuccess.value = false
|
||||
try {
|
||||
// 先上传图片
|
||||
val imageUrls = mutableListOf<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Post?>(null)
|
||||
val post: StateFlow<Post?> = _post
|
||||
|
||||
private val _comments = MutableStateFlow<List<Comment>>(emptyList())
|
||||
val comments: StateFlow<List<Comment>> = _comments
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
private val _isDeleted = MutableStateFlow(false)
|
||||
val isDeleted: StateFlow<Boolean> = _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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<User?>(null)
|
||||
val user: StateFlow<User?> = _user
|
||||
|
||||
private val _postCount = MutableStateFlow(0)
|
||||
val postCount: StateFlow<Int> = _postCount
|
||||
|
||||
private val _likeCount = MutableStateFlow(0)
|
||||
val likeCount: StateFlow<Int> = _likeCount
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _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
|
||||
}
|
||||
}
|
||||
@@ -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<String> = _searchQuery
|
||||
|
||||
private val _startDate = MutableStateFlow<LocalDate?>(null)
|
||||
val startDate: StateFlow<LocalDate?> = _startDate
|
||||
|
||||
private val _endDate = MutableStateFlow<LocalDate?>(null)
|
||||
val endDate: StateFlow<LocalDate?> = _endDate
|
||||
|
||||
private val _posts = MutableStateFlow<List<Post>>(emptyList())
|
||||
val posts: StateFlow<List<Post>> = _posts
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
android/app/src/main/java/com/memory/app/util/TimeUtils.kt
Normal file
49
android/app/src/main/java/com/memory/app/util/TimeUtils.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
15
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
15
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.4"
|
||||
android:scaleY="0.4"
|
||||
android:translateX="32.4"
|
||||
android:translateY="32.4">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,27C38.5,27 27,38.5 27,54C27,69.5 38.5,81 54,81C69.5,81 81,69.5 81,54C81,38.5 69.5,27 54,27ZM54,36C64.5,36 72,43.5 72,54C72,64.5 64.5,72 54,72C43.5,72 36,64.5 36,54C36,43.5 43.5,36 54,36ZM54,45C48,45 45,48 45,54C45,60 48,63 54,63C60,63 63,60 63,54C63,48 60,45 54,45Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
5
android/app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-mdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-mdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
Normal file
5
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/themes.xml
Normal file
8
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Memory" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
android/build.gradle.kts
Normal file
6
android/build.gradle.kts
Normal file
@@ -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
|
||||
}
|
||||
12
android/gradle.properties
Normal file
12
android/gradle.properties
Normal file
@@ -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
|
||||
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||
24
android/settings.gradle.kts
Normal file
24
android/settings.gradle.kts
Normal file
@@ -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")
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -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}
|
||||
509
memory-ui-preview.html
Normal file
509
memory-ui-preview.html
Normal file
@@ -0,0 +1,509 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memory - UI Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #000; display: flex; justify-content: center; gap: 40px;
|
||||
padding: 40px; min-height: 100vh; flex-wrap: wrap;
|
||||
}
|
||||
.phone-frame {
|
||||
width: 375px; height: 812px; background: #000; border-radius: 40px;
|
||||
box-shadow: 0 0 0 3px #333; overflow: hidden; position: relative;
|
||||
}
|
||||
.status-bar {
|
||||
height: 44px; background: #000; display: flex; justify-content: center;
|
||||
align-items: center; font-size: 14px; font-weight: 600; color: #fff;
|
||||
}
|
||||
.header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 16px; border-bottom: 1px solid #2f3336;
|
||||
}
|
||||
.header-title { font-size: 20px; font-weight: 700; color: #e7e9ea; }
|
||||
.back-btn { padding: 8px; margin: -8px; cursor: pointer; }
|
||||
.back-btn svg { width: 20px; height: 20px; fill: #e7e9ea; }
|
||||
.home-content { height: calc(100% - 44px - 50px - 83px); overflow-y: auto; }
|
||||
.detail-content { height: calc(100% - 44px - 50px - 60px); overflow-y: auto; }
|
||||
.post-card { padding: 12px 16px; border-bottom: 1px solid #2f3336; display: flex; gap: 12px; }
|
||||
.avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #1d9bf0 0%, #1a8cd8 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 16px;
|
||||
}
|
||||
.avatar.small { width: 32px; height: 32px; font-size: 13px; }
|
||||
.avatar.purple { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); }
|
||||
.avatar.green { background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%); }
|
||||
.post-body { flex: 1; min-width: 0; }
|
||||
.post-header { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; flex-wrap: wrap; }
|
||||
.username { font-weight: 700; font-size: 15px; color: #e7e9ea; }
|
||||
.handle, .post-time, .dot { font-size: 15px; color: #71767b; }
|
||||
.post-content { font-size: 15px; line-height: 1.4; color: #e7e9ea; margin-bottom: 12px; }
|
||||
.post-media { border-radius: 16px; overflow: hidden; border: 1px solid #2f3336; margin-bottom: 12px; }
|
||||
.post-media.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; }
|
||||
.media-item { aspect-ratio: 16/10; background: #16181c; display: flex; align-items: center; justify-content: center; }
|
||||
.media-item svg { width: 32px; height: 32px; fill: #71767b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
/* 表情反应标签 */
|
||||
.reactions { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.reaction {
|
||||
display: flex; align-items: center; gap: 4px; background: #16181c;
|
||||
border: 1px solid #2f3336; border-radius: 9999px; padding: 4px 10px;
|
||||
font-size: 13px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.reaction:hover { border-color: #1d9bf0; background: rgba(29,155,240,0.1); }
|
||||
.reaction.active { border-color: #1d9bf0; background: rgba(29,155,240,0.15); }
|
||||
.reaction-emoji { font-size: 14px; }
|
||||
.reaction-count { color: #71767b; }
|
||||
.reaction.active .reaction-count { color: #1d9bf0; }
|
||||
/* 操作栏 - 评论、点赞、表情 */
|
||||
.post-actions { display: flex; gap: 4px; }
|
||||
.action-btn {
|
||||
display: flex; align-items: center; gap: 6px; color: #71767b;
|
||||
font-size: 13px; cursor: pointer; padding: 8px 12px; border-radius: 9999px; transition: all 0.2s;
|
||||
}
|
||||
.action-btn:hover { background: rgba(29,155,240,0.1); }
|
||||
.action-btn.comment:hover { color: #1d9bf0; }
|
||||
.action-btn.like:hover { color: #f91880; }
|
||||
.action-btn.like.active { color: #f91880; }
|
||||
.action-btn.like.active svg { fill: #f91880; }
|
||||
.action-btn.emoji:hover { color: #ffd93d; }
|
||||
.action-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
||||
.action-btn span { font-size: 13px; }
|
||||
.fab-button {
|
||||
position: absolute; right: 16px; bottom: 95px; width: 56px; height: 56px;
|
||||
background: #1d9bf0; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(29,155,240,0.4); cursor: pointer;
|
||||
}
|
||||
.fab-button svg { width: 26px; height: 26px; fill: #fff; }
|
||||
.bottom-nav {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; height: 83px;
|
||||
background: #000; border-top: 1px solid #2f3336;
|
||||
display: flex; justify-content: space-around; align-items: flex-start; padding-top: 12px;
|
||||
}
|
||||
.nav-item { padding: 12px; cursor: pointer; border-radius: 50%; transition: background 0.2s; }
|
||||
.nav-item:hover { background: rgba(231,233,234,0.1); }
|
||||
.nav-item svg { width: 26px; height: 26px; fill: #e7e9ea; }
|
||||
.nav-item.active svg { fill: #1d9bf0; }
|
||||
</style>
|
||||
<style>
|
||||
/* 评论区样式 */
|
||||
.comments-section { border-top: 1px solid #2f3336; }
|
||||
.comment { padding: 12px 16px; display: flex; gap: 10px; border-bottom: 1px solid #2f3336; }
|
||||
.comment-body { flex: 1; }
|
||||
.comment-header { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; }
|
||||
.comment-content { font-size: 14px; line-height: 1.4; color: #e7e9ea; }
|
||||
.comment-actions { display: flex; gap: 16px; margin-top: 8px; }
|
||||
.comment-action { display: flex; align-items: center; gap: 4px; color: #71767b; font-size: 12px; cursor: pointer; }
|
||||
.comment-action:hover { color: #1d9bf0; }
|
||||
.comment-action svg { width: 14px; height: 14px; fill: currentColor; }
|
||||
/* 评论输入框 */
|
||||
.comment-input-bar {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: #000; border-top: 1px solid #2f3336;
|
||||
padding: 12px 16px; display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
.comment-input {
|
||||
flex: 1; background: #16181c; border: 1px solid #2f3336; border-radius: 9999px;
|
||||
padding: 10px 16px; color: #e7e9ea; font-size: 14px; outline: none;
|
||||
}
|
||||
.comment-input::placeholder { color: #71767b; }
|
||||
.comment-input:focus { border-color: #1d9bf0; }
|
||||
.send-btn {
|
||||
width: 36px; height: 36px; background: #1d9bf0; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
}
|
||||
.send-btn svg { width: 18px; height: 18px; fill: #fff; }
|
||||
/* 发帖页面 */
|
||||
.post-page-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 16px; border-bottom: 1px solid #2f3336;
|
||||
}
|
||||
.close-btn { padding: 8px; cursor: pointer; }
|
||||
.close-btn svg { width: 20px; height: 20px; fill: #e7e9ea; }
|
||||
.post-btn {
|
||||
background: #1d9bf0; color: #fff; border: none; padding: 8px 16px;
|
||||
border-radius: 9999px; font-size: 14px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.compose-area { padding: 12px 16px; display: flex; gap: 12px; }
|
||||
.compose-input { flex: 1; }
|
||||
.compose-input textarea {
|
||||
width: 100%; border: none; background: transparent; font-size: 20px;
|
||||
line-height: 1.4; resize: none; outline: none; color: #e7e9ea;
|
||||
min-height: 150px; font-family: inherit;
|
||||
}
|
||||
.compose-input textarea::placeholder { color: #71767b; }
|
||||
.compose-toolbar { display: flex; gap: 4px; padding: 12px 0; border-top: 1px solid #2f3336; margin-top: 12px; }
|
||||
.toolbar-btn { padding: 8px; border-radius: 50%; cursor: pointer; transition: background 0.2s; }
|
||||
.toolbar-btn:hover { background: rgba(29,155,240,0.1); }
|
||||
.toolbar-btn svg { width: 20px; height: 20px; fill: #1d9bf0; }
|
||||
.media-upload-area {
|
||||
margin: 16px; border: 1px dashed #2f3336; border-radius: 16px;
|
||||
padding: 60px 40px; text-align: center; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.media-upload-area:hover { border-color: #1d9bf0; background: rgba(29,155,240,0.05); }
|
||||
.upload-icon { width: 40px; height: 40px; fill: #1d9bf0; margin-bottom: 8px; }
|
||||
.upload-text { color: #71767b; font-size: 15px; }
|
||||
.page-label {
|
||||
position: absolute; top: -35px; left: 50%; transform: translateX(-50%);
|
||||
background: #1d9bf0; color: #fff; padding: 6px 16px;
|
||||
border-radius: 9999px; font-size: 13px; font-weight: 500; white-space: nowrap;
|
||||
}
|
||||
.phone-container { position: relative; padding-top: 45px; }
|
||||
</style>
|
||||
|
||||
<!-- 首页 -->
|
||||
<div class="phone-container">
|
||||
<div class="page-label">首页 - 时间线</div>
|
||||
<div class="phone-frame">
|
||||
<div class="status-bar">9:41</div>
|
||||
<div class="header"><span class="header-title">Memory</span></div>
|
||||
<div class="home-content">
|
||||
<!-- 帖子1 -->
|
||||
<div class="post-card">
|
||||
<div class="avatar">M</div>
|
||||
<div class="post-body">
|
||||
<div class="post-header">
|
||||
<span class="username">Memory</span>
|
||||
<span class="handle">@memory_user</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time">5分钟</span>
|
||||
</div>
|
||||
<div class="post-content">今天天气真好,出门散步拍了几张照片 📸</div>
|
||||
<div class="post-media grid">
|
||||
<div class="media-item"><svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg></div>
|
||||
<div class="media-item"><svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg></div>
|
||||
</div>
|
||||
<div class="reactions">
|
||||
<div class="reaction active"><span class="reaction-emoji">❤️</span><span class="reaction-count">12</span></div>
|
||||
<div class="reaction"><span class="reaction-emoji">👍</span><span class="reaction-count">5</span></div>
|
||||
<div class="reaction"><span class="reaction-emoji">😊</span><span class="reaction-count">3</span></div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<div class="action-btn comment"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg><span>8</span></div>
|
||||
<div class="action-btn like active"><svg viewBox="0 0 24 24"><path d="M20.884 13.19c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.45-4.55-.782-6.14.657-1.57 2.03-2.64 3.76-3.02 1.36-.3 2.94-.07 4.41.87.57.36 1.1.81 1.58 1.34.48-.53 1.01-.98 1.58-1.34 1.47-.94 3.05-1.17 4.41-.87 1.73.38 3.1 1.45 3.76 3.02.67 1.59.58 3.64-.78 6.14z"/></svg><span>20</span></div>
|
||||
<div class="action-btn emoji"><svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm3.5-9c.828 0 1.5-.672 1.5-1.5S16.328 8 15.5 8 14 8.672 14 9.5s.672 1.5 1.5 1.5zm-7 0c.828 0 1.5-.672 1.5-1.5S9.328 8 8.5 8 7 8.672 7 9.5 7.672 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 帖子2 -->
|
||||
<div class="post-card">
|
||||
<div class="avatar">M</div>
|
||||
<div class="post-body">
|
||||
<div class="post-header">
|
||||
<span class="username">Memory</span>
|
||||
<span class="handle">@memory_user</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time">11月12日</span>
|
||||
</div>
|
||||
<div class="post-content">记录一下今天的午餐,自己做的意面 🍝</div>
|
||||
<div class="post-media">
|
||||
<div class="media-item"><svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg></div>
|
||||
</div>
|
||||
<div class="reactions">
|
||||
<div class="reaction"><span class="reaction-emoji">😋</span><span class="reaction-count">8</span></div>
|
||||
<div class="reaction"><span class="reaction-emoji">👏</span><span class="reaction-count">4</span></div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<div class="action-btn comment"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg><span>3</span></div>
|
||||
<div class="action-btn like"><svg viewBox="0 0 24 24"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91z"/></svg><span>12</span></div>
|
||||
<div class="action-btn emoji"><svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm3.5-9c.828 0 1.5-.672 1.5-1.5S16.328 8 15.5 8 14 8.672 14 9.5s.672 1.5 1.5 1.5zm-7 0c.828 0 1.5-.672 1.5-1.5S9.328 8 8.5 8 7 8.672 7 9.5 7.672 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 帖子3 -->
|
||||
<div class="post-card">
|
||||
<div class="avatar">M</div>
|
||||
<div class="post-body">
|
||||
<div class="post-header">
|
||||
<span class="username">Memory</span>
|
||||
<span class="handle">@memory_user</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time">11月10日</span>
|
||||
</div>
|
||||
<div class="post-content">新的一周开始了,给自己加油 💪</div>
|
||||
<div class="reactions">
|
||||
<div class="reaction"><span class="reaction-emoji">💪</span><span class="reaction-count">15</span></div>
|
||||
<div class="reaction"><span class="reaction-emoji">🔥</span><span class="reaction-count">6</span></div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<div class="action-btn comment"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg><span>5</span></div>
|
||||
<div class="action-btn like"><svg viewBox="0 0 24 24"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91z"/></svg><span>21</span></div>
|
||||
<div class="action-btn emoji"><svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm3.5-9c.828 0 1.5-.672 1.5-1.5S16.328 8 15.5 8 14 8.672 14 9.5s.672 1.5 1.5 1.5zm-7 0c.828 0 1.5-.672 1.5-1.5S9.328 8 8.5 8 7 8.672 7 9.5 7.672 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fab-button"><svg viewBox="0 0 24 24"><path d="M23 3c-6.62-.1-10.38 2.421-13.05 6.03C7.29 12.61 6 17.331 6 22h2c0-1.007.07-2.012.19-3H12c4.1 0 7.48-3.082 7.94-7.054C22.79 10.147 23.17 6.359 23 3zm-7 8h-1.5v2H16c.63-.016 1.2-.08 1.72-.188C16.95 15.24 14.68 17 12 17H8.55c.57-2.512 1.57-4.851 3-6.78 2.16-2.912 5.29-4.911 9.45-5.187C20.95 8.079 19.9 11 16 11zM4 9V6H1V4h3V1h2v3h3v2H6v3H4z"/></svg></div>
|
||||
<div class="bottom-nav">
|
||||
<div class="nav-item active"><svg viewBox="0 0 24 24"><path d="M21.591 7.146L12.52 1.157c-.316-.21-.724-.21-1.04 0l-9.071 5.99c-.26.173-.409.456-.409.757v13.183c0 .502.418.913.929.913H9.14c.51 0 .929-.41.929-.913v-7.075h3.909v7.075c0 .502.417.913.928.913h6.165c.511 0 .929-.41.929-.913V7.904c0-.301-.158-.584-.409-.758z"/></svg></div>
|
||||
<div class="nav-item"><svg viewBox="0 0 24 24"><path d="M10.25 3.75c-3.59 0-6.5 2.91-6.5 6.5s2.91 6.5 6.5 6.5c1.795 0 3.419-.726 4.596-1.904 1.178-1.177 1.904-2.801 1.904-4.596 0-3.59-2.91-6.5-6.5-6.5zm-8.5 6.5c0-4.694 3.806-8.5 8.5-8.5s8.5 3.806 8.5 8.5c0 1.986-.682 3.815-1.824 5.262l4.781 4.781-1.414 1.414-4.781-4.781c-1.447 1.142-3.276 1.824-5.262 1.824-4.694 0-8.5-3.806-8.5-8.5z"/></svg></div>
|
||||
<div class="nav-item"><svg viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/></svg></div>
|
||||
<div class="nav-item"><svg viewBox="0 0 24 24"><path d="M12 11.816c1.355 0 2.872-.15 3.84-1.256.814-.93 1.078-2.368.806-4.392-.38-2.825-2.117-4.512-4.646-4.512S7.734 3.343 7.354 6.168c-.272 2.024-.008 3.462.806 4.392.968 1.107 2.485 1.256 3.84 1.256zM8.84 6.368c.162-1.2.787-3.212 3.16-3.212s2.998 2.013 3.16 3.212c.207 1.55.057 2.627-.45 3.205-.455.52-1.266.743-2.71.743s-2.255-.223-2.71-.743c-.507-.578-.657-1.656-.45-3.205zm11.44 12.868c-.877-3.526-4.282-5.99-8.28-5.99s-7.403 2.464-8.28 5.99c-.172.692-.028 1.4.395 1.94.408.52 1.04.82 1.733.82h12.304c.693 0 1.325-.3 1.733-.82.424-.54.567-1.247.394-1.94zm-1.576 1.016c-.126.16-.316.252-.552.252H5.848c-.235 0-.426-.092-.552-.252-.137-.175-.18-.412-.12-.654.71-2.855 3.517-4.85 6.824-4.85s6.114 1.994 6.824 4.85c.06.242.017.479-.12.654z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帖子详情页 - 带评论 -->
|
||||
<div class="phone-container">
|
||||
<div class="page-label">帖子详情 - 评论区</div>
|
||||
<div class="phone-frame">
|
||||
<div class="status-bar">9:41</div>
|
||||
<div class="header">
|
||||
<div class="back-btn"><svg viewBox="0 0 24 24"><path d="M7.414 13l5.043 5.04-1.414 1.42L3.586 12l7.457-7.46 1.414 1.42L7.414 11H21v2H7.414z"/></svg></div>
|
||||
<span class="header-title">帖子</span>
|
||||
<div style="width:36px"></div>
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<div class="post-card">
|
||||
<div class="avatar">M</div>
|
||||
<div class="post-body">
|
||||
<div class="post-header">
|
||||
<span class="username">Memory</span>
|
||||
<span class="handle">@memory_user</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time">5分钟</span>
|
||||
</div>
|
||||
<div class="post-content">今天天气真好,出门散步拍了几张照片 📸</div>
|
||||
<div class="post-media grid">
|
||||
<div class="media-item"><svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg></div>
|
||||
<div class="media-item"><svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg></div>
|
||||
</div>
|
||||
<div class="reactions">
|
||||
<div class="reaction active"><span class="reaction-emoji">❤️</span><span class="reaction-count">12</span></div>
|
||||
<div class="reaction"><span class="reaction-emoji">👍</span><span class="reaction-count">5</span></div>
|
||||
<div class="reaction"><span class="reaction-emoji">😊</span><span class="reaction-count">3</span></div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<div class="action-btn comment"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg><span>8</span></div>
|
||||
<div class="action-btn like active"><svg viewBox="0 0 24 24"><path d="M20.884 13.19c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.45-4.55-.782-6.14.657-1.57 2.03-2.64 3.76-3.02 1.36-.3 2.94-.07 4.41.87.57.36 1.1.81 1.58 1.34.48-.53 1.01-.98 1.58-1.34 1.47-.94 3.05-1.17 4.41-.87 1.73.38 3.1 1.45 3.76 3.02.67 1.59.58 3.64-.78 6.14z"/></svg><span>20</span></div>
|
||||
<div class="action-btn emoji"><svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm3.5-9c.828 0 1.5-.672 1.5-1.5S16.328 8 15.5 8 14 8.672 14 9.5s.672 1.5 1.5 1.5zm-7 0c.828 0 1.5-.672 1.5-1.5S9.328 8 8.5 8 7 8.672 7 9.5 7.672 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 评论区 -->
|
||||
<div class="comments-section">
|
||||
<div class="comment">
|
||||
<div class="avatar small purple">A</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<span class="username" style="font-size:14px">Alice</span>
|
||||
<span class="handle" style="font-size:13px">@alice</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time" style="font-size:13px">2分钟</span>
|
||||
</div>
|
||||
<div class="comment-content">照片拍得真好看!是在哪里拍的呀?</div>
|
||||
<div class="comment-actions">
|
||||
<div class="comment-action"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg>回复</div>
|
||||
<div class="comment-action"><svg viewBox="0 0 24 24"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91z"/></svg>2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<div class="avatar small green">B</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<span class="username" style="font-size:14px">Bob</span>
|
||||
<span class="handle" style="font-size:13px">@bob_dev</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time" style="font-size:13px">1分钟</span>
|
||||
</div>
|
||||
<div class="comment-content">天气确实不错,适合出门走走 ☀️</div>
|
||||
<div class="comment-actions">
|
||||
<div class="comment-action"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg>回复</div>
|
||||
<div class="comment-action"><svg viewBox="0 0 24 24"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91z"/></svg>5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<div class="avatar small">C</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<span class="username" style="font-size:14px">Charlie</span>
|
||||
<span class="handle" style="font-size:13px">@charlie</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="post-time" style="font-size:13px">刚刚</span>
|
||||
</div>
|
||||
<div class="comment-content">羡慕!我这边下雨了 🌧️</div>
|
||||
<div class="comment-actions">
|
||||
<div class="comment-action"><svg viewBox="0 0 24 24"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg>回复</div>
|
||||
<div class="comment-action"><svg viewBox="0 0 24 24"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91z"/></svg>1</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-input-bar">
|
||||
<div class="avatar small">M</div>
|
||||
<input type="text" class="comment-input" placeholder="发表评论...">
|
||||
<div class="send-btn"><svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发帖页面 -->
|
||||
<div class="phone-container">
|
||||
<div class="page-label">发帖页面</div>
|
||||
<div class="phone-frame">
|
||||
<div class="status-bar">9:41</div>
|
||||
<div class="post-page-header">
|
||||
<div class="close-btn"><svg viewBox="0 0 24 24"><path d="M10.59 12L4.54 5.96l1.42-1.42L12 10.59l6.04-6.05 1.42 1.42L13.41 12l6.05 6.04-1.42 1.42L12 13.41l-6.04 6.05-1.42-1.42L10.59 12z"/></svg></div>
|
||||
<button class="post-btn">发布</button>
|
||||
</div>
|
||||
<div class="compose-area">
|
||||
<div class="avatar">M</div>
|
||||
<div class="compose-input">
|
||||
<textarea placeholder="有什么新鲜事?"></textarea>
|
||||
<div class="compose-toolbar">
|
||||
<div class="toolbar-btn"><svg viewBox="0 0 24 24"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z"/></svg></div>
|
||||
<div class="toolbar-btn"><svg viewBox="0 0 24 24"><path d="M12 7c-1.93 0-3.5 1.57-3.5 3.5S10.07 14 12 14s3.5-1.57 3.5-3.5S13.93 7 12 7zm0 5c-.827 0-1.5-.673-1.5-1.5S11.173 9 12 9s1.5.673 1.5 1.5S12.827 12 12 12zm0-10c-4.687 0-8.5 3.813-8.5 8.5 0 5.967 7.621 11.116 7.945 11.332l.555.37.555-.37c.324-.216 7.945-5.365 7.945-11.332C20.5 5.813 16.687 2 12 2zm0 17.77c-1.665-1.241-6.5-5.196-6.5-9.27C5.5 6.916 8.416 4 12 4s6.5 2.916 6.5 6.5c0 4.073-4.835 8.028-6.5 9.27z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-upload-area">
|
||||
<svg class="upload-icon" viewBox="0 0 24 24"><path d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086z"/></svg>
|
||||
<div class="upload-text">点击上传图片或视频</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的页面 -->
|
||||
<style>
|
||||
.profile-content { height: calc(100% - 44px - 50px - 83px); overflow-y: auto; }
|
||||
.profile-header-bg { height: 120px; background: linear-gradient(135deg, #1d9bf0 0%, #0d47a1 100%); position: relative; }
|
||||
.profile-avatar-wrapper { position: absolute; bottom: -40px; left: 16px; }
|
||||
.profile-avatar {
|
||||
width: 80px; height: 80px; border-radius: 50%; border: 4px solid #000;
|
||||
background: linear-gradient(135deg, #1d9bf0 0%, #1a8cd8 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 32px; cursor: pointer;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.profile-avatar:hover .avatar-overlay { opacity: 1; }
|
||||
.avatar-overlay {
|
||||
position: absolute; inset: 0; background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity 0.2s;
|
||||
}
|
||||
.avatar-overlay svg { width: 24px; height: 24px; fill: #fff; }
|
||||
.profile-info { padding: 50px 16px 16px; }
|
||||
.profile-name-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.profile-name { font-size: 20px; font-weight: 700; color: #e7e9ea; }
|
||||
.edit-icon { cursor: pointer; padding: 4px; border-radius: 50%; transition: background 0.2s; }
|
||||
.edit-icon:hover { background: rgba(29,155,240,0.1); }
|
||||
.edit-icon svg { width: 16px; height: 16px; fill: #71767b; }
|
||||
.profile-handle { font-size: 15px; color: #71767b; margin-bottom: 12px; }
|
||||
.profile-bio { font-size: 15px; color: #e7e9ea; line-height: 1.4; margin-bottom: 12px; }
|
||||
.profile-stats { display: flex; gap: 20px; margin-bottom: 16px; }
|
||||
.stat { display: flex; gap: 4px; }
|
||||
.stat-num { font-weight: 700; color: #e7e9ea; font-size: 14px; }
|
||||
.stat-label { color: #71767b; font-size: 14px; }
|
||||
.profile-joined { display: flex; align-items: center; gap: 4px; color: #71767b; font-size: 14px; }
|
||||
.profile-joined svg { width: 18px; height: 18px; fill: #71767b; }
|
||||
.settings-section { border-top: 1px solid #2f3336; padding: 16px; }
|
||||
.settings-title { font-size: 15px; font-weight: 700; color: #e7e9ea; margin-bottom: 16px; }
|
||||
.setting-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 0; border-bottom: 1px solid #2f3336; cursor: pointer;
|
||||
}
|
||||
.setting-item:last-child { border-bottom: none; }
|
||||
.setting-left { display: flex; align-items: center; gap: 12px; }
|
||||
.setting-icon { width: 36px; height: 36px; border-radius: 50%; background: #16181c;
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
.setting-icon svg { width: 20px; height: 20px; fill: #e7e9ea; }
|
||||
.setting-text { font-size: 15px; color: #e7e9ea; }
|
||||
.setting-arrow svg { width: 20px; height: 20px; fill: #71767b; }
|
||||
.logout-btn {
|
||||
margin: 16px; padding: 14px; background: transparent; border: 1px solid #f4212e;
|
||||
border-radius: 9999px; color: #f4212e; font-size: 15px; font-weight: 700;
|
||||
cursor: pointer; transition: background 0.2s; text-align: center;
|
||||
}
|
||||
.logout-btn:hover { background: rgba(244,33,46,0.1); }
|
||||
</style>
|
||||
<div class="phone-container">
|
||||
<div class="page-label">我的页面</div>
|
||||
<div class="phone-frame">
|
||||
<div class="status-bar">9:41</div>
|
||||
<div class="header">
|
||||
<span class="header-title">我的</span>
|
||||
</div>
|
||||
<div class="profile-content">
|
||||
<div class="profile-header-bg">
|
||||
<div class="profile-avatar-wrapper">
|
||||
<div class="profile-avatar">
|
||||
M
|
||||
<div class="avatar-overlay">
|
||||
<svg viewBox="0 0 24 24"><path d="M9.697 3H11v2h-.697l-3 2H5c-.276 0-.5.224-.5.5v11c0 .276.224.5.5.5h14c.276 0 .5-.224.5-.5V10h2v8.5c0 1.381-1.119 2.5-2.5 2.5h-14C3.119 21 2 19.881 2 18.5v-11C2 6.119 3.119 5 4.5 5h1.697l3-2zM12 10.5c-1.38 0-2.5 1.12-2.5 2.5s1.12 2.5 2.5 2.5 2.5-1.12 2.5-2.5-1.12-2.5-2.5-2.5zm0-2c2.485 0 4.5 2.015 4.5 4.5s-2.015 4.5-4.5 4.5-4.5-2.015-4.5-4.5 2.015-4.5 4.5-4.5zM17.5 6H20V3h2v3h3v2h-3v3h-2V8h-2.5V6z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="profile-name-row">
|
||||
<span class="profile-name">Memory 用户</span>
|
||||
<div class="edit-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-handle">@memory_user</div>
|
||||
<div class="profile-bio">记录生活的点点滴滴 ✨ 用 Memory 留住美好时光</div>
|
||||
<div class="profile-stats">
|
||||
<div class="stat"><span class="stat-num">128</span><span class="stat-label">帖子</span></div>
|
||||
<div class="stat"><span class="stat-num">1,024</span><span class="stat-label">获赞</span></div>
|
||||
</div>
|
||||
<div class="profile-joined">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 4V3h2v1h6V3h2v1h1.5C19.89 4 21 5.12 21 6.5v12c0 1.38-1.11 2.5-2.5 2.5h-13C4.12 21 3 19.88 3 18.5v-12C3 5.12 4.12 4 5.5 4H7zm0 2H5.5c-.27 0-.5.22-.5.5v12c0 .28.23.5.5.5h13c.28 0 .5-.22.5-.5v-12c0-.28-.22-.5-.5-.5H17v1h-2V6H9v1H7V6zm0 6h2v2H7v-2zm4 0h2v2h-2v-2zm4 0h2v2h-2v-2z"/></svg>
|
||||
<span>2024年11月 加入</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<div class="settings-title">设置</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-left">
|
||||
<div class="setting-icon"><svg viewBox="0 0 24 24"><path d="M12 11.816c1.355 0 2.872-.15 3.84-1.256.814-.93 1.078-2.368.806-4.392-.38-2.825-2.117-4.512-4.646-4.512S7.734 3.343 7.354 6.168c-.272 2.024-.008 3.462.806 4.392.968 1.107 2.485 1.256 3.84 1.256zM8.84 6.368c.162-1.2.787-3.212 3.16-3.212s2.998 2.013 3.16 3.212c.207 1.55.057 2.627-.45 3.205-.455.52-1.266.743-2.71.743s-2.255-.223-2.71-.743c-.507-.578-.657-1.656-.45-3.205zm11.44 12.868c-.877-3.526-4.282-5.99-8.28-5.99s-7.403 2.464-8.28 5.99c-.172.692-.028 1.4.395 1.94.408.52 1.04.82 1.733.82h12.304c.693 0 1.325-.3 1.733-.82.424-.54.567-1.247.394-1.94z"/></svg></div>
|
||||
<span class="setting-text">编辑个人资料</span>
|
||||
</div>
|
||||
<div class="setting-arrow"><svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg></div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-left">
|
||||
<div class="setting-icon"><svg viewBox="0 0 24 24"><path d="M12 1.75C6.34 1.75 1.75 6.34 1.75 12S6.34 22.25 12 22.25 22.25 17.66 22.25 12 17.66 1.75 12 1.75zm-.25 10.48L10.5 17.5l-2-1.5v-3.5L7.25 9 12 5.5l4.75 3.5-1.25 3.5v3.5l-2 1.5-1.25-5.27z"/></svg></div>
|
||||
<span class="setting-text">隐私设置</span>
|
||||
</div>
|
||||
<div class="setting-arrow"><svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg></div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-left">
|
||||
<div class="setting-icon"><svg viewBox="0 0 24 24"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2c4.411 0 8-3.589 8-8s-3.589-8-8-8-8 3.589-8 8 3.589 8 8 8zm-1-8v4h2v-4h3l-4-4-4 4h3z"/></svg></div>
|
||||
<span class="setting-text">数据备份</span>
|
||||
</div>
|
||||
<div class="setting-arrow"><svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg></div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-left">
|
||||
<div class="setting-icon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg></div>
|
||||
<span class="setting-text">帮助与反馈</span>
|
||||
</div>
|
||||
<div class="setting-arrow"><svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logout-btn">退出登录</div>
|
||||
</div>
|
||||
<div class="bottom-nav">
|
||||
<div class="nav-item"><svg viewBox="0 0 24 24"><path d="M21.591 7.146L12.52 1.157c-.316-.21-.724-.21-1.04 0l-9.071 5.99c-.26.173-.409.456-.409.757v13.183c0 .502.418.913.929.913H9.14c.51 0 .929-.41.929-.913v-7.075h3.909v7.075c0 .502.417.913.928.913h6.165c.511 0 .929-.41.929-.913V7.904c0-.301-.158-.584-.409-.758z"/></svg></div>
|
||||
<div class="nav-item"><svg viewBox="0 0 24 24"><path d="M10.25 3.75c-3.59 0-6.5 2.91-6.5 6.5s2.91 6.5 6.5 6.5c1.795 0 3.419-.726 4.596-1.904 1.178-1.177 1.904-2.801 1.904-4.596 0-3.59-2.91-6.5-6.5-6.5zm-8.5 6.5c0-4.694 3.806-8.5 8.5-8.5s8.5 3.806 8.5 8.5c0 1.986-.682 3.815-1.824 5.262l4.781 4.781-1.414 1.414-4.781-4.781c-1.447 1.142-3.276 1.824-5.262 1.824-4.694 0-8.5-3.806-8.5-8.5z"/></svg></div>
|
||||
<div class="nav-item"><svg viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/></svg></div>
|
||||
<div class="nav-item active"><svg viewBox="0 0 24 24"><path d="M12 11.816c1.355 0 2.872-.15 3.84-1.256.814-.93 1.078-2.368.806-4.392-.38-2.825-2.117-4.512-4.646-4.512S7.734 3.343 7.354 6.168c-.272 2.024-.008 3.462.806 4.392.968 1.107 2.485 1.256 3.84 1.256zM8.84 6.368c.162-1.2.787-3.212 3.16-3.212s2.998 2.013 3.16 3.212c.207 1.55.057 2.627-.45 3.205-.455.52-1.266.743-2.71.743s-2.255-.223-2.71-.743c-.507-.578-.657-1.656-.45-3.205zm11.44 12.868c-.877-3.526-4.282-5.99-8.28-5.99s-7.403 2.464-8.28 5.99c-.172.692-.028 1.4.395 1.94.408.52 1.04.82 1.733.82h12.304c.693 0 1.325-.3 1.733-.82.424-.54.567-1.247.394-1.94zm-1.576 1.016c-.126.16-.316.252-.552.252H5.848c-.235 0-.426-.092-.552-.252-.137-.175-.18-.412-.12-.654.71-2.855 3.517-4.85 6.824-4.85s6.114 1.994 6.824 4.85c.06.242.017.479-.12.654z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
server/.env.example
Normal file
15
server/.env.example
Normal file
@@ -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
|
||||
17
server/Dockerfile
Normal file
17
server/Dockerfile
Normal file
@@ -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"]
|
||||
55
server/go.mod
Normal file
55
server/go.mod
Normal file
@@ -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
|
||||
)
|
||||
128
server/go.sum
Normal file
128
server/go.sum
Normal file
@@ -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=
|
||||
44
server/internal/config/config.go
Normal file
44
server/internal/config/config.go
Normal file
@@ -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
|
||||
}
|
||||
115
server/internal/database/database.go
Normal file
115
server/internal/database/database.go
Normal file
@@ -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
|
||||
}
|
||||
147
server/internal/handler/auth.go
Normal file
147
server/internal/handler/auth.go
Normal file
@@ -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))
|
||||
}
|
||||
119
server/internal/handler/comment.go
Normal file
119
server/internal/handler/comment.go
Normal file
@@ -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"})
|
||||
}
|
||||
291
server/internal/handler/post.go
Normal file
291
server/internal/handler/post.go
Normal file
@@ -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
|
||||
}
|
||||
135
server/internal/handler/search.go
Normal file
135
server/internal/handler/search.go
Normal file
@@ -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)
|
||||
}
|
||||
171
server/internal/handler/upload.go
Normal file
171
server/internal/handler/upload.go
Normal file
@@ -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
|
||||
}
|
||||
126
server/internal/handler/user.go
Normal file
126
server/internal/handler/user.go
Normal file
@@ -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"})
|
||||
}
|
||||
71
server/internal/middleware/auth.go
Normal file
71
server/internal/middleware/auth.go
Normal file
@@ -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)
|
||||
}
|
||||
114
server/internal/model/model.go
Normal file
114
server/internal/model/model.go
Normal file
@@ -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"`
|
||||
}
|
||||
88
server/internal/router/router.go
Normal file
88
server/internal/router/router.go
Normal file
@@ -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
|
||||
}
|
||||
27
server/main.go
Normal file
27
server/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user