diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go index fa86a84..132e418 100644 --- a/backend/handler/admin_user.go +++ b/backend/handler/admin_user.go @@ -102,7 +102,7 @@ func (h *Handlers) AdminUserLoginLogs(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取登录历史失败"}) return } - c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{"logs": nonNilSlice(logs), "total": total, "page": page}) } // AdminGetUserAudit 站长:用户内容档案摘要 @@ -131,7 +131,7 @@ func (h *Handlers) AdminUserAuditPosts(c *gin.Context) { respondAdminUserError(c, err) return } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page}) } // AdminUserAuditComments 站长:用户评论(含驳回/软删) @@ -146,7 +146,7 @@ func (h *Handlers) AdminUserAuditComments(c *gin.Context) { respondAdminUserError(c, err) return } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page}) } // AdminUserAuditMessages 站长:用户聊天消息(含撤回/软删) @@ -161,7 +161,7 @@ func (h *Handlers) AdminUserAuditMessages(c *gin.Context) { respondAdminUserError(c, err) return } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page}) } // parseAdminUserID 解析路径中的用户 ID,失败时直接写出 400 diff --git a/backend/handler/announcement.go b/backend/handler/announcement.go index 3c5fcef..e6cac85 100644 --- a/backend/handler/announcement.go +++ b/backend/handler/announcement.go @@ -19,7 +19,7 @@ func (h *Handlers) AnnouncementsList(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"}) return } - c.JSON(http.StatusOK, gin.H{"announcements": list}) + c.JSON(http.StatusOK, gin.H{"announcements": nonNilSlice(list)}) } // AnnouncementDetail 已发布公告详情 @@ -50,7 +50,7 @@ func (h *Handlers) AdminListAnnouncements(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"}) return } - c.JSON(http.StatusOK, gin.H{"announcements": list}) + c.JSON(http.StatusOK, gin.H{"announcements": nonNilSlice(list)}) } // AdminCreateAnnouncement 新建公告 diff --git a/backend/handler/board.go b/backend/handler/board.go index 36801b6..0e75520 100644 --- a/backend/handler/board.go +++ b/backend/handler/board.go @@ -13,5 +13,5 @@ func (h *Handlers) Boards(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"boards": boards}) + c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)}) } diff --git a/backend/handler/chat.go b/backend/handler/chat.go index ebfd55c..28a4e30 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -98,7 +98,7 @@ func (h *Handlers) ListChatRooms(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取会话列表失败"}) return } - c.JSON(http.StatusOK, gin.H{"rooms": rooms, "total": total, "page": page, "size": size}) + c.JSON(http.StatusOK, gin.H{"rooms": nonNilSlice(rooms), "total": total, "page": page, "size": size}) } // GetChatRoom 群详情(公开非成员可预览;私密非成员 404) @@ -273,7 +273,7 @@ func (h *Handlers) ListChatMembers(c *gin.Context) { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"members": list}) + c.JSON(http.StatusOK, gin.H{"members": nonNilSlice(list)}) } // KickChatMember 群主踢人 @@ -315,7 +315,7 @@ func (h *Handlers) ListChatMessages(c *gin.Context) { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"messages": msgs, "has_more": hasMore}) + c.JSON(http.StatusOK, gin.H{"messages": nonNilSlice(msgs), "has_more": hasMore}) } // SendChatMessage 发消息 @@ -387,16 +387,30 @@ func (h *Handlers) RecallChatMessage(c *gin.Context) { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } + data := gin.H{ + "id": msg.ID, + "room_id": roomID, + "sender_id": msg.SenderID, + "recalled_by": msg.RecalledBy, + "recalled_at": msg.RecalledAt, + } + if msg.Recaller != nil && msg.Recaller.ID > 0 { + data["recaller"] = gin.H{ + "id": msg.Recaller.ID, + "username": msg.Recaller.Username, + "nickname": msg.Recaller.Nickname, + "avatar": msg.Recaller.Avatar, + "role": msg.Recaller.Role, + } + } h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ Type: realtime.EventChatRecalled, - Data: gin.H{ - "id": msg.ID, - "room_id": roomID, - "sender_id": msg.SenderID, - "recalled_by": msg.RecalledBy, - "recalled_at": msg.RecalledAt, - }, + Data: data, }) + // 监管者保留正文供界面展示;撤回者本人保留正文供「重新编辑」;其余不回传 + if !oversee && msg.SenderID != claims.ID { + msg.Content = "" + } c.JSON(http.StatusOK, gin.H{"message": msg}) } @@ -444,7 +458,7 @@ func (h *Handlers) SearchChatUsers(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"}) return } - c.JSON(http.StatusOK, gin.H{"users": users}) + c.JSON(http.StatusOK, gin.H{"users": nonNilSlice(users)}) } // ChatUnreadSummary 未读汇总 diff --git a/backend/handler/comment.go b/backend/handler/comment.go index b8a4860..a6ffe63 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -44,7 +44,7 @@ func (h *Handlers) PostComments(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{ - "comments": comments, + "comments": nonNilSlice(comments), "total": floors, // 楼层数(主评论数)→ 前端分页与楼层号计算 "total_comments": totalComments, // 全部评论数(含回复)→ 展示徽标 "page": page, diff --git a/backend/handler/jsonutil.go b/backend/handler/jsonutil.go new file mode 100644 index 0000000..9d83cd3 --- /dev/null +++ b/backend/handler/jsonutil.go @@ -0,0 +1,9 @@ +package handler + +// nonNilSlice 保证 JSON 序列化为 [] 而非 null(Go nil slice 会编成 null)。 +func nonNilSlice[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index c8f22f1..640f940 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -21,7 +21,7 @@ func (h *Handlers) AdminPendingPosts(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取待审帖子失败"}) return } - c.JSON(http.StatusOK, gin.H{"posts": posts, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{"posts": nonNilSlice(posts), "total": total, "page": page}) } // AdminPendingComments 待审核评论分页 @@ -32,7 +32,7 @@ func (h *Handlers) AdminPendingComments(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取待审评论失败"}) return } - c.JSON(http.StatusOK, gin.H{"comments": comments, "total": total, "page": page}) + c.JSON(http.StatusOK, gin.H{"comments": nonNilSlice(comments), "total": total, "page": page}) } // AdminPendingCounts 当前操作者可见的待审数量(导航角标) diff --git a/backend/handler/notification.go b/backend/handler/notification.go index 82fb432..6085b16 100644 --- a/backend/handler/notification.go +++ b/backend/handler/notification.go @@ -20,7 +20,7 @@ func (h *Handlers) Notifications(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{ - "notifications": list, + "notifications": nonNilSlice(list), "total": total, "page": page, "size": size, diff --git a/backend/handler/upload.go b/backend/handler/upload.go index 8ff57f1..98246b0 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -77,7 +77,7 @@ func (h *Handlers) MyMedia(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"attachments": list}) + c.JSON(http.StatusOK, gin.H{"attachments": nonNilSlice(list)}) } // DeleteAttachment 彻底删除本人附件(DB + 物理文件) diff --git a/backend/handler/user.go b/backend/handler/user.go index b880e84..c0ed1c6 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -104,6 +104,9 @@ func (h *Handlers) UserProfile(c *gin.Context) { if user.Role == model.RoleBoardAdmin { boardIDs, _ = h.Auth.GetUserBoardIDs(user.ID) } + if boardIDs == nil { + boardIDs = []uint{} + } c.JSON(http.StatusOK, gin.H{ "user": gin.H{ @@ -122,7 +125,7 @@ func (h *Handlers) UserProfile(c *gin.Context) { "points": pointsTotal, "streak": streak, }, - "posts": posts, + "posts": nonNilSlice(posts), "posts_total": postsTotal, "page": page, "size": size, @@ -152,7 +155,7 @@ func (h *Handlers) UserComments(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "comments": comments, + "comments": nonNilSlice(comments), "total": total, "page": page, "size": size, diff --git a/backend/model/models.go b/backend/model/models.go index 27d8ed4..6ba33f4 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -318,5 +318,7 @@ type ChatMessage struct { CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"` + Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"` + // RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称 + Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"` } diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index 1f86538..a826862 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -652,8 +652,9 @@ func (s *AdminUserService) ListAuditMessages(actor *Actor, userID uint, page, si } content := m.Content recalled := m.RecalledAt != nil + // 历史数据:早期撤回曾清空正文,无法还原 if recalled && content == "" { - content = "(已撤回的消息)" + content = "(撤回时未保留正文)" } it := AdminAuditMessageItem{ ID: m.ID, diff --git a/backend/service/chat.go b/backend/service/chat.go index 05c5a36..9340d74 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -153,6 +153,10 @@ func (s *ChatService) toViews(rooms []model.ChatRoom, userID uint) []RoomView { unread := s.unreadCounts(userID, ids) views := make([]RoomView, 0, len(rooms)) for _, r := range rooms { + // 会话列表预览不向前台下发已撤回正文 + if r.LastMessage != nil && r.LastMessage.RecalledAt != nil { + r.LastMessage.Content = "" + } v := RoomView{ChatRoom: r, UnreadCount: unread[r.ID]} if r.IsDefault { v.Pinned = true @@ -887,7 +891,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over q = q.Where("id < ?", beforeID) } var msgs []model.ChatMessage - if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Find(&msgs).Error; err != nil { + if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil { return nil, false, err } hasMore := len(msgs) > size @@ -897,7 +901,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { msgs[i], msgs[j] = msgs[j], msgs[i] } - // 对非监管者清空已撤回正文 + // 已撤回正文:仅监管者(站长/超管)保留,供聊天界面审计展示;普通成员清空 if !oversee { for i := range msgs { if msgs[i].RecalledAt != nil { @@ -908,7 +912,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over return msgs, hasMore, nil } -// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt 不清行 +// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计 func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, error) { if _, err := s.getRoom(roomID); err != nil { return nil, err @@ -935,17 +939,16 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee } } now := time.Now() + // 只打撤回标记,保留正文供站长审计;前台 ListMessages 对非监管者置空 content if err := s.db.Model(&msg).Updates(map[string]interface{}{ "recalled_at": now, "recalled_by": operatorID, - "content": "", }).Error; err != nil { return nil, err } msg.RecalledAt = &now msg.RecalledBy = operatorID - msg.Content = "" - _ = s.db.Preload("Sender").First(&msg, msg.ID) + _ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID) return &msg, nil } diff --git a/backend/service/comment.go b/backend/service/comment.go index 1ae3df9..d6d879a 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -225,6 +225,9 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil { return nil, 0, err } + if items == nil { + items = []UserCommentItem{} + } return items, total, nil } diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx index ebb0e65..425860c 100644 --- a/frontend/app/admin/AdminNav.tsx +++ b/frontend/app/admin/AdminNav.tsx @@ -22,6 +22,7 @@ import { type RoleUserLike, } from "@/lib/roles"; import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; +import { onDocumentVisible } from "@/lib/visibilityReconcile"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; @@ -85,14 +86,11 @@ function usePendingCounts(enabled: boolean) { }; load(); const off = realtime.on(RT_MODERATION_CHANGED, () => load()); - const onVisible = () => { - if (document.visibilityState === "visible") load(); - }; - document.addEventListener("visibilitychange", onVisible); + const offVisible = onDocumentVisible(load); return () => { alive = false; off(); - document.removeEventListener("visibilitychange", onVisible); + offVisible(); }; }, [enabled]); return counts.posts + counts.comments; diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx index 715aa70..e9ba07e 100644 --- a/frontend/app/admin/users/UsersAdmin.tsx +++ b/frontend/app/admin/users/UsersAdmin.tsx @@ -32,7 +32,7 @@ import { type Board, type LoginLog, } from "@/lib/api"; -import { isOwner, ROLES } from "@/lib/roles"; +import { isOwner, ROLES, ROLE_META } from "@/lib/roles"; import { formatDate, formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import { realtime } from "@/lib/realtime"; @@ -60,7 +60,6 @@ interface RoleOption { value: string; title: string; desc: string; - icon: React.ReactNode; token: string; } @@ -131,9 +130,7 @@ export default function UsersAdmin({ const [loading, setLoading] = useState(false); const [error, setError] = useState(initError); const [actingId, setActingId] = useState(null); - // 封禁二次确认弹窗;null 关闭 - const [banTarget, setBanTarget] = useState(null); - // 角色授权弹窗 / 登录历史弹窗 + // 角色与账号弹窗 / 登录历史弹窗 const [roleTarget, setRoleTarget] = useState(null); const [logsTarget, setLogsTarget] = useState(null); @@ -412,42 +409,33 @@ export default function UsersAdmin({ - ); }; @@ -475,7 +463,7 @@ export default function UsersAdmin({ 用户管理

