feat:超管显示功能

This commit is contained in:
amos
2025-12-19 10:45:57 +08:00
parent afdef11ea4
commit d375c967e7
7 changed files with 427 additions and 14 deletions

View File

@@ -83,6 +83,9 @@ interface ApiService {
@PUT("user/avatar")
suspend fun updateAvatar(@Body request: Map<String, String>): Response<MessageResponse>
@GET("user/{id}")
suspend fun getUserInfo(@Path("id") userId: Long): Response<UserInfoResponse>
// Search
@GET("search")
suspend fun search(

View File

@@ -11,6 +11,7 @@ data class User(
@SerialName("avatar_url") val avatarUrl: String = "",
val bio: String = "",
@SerialName("is_admin") val isAdmin: Boolean = false,
@SerialName("is_superadmin") val isSuperAdmin: Boolean = false,
@SerialName("created_at") val createdAt: String = ""
)
@@ -137,6 +138,26 @@ data class ProfileResponse(
}
}
@Serializable
data class UserInfoResponse(
val user: User,
@SerialName("post_count") val postCount: Int,
@SerialName("like_count") val likeCount: Int
) {
fun getDaysSinceRegistration(): Int {
return try {
val createdAt = user.createdAt
if (createdAt.isEmpty()) return 0
val formatter = java.time.format.DateTimeFormatter.ISO_DATE_TIME
val createdDate = java.time.LocalDateTime.parse(createdAt.replace(" ", "T").substringBefore("+").substringBefore("Z"), formatter).toLocalDate()
val today = java.time.LocalDate.now()
java.time.temporal.ChronoUnit.DAYS.between(createdDate, today).toInt() + 1
} catch (e: Exception) {
1
}
}
}
@Serializable
data class IdResponse(
val id: Long

View File

@@ -18,9 +18,17 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Mood
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Shield
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.Canvas
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.geometry.Offset
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateMap
@@ -29,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
@@ -38,9 +47,12 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.memory.app.data.api.ApiClient
import com.memory.app.data.model.Post
import com.memory.app.data.model.UserInfoResponse
import com.memory.app.ui.theme.*
import com.memory.app.util.TimeUtils
import kotlinx.coroutines.launch
@Composable
fun PostCard(
@@ -55,6 +67,7 @@ fun PostCard(
) {
var showEmojiPicker by remember { mutableStateOf(false) }
var showImageViewerIndex by remember { mutableStateOf<Int?>(null) }
var showUserInfo by remember { mutableStateOf(false) }
val canEdit = currentUserId > 0 && post.userId == currentUserId
Column(
@@ -65,12 +78,15 @@ fun PostCard(
.padding(horizontal = 20.dp, vertical = 20.dp)
) {
Row(modifier = Modifier.fillMaxWidth()) {
// Avatar
// Avatar - 点击显示用户信息
val isSuperAdmin = post.user?.isSuperAdmin == true
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { showUserInfo = true },
contentAlignment = Alignment.Center
) {
if (post.user?.avatarUrl?.isNotEmpty() == true) {
@@ -101,14 +117,19 @@ fun PostCard(
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
// 认证徽章
// 超管/认证徽章
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = "已认证",
tint = Brand500,
modifier = Modifier.size(18.dp)
)
if (isSuperAdmin) {
// 超管专属徽章 - 彩虹渐变盾牌
RainbowShieldBadge(size = 18.dp)
} else {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = "已认证",
tint = Brand500,
modifier = Modifier.size(18.dp)
)
}
Text(
text = " · ${TimeUtils.formatRelative(post.createdAt)}",
fontSize = 12.sp,
@@ -246,6 +267,14 @@ fun PostCard(
onDismiss = { showImageViewerIndex = null }
)
}
// User Info Dialog
if (showUserInfo && post.user != null) {
UserInfoDialog(
userId = post.userId,
onDismiss = { showUserInfo = false }
)
}
}
@Composable
@@ -381,6 +410,334 @@ fun EmojiPickerDialog(
}
}
@Composable
fun UserInfoDialog(
userId: Long,
onDismiss: () -> Unit
) {
var userInfo by remember { mutableStateOf<UserInfoResponse?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var showAvatarViewer by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(userId) {
scope.launch {
try {
val response = ApiClient.api.getUserInfo(userId)
if (response.isSuccessful) {
userInfo = response.body()
} else {
error = "加载失败"
}
} catch (e: Exception) {
error = "网络错误"
} finally {
isLoading = false
}
}
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(56.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = Brand500,
strokeWidth = 2.5.dp
)
}
}
error != null -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = error!!,
color = MaterialTheme.colorScheme.error,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = onDismiss) {
Text("关闭", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
userInfo != null -> {
val info = userInfo!!
val isSuperAdmin = info.user.isSuperAdmin
Column(modifier = Modifier.fillMaxWidth()) {
// 关闭按钮
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "关闭",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
// 头像和基本信息
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 头像 - 可点击放大
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
if (info.user.avatarUrl.isNotEmpty()) {
showAvatarViewer = true
}
},
contentAlignment = Alignment.Center
) {
if (info.user.avatarUrl.isNotEmpty()) {
AsyncImage(
model = info.user.avatarUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Text(
text = info.user.nickname.firstOrNull()?.toString() ?: "?",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
fontSize = 26.sp
)
}
}
Spacer(modifier = Modifier.height(14.dp))
// 用户名和徽章
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = info.user.nickname.ifEmpty { info.user.username },
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(6.dp))
if (isSuperAdmin) {
RainbowShieldBadge(size = 20.dp)
} else {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
tint = Brand500,
modifier = Modifier.size(18.dp)
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "@${info.user.username}",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 简介
if (info.user.bio.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = info.user.bio,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 18.sp
)
}
}
// 统计数据卡片
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 20.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
UserStatItem(
value = info.postCount.toString(),
label = "帖子"
)
// 分隔线
Box(
modifier = Modifier
.width(1.dp)
.height(32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
)
UserStatItem(
value = info.likeCount.toString(),
label = "获赞"
)
// 分隔线
Box(
modifier = Modifier
.width(1.dp)
.height(32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
)
UserStatItem(
value = "${info.getDaysSinceRegistration()}",
label = ""
)
}
}
}
}
}
}
}
// 头像放大查看
if (showAvatarViewer && userInfo?.user?.avatarUrl?.isNotEmpty() == true) {
Dialog(
onDismissRequest = { showAvatarViewer = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f))
.clickable { showAvatarViewer = false },
contentAlignment = Alignment.Center
) {
AsyncImage(
model = userInfo!!.user.avatarUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth(0.85f)
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop
)
// 关闭按钮
IconButton(
onClick = { showAvatarViewer = false },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.size(40.dp)
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "关闭",
tint = Color.White
)
}
}
}
}
}
@Composable
private fun UserStatItem(
value: String,
label: String
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = label,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 彩虹渐变盾牌徽章
@Composable
fun RainbowShieldBadge(
size: androidx.compose.ui.unit.Dp = 18.dp,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color(0xFFFF6B6B), // 红
Color(0xFFFF9F43), // 橙
Color(0xFFFECA57), // 黄
Color(0xFF5CD85A), // 绿
Color(0xFF54A0FF), // 蓝
Color(0xFF9B59B6), // 紫
Color(0xFFFF6B6B) // 红(循环)
)
val rainbowBrush = Brush.sweepGradient(rainbowColors)
Box(
modifier = modifier.size(size),
contentAlignment = Alignment.Center
) {
// 彩虹渐变背景圆环
Canvas(modifier = Modifier.size(size)) {
drawCircle(
brush = rainbowBrush,
radius = size.toPx() / 2 - 1.dp.toPx(),
style = Stroke(width = 2.dp.toPx())
)
}
// 盾牌图标
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = "超级管理员",
tint = Color(0xFF54A0FF),
modifier = Modifier.size(size * 0.7f)
)
}
}
@Composable
fun EditPostDialog(
post: Post,

View File

@@ -80,7 +80,7 @@ func (h *PostHandler) List(c *gin.Context) {
// 超管:看到所有帖子
rows, err = h.db.Query(`
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at,
u.id, u.username, u.nickname, u.avatar_url,
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,
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
@@ -93,7 +93,7 @@ func (h *PostHandler) List(c *gin.Context) {
// 普通用户:只能看到非超管的帖子
rows, err = h.db.Query(`
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at,
u.id, u.username, u.nickname, u.avatar_url,
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,
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
@@ -119,7 +119,7 @@ func (h *PostHandler) List(c *gin.Context) {
var updatedAt sql.NullTime
err := rows.Scan(
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt,
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL,
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.IsSuperAdmin,
&post.LikeCount, &liked, &post.CommentCount,
)
if err != nil {
@@ -153,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,
u.id, u.username, u.nickname, u.avatar_url,
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,
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
@@ -162,7 +162,7 @@ func (h *PostHandler) Get(c *gin.Context) {
WHERE p.id = ?
`, userID, postID).Scan(
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt,
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL,
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.IsSuperAdmin,
&post.LikeCount, &liked, &post.CommentCount,
)

View File

@@ -88,6 +88,37 @@ func (h *UserHandler) UpdateAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "avatar updated"})
}
// 获取指定用户的公开信息
func (h *UserHandler) GetUserInfo(c *gin.Context) {
targetUserID := c.Param("id")
var user model.User
err := h.db.QueryRow(`
SELECT id, username, nickname, avatar_url, bio, COALESCE(is_superadmin, 0), created_at
FROM users WHERE id = ?
`, targetUserID).Scan(&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.Bio, &user.IsSuperAdmin, &user.CreatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
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 = ?", targetUserID).Scan(&postCount)
h.db.QueryRow("SELECT COUNT(*) FROM likes l JOIN posts p ON l.post_id = p.id WHERE p.user_id = ?", targetUserID).Scan(&likeCount)
c.JSON(http.StatusOK, gin.H{
"user": user,
"post_count": postCount,
"like_count": likeCount,
})
}
// 管理员接口
func (h *UserHandler) GetSettings(c *gin.Context) {
rows, err := h.db.Query("SELECT key, value FROM settings")

View File

@@ -71,6 +71,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
auth.GET("/user/profile", userHandler.GetProfile)
auth.PUT("/user/profile", userHandler.UpdateProfile)
auth.PUT("/user/avatar", userHandler.UpdateAvatar)
auth.GET("/user/:id", userHandler.GetUserInfo)
// 搜索
auth.GET("/search", searchHandler.Search)

Binary file not shown.