feat:项目完成

This commit is contained in:
amos wong
2025-12-14 20:33:33 +08:00
commit 0edaa855db
62 changed files with 6542 additions and 0 deletions

33
.gitignore vendored Normal file
View 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
View 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

View 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
View 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.** { *; }

View 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>

View 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
}
}
)
}
}
}
}
}
}
}

View File

@@ -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
}

View 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>
}

View 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
)

View File

@@ -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)
}
}
}

View File

@@ -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
)
}
}
}
}
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}
}
}
}

View 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))
}
}
}
}
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View 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
)
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View 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
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View 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
View 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
View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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=

View 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
}

View 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
}

View 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))
}

View 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"})
}

View 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
}

View 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)
}

View 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
}

View 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"})
}

View 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)
}

View 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"`
}

View 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
View 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)
}
}