- 检索全部成员,配置管理角色与板块授权;站长账号受保护,操作即时生效 + 检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护

@@ -738,24 +726,16 @@ export default function UsersAdmin({ disabled={actingId === u.id} onClick={() => setRoleTarget(u)} className="btn btn-line btn-sm" - style={u.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined} - > - - 角色权限 - - )} @@ -811,33 +791,12 @@ export default function UsersAdmin({ setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)} /> - setBanTarget(null)} - onConfirm={async () => { - const u = banTarget; - if (!u) return; - setActingId(u.id); - try { - await apiAdminSetUserBan(u.id, !u.banned); - toast(u.banned ? "已解封" : "已封禁", "ok"); - setBanTarget(null); - await reload(); - } catch (e) { - toast(e instanceof Error ? e.message : "操作失败"); - } finally { - setActingId(null); - } - }} - /> - setLogsTarget(null)} /> ); } -// ===== 角色授权弹窗 ===== +// ===== 角色与账号弹窗(角色授权 + 封禁状态) ===== function RoleAssignDialog({ target, @@ -856,47 +815,35 @@ function RoleAssignDialog({ onSaved: () => void; setActing: (v: boolean) => void; }) { - // 可授予角色(按等级从高到低);超管选项仅站长可见 - const options: RoleOption[] = [ - { - value: ROLES.SUPER_ADMIN, - title: "超级管理员", - desc: "可操作全站后台:用户权限、公告、外观设置、内容审核;站长账号除外", - icon: , - token: "--accent", - }, - { - value: ROLES.ADMIN, - title: "管理员", - desc: "可管理公告,审核全站帖子与评论,可置顶/加精", - icon: , - token: "--accent", - }, - { - value: ROLES.BOARD_ADMIN, - title: "板块管理员", - desc: "仅可审核被授权板块的帖子与评论", - icon: , - token: "--accent", - }, - { - value: ROLES.USER, - title: "普通用户", - desc: "无后台权限;发帖与评论需通过审核", - icon: , - token: "--ink-3", - }, - ].filter((o) => canGrantSuper || o.value !== ROLES.SUPER_ADMIN); + const grantKeys = [ + ROLES.SUPER_ADMIN, + ROLES.ADMIN, + ROLES.BOARD_ADMIN, + ROLES.USER, + ] as const; + + const options: RoleOption[] = grantKeys + .filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN) + .map((k) => { + const meta = ROLE_META[k]; + return { + value: k, + title: meta.label, + desc: meta.grantDesc || "", + token: meta.colorToken, + }; + }); const [role, setRole] = useState(ROLES.USER); const [boardIds, setBoardIds] = useState([]); + const [banned, setBanned] = useState(false); const [err, setErr] = useState(""); - // 每次打开按目标当前角色/授权初始化 useEffect(() => { if (target) { setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role); setBoardIds(target.board_ids ?? []); + setBanned(!!target.banned); setErr(""); } }, [target]); @@ -916,15 +863,36 @@ function RoleAssignDialog({ setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id])); }; + const sameBoards = + JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) === + JSON.stringify([...boardIds].sort((a, b) => a - b)); + const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards); + const banChanged = banned !== !!target.banned; + const willBan = banChanged && banned; + const willUnban = banChanged && !banned; + const submit = async () => { if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) { setErr("请至少选择一个授权板块"); return; } + if (!roleChanged && !banChanged) { + onClose(); + return; + } setActing(true); try { - await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []); - toast("角色权限已更新", "ok"); + if (roleChanged) { + await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []); + } + if (banChanged) { + await apiAdminSetUserBan(target.id, banned); + } + const parts: string[] = []; + if (roleChanged) parts.push("角色已更新"); + if (willBan) parts.push("已封禁"); + if (willUnban) parts.push("已解封"); + toast(parts.join(" · ") || "已保存", "ok"); onSaved(); } catch (e) { toast(e instanceof Error ? e.message : "操作失败"); @@ -933,6 +901,8 @@ function RoleAssignDialog({ } }; + const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存"; + return (
!acting && onClose()} >
e.stopPropagation()} role="dialog" aria-modal="true" - aria-label="设置管理角色" + aria-label="角色与账号" >

- 设置管理角色 + 角色与账号

@{target.username}({target.nickname}) @@ -968,7 +938,14 @@ function RoleAssignDialog({

-
+

+ 角色决定后台入口与前台管理能力;封禁立即强制下线。登录历史、站长内容档案仍走列表其他入口。 +

+ +

+ 管理角色 +

+
{options.map((o) => { const active = role === o.value; return ( @@ -981,7 +958,7 @@ function RoleAssignDialog({ setRole(o.value); setErr(""); }} - className="text-left rounded-xl p-3 flex items-start gap-3 transition-colors" + className="text-left rounded-xl p-3.5 flex items-start gap-3 transition-colors" style={{ border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`, background: active ? "var(--accent-soft)" : "var(--panel-2)", @@ -995,13 +972,13 @@ function RoleAssignDialog({ }} aria-hidden="true" > - {o.icon} + {o.value === ROLES.USER ? : } {o.title} - + {o.desc} @@ -1017,7 +994,6 @@ function RoleAssignDialog({ })}
- {/* 板块授权(仅板块管理员) */} {role === ROLES.BOARD_ADMIN && (

@@ -1060,111 +1036,89 @@ function RoleAssignDialog({ })}

)} - {err &&

{err}

} + {err && ( +

+ {err} +

+ )}
)} +
+

+ 账号状态 +

+
+ + +
+ {willBan && ( +

+ 封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。 +

+ )} +
+
- -
-
-
- ); -} - -// ===== 封禁确认弹窗 ===== - -function BanConfirmDialog({ - target, - busy, - onCancel, - onConfirm, -}: { - target: AdminUser | null; - busy: boolean; - onCancel: () => void; - onConfirm: () => void; -}) { - useEffect(() => { - if (!target) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) onCancel(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [target, busy, onCancel]); - - if (!target) return null; - const banning = !target.banned; - - return ( -
!busy && onCancel()} - > -
e.stopPropagation()} - role="dialog" - aria-modal="true" - > -
- -
-

- {banning ? "封禁账号" : "解除封禁"} -

-

- {banning - ? `封禁后 @${target.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。` - : `解除后 @${target.username} 可以重新登录,并恢复发帖、回复等全部功能。`} -

-
-
-
- - {banning ? ( - - ) : ( - - )}
); } + // ===== 登录历史弹窗 ===== function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) { diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx index 336c390..4c20aac 100644 --- a/frontend/app/chat/ChatShell.tsx +++ b/frontend/app/chat/ChatShell.tsx @@ -22,6 +22,7 @@ import { } from "@/lib/api"; import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime"; import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime"; +import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; @@ -114,28 +115,35 @@ export default function ChatShell({ setRooms(sortRooms(initialRooms)); }, [initialRooms]); - const refreshRooms = useCallback(async () => { + const roomsInFlight = useRef(false); + const lastRoomsFetchAt = useRef(0); + + const refreshRooms = useCallback(async (opts?: { force?: boolean }) => { + const now = Date.now(); + if (!opts?.force) { + if (roomsInFlight.current) return; + // 切房/回前台可合并;建群、被邀请等走 force + if (now - lastRoomsFetchAt.current < 2_000) return; + } + roomsInFlight.current = true; + lastRoomsFetchAt.current = now; try { const res = await apiListChatRooms(1, 50, "", "joined"); setRooms(sortRooms(res.rooms || [])); } catch { // 静默 + } finally { + roomsInFlight.current = false; } }, []); - // 每次进入消息壳都用 HTTP 拉最新排序(避免首页 Link 预取/路由缓存导致新消息会话不置顶) + // 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存) useEffect(() => { - refreshRooms(); + void refreshRooms(); }, [refreshRooms, activeRoomId]); - // 从其他标签页切回时也对账一次 - useEffect(() => { - const onVisible = () => { - if (document.visibilityState === "visible") refreshRooms(); - }; - document.addEventListener("visibilitychange", onVisible); - return () => document.removeEventListener("visibilitychange", onVisible); - }, [refreshRooms]); + // 从其他标签页切回时对账(10s 限频 + 上方 2s 合并) + useEffect(() => onDocumentVisible(() => void refreshRooms()), [refreshRooms]); // 实时未读:更新侧栏角标与预览,按置顶规则重排(不盲目置顶到最前) useEffect(() => { @@ -145,7 +153,7 @@ export default function ChatShell({ setRooms((prev) => { const idx = prev.findIndex((r) => r.id === data.room_id); if (idx < 0) { - refreshRooms(); + void refreshRooms({ force: true }); return prev; } const next = [...prev]; @@ -179,7 +187,7 @@ export default function ChatShell({ const off = realtime.on(RT_CHAT_MEMBERSHIP, (data) => { if (!data) return; if (data.status === "invited" || data.status === "dissolved" || data.status === "kicked") { - refreshRooms(); + void refreshRooms({ force: true }); } }); return off; @@ -230,7 +238,7 @@ export default function ChatShell({ setQuery(""); setUserHits([]); router.push(`/chat/${room.id}`); - refreshRooms(); + void refreshRooms({ force: true }); } catch (e) { toast(e instanceof Error ? e.message : "无法发起私聊"); } @@ -524,7 +532,7 @@ export default function ChatShell({ setShowCreate(false); toast("群已创建", "ok"); router.push(`/chat/${id}`); - refreshRooms(); + void refreshRooms({ force: true }); }} /> )} diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index d296119..4ce2476 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -31,6 +31,7 @@ import { apiRecallChatMessage, type ChatMessage, type ChatRoomMember, + type User, } from "@/lib/api"; import { realtime, @@ -49,6 +50,30 @@ import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; +/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */ +const MARK_READ_GAP_MS = 2_000; +const markReadInflight = new Set(); +const markReadLastAt = new Map(); + +function requestMarkChatRead(roomId: number): void { + const now = Date.now(); + const last = markReadLastAt.get(roomId) || 0; + if (markReadInflight.has(roomId) || now - last < MARK_READ_GAP_MS) return; + markReadInflight.add(roomId); + markReadLastAt.set(roomId, now); + apiMarkChatRead(roomId, 0) + .then(() => { + window.dispatchEvent(new Event("j13-chat-unread-sync")); + }) + .catch(() => {}) + .finally(() => { + markReadInflight.delete(roomId); + }); +} + +/** 成员列表:并发去重(进房 effect 与面板刷新) */ +const membersInflight = new Set(); + type RightPanel = null | "members" | "settings" | "invite"; type ConfirmState = { @@ -59,18 +84,84 @@ type ConfirmState = { action: () => Promise | void; } | null; -function recallLabel(m: ChatMessage, meId: number): string { - if (!m.recalled_at) return ""; - // 操作者不是发送者 → 管理员/监管撤回 - if (m.recalled_by && m.recalled_by !== m.sender_id) { - return "管理员撤回了此消息"; +/** 撤回提示:本人 / 他人撤回 / 管理员代撤 */ +function RecallNotice({ + message, + meId, + members, +}: { + message: ChatMessage; + meId: number; + members: ChatRoomMember[]; +}) { + if (!message.recalled_at) return null; + const by = + message.recalled_by || + message.recaller?.id || + message.sender_id; + // 操作者不是原发送者 → 监管代撤 + const adminRecall = !!(by && by !== message.sender_id); + + if (by === meId) { + return ( + + {adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"} + + ); } - if (m.sender_id === meId || m.recalled_by === meId) { - return "你撤回了一条消息"; + + let recaller: User | null | undefined = + message.recaller && message.recaller.id + ? message.recaller + : by === message.sender_id + ? message.sender + : null; + if ((!recaller || !recaller.id) && by > 0) { + const hit = members.find((x) => x.user_id === by); + if (hit) recaller = hit.user; } - return "此消息已撤回"; + const name = + (recaller?.nickname || recaller?.username || "").trim() || "有人"; + const uid = recaller?.id || by; + const nameStyle = { + color: "color-mix(in srgb, var(--accent) 72%, var(--ink))", + } as const; + const nameNode = + uid > 0 ? ( + + {name} + + ) : ( + + {name} + + ); + + if (adminRecall) { + return ( + + 管理员 + {nameNode} + 撤回了一条成员消息 + + ); + } + return ( + + {nameNode} + 撤回了一条消息 + + ); } +/** 撤回后「重新编辑」有效窗口(接近微信体验) */ +const RE_EDIT_MS = 2 * 60 * 1000; + interface Props { roomId: number; roomName: string; @@ -236,6 +327,10 @@ export default function ChatRoomClient({ const [mentionIndex, setMentionIndex] = useState(0); /** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */ const [mentionTokens, setMentionTokens] = useState>(() => new Set()); + /** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */ + const [reEditDrafts, setReEditDrafts] = useState< + Record + >({}); const longPressTimer = useRef | null>(null); const listRef = useRef(null); @@ -250,6 +345,40 @@ export default function ChatRoomClient({ const oversee = canOverseeChat(meRole); const canManageInvite = !isDirect && (isOwner || oversee); + const rememberReEdit = useCallback((id: number, text: string) => { + const t = text.trim(); + if (!t) return; + setReEditDrafts((prev) => ({ + ...prev, + [id]: { text: t, until: Date.now() + RE_EDIT_MS }, + })); + }, []); + + const takeReEditDraft = useCallback((id: number): string | null => { + const d = reEditDrafts[id]; + if (!d || Date.now() > d.until) return null; + return d.text; + }, [reEditDrafts]); + + // 到期后清掉「重新编辑」,避免按钮残留 + useEffect(() => { + const ids = Object.keys(reEditDrafts); + if (ids.length === 0) return; + const nearest = Math.min(...Object.values(reEditDrafts).map((d) => d.until)); + const wait = Math.max(0, nearest - Date.now()) + 50; + const t = setTimeout(() => { + setReEditDrafts((prev) => { + const now = Date.now(); + const next: typeof prev = {}; + for (const [k, v] of Object.entries(prev)) { + if (v.until > now) next[Number(k)] = v; + } + return next; + }); + }, wait); + return () => clearTimeout(t); + }, [reEditDrafts]); + const scrollToBottom = useCallback((smooth = false) => { const el = listRef.current; if (!el) return; @@ -261,34 +390,36 @@ export default function ChatRoomClient({ const refreshMembers = useCallback(async () => { if (!canView) return; + if (membersInflight.has(roomId)) return; + membersInflight.add(roomId); try { const res = await apiListChatMembers(roomId); setMembers(res.members || []); if (res.members) setMemberCount(res.members.length); } catch { // 静默:@ 功能降级为纯文本 + } finally { + membersInflight.delete(roomId); } }, [roomId, canView]); const markReadToLatest = useCallback(() => { if (!isRealMember) return; - apiMarkChatRead(roomId, 0) - .then(() => { - window.dispatchEvent(new Event("j13-chat-unread-sync")); - }) - .catch(() => {}); + requestMarkChatRead(roomId); }, [roomId, isRealMember]); useEffect(() => { if (!canView) return; realtime.joinRoom(roomId); - refreshMembers(); + void refreshMembers(); requestAnimationFrame(() => scrollToBottom(false)); markReadToLatest(); return () => { realtime.leaveRoom(roomId); }; - }, [roomId, canView, refreshMembers, scrollToBottom, markReadToLatest]); + // 只随房间切换订阅/首拉;避免回调身份变化重复打 members/read + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId, canView]); useEffect(() => { const off = realtime.on(RT_CHAT_MESSAGE, (data) => { @@ -315,21 +446,34 @@ export default function ChatRoomClient({ const off = realtime.on(RT_CHAT_RECALLED, (data) => { if (!data || data.room_id !== roomId) return; setMessages((prev) => - prev.map((m) => - m.id === data.id - ? { - ...m, - content: "", - recalled_at: data.recalled_at || new Date().toISOString(), - recalled_by: data.recalled_by, - sender_id: data.sender_id || m.sender_id, - } - : m - ) + prev.map((m) => { + if (m.id !== data.id) return m; + // 本人撤回:草稿留给「重新编辑」,气泡不展示正文 + if (m.sender_id === meId && m.content) { + rememberReEdit(m.id, m.content); + } + return { + ...m, + // 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示 + content: oversee ? m.content : "", + recalled_at: data.recalled_at || new Date().toISOString(), + recalled_by: data.recalled_by, + sender_id: data.sender_id || m.sender_id, + recaller: data.recaller + ? { + id: data.recaller.id, + username: data.recaller.username, + nickname: data.recaller.nickname, + avatar: data.recaller.avatar, + role: data.recaller.role || "user", + } + : m.recaller, + }; + }) ); }); return off; - }, [roomId]); + }, [roomId, oversee, meId, rememberReEdit]); useEffect(() => { const off = realtime.on( @@ -359,7 +503,7 @@ export default function ChatRoomClient({ try { const before = messages[0].id; const res = await apiListChatMessages(roomId, before, 30); - if (res.messages.length > 0) { + if ((res.messages || []).length > 0) { setMessages((prev) => [...res.messages, ...prev]); setHasMore(res.has_more); requestAnimationFrame(() => { @@ -672,6 +816,7 @@ export default function ChatRoomClient({ }; const handleRecall = (mid: number) => { + const snapshot = messages.find((m) => m.id === mid); setCtxMenu(null); setConfirm({ title: "撤回消息", @@ -680,14 +825,18 @@ export default function ChatRoomClient({ danger: true, action: async () => { const { message } = await apiRecallChatMessage(roomId, mid); + const body = (message.content || snapshot?.content || "").trim(); + const mine = (snapshot?.sender_id ?? message.sender_id) === meId; + if (mine && body) rememberReEdit(mid, body); setMessages((prev) => prev.map((m) => m.id === mid ? { ...m, - content: "", + content: oversee ? body || m.content : "", recalled_at: message.recalled_at || new Date().toISOString(), recalled_by: message.recalled_by, + recaller: message.recaller || m.recaller, } : m ) @@ -697,6 +846,24 @@ export default function ChatRoomClient({ }); }; + const handleReEdit = (mid: number) => { + const text = takeReEditDraft(mid); + if (!text) { + toast("重新编辑已过期"); + return; + } + setReplyTo(null); + setContent(text); + requestAnimationFrame(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(text.length, text.length); + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }); + }; + const scrollToMsg = (id: number) => { const el = msgRefs.current.get(id); el?.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -925,6 +1092,114 @@ export default function ChatRoomClient({ new Date(m.created_at).getTime() - new Date(prev.created_at).getTime() < 5 * 60_000; + const reEditText = mine ? takeReEditDraft(m.id) : null; + + // 普通成员:居中系统提示(本人可重新编辑);不展示原文 + if (recalled && !(oversee && m.content)) { + return ( +
{ + if (node) msgRefs.current.set(m.id, node); + else msgRefs.current.delete(m.id); + }} + className="flex justify-center px-2 py-0.5" + > +

+ + {reEditText ? ( + <> + {" "} + + + ) : null} +

+
+ ); + } + + // 站长/超管:单气泡呈现撤回原文(可读,非双气泡) + if (recalled && oversee && m.content) { + return ( +
{ + if (node) msgRefs.current.set(m.id, node); + else msgRefs.current.delete(m.id); + }} + className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`} + > +
+ +
+
+
+ {!isDirect && m.sender_id === ownerId && ( + + )} + + {m.sender.nickname} + + + {formatTime(m.created_at)} + +
+
+
+ + 已撤回 +
+
+ {highlightMentions(m.content, members)} +
+
+
+
+ ); + } + return (
)} - {recalled ? ( -
- {recallLabel(m, meId)} -
- ) : ( -
{ - e.preventDefault(); - e.stopPropagation(); - openMsgMenu(m, e.clientX, e.clientY); - }} - onTouchStart={(e) => { - const t = e.touches[0]; - if (!t) return; - clearLongPress(); - longPressTimer.current = setTimeout(() => { - openMsgMenu(m, t.clientX, t.clientY); - }, 480); - }} - onTouchEnd={clearLongPress} - onTouchMove={clearLongPress} - style={ - mine - ? { - background: - "color-mix(in srgb, var(--accent) 14%, var(--panel))", - color: "var(--ink)", - borderBottomRightRadius: 4, - } - : { - background: "var(--tint)", - color: "var(--ink)", - borderBottomLeftRadius: 4, - } - } - > - {!!m.reply_to_id && m.reply_snap && ( - - )} - {highlightMentions(m.content, members)} -
- )} +
{ + e.preventDefault(); + e.stopPropagation(); + openMsgMenu(m, e.clientX, e.clientY); + }} + onTouchStart={(e) => { + const t = e.touches[0]; + if (!t) return; + clearLongPress(); + longPressTimer.current = setTimeout(() => { + openMsgMenu(m, t.clientX, t.clientY); + }, 480); + }} + onTouchEnd={clearLongPress} + onTouchMove={clearLongPress} + style={ + mine + ? { + background: + "color-mix(in srgb, var(--accent) 14%, var(--panel))", + color: "var(--ink)", + borderBottomRightRadius: 4, + } + : { + background: "var(--tint)", + color: "var(--ink)", + borderBottomLeftRadius: 4, + } + } + > + {!!m.reply_to_id && m.reply_snap && ( + + )} + {highlightMentions(m.content, members)} +
); diff --git a/frontend/app/u/[id]/ProfileForm.tsx b/frontend/app/u/[id]/ProfileForm.tsx index f48692c..f456575 100644 --- a/frontend/app/u/[id]/ProfileForm.tsx +++ b/frontend/app/u/[id]/ProfileForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { Check } from "lucide-react"; import { apiUpdateProfile, type User } from "@/lib/api"; @@ -23,6 +23,34 @@ export default function ProfileForm({ user }: ProfileFormProps) { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const [avatarDragOver, setAvatarDragOver] = useState(false); + const [incomingAvatarFile, setIncomingAvatarFile] = useState(null); + + const clearIncomingAvatar = useCallback(() => setIncomingAvatarFile(null), []); + + const handleAvatarDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes("Files")) setAvatarDragOver(true); + }; + + const handleAvatarDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // 仅在离开整块卡片时取消高亮(避免子元素冒泡闪烁) + const related = e.relatedTarget as Node | null; + if (related && e.currentTarget.contains(related)) return; + setAvatarDragOver(false); + }; + + const handleAvatarDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setAvatarDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (!file) return; + setIncomingAvatarFile(file); + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -72,20 +100,32 @@ export default function ProfileForm({ user }: ProfileFormProps) { )} {error &&
{error}
} - {/* 头像:点击上传/裁剪,或从历史图片中选择 */} + {/* 头像:点击上传/裁剪,或拖放图片到本区域 */}

头像

- 点击头像上传新图片,支持拖动裁剪;统一转为 WebP,裁剪后不超过 2MB + {avatarDragOver + ? "松开即可上传并裁剪" + : "点击头像或拖放图片到此处;支持拖动裁剪,统一转为 WebP,裁剪后不超过 2MB"}

diff --git a/frontend/app/u/[id]/UserProfileContent.tsx b/frontend/app/u/[id]/UserProfileContent.tsx index 63a0ea5..4af51f2 100644 --- a/frontend/app/u/[id]/UserProfileContent.tsx +++ b/frontend/app/u/[id]/UserProfileContent.tsx @@ -28,7 +28,7 @@ export default function UserProfileContent({
{tab === "posts" ? (
- {profile.posts.length > 0 ? ( + {Array.isArray(profile.posts) && profile.posts.length > 0 ? (
{profile.posts.map((post, i) => ( 网络异常,请稍后重试

重新加载
- ) : commentData && commentData.comments.length > 0 ? ( + ) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
- {commentData.comments.map((c, i) => ( + {(commentData.comments ?? []).map((c, i) => ( void; + /** 外部拖入的图片:打开后直接进入裁剪 */ + seedFile?: File | null; + /** 已消费 seedFile(成功或校验失败后均调用,便于父级清空) */ + onSeedConsumed?: () => void; } // 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准) const SOURCE_MAX_BYTES = 15 * 1024 * 1024; export default function AvatarCropModal({ - open, onClose, currentAvatar, onChanged, + open, onClose, currentAvatar, onChanged, seedFile, onSeedConsumed, }: AvatarCropModalProps) { const [media, setMedia] = useState([]); const [imageSrc, setImageSrc] = useState(null); // blob: 或同源 /uploads URL @@ -37,6 +41,7 @@ export default function AvatarCropModal({ const [confirmDeleteId, setConfirmDeleteId] = useState(null); // 正在"直接选用"的历史头像 id(区别于裁剪上传,走 PUT /avatar/use) const [usingId, setUsingId] = useState(null); + const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); const confirmTimer = useRef | null>(null); @@ -64,6 +69,7 @@ export default function AvatarCropModal({ setBusy(false); setConfirmDeleteId(null); setUsingId(null); + setDragOver(false); } }, [open]); @@ -85,13 +91,36 @@ export default function AvatarCropModal({ setPixelCrop(areaPixels); }, []); - const enterCrop = (src: string, isBlob: boolean) => { + const enterCrop = useCallback((src: string, isBlob: boolean) => { setError(""); - setImageSrc(src); + setImageSrc((prev) => { + if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev); + return src; + }); setSrcIsBlob(isBlob); setCrop({ x: 0, y: 0 }); setZoom(1); - }; + }, []); + + /** 校验并进入裁剪;返回错误文案,成功返回空串 */ + const acceptImageFile = useCallback((file: File): string => { + if (!file.type.startsWith("image/")) { + return "请选择图片文件(JPG / PNG / GIF / WebP)"; + } + if (file.size > SOURCE_MAX_BYTES) { + return "原图不能超过 15MB,裁剪后的头像需在 2MB 以内"; + } + enterCrop(URL.createObjectURL(file), true); + return ""; + }, [enterCrop]); + + // 外部拖入:弹窗打开后立刻进入裁剪 + useEffect(() => { + if (!open || !seedFile) return; + const errMsg = acceptImageFile(seedFile); + if (errMsg) setError(errMsg); + onSeedConsumed?.(); + }, [open, seedFile, acceptImageFile, onSeedConsumed]); /** * 点击媒体库图片: @@ -128,15 +157,31 @@ export default function AvatarCropModal({ const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; - if (!file.type.startsWith("image/")) { - setError("请选择图片文件(JPG / PNG / GIF / WebP)"); - return; - } - if (file.size > SOURCE_MAX_BYTES) { - setError("原图不能超过 15MB,裁剪后的头像需在 2MB 以内"); - return; - } - enterCrop(URL.createObjectURL(file), true); + const errMsg = acceptImageFile(file); + if (errMsg) setError(errMsg); + }; + + const onDropZoneDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!busy) setDragOver(true); + }; + + const onDropZoneDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }; + + const onDropZoneDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + if (busy) return; + const file = e.dataTransfer.files?.[0]; + if (!file) return; + const errMsg = acceptImageFile(file); + if (errMsg) setError(errMsg); }; const backToLibrary = () => { @@ -233,14 +278,19 @@ export default function AvatarCropModal({ {!imageSrc ? ( <> - {/* 选择新图片 */} + {/* 选择新图片:点击或拖放 */}