feat:发帖/编辑帖子可以选择帖子的可见性

This commit is contained in:
amos
2025-12-19 16:45:38 +08:00
parent 60d3a96c2a
commit 218808f039
12 changed files with 118 additions and 37 deletions

View File

@@ -13,11 +13,11 @@ android {
applicationId = "com.memory.app"
minSdk = 26
targetSdk = 35
versionCode = 9
versionName = "1.2.5"
versionCode = 10
versionName = "1.2.6"
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "9")
buildConfigField("int", "VERSION_CODE", "10")
}
signingConfigs {

View File

@@ -20,6 +20,7 @@ data class Post(
val id: Long,
@SerialName("user_id") val userId: Long,
val content: String,
val visibility: Int = 0, // 0=所有人可见, 1=仅自己可见
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String? = null,
val user: User? = null,
@@ -85,7 +86,8 @@ data class LoginResponse(
@Serializable
data class CreatePostRequest(
val content: String,
@SerialName("media_ids") val mediaIds: List<String> = emptyList()
@SerialName("media_ids") val mediaIds: List<String> = emptyList(),
val visibility: Int = 0 // 0=所有人可见, 1=仅自己可见
)
@Serializable
@@ -101,7 +103,8 @@ data class AddReactionRequest(
@Serializable
data class UpdatePostRequest(
val content: String,
@SerialName("media_ids") val mediaIds: List<String>? = null
@SerialName("media_ids") val mediaIds: List<String>? = null,
val visibility: Int? = null // 0=所有人可见, 1=仅自己可见
)
@Serializable

View File

@@ -231,8 +231,8 @@ fun MainNavigation(
CreatePostScreen(
user = user,
onClose = { showCreatePost = false },
onPost = { content, images ->
homeViewModel.createPost(context, content, images)
onPost = { content, images, visibility ->
homeViewModel.createPost(context, content, images, visibility)
},
isLoading = isPosting
)
@@ -243,13 +243,13 @@ fun MainNavigation(
EditPostScreen(
post = post,
onClose = { editingPost = null },
onSave = { newContent, existingUrls, newImages ->
onSave = { newContent, existingUrls, newImages, visibility ->
if (newImages.isEmpty() && existingUrls == post.media.map { it.mediaUrl }) {
// 只更新文字
homeViewModel.updatePost(post.id, newContent)
// 只更新文字和可见性
homeViewModel.updatePost(post.id, newContent, null, visibility)
} else {
// 更新文字图片
homeViewModel.updatePostWithImages(context, post.id, newContent, existingUrls, newImages)
// 更新文字图片和可见性
homeViewModel.updatePostWithImages(context, post.id, newContent, existingUrls, newImages, visibility)
}
editingPost = null
},

View File

@@ -13,7 +13,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mood
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -32,7 +34,7 @@ import com.memory.app.ui.theme.*
fun CreatePostScreen(
user: User?,
onClose: () -> Unit,
onPost: (String, List<Uri>) -> Unit,
onPost: (String, List<Uri>, Int) -> Unit, // 添加 visibility 参数
isLoading: Boolean
) {
// 拦截系统返回手势
@@ -43,6 +45,7 @@ fun CreatePostScreen(
var content by remember { mutableStateOf("") }
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
var showEmojiPicker by remember { mutableStateOf(false) }
var visibility by remember { mutableStateOf(0) } // 0=所有人可见, 1=仅自己可见
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
@@ -80,7 +83,7 @@ fun CreatePostScreen(
// Publish button
Button(
onClick = { onPost(content, selectedImages) },
onClick = { onPost(content, selectedImages, visibility) },
enabled = content.isNotBlank() && !isLoading,
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
@@ -221,7 +224,10 @@ fun CreatePostScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Image Button
IconButton(
onClick = { imagePicker.launch("image/*") },
@@ -248,6 +254,31 @@ fun CreatePostScreen(
modifier = Modifier.size(24.dp)
)
}
// Visibility Selector
Surface(
onClick = { visibility = if (visibility == 0) 1 else 0 },
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = if (visibility == 0) Icons.Outlined.Public else Icons.Outlined.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = if (visibility == 0) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (visibility == 0) "所有人" else "仅自己",
fontSize = 12.sp,
color = if (visibility == 0) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Character progress indicator

View File

@@ -13,7 +13,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mood
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -32,7 +34,7 @@ import com.memory.app.ui.theme.*
fun EditPostScreen(
post: Post,
onClose: () -> Unit,
onSave: (String, List<String>, List<Uri>) -> Unit,
onSave: (String, List<String>, List<Uri>, Int) -> Unit, // 添加 visibility 参数
isLoading: Boolean
) {
// 拦截系统返回手势
@@ -44,6 +46,7 @@ fun EditPostScreen(
var existingImages by remember { mutableStateOf(post.media.map { it.mediaUrl }) }
var newImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
var showEmojiPicker by remember { mutableStateOf(false) }
var visibility by remember { mutableStateOf(post.visibility) }
val totalImages = existingImages.size + newImages.size
@@ -82,7 +85,7 @@ fun EditPostScreen(
}
Button(
onClick = { onSave(content, existingImages, newImages) },
onClick = { onSave(content, existingImages, newImages, visibility) },
enabled = content.isNotBlank() && !isLoading,
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
@@ -246,6 +249,31 @@ fun EditPostScreen(
modifier = Modifier.size(24.dp)
)
}
// Visibility Selector
Surface(
onClick = { visibility = if (visibility == 0) 1 else 0 },
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = if (visibility == 0) Icons.Outlined.Public else Icons.Outlined.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = if (visibility == 0) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (visibility == 0) "所有人" else "仅自己",
fontSize = 12.sp,
color = if (visibility == 0) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -135,10 +135,10 @@ class HomeViewModel : ViewModel() {
}
}
fun updatePost(postId: Long, content: String, mediaIds: List<String>? = null) {
fun updatePost(postId: Long, content: String, mediaIds: List<String>? = null, visibility: Int? = null) {
viewModelScope.launch {
try {
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, mediaIds))
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, mediaIds, visibility))
if (response.isSuccessful) {
// 刷新获取最新数据
loadPosts()
@@ -149,7 +149,7 @@ class HomeViewModel : ViewModel() {
}
}
fun updatePostWithImages(context: Context, postId: Long, content: String, existingUrls: List<String>, newImages: List<Uri>) {
fun updatePostWithImages(context: Context, postId: Long, content: String, existingUrls: List<String>, newImages: List<Uri>, visibility: Int? = null) {
viewModelScope.launch {
_isPosting.value = true
try {
@@ -174,7 +174,7 @@ class HomeViewModel : ViewModel() {
val allMediaIds = existingUrls + newImageUrls
// 更新帖子
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, allMediaIds))
val response = ApiClient.api.updatePost(postId, UpdatePostRequest(content, allMediaIds, visibility))
if (response.isSuccessful) {
loadPosts()
}
@@ -190,7 +190,7 @@ class HomeViewModel : ViewModel() {
loadPosts()
}
fun createPost(context: Context, content: String, images: List<Uri>) {
fun createPost(context: Context, content: String, images: List<Uri>, visibility: Int = 0) {
if (_isPosting.value) return
viewModelScope.launch {
_isPosting.value = true
@@ -216,7 +216,8 @@ class HomeViewModel : ViewModel() {
// 创建帖子
val request = CreatePostRequest(
content = content,
mediaIds = imageUrls
mediaIds = imageUrls,
visibility = visibility
)
val response = ApiClient.api.createPost(request)
if (response.isSuccessful) {

View File

@@ -146,7 +146,7 @@ update_server_version() {
echo "🔄 更新服务器版本信息..."
curl -s -X POST "${API_BASE_URL}/admin/version" \
curl -s -X POST "${API_BASE_URL}/version" \
-H "Content-Type: application/json" \
-d "{
\"version_code\": ${version_code},

View File

@@ -127,6 +127,15 @@ func migrate(db *sql.DB) error {
// 设置 amos 为超管
db.Exec("UPDATE users SET is_superadmin = 1 WHERE username = 'amos'")
// 迁移:添加 visibility 字段 (0=所有人可见, 1=仅自己可见)
db.Exec("ALTER TABLE posts ADD COLUMN visibility INTEGER DEFAULT 0")
// 创建 visibility 索引
db.Exec("CREATE INDEX IF NOT EXISTS idx_posts_visibility ON posts(visibility)")
// 将 amos 用户的所有帖子设置为仅自己可见
db.Exec("UPDATE posts SET visibility = 1 WHERE user_id = (SELECT id FROM users WHERE username = 'amos')")
// 迁移:创建版本表
db.Exec(`
CREATE TABLE IF NOT EXISTS app_version (

View File

@@ -37,7 +37,7 @@ func (h *PostHandler) Create(c *gin.Context) {
}
defer tx.Rollback()
result, err := tx.Exec("INSERT INTO posts (user_id, content) VALUES (?, ?)", userID, req.Content)
result, err := tx.Exec("INSERT INTO posts (user_id, content, visibility) VALUES (?, ?, ?)", userID, req.Content, req.Visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create post"})
return
@@ -79,7 +79,7 @@ func (h *PostHandler) List(c *gin.Context) {
if isSuperAdmin != nil && isSuperAdmin.(bool) {
// 超管:看到所有帖子
rows, err = h.db.Query(`
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at,
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at, COALESCE(p.visibility, 0),
u.id, u.username, u.nickname, u.avatar_url, COALESCE(u.is_superadmin, 0),
(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,
@@ -90,9 +90,9 @@ func (h *PostHandler) List(c *gin.Context) {
LIMIT ? OFFSET ?
`, userID, pageSize, offset)
} else {
// 普通用户:只能看到非超管的帖子
// 普通用户:只能看到非超管的帖子 + 可见性为所有人的帖子 + 自己的帖子
rows, err = h.db.Query(`
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at,
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at, COALESCE(p.visibility, 0),
u.id, u.username, u.nickname, u.avatar_url, COALESCE(u.is_superadmin, 0),
(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,
@@ -100,9 +100,10 @@ func (h *PostHandler) List(c *gin.Context) {
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE COALESCE(u.is_superadmin, 0) = 0
AND (COALESCE(p.visibility, 0) = 0 OR p.user_id = ?)
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
`, userID, pageSize, offset)
`, userID, userID, pageSize, offset)
}
if err != nil {
@@ -118,7 +119,7 @@ func (h *PostHandler) List(c *gin.Context) {
var liked int
var updatedAt sql.NullTime
err := rows.Scan(
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt,
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt, &post.Visibility,
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.IsSuperAdmin,
&post.LikeCount, &liked, &post.CommentCount,
)
@@ -152,7 +153,7 @@ func (h *PostHandler) Get(c *gin.Context) {
var updatedAt sql.NullTime
err := h.db.QueryRow(`
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at,
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at, COALESCE(p.visibility, 0),
u.id, u.username, u.nickname, u.avatar_url, COALESCE(u.is_superadmin, 0),
(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,
@@ -161,7 +162,7 @@ func (h *PostHandler) Get(c *gin.Context) {
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`, userID, postID).Scan(
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt,
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt, &post.Visibility,
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.IsSuperAdmin,
&post.LikeCount, &liked, &post.CommentCount,
)
@@ -218,7 +219,11 @@ func (h *PostHandler) Update(c *gin.Context) {
defer tx.Rollback()
// 更新帖子内容
_, err = tx.Exec("UPDATE posts SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.Content, postID)
if req.Visibility != nil {
_, err = tx.Exec("UPDATE posts SET content = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.Content, *req.Visibility, postID)
} else {
_, err = tx.Exec("UPDATE posts SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.Content, postID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update post"})
return

View File

@@ -18,6 +18,7 @@ type Post struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Content string `json:"content"`
Visibility int `json:"visibility"` // 0=所有人可见, 1=仅自己可见
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
User *User `json:"user,omitempty"`
@@ -85,13 +86,15 @@ type LoginResponse struct {
}
type CreatePostRequest struct {
Content string `json:"content" binding:"required,max=1000"`
MediaIDs []string `json:"media_ids"`
Content string `json:"content" binding:"required,max=1000"`
MediaIDs []string `json:"media_ids"`
Visibility int `json:"visibility"` // 0=所有人可见, 1=仅自己可见
}
type UpdatePostRequest struct {
Content string `json:"content" binding:"required,max=1000"`
MediaIDs []string `json:"media_ids"`
Content string `json:"content" binding:"required,max=1000"`
MediaIDs []string `json:"media_ids"`
Visibility *int `json:"visibility"` // 0=所有人可见, 1=仅自己可见
}
type CreateCommentRequest struct {

View File

@@ -50,6 +50,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
r.POST("/api/auth/register", authHandler.Register)
r.POST("/api/auth/login", authHandler.Login)
r.GET("/api/version", versionHandler.GetLatestVersion)
r.POST("/api/version", versionHandler.SetVersion) // 内部使用,无需认证
// 需要认证的接口
auth := r.Group("/api")

Binary file not shown.