Compare commits
28 Commits
72c08f3689
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ba7799fe | |||
| 8bf6ac9e51 | |||
| 2ea9788ec7 | |||
| be729c29a1 | |||
| 584d7fd146 | |||
| 6c9a4414cb | |||
| 864a488ee0 | |||
| fb9724faed | |||
| 58aabb9bf0 | |||
| ae15025b1d | |||
| be8ce04334 | |||
| 4ac4f64f33 | |||
| 2d05da6709 | |||
| 1af9fba291 | |||
| 4442da31c3 | |||
| 0af6f7c971 | |||
| b74ce2da93 | |||
| fcff28ab60 | |||
| ea2f196ff7 | |||
| b0a1ff3c3f | |||
| a806532d25 | |||
| ca21ae7216 | |||
| 7d944cfa35 | |||
| dd6efd0c50 | |||
| fa3b20d1bc | |||
| 543325105c | |||
| 983f539268 | |||
| ee313675ca |
@ -1,3 +1,10 @@
|
||||
> [!IMPORTANT]
|
||||
> **本项目已迁移至 [MetaZone Community Engine (MCE)](https://git.metazone.cc/MetaZone/mce)**
|
||||
>
|
||||
> MetaLab 已更名为 MCE 并继续开发。此仓库仅为历史存档,不再接受 Pull Request、Issue 或任何代码更新。
|
||||
>
|
||||
> 请前往 👉 **[mce](https://git.metazone.cc/MetaZone/mce)** 获取最新版本。
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26">
|
||||
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
||||
|
||||
@ -15,8 +15,8 @@ const (
|
||||
)
|
||||
|
||||
// SetSessionCookie 写入会话 ID Cookie
|
||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
||||
// rememberMe=false → Cookie session 模式(maxAge=0),关浏览器即清除
|
||||
// rememberMe=true → Cookie maxAge=30天,关浏览器后仍保持登录
|
||||
// rememberMe=false → Cookie maxAge=空闲超时(默认2h),与Redis TTL一致,关浏览器后自动清除
|
||||
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
@ -25,7 +25,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
|
||||
if rememberMe {
|
||||
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
||||
} else {
|
||||
maxAge = 0 // session cookie
|
||||
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
|
||||
}
|
||||
|
||||
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
||||
|
||||
@ -23,7 +23,9 @@ func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
|
||||
// CommentsPage SSR 页面
|
||||
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "评论管理",
|
||||
"Title": "评论管理",
|
||||
"ExtraCSS": "/admin/static/css/comments.css",
|
||||
"ExtraJS": "/admin/static/js/comments.js",
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -50,17 +50,17 @@ func (ac *AuditController) ListAudits(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 为每个审核记录附加提交者的用户名
|
||||
// 为每个审核记录附加提交者的当前用户名
|
||||
type auditItem struct {
|
||||
model.AuditSubmission
|
||||
SubmitterUsername string `json:"submitter_username"`
|
||||
CurrentUsername string `json:"current_username"`
|
||||
}
|
||||
|
||||
items := make([]auditItem, 0, len(result.Items))
|
||||
for _, a := range result.Items {
|
||||
item := auditItem{AuditSubmission: a}
|
||||
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
|
||||
item.SubmitterUsername = user.Username
|
||||
item.CurrentUsername = user.Username
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ func (ctrl *CommentController) Delete(c *gin.Context) {
|
||||
|
||||
// SearchUsers GET /api/users/search?q=keyword
|
||||
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||
_, _, ok := common.GetGinUser(c)
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
@ -53,7 +53,7 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
users, err := ctrl.commentService.SearchUsers(q)
|
||||
users, err := ctrl.commentService.SearchUsers(q, uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
||||
return
|
||||
|
||||
@ -37,7 +37,7 @@ func (ec *EnergyController) Energize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
expGained, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case common.ErrPostNotFound:
|
||||
@ -56,21 +56,30 @@ func (ec *EnergyController) Energize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
truncated := actualAmount != req.Amount
|
||||
common.Ok(c, gin.H{
|
||||
"exp_gained": expGained,
|
||||
"message": buildEnergizeMessage(expGained),
|
||||
"exp_gained": expGained,
|
||||
"actual_amount": actualAmount,
|
||||
"truncated": truncated,
|
||||
"message": buildEnergizeMessage(expGained, truncated),
|
||||
})
|
||||
}
|
||||
|
||||
// buildEnergizeMessage 根据获得的经验生成提示消息
|
||||
func buildEnergizeMessage(expGained int) string {
|
||||
if expGained > 0 {
|
||||
return "赋能成功!" + strconv.Itoa(expGained) + "经验已加入你的旅程。"
|
||||
func buildEnergizeMessage(expGained int, truncated bool) string {
|
||||
if truncated {
|
||||
if expGained > 0 {
|
||||
return "单篇额度已满,实际赋能部分额度,+" + strconv.Itoa(expGained) + " 经验"
|
||||
}
|
||||
return "单篇额度已满,实际赋能部分额度"
|
||||
}
|
||||
return "赋能成功!今日赋能经验奖励已达上限,对方仍会获得激励。"
|
||||
if expGained > 0 {
|
||||
return "赋能成功!+" + strconv.Itoa(expGained) + " 经验"
|
||||
}
|
||||
return "赋能成功!今日经验已满,对方仍获得激励"
|
||||
}
|
||||
|
||||
// GetEnergyInfo 获取域能余额和经验上限状态
|
||||
// GetEnergyInfo 获取域能余额和经验上限状态,支持 ?post_id= 查询文章赋能总量
|
||||
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
@ -78,7 +87,10 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
info, err := ec.energySvc.GetEnergyInfo(uid)
|
||||
postIDStr := c.DefaultQuery("post_id", "0")
|
||||
postID, _ := strconv.ParseUint(postIDStr, 10, 64)
|
||||
|
||||
info, err := ec.energySvc.GetEnergyInfo(uid, uint(postID))
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
|
||||
return
|
||||
@ -91,6 +103,7 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
||||
"daily_energize_exp": info.DailyEnergizeExp,
|
||||
"daily_exp_cap": info.DailyExpCap,
|
||||
"daily_exp_cap_reached": info.DailyExpCapReached,
|
||||
"post_energize_total": info.PostEnergizeTotal,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -85,8 +85,8 @@ type levelUseCase interface {
|
||||
|
||||
// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法)
|
||||
type energyUseCase interface {
|
||||
Energize(energizerID uint, postID uint, amount int) (int, error)
|
||||
GetEnergyInfo(userID uint) (*service.EnergyInfo, error)
|
||||
Energize(energizerID uint, postID uint, amount int) (int, int, error)
|
||||
GetEnergyInfo(userID uint, postID uint) (*service.EnergyInfo, error)
|
||||
GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ type commentUseCase interface {
|
||||
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
||||
ListReplies(rootID uint) ([]model.Comment, error)
|
||||
SearchUsers(keyword string) ([]model.UserSearchResult, error)
|
||||
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
|
||||
}
|
||||
|
||||
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
||||
|
||||
@ -58,15 +58,23 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
||||
// 查询待审核类型(用于前端显示审核提示)
|
||||
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
|
||||
|
||||
// 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
|
||||
hasCompletedRename := false
|
||||
if sc.levelSvc != nil {
|
||||
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
|
||||
hasCompletedRename = completed
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"Title": "个人设置",
|
||||
"ExtraCSS": "/static/css/settings.css",
|
||||
"ActiveTab": tab,
|
||||
"User": user,
|
||||
"RoleName": model.RoleDisplayNames[user.Role],
|
||||
"StatusName": model.StatusDisplayNames[user.Status],
|
||||
"PendingTypes": pendingTypes,
|
||||
"AuditTypes": model.AuditTypeNames,
|
||||
"Title": "个人设置",
|
||||
"ExtraCSS": "/static/css/settings.css",
|
||||
"ActiveTab": tab,
|
||||
"User": user,
|
||||
"RoleName": model.RoleDisplayNames[user.Role],
|
||||
"StatusName": model.StatusDisplayNames[user.Status],
|
||||
"PendingTypes": pendingTypes,
|
||||
"AuditTypes": model.AuditTypeNames,
|
||||
"HasCompletedRename": hasCompletedRename,
|
||||
}
|
||||
|
||||
// 登录管理 tab 需要额外数据
|
||||
|
||||
@ -32,8 +32,8 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
||||
c.Header("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'nonce-"+nonce+"'; "+
|
||||
"style-src 'self' 'nonce-"+nonce+"'; "+
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data: blob:; "+
|
||||
"connect-src 'self'")
|
||||
|
||||
|
||||
@ -25,13 +25,23 @@ type Comment struct {
|
||||
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
||||
// RepliesCount 回复数(非DB字段,查询填充)
|
||||
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
||||
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
||||
// key=创建时的原始@用户名,value={uid,当前显示名},用户改名后链接仍有效且显示新名
|
||||
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
||||
}
|
||||
|
||||
// CommentMention @提及映射(绑定UID,不是username,支持改名后自动更新显示名)
|
||||
// CommentMention @提及映射(绑定UID + 原始用户名,支持改名后自动更新显示名)
|
||||
type CommentMention struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
||||
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
||||
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
||||
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
|
||||
}
|
||||
|
||||
// MentionInfo @提及显示信息(前端渲染用)
|
||||
type MentionInfo struct {
|
||||
UID uint `json:"uid"`
|
||||
Name string `json:"name"` // 当前显示名
|
||||
}
|
||||
|
||||
// UserSearchResult @搜索用户结果
|
||||
@ -39,6 +49,7 @@ type UserSearchResult struct {
|
||||
UID uint `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Level int `json:"level"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// AdminCommentRow 后台评论列表行
|
||||
|
||||
@ -6,11 +6,14 @@ const (
|
||||
NotifyAuditRejected = "audit_rejected"
|
||||
NotifyPostApproved = "post_approved"
|
||||
NotifyPostRejected = "post_rejected"
|
||||
NotifyPostLocked = "post_locked"
|
||||
NotifyPostUnlocked = "post_unlocked"
|
||||
NotifyLevelUp = "level_up"
|
||||
NotifyComment = "comment"
|
||||
NotifyCommentReply = "comment_reply"
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||
)
|
||||
|
||||
// Notification 消息/通知模型
|
||||
@ -32,9 +35,12 @@ var NotifyTypeNames = map[string]string{
|
||||
NotifyAuditRejected: "资料审核",
|
||||
NotifyPostApproved: "稿件审核",
|
||||
NotifyPostRejected: "稿件审核",
|
||||
NotifyPostLocked: "稿件审核",
|
||||
NotifyPostUnlocked: "稿件审核",
|
||||
NotifyLevelUp: "等级提升",
|
||||
NotifyComment: "评论",
|
||||
NotifyCommentReply: "回复",
|
||||
NotifyCommentMention: "@ 提及",
|
||||
NotifyLikeAggregated: "点赞",
|
||||
NotifyFollow: "关注",
|
||||
}
|
||||
@ -45,9 +51,12 @@ var NotifyCategory = map[string]string{
|
||||
NotifyAuditRejected: "system",
|
||||
NotifyPostApproved: "system",
|
||||
NotifyPostRejected: "system",
|
||||
NotifyPostLocked: "system",
|
||||
NotifyPostUnlocked: "system",
|
||||
NotifyLevelUp: "system",
|
||||
NotifyComment: "mention",
|
||||
NotifyCommentReply: "mention",
|
||||
NotifyCommentMention: "mention",
|
||||
NotifyLikeAggregated: "like",
|
||||
NotifyFollow: "follow",
|
||||
}
|
||||
|
||||
@ -17,39 +17,98 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent
|
||||
return list, err
|
||||
}
|
||||
|
||||
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp
|
||||
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct {
|
||||
// LoadMentionsForComments 批量加载多条评论的有效@提及映射
|
||||
// key=创建时的原始@用户名, value={uid, 用户当前显示名}
|
||||
// 用户改名后:原始名匹配body中的旧@文本,显示名随users表更新
|
||||
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
||||
if len(comments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commentIDs := make([]uint, len(comments))
|
||||
for i, c := range comments {
|
||||
commentIDs[i] = c.ID
|
||||
}
|
||||
|
||||
type mentionRow struct {
|
||||
CommentID uint
|
||||
UID uint
|
||||
OriginalUsername string
|
||||
CurrentName string
|
||||
}
|
||||
var rows []mentionRow
|
||||
err := r.db.Table("comment_mentions").
|
||||
Select("comment_mentions.comment_id, comment_mentions.uid, comment_mentions.original_username, users.username AS current_name").
|
||||
Joins("LEFT JOIN users ON users.id = comment_mentions.uid").
|
||||
Where("comment_mentions.comment_id IN ?", commentIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 按 comment_id 分组,key = original_username, value = MentionInfo
|
||||
mentionMap := make(map[uint]map[string]model.MentionInfo)
|
||||
for _, row := range rows {
|
||||
if mentionMap[row.CommentID] == nil {
|
||||
mentionMap[row.CommentID] = make(map[string]model.MentionInfo)
|
||||
}
|
||||
mentionMap[row.CommentID][row.OriginalUsername] = model.MentionInfo{
|
||||
UID: row.UID,
|
||||
Name: row.CurrentName,
|
||||
}
|
||||
}
|
||||
|
||||
for i := range comments {
|
||||
if m, ok := mentionMap[comments[i].ID]; ok {
|
||||
comments[i].Mentions = m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp
|
||||
// followerUID > 0 时按已关注优先排序,其次按exp降序
|
||||
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
|
||||
UID uint
|
||||
Username string
|
||||
Avatar string
|
||||
Exp int
|
||||
}, error) {
|
||||
type result struct {
|
||||
UID uint
|
||||
Username string
|
||||
Avatar string
|
||||
Exp int
|
||||
}
|
||||
var list []result
|
||||
|
||||
threshold := model.LevelThresholds[minLevel]
|
||||
|
||||
err := r.db.Table("users").
|
||||
Select("id, username, exp").
|
||||
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
|
||||
Order("exp DESC").
|
||||
Limit(limit).
|
||||
Find(&list).Error
|
||||
q := r.db.Table("users AS u").
|
||||
Select("u.id AS uid, u.username, u.avatar, u.exp").
|
||||
Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
|
||||
Order("u.exp DESC")
|
||||
|
||||
if followerUID > 0 {
|
||||
q = q.Joins("LEFT JOIN user_follows f ON f.followee_id = u.id AND f.follower_id = ?", followerUID).
|
||||
Order("(f.follower_id IS NOT NULL) DESC, u.exp DESC")
|
||||
}
|
||||
|
||||
err := q.Limit(limit).Find(&list).Error
|
||||
|
||||
var res []struct {
|
||||
UID uint
|
||||
Username string
|
||||
Avatar string
|
||||
Exp int
|
||||
}
|
||||
for _, u := range list {
|
||||
res = append(res, struct {
|
||||
UID uint
|
||||
Username string
|
||||
Avatar string
|
||||
Exp int
|
||||
}{UID: u.UID, Username: u.Username, Exp: u.Exp})
|
||||
}{UID: u.UID, Username: u.Username, Avatar: u.Avatar, Exp: u.Exp})
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@ -40,10 +41,11 @@ type overviewRow struct {
|
||||
PendingCount int64
|
||||
ApprovedCount int64
|
||||
RejectedCount int64
|
||||
TotalViews int64
|
||||
}
|
||||
|
||||
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数
|
||||
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error) {
|
||||
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 + 总阅读量
|
||||
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error) {
|
||||
var row overviewRow
|
||||
err = r.db.Model(&model.Post{}).
|
||||
Select(`
|
||||
@ -51,11 +53,12 @@ func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pen
|
||||
COALESCE(SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END), 0) AS draft_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) AS approved_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count
|
||||
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count,
|
||||
COALESCE(SUM(views_count), 0) AS total_views
|
||||
`).
|
||||
Where("user_id = ? AND deleted_at IS NULL", userID).
|
||||
Scan(&row).Error
|
||||
return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, err
|
||||
return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, row.TotalViews, err
|
||||
}
|
||||
|
||||
// readCooldown 两次阅读记录最小间隔
|
||||
@ -106,3 +109,26 @@ func (r *PostRepo) IncrementViewsCount(postID uint) error {
|
||||
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||
Update("views_count", gorm.Expr("views_count + 1")).Error
|
||||
}
|
||||
|
||||
// AggregateViewsByAuthor 按日期聚合阅读量(作者所有文章在时间段内的阅读记录,含已登录+访客)
|
||||
func (r *PostRepo) AggregateViewsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
|
||||
var results []service.TrendPoint
|
||||
err := r.db.Raw(`
|
||||
SELECT TO_CHAR(t.read_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value
|
||||
FROM (
|
||||
SELECT prl.read_at FROM post_read_logs prl
|
||||
JOIN posts p ON p.id = prl.post_id
|
||||
WHERE p.user_id = ? AND prl.read_at >= ?
|
||||
UNION ALL
|
||||
SELECT pgrl.read_at FROM post_guest_read_logs pgrl
|
||||
JOIN posts p ON p.id = pgrl.post_id
|
||||
WHERE p.user_id = ? AND pgrl.read_at >= ?
|
||||
) t
|
||||
GROUP BY TO_CHAR(t.read_at, 'YYYY-MM-DD')
|
||||
ORDER BY date ASC
|
||||
`, userID, since, userID, since).Scan(&results).Error
|
||||
if results == nil {
|
||||
results = []service.TrendPoint{}
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
|
||||
@ -136,10 +136,14 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
}
|
||||
|
||||
// --- 关注 API ---
|
||||
// 公开(未登录返回 false)
|
||||
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus)
|
||||
r.GET("/api/users/:uid/followers", d.followCtrl.Followers)
|
||||
r.GET("/api/users/:uid/following", d.followCtrl.Following)
|
||||
// 公开(Optional 认证以获取当前用户关注状态,未登录返回 false)
|
||||
followPublic := r.Group("/api/users/:uid")
|
||||
followPublic.Use(d.authMdw.Optional())
|
||||
{
|
||||
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
|
||||
followPublic.GET("/followers", d.followCtrl.Followers)
|
||||
followPublic.GET("/following", d.followCtrl.Following)
|
||||
}
|
||||
// 需登录:切换关注
|
||||
followAPI := r.Group("/api/users/:uid")
|
||||
followAPI.Use(d.authMdw.Required())
|
||||
|
||||
@ -38,6 +38,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
|
||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
||||
auditService.WithEnergyRefundService(energyService)
|
||||
auditService.WithAvatarFileCleaner(avatarSvc)
|
||||
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||
|
||||
messageController := controller.NewMessageController(notificationService)
|
||||
@ -97,7 +98,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
spaceCtrl.WithFavoriteService(favoriteService)
|
||||
|
||||
// Studio 趋势图
|
||||
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo)
|
||||
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo, postRepo)
|
||||
studioCtrl.WithTrendsService(trendsService)
|
||||
|
||||
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
||||
|
||||
@ -45,17 +45,32 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
|
||||
} else {
|
||||
submission.Status = model.AuditStatusRejected
|
||||
submission.RejectReason = &reason
|
||||
// 通知用户审核被拒(改名审核追加域能返还提示)
|
||||
|
||||
// 改名审核被拒 → 仅非首次改名(已扣费)才退还域能
|
||||
// 首次改名免费,被拒不退还
|
||||
shouldRefund := false
|
||||
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
|
||||
if s.levelSvc != nil {
|
||||
hasCompleted, _ := s.levelSvc.HasCompletedTask(submission.UserID, "username")
|
||||
shouldRefund = hasCompleted
|
||||
}
|
||||
if shouldRefund {
|
||||
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// 通知用户审核被拒(仅实际退款时追加域能返还提示)
|
||||
if s.notifier != nil {
|
||||
reasonMsg := reason
|
||||
if submission.AuditType == model.AuditTypeUsername {
|
||||
if shouldRefund {
|
||||
reasonMsg += "。扣除的域能已被返还"
|
||||
}
|
||||
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
||||
}
|
||||
// 改名审核被拒 → 返还域能
|
||||
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
|
||||
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
|
||||
|
||||
// 头像审核被拒 → 删除提交的新头像文件
|
||||
if submission.AuditType == model.AuditTypeAvatar && s.avatarCleaner != nil {
|
||||
_ = s.avatarCleaner.DeleteAvatarFile(submission.NewValue)
|
||||
}
|
||||
}
|
||||
return s.auditRepo.Update(submission)
|
||||
@ -80,7 +95,16 @@ func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
||||
}
|
||||
user.Username = submission.NewValue
|
||||
case model.AuditTypeAvatar:
|
||||
oldAvatar := user.Avatar
|
||||
user.Avatar = submission.NewValue
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
// 审核通过,删除旧头像文件
|
||||
if s.avatarCleaner != nil {
|
||||
_ = s.avatarCleaner.DeleteAvatarFile(oldAvatar)
|
||||
}
|
||||
return nil
|
||||
case model.AuditTypeBio:
|
||||
user.Bio = submission.NewValue
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ type auditNotifier interface {
|
||||
// auditTaskCompleter 审核通过时触发首次任务奖励的接口
|
||||
type auditTaskCompleter interface {
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
HasCompletedTask(userID uint, taskType string) (bool, error)
|
||||
}
|
||||
|
||||
// energyRenameRefunder 改名审核被拒时返还域能的接口(ISP)
|
||||
@ -47,6 +48,12 @@ type energyRenameRefunder interface {
|
||||
RefundOnRenameReject(userID uint) error
|
||||
}
|
||||
|
||||
// avatarFileCleaner 头像审核通过/拒绝时清理头像文件的接口(ISP)
|
||||
type avatarFileCleaner interface {
|
||||
CleanOldAvatars(userID uint)
|
||||
DeleteAvatarFile(url string) error
|
||||
}
|
||||
|
||||
// AuditService 审核业务逻辑
|
||||
type AuditService struct {
|
||||
auditRepo auditRepo
|
||||
@ -56,6 +63,7 @@ type AuditService struct {
|
||||
notifier auditNotifier
|
||||
levelSvc auditTaskCompleter
|
||||
energyRefundSvc energyRenameRefunder
|
||||
avatarCleaner avatarFileCleaner
|
||||
}
|
||||
|
||||
// NewAuditService 构造函数
|
||||
@ -76,6 +84,12 @@ func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditS
|
||||
return s
|
||||
}
|
||||
|
||||
// WithAvatarFileCleaner 链式注入头像文件清理服务
|
||||
func (s *AuditService) WithAvatarFileCleaner(svc avatarFileCleaner) *AuditService {
|
||||
s.avatarCleaner = svc
|
||||
return s
|
||||
}
|
||||
|
||||
// ShouldAudit 判断指定用户是否需要走审核流程
|
||||
// 规则:admin 及以上角色免审
|
||||
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
|
||||
|
||||
@ -57,6 +57,9 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 直接更新场景:删除旧头像文件,仅保留新文件
|
||||
s.CleanOldAvatars(userID)
|
||||
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("用户不存在")
|
||||
@ -134,10 +137,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
||||
return "", fmt.Errorf("创建存储目录失败")
|
||||
}
|
||||
|
||||
// 8. 删除该用户的旧头像,仅保留最新
|
||||
s.cleanOldAvatars(userID)
|
||||
|
||||
// 9. 原子写入:先写临时文件,再 rename 到最终路径
|
||||
// 8. 原子写入:先写临时文件,再 rename 到最终路径
|
||||
// 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
|
||||
ts := time.Now().Unix()
|
||||
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
|
||||
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
||||
@ -153,8 +154,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
||||
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
|
||||
}
|
||||
|
||||
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
|
||||
func (s *AvatarService) cleanOldAvatars(userID uint) {
|
||||
// CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
|
||||
func (s *AvatarService) CleanOldAvatars(userID uint) {
|
||||
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
|
||||
entries, err := os.ReadDir(s.storageDir)
|
||||
if err != nil {
|
||||
@ -167,6 +168,19 @@ func (s *AvatarService) cleanOldAvatars(userID uint) {
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAvatarFile 根据头像 URL 删除对应的磁盘文件
|
||||
func (s *AvatarService) DeleteAvatarFile(url string) error {
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
filename := filepath.Base(url)
|
||||
if filename == "." || filename == "/" {
|
||||
return nil
|
||||
}
|
||||
fullPath := filepath.Join(s.storageDir, filename)
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
// subImage 从原图中裁切指定正方形区域
|
||||
func subImage(img image.Image, x, y, size int) image.Image {
|
||||
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
@ -55,15 +55,15 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
|
||||
// 文章评论数+1
|
||||
_ = s.repo.IncrCommentsCount(postID)
|
||||
|
||||
// 解析 @ 提及
|
||||
s.parseMentions(comment.ID, body)
|
||||
// 解析 @ 提及 + 发送通知
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.parseMentions(comment.ID, postID, userID, commenterName, body)
|
||||
|
||||
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||
if s.notifier != nil && userID != postAuthorID {
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||
"新评论",
|
||||
fmt.Sprintf("%s 评论了你的文章", commenterName),
|
||||
@ -103,15 +103,15 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
|
||||
// 文章评论数+1
|
||||
_ = s.repo.IncrCommentsCount(parent.PostID)
|
||||
|
||||
// 解析 @ 提及
|
||||
s.parseMentions(comment.ID, body)
|
||||
// 解析 @ 提及 + 发送通知
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
|
||||
|
||||
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
||||
if s.notifier != nil && parent.UserID != userID {
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
||||
"新回复",
|
||||
fmt.Sprintf("%s 回复了你的评论", commenterName),
|
||||
@ -152,7 +152,15 @@ func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool)
|
||||
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
return s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
|
||||
comments, total, err := s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// 批量加载有效@提及映射,前端据此决定是否为链接
|
||||
if len(comments) > 0 {
|
||||
_ = s.repo.LoadMentionsForComments(comments)
|
||||
}
|
||||
return comments, total, nil
|
||||
}
|
||||
|
||||
// ListReplies 获取某条顶级评论的全部回复
|
||||
@ -164,6 +172,10 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
|
||||
if list == nil {
|
||||
list = []model.Comment{}
|
||||
}
|
||||
// 批量加载有效@提及映射
|
||||
if len(list) > 0 {
|
||||
_ = s.repo.LoadMentionsForComments(list)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
@ -178,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page,
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// SearchUsers 搜索LV3+用户(用于@艾特)
|
||||
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
|
||||
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
|
||||
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗),已关注优先,最多5条
|
||||
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
|
||||
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索用户失败: %w", err)
|
||||
}
|
||||
@ -191,27 +203,42 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult,
|
||||
UID: row.UID,
|
||||
Username: row.Username,
|
||||
Level: model.GetLevelByExp(row.Exp),
|
||||
Avatar: row.Avatar,
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// parseMentions 解析@用户并存储到 comment_mentions 表
|
||||
func (s *CommentService) parseMentions(commentID uint, body string) {
|
||||
// parseMentions 解析@用户,存储到 comment_mentions 表,并向被@用户发送通知
|
||||
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
|
||||
matches := mentionPattern.FindAllStringSubmatch(body, -1)
|
||||
seen := make(map[string]bool)
|
||||
notified := make(map[uint]bool) // 防止重复通知同一用户
|
||||
|
||||
for _, m := range matches {
|
||||
username := m[1]
|
||||
if seen[username] {
|
||||
continue
|
||||
}
|
||||
seen[username] = true
|
||||
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1)
|
||||
if len(rows) > 0 {
|
||||
_ = s.repo.CreateMention(&model.CommentMention{
|
||||
CommentID: commentID,
|
||||
UID: rows[0].UID,
|
||||
})
|
||||
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
mentionedUID := rows[0].UID
|
||||
_ = s.repo.CreateMention(&model.CommentMention{
|
||||
CommentID: commentID,
|
||||
UID: mentionedUID,
|
||||
OriginalUsername: username,
|
||||
})
|
||||
|
||||
// 向被@用户发送通知(排除自己@自己、重复通知)
|
||||
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
|
||||
notified[mentionedUID] = true
|
||||
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
|
||||
"新提及",
|
||||
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
|
||||
&postID, &commentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,11 +9,11 @@ import (
|
||||
)
|
||||
|
||||
// Energize 赋能文章
|
||||
// 返回:获得的经验值、错误
|
||||
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) {
|
||||
// 返回:获得的经验值、实际赋能量(DB乘10)、错误
|
||||
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, int, error) {
|
||||
// 验证赋能量
|
||||
if amount != EnergyEnergize1 && amount != EnergyEnergize2 {
|
||||
return 0, common.ErrInvalidEnergyAmount
|
||||
return 0, 0, common.ErrInvalidEnergyAmount
|
||||
}
|
||||
|
||||
var expGained int
|
||||
@ -21,6 +21,9 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
||||
var expToday time.Time
|
||||
var expTotal int
|
||||
|
||||
// 实际使用的赋能量(可能因上限被截断)
|
||||
actualAmount := amount
|
||||
|
||||
err := s.repo.Transaction(func(txRepo EnergyStore, txFundRepo FundStore) error {
|
||||
// 1. 检查帖子存在,获取作者
|
||||
post, err := txRepo.FindPostByID(postID)
|
||||
@ -42,14 +45,21 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
// 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost
|
||||
// 4. 检查单用户单文章赋能上限,超出部分截断(类似每日经验溢出截断)
|
||||
total, err := txRepo.SumUserPostEnergy(energizerID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询赋能记录失败: %w", err)
|
||||
}
|
||||
if total+amount > MaxEnergizePerPost {
|
||||
room := MaxEnergizePerPost - total
|
||||
if room <= 0 {
|
||||
return common.ErrEnergizeLimitReached
|
||||
}
|
||||
if amount > room {
|
||||
actualAmount = room // 截断到剩余空间
|
||||
}
|
||||
|
||||
// 重新赋值 amount 用于后续扣域能、写日志等
|
||||
amount = actualAmount
|
||||
|
||||
// 5. 扣赋能者域能
|
||||
if err := txRepo.AddEnergy(energizerID, -amount); err != nil {
|
||||
@ -155,14 +165,14 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
|
||||
if needExpUpdate {
|
||||
if s.expSvc != nil {
|
||||
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
|
||||
return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
|
||||
return expGained, actualAmount, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
|
||||
}
|
||||
}
|
||||
newSummary := &model.DailyExpSummary{
|
||||
@ -171,9 +181,9 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
||||
ExpEarned: expTotal,
|
||||
}
|
||||
if err := s.repo.UpsertDailyExp(newSummary); err != nil {
|
||||
return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
|
||||
return expGained, actualAmount, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return expGained, nil
|
||||
return expGained, actualAmount, nil
|
||||
}
|
||||
|
||||
@ -8,7 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
|
||||
func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
|
||||
// postID 为 0 时不查文章赋能总量
|
||||
func (s *EnergyService) GetEnergyInfo(userID uint, postID uint) (*EnergyInfo, error) {
|
||||
user, err := s.repo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -24,12 +25,21 @@ func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
|
||||
dailyExp = summary.ExpEarned
|
||||
}
|
||||
|
||||
return &EnergyInfo{
|
||||
info := &EnergyInfo{
|
||||
Energy: user.Energy,
|
||||
DailyEnergizeExp: dailyExp,
|
||||
DailyExpCap: DailyEnergizeExpCap,
|
||||
DailyExpCapReached: dailyExp >= DailyEnergizeExpCap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if postID != 0 {
|
||||
total, err := s.repo.SumUserPostEnergy(userID, postID)
|
||||
if err == nil {
|
||||
info.PostEnergizeTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetEnergyLogs 分页查询用户域能流水(近 N 天)
|
||||
|
||||
@ -30,10 +30,11 @@ func (s *EnergyService) SetFundRepo(repo FundStore) {
|
||||
|
||||
// EnergyInfo 域能信息(含每日赋能经验状态)
|
||||
type EnergyInfo struct {
|
||||
Energy int // 域能余额(乘10存储)
|
||||
DailyEnergizeExp int // 当日已通过赋能获得的经验值
|
||||
DailyExpCap int // 每日赋能经验上限
|
||||
DailyExpCapReached bool // 是否已达上限
|
||||
Energy int // 域能余额(乘10存储)
|
||||
DailyEnergizeExp int // 当日已通过赋能获得的经验值
|
||||
DailyExpCap int // 每日赋能经验上限
|
||||
DailyExpCapReached bool // 是否已达上限
|
||||
PostEnergizeTotal int // 当前文章已赋能量(DB乘10,仅在传 postID 时返回)
|
||||
}
|
||||
|
||||
// 域能相关常量(乘10存储,DB值)
|
||||
|
||||
@ -47,9 +47,9 @@ func countUIDs(uids string) int {
|
||||
func categoryTypes(category string) []string {
|
||||
switch category {
|
||||
case "system":
|
||||
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp}
|
||||
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyPostLocked, model.NotifyPostUnlocked, model.NotifyLevelUp}
|
||||
case "mention":
|
||||
return []string{model.NotifyComment, model.NotifyCommentReply}
|
||||
return []string{model.NotifyComment, model.NotifyCommentReply, model.NotifyCommentMention}
|
||||
case "like":
|
||||
return []string{model.NotifyLikeAggregated}
|
||||
case "follow":
|
||||
@ -111,6 +111,24 @@ func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason
|
||||
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyPostLocked 稿件锁定通知
|
||||
func (s *NotificationService) NotifyPostLocked(userID uint, postTitle, reason string, postID uint) {
|
||||
title := "稿件被锁定"
|
||||
content := "你的稿件《" + postTitle + "》已被锁定"
|
||||
if reason != "" {
|
||||
content += ",理由:" + reason
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyPostLocked, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyPostUnlocked 稿件解除锁定通知
|
||||
func (s *NotificationService) NotifyPostUnlocked(userID uint, postTitle string, postID uint) {
|
||||
_ = s.Create(userID, model.NotifyPostUnlocked,
|
||||
"稿件被解除锁定",
|
||||
"你的稿件《"+postTitle+"》已被解除锁定",
|
||||
&postID, nil)
|
||||
}
|
||||
|
||||
// NotifyFollow 关注通知(followerID 关注了 followeeID)
|
||||
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
|
||||
// 获取关注者用户名
|
||||
|
||||
@ -155,18 +155,22 @@ func (s *PostService) Reject(postID uint, reason string) error {
|
||||
}
|
||||
|
||||
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
||||
// 锁定状态独立于审核状态,任何状态的帖子均可锁定
|
||||
func (s *PostService) Lock(postID uint, reason string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 待审核帖子不允许锁定(审核流程中)
|
||||
if post.Status == model.PostStatusPending {
|
||||
return common.ErrPostCannotLock
|
||||
}
|
||||
post.IsLocked = true
|
||||
post.LockReason = reason
|
||||
return s.repo.Update(post)
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者稿件被锁定
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostLocked(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock 解锁:清除 IsLocked 标志,恢复可编辑
|
||||
@ -180,7 +184,14 @@ func (s *PostService) Unlock(postID uint) error {
|
||||
}
|
||||
post.IsLocked = false
|
||||
post.LockReason = ""
|
||||
return s.repo.Update(post)
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者稿件被解除锁定
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostUnlocked(post.UserID, post.Title, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore 恢复软删除
|
||||
|
||||
@ -132,11 +132,12 @@ type StudioOverview struct {
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
ApprovedCount int64 `json:"approved_count"`
|
||||
RejectedCount int64 `json:"rejected_count"`
|
||||
TotalViews int64 `json:"total_views"`
|
||||
}
|
||||
|
||||
// GetOverview 获取创作中心数据概览(单次 GROUP BY 查询)
|
||||
func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
|
||||
totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, err := s.repo.GetOverviewByUserID(userID)
|
||||
totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews, err := s.repo.GetOverviewByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -146,6 +147,7 @@ func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
|
||||
PendingCount: pendingCount,
|
||||
ApprovedCount: approvedCount,
|
||||
RejectedCount: rejectedCount,
|
||||
TotalViews: totalViews,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ type postStore interface {
|
||||
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
|
||||
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
|
||||
FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error)
|
||||
GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount int64, err error)
|
||||
GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error)
|
||||
FindPendingRevisions(keyword string, offset, limit int) ([]model.Post, int64, error)
|
||||
Update(post *model.Post) error
|
||||
SoftDelete(id uint) error
|
||||
@ -75,6 +75,8 @@ type userExpReader interface {
|
||||
type postNotifier interface {
|
||||
NotifyPostApproved(userID uint, postTitle string, postID uint)
|
||||
NotifyPostRejected(userID uint, postTitle, reason string, postID uint)
|
||||
NotifyPostLocked(userID uint, postTitle, reason string, postID uint)
|
||||
NotifyPostUnlocked(userID uint, postTitle string, postID uint)
|
||||
}
|
||||
|
||||
// userLevelStore LevelService 所需的用户仓储接口
|
||||
@ -153,12 +155,14 @@ type commentStore interface {
|
||||
CreateMention(mention *model.CommentMention) error
|
||||
FindPostIDByComment(commentID uint) (uint, error)
|
||||
FindPostAuthorID(postID uint) (uint, error)
|
||||
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
|
||||
SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
|
||||
UID uint
|
||||
Username string
|
||||
Avatar string
|
||||
Exp int
|
||||
}, error)
|
||||
GetUsernameByID(userID uint) (string, error)
|
||||
LoadMentionsForComments(comments []model.Comment) error
|
||||
ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error)
|
||||
}
|
||||
|
||||
@ -229,3 +233,8 @@ type LikeTrendStore interface {
|
||||
type FavoriteTrendStore interface {
|
||||
AggregateFavoritesByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||
}
|
||||
|
||||
// ViewTrendStore 阅读量趋势聚合(ISP)
|
||||
type ViewTrendStore interface {
|
||||
AggregateViewsByAuthor(userID uint, since string) ([]TrendPoint, error)
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ type TrendResult struct {
|
||||
Comments []TrendPoint `json:"comments"`
|
||||
Likes []TrendPoint `json:"likes"`
|
||||
Favorites []TrendPoint `json:"favorites"`
|
||||
Views []TrendPoint `json:"views"`
|
||||
}
|
||||
|
||||
// TrendsService Studio 趋势分析服务
|
||||
@ -20,15 +21,17 @@ type TrendsService struct {
|
||||
commentRepo CommentTrendStore
|
||||
likeRepo LikeTrendStore
|
||||
favoriteRepo FavoriteTrendStore
|
||||
viewRepo ViewTrendStore
|
||||
}
|
||||
|
||||
// NewTrendsService 构造函数
|
||||
func NewTrendsService(energyRepo EnergyTrendStore, commentRepo CommentTrendStore, likeRepo LikeTrendStore, favoriteRepo FavoriteTrendStore) *TrendsService {
|
||||
func NewTrendsService(energyRepo EnergyTrendStore, commentRepo CommentTrendStore, likeRepo LikeTrendStore, favoriteRepo FavoriteTrendStore, viewRepo ViewTrendStore) *TrendsService {
|
||||
return &TrendsService{
|
||||
energyRepo: energyRepo,
|
||||
commentRepo: commentRepo,
|
||||
likeRepo: likeRepo,
|
||||
favoriteRepo: favoriteRepo,
|
||||
viewRepo: viewRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +76,15 @@ func (s *TrendsService) GetTrends(userID uint, since string) (*TrendResult, erro
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
result.Views, err = s.viewRepo.AggregateViewsByAuthor(userID, since)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询阅读趋势: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/login.js?v={{assetV "/static/js/login.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -89,7 +89,6 @@
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/register.js?v={{assetV "/static/js/register.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -65,9 +65,8 @@
|
||||
{{if .IsLoggedIn}}
|
||||
{{if .Avatar}}
|
||||
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img">
|
||||
<div class="user-avatar-fallback" style="display:none">{{substr .Username 0 1}}</div>
|
||||
{{else}}
|
||||
<div class="user-avatar-fallback">{{substr .Username 0 1}}</div>
|
||||
<div class="user-avatar-fallback"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
|
||||
{{end}}
|
||||
<h3 class="user-card-name">{{.Username}}</h3>
|
||||
<p class="user-card-desc">欢迎加入 MetaLab</p>
|
||||
@ -117,6 +116,5 @@
|
||||
|
||||
{{template "layout/footer.html" .}}
|
||||
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
<p class="copyright">© {{.CopyrightStart}}-2026 MetaLab{{if .Framework}} | {{.Framework}}{{end}}{{if .CanAccessAdmin}} | <a href="/admin">管理面板</a>{{end}}</p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
|
||||
@ -21,9 +21,8 @@
|
||||
<a href="/space" class="nav-avatar-link">
|
||||
{{if .Avatar}}
|
||||
<img src="{{.Avatar}}" alt="" class="nav-avatar">
|
||||
<span class="nav-avatar nav-avatar-default" style="display:none">{{substr .Username 0 1}}</span>
|
||||
{{else}}
|
||||
<span class="nav-avatar nav-avatar-default">{{substr .Username 0 1}}</span>
|
||||
<span class="nav-avatar nav-avatar-default"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>
|
||||
{{end}}
|
||||
</a>
|
||||
<div class="nav-dropdown">
|
||||
@ -31,9 +30,8 @@
|
||||
<div class="nav-dropdown-avatar">
|
||||
{{if .Avatar}}
|
||||
<img src="{{.Avatar}}" alt="">
|
||||
<span style="display:none">{{substr .Username 0 1}}</span>
|
||||
{{else}}
|
||||
<span>{{substr .Username 0 1}}</span>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="nav-dropdown-meta">
|
||||
|
||||
@ -97,8 +97,16 @@
|
||||
{{else}}
|
||||
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
|
||||
{{end}}
|
||||
{{else if eq $m.NotifyType "comment_mention"}}
|
||||
{{if $m.CommentID}}
|
||||
{{$link = printf "/posts/%d#mention-%d" (derefUint $m.RelatedID) (derefUint $m.CommentID)}}
|
||||
{{else}}
|
||||
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
|
||||
{{end}}
|
||||
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
|
||||
{{$link = "/settings"}}
|
||||
{{else if eq $m.NotifyType "follow"}}
|
||||
{{$link = printf "/space/%d" (derefUint $m.RelatedID)}}
|
||||
{{end}}
|
||||
{{if $link}}
|
||||
<a href="{{$link}}" class="msg-card-link">
|
||||
@ -262,6 +270,5 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -17,6 +17,5 @@
|
||||
</div>
|
||||
</div>
|
||||
{{template "layout/footer.html" .}}
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -50,6 +50,5 @@
|
||||
|
||||
{{template "layout/footer.html" .}}
|
||||
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -8,13 +8,18 @@
|
||||
<header class="post-detail-header">
|
||||
<h1 class="post-detail-title">{{.Post.Title}}</h1>
|
||||
<div class="post-detail-meta">
|
||||
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
|
||||
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
{{if ne .Post.Status "approved"}}
|
||||
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
|
||||
{{if .Post.AuthorName}}
|
||||
<span>作者:<a href="/space/{{.Post.UserID}}" class="post-meta-author">{{.Post.AuthorName}}</a></span>
|
||||
{{else}}
|
||||
<span>作者:该用户已注销</span>
|
||||
{{end}}
|
||||
{{if .Post.IsLocked}}
|
||||
<span class="post-status post-status-locked">已锁定</span>
|
||||
<span>阅读:{{.Post.ViewsCount}}</span>
|
||||
<span>点赞:{{.Post.LikesCount}}</span>
|
||||
<span>收藏:{{.Post.FavoritesCount}}</span>
|
||||
<span>评论:{{.Post.CommentsCount}}</span>
|
||||
<span>发布时间:{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
{{if and .Post.IsLocked (eq $.Post.UserID $.UID)}}
|
||||
<span class="post-status post-status-locked">已锁定{{if .Post.LockReason}}:{{.Post.LockReason}}{{end}}</span>
|
||||
{{end}}
|
||||
{{if and .Post.PendingBody (eq $.Post.UserID $.UID)}}
|
||||
<span class="post-status post-status-revision">修订审核中</span>
|
||||
@ -31,18 +36,37 @@
|
||||
<div class="post-detail-body" id="postContent"></div>
|
||||
</article>
|
||||
|
||||
<!-- 赞/踩/收藏按钮 -->
|
||||
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}">
|
||||
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
|
||||
<span class="reaction-like-count" id="likesCount">{{.Post.LikesCount}}</span>
|
||||
<!-- 右下角悬浮操作栏 -->
|
||||
<div class="post-float-bar" id="postFloatBar" data-post-id="{{.Post.ID}}">
|
||||
<button class="float-btn float-like-btn" id="likeBtn" title="点赞">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
|
||||
<span class="float-count" id="likesCount">{{.Post.LikesCount}}</span>
|
||||
</button>
|
||||
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
|
||||
<button class="float-btn float-dislike-btn" id="dislikeBtn" title="踩">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
|
||||
</button>
|
||||
<button class="reaction-btn reaction-fav-btn" id="favBtn" title="收藏">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="reaction-like-count" id="favCount">{{.Post.FavoritesCount}}</span>
|
||||
{{if .IsLoggedIn}}
|
||||
<button class="float-btn float-energize-btn" id="energizeBtn" data-id="{{.Post.ID}}" data-uid="{{$.UID}}" data-author-uid="{{.Post.UserID}}" title="赋能">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
</button>
|
||||
<button class="float-btn float-fav-btn" id="favBtn" title="收藏">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="float-count" id="favCount">{{.Post.FavoritesCount}}</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="float-btn float-energize-btn" onclick="location.href='/auth/login?redirect=/posts/{{.Post.ID}}'" title="赋能">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
</button>
|
||||
<button class="float-btn float-fav-btn" onclick="location.href='/auth/login?redirect=/posts/{{.Post.ID}}'" title="收藏">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="float-count">0</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="float-btn float-comment-btn" id="floatCommentBtn" title="评论">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</button>
|
||||
<button class="float-btn float-top-btn" id="floatTopBtn" title="回顶部" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -54,9 +78,6 @@
|
||||
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}}
|
||||
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
|
||||
{{end}}
|
||||
{{if and $.Post (ne $.Post.UserID $.UID)}}
|
||||
<button class="btn btn-primary" id="energizeBtn" data-id="{{$.Post.ID}}">赋能</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- 赋能弹窗 -->
|
||||
@ -147,12 +168,48 @@
|
||||
|
||||
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
|
||||
<script src="/static/vditor/dist/method.min.js?v={{assetV "/static/vditor/dist/method.min.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/shortcode.js?v={{assetV "/static/js/shortcode.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/post-view.js?v={{assetV "/static/js/post-view.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/post-energize.js?v={{assetV "/static/js/post-energize.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/post-reactions.js?v={{assetV "/static/js/post-reactions.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/post-favorite.js?v={{assetV "/static/js/post-favorite.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/post-comments.js?v={{assetV "/static/js/post-comments.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
(function() {
|
||||
var topBtn = document.getElementById('floatTopBtn');
|
||||
var commentBtn = document.getElementById('floatCommentBtn');
|
||||
var comments = document.getElementById('comments');
|
||||
var nav = document.querySelector('nav');
|
||||
var navHeight = nav ? nav.offsetHeight : 60;
|
||||
|
||||
// 回顶部:页面顶部时隐藏按钮
|
||||
function toggleTopBtn() {
|
||||
if (!topBtn) return;
|
||||
if (window.scrollY > 300) {
|
||||
topBtn.style.display = '';
|
||||
} else {
|
||||
topBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', toggleTopBtn);
|
||||
toggleTopBtn();
|
||||
|
||||
if (topBtn) {
|
||||
topBtn.addEventListener('click', function() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// 评论按钮:滚动到评论区域顶部(NAV 下方)
|
||||
if (commentBtn && comments) {
|
||||
commentBtn.addEventListener('click', function() {
|
||||
var rect = comments.getBoundingClientRect();
|
||||
var scrollTop = window.scrollY + rect.top - navHeight - 12;
|
||||
window.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -79,7 +79,7 @@
|
||||
<div class="info-row">
|
||||
<span class="info-label">用户名</span>
|
||||
<span class="info-value">
|
||||
<input type="text" id="usernameInput" class="settings-text-input" value="{{.User.Username}}" maxlength="16" autocomplete="off">
|
||||
<input type="text" id="usernameInput" class="settings-text-input" value="{{.User.Username}}" maxlength="16" autocomplete="off" data-has-completed-rename="{{.HasCompletedRename}}">
|
||||
<span class="char-counter" id="usernameCounter"></span>
|
||||
</span>
|
||||
</div>
|
||||
@ -328,7 +328,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
(function () {
|
||||
'use strict';
|
||||
@ -375,6 +374,7 @@
|
||||
// ---- 原始值(用于判断是否变更) ----
|
||||
var origUsername = usernameInput.value.trim();
|
||||
var origBio = bioInput.value;
|
||||
var hasCompletedRename = usernameInput.getAttribute('data-has-completed-rename') === 'true';
|
||||
|
||||
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
|
||||
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
|
||||
@ -409,6 +409,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户名有变更时弹出消耗确认
|
||||
if (username !== origUsername) {
|
||||
var confirmMsg = hasCompletedRename
|
||||
? '确认修改用户名?\n\n本次将消耗 6 域能,是否继续?'
|
||||
: '确认修改用户名?\n\n首次更名无需消耗域能。';
|
||||
showConfirm('修改用户名', confirmMsg).then(function(ok) {
|
||||
if (ok) doSave(username, bio);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
doSave(username, bio);
|
||||
});
|
||||
|
||||
function doSave(username, bio) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '保存中...';
|
||||
|
||||
@ -434,7 +449,7 @@
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ username: username, bio: bio }));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 头像裁切上传 ----
|
||||
var avatarWrap = document.getElementById('avatarWrap');
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
{{if .SpaceUser.Avatar}}
|
||||
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img">
|
||||
{{else}}
|
||||
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
|
||||
<div class="space-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
|
||||
{{end}}
|
||||
</a>
|
||||
<div class="space-header-info">
|
||||
@ -35,10 +35,8 @@
|
||||
|
||||
<!-- 右:操作按钮 -->
|
||||
<div class="space-header-actions">
|
||||
{{if .IsOwnSpace}}
|
||||
<!-- 本人无关注按钮 -->
|
||||
{{else}}
|
||||
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}">+ 关注</button>
|
||||
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}" data-current-uid="{{.CurrentUID}}">+ 关注</button>
|
||||
{{if not .IsOwnSpace}}
|
||||
<button class="space-action-btn">发消息</button>
|
||||
<button class="space-action-btn icon-only" title="更多">⋮</button>
|
||||
{{end}}
|
||||
@ -225,7 +223,7 @@
|
||||
{{if $item.FolloweeAvatar}}
|
||||
<img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img">
|
||||
{{else}}
|
||||
<div class="space-user-avatar-placeholder">{{slice $item.FolloweeUsername 0 1}}</div>
|
||||
<div class="space-user-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
|
||||
{{end}}
|
||||
</a>
|
||||
<div class="space-user-info">
|
||||
@ -293,7 +291,7 @@
|
||||
{{if $item.FollowerAvatar}}
|
||||
<img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img">
|
||||
{{else}}
|
||||
<div class="space-user-avatar-placeholder">{{slice $item.FollowerUsername 0 1}}</div>
|
||||
<div class="space-user-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
|
||||
{{end}}
|
||||
</a>
|
||||
<div class="space-user-info">
|
||||
@ -576,12 +574,60 @@
|
||||
|
||||
{{template "layout/footer.html" .}}
|
||||
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
|
||||
<!-- 访客空间:关注按钮交互 -->
|
||||
{{if and (not .IsOwnSpace) (or (eq .ActiveTab "following") (eq .ActiveTab "followers"))}}
|
||||
<!-- 关注按钮交互 -->
|
||||
<!-- 顶部关注按钮(所有标签页通用) -->
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
(function() {
|
||||
var topFollowBtn = document.getElementById('space-follow-btn');
|
||||
if (!topFollowBtn) return;
|
||||
|
||||
var topUid = topFollowBtn.getAttribute('data-uid');
|
||||
var currentUID = parseInt(topFollowBtn.getAttribute('data-current-uid')) || 0;
|
||||
|
||||
if (currentUID && parseInt(topUid) !== currentUID) {
|
||||
fetch('/api/users/' + topUid + '/follow-status')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (data && data.success && data.data) {
|
||||
var s = data.data;
|
||||
topFollowBtn.textContent = (s.i_follow ? (s.they_follow ? '已互粉' : '已关注') : (s.they_follow ? '回关' : '+ 关注'));
|
||||
if (s.i_follow) topFollowBtn.classList.add('followed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
topFollowBtn.addEventListener('click', function() {
|
||||
if (currentUID && parseInt(topUid) === currentUID) {
|
||||
showToast('不能关注自己', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!currentUID) {
|
||||
window.location.href = '/auth/login?redirect=/space/' + topUid;
|
||||
return;
|
||||
}
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/users/' + topUid + '/follow', true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
try {
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
if (resp.data && resp.data.status) {
|
||||
var st = resp.data.status;
|
||||
topFollowBtn.textContent = (st.i_follow ? (st.they_follow ? '已互粉' : '已关注') : (st.they_follow ? '回关' : '+ 关注'));
|
||||
topFollowBtn.classList.toggle('followed', st.i_follow);
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{if or (eq .ActiveTab "following") (eq .ActiveTab "followers")}}
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
(function() {
|
||||
var currentUID = {{.CurrentUID}} || 0;
|
||||
var cards = document.querySelectorAll('.space-user-card');
|
||||
if (!cards.length) return;
|
||||
|
||||
@ -607,7 +653,7 @@
|
||||
if (state === 'following' || state === 'mutual') btn.classList.add('followed');
|
||||
}
|
||||
|
||||
// 批量获取并更新每个卡片关注状态
|
||||
// 批量获取并更新每个卡片关注状态(仅非自己)
|
||||
cards.forEach(function(card) {
|
||||
var btn = card.querySelector('.space-follow-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
@ -620,6 +666,10 @@
|
||||
});
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (currentUID && parseInt(targetUid) === currentUID) {
|
||||
showToast('不能关注自己');
|
||||
return;
|
||||
}
|
||||
btn.classList.add('waiting');
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
|
||||
@ -634,44 +684,6 @@
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
|
||||
// 顶部关注按钮
|
||||
var topFollowBtn = document.getElementById('space-follow-btn');
|
||||
if (topFollowBtn) {
|
||||
var topUid = topFollowBtn.getAttribute('data-uid');
|
||||
fetch('/api/users/' + topUid + '/follow-status')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (data && data.success && data.data) {
|
||||
var s = data.data;
|
||||
topFollowBtn.textContent = (s.i_follow ? (s.they_follow ? '已互粉' : '已关注') : (s.they_follow ? '回关' : '+ 关注'));
|
||||
if (s.i_follow) topFollowBtn.classList.add('followed');
|
||||
}
|
||||
});
|
||||
topFollowBtn.addEventListener('click', function() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/users/' + topUid + '/follow', true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
try {
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
if (resp.data && resp.data.status) {
|
||||
var st = resp.data.status;
|
||||
topFollowBtn.textContent = (st.i_follow ? (st.they_follow ? '已互粉' : '已关注') : (st.they_follow ? '回关' : '+ 关注'));
|
||||
topFollowBtn.classList.toggle('followed', st.i_follow);
|
||||
// 同时更新下方卡片中的对应按钮
|
||||
cards.forEach(function(c) {
|
||||
if (c.getAttribute('data-uid') === topUid) {
|
||||
var cb = c.querySelector('.space-follow-btn'); if(cb) updateBtn(cb, st);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@ -697,7 +709,7 @@ function createFolder() {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, is_public: false})
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success) { location.reload(); } else { alert(d.message || '创建失败'); }
|
||||
if (d.success) { location.reload(); } else { showToast(d.message || '创建失败', 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
@ -735,7 +747,7 @@ document.querySelectorAll('#privacy-follow-list, #privacy-follower-list').forEac
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({field: field, value: value})
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.success) { this.checked=!value; alert(d.message||'保存失败'); }
|
||||
if(!d.success) { this.checked=!value; showToast(d.message||'保存失败', 'error'); }
|
||||
}.bind(this));
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,6 +37,13 @@
|
||||
<span class="stat-card-label">草稿</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-icon">👁</div>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-value">{{.Overview.TotalViews}}</span>
|
||||
<span class="stat-card-label">总阅读量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="studio-section">
|
||||
@ -78,6 +85,13 @@
|
||||
</div>
|
||||
<div class="trends-loading" id="favoritesLoading">加载中...</div>
|
||||
</div>
|
||||
<div class="trends-card">
|
||||
<h3 class="trends-card-title">阅读量</h3>
|
||||
<div class="trends-chart-wrap">
|
||||
<canvas id="viewsChart"></canvas>
|
||||
</div>
|
||||
<div class="trends-loading" id="viewsLoading">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -98,7 +112,8 @@
|
||||
energy: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
||||
comments: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
||||
likes: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||
favorites: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' }
|
||||
favorites: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' },
|
||||
views: { border: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' }
|
||||
};
|
||||
|
||||
function fillDates(points, days) {
|
||||
@ -193,8 +208,9 @@
|
||||
showLoading('commentsLoading');
|
||||
showLoading('likesLoading');
|
||||
showLoading('favoritesLoading');
|
||||
showLoading('viewsLoading');
|
||||
|
||||
var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart'];
|
||||
var canvasIds = ['energyChart', 'commentsChart', 'likesChart', 'favoritesChart', 'viewsChart'];
|
||||
canvasIds.forEach(function (id) {
|
||||
var canvas = document.getElementById(id);
|
||||
canvas.style.display = 'none';
|
||||
@ -217,7 +233,8 @@
|
||||
{ id: 'energyChart', key: 'energy', color: chartColors.energy, label: '赋能值', loading: 'energyLoading' },
|
||||
{ id: 'commentsChart', key: 'comments', color: chartColors.comments, label: '评论', loading: 'commentsLoading' },
|
||||
{ id: 'likesChart', key: 'likes', color: chartColors.likes, label: '点赞', loading: 'likesLoading' },
|
||||
{ id: 'favoritesChart', key: 'favorites', color: chartColors.favorites, label: '收藏', loading: 'favoritesLoading' }
|
||||
{ id: 'favoritesChart', key: 'favorites', color: chartColors.favorites, label: '收藏', loading: 'favoritesLoading' },
|
||||
{ id: 'viewsChart', key: 'views', color: chartColors.views, label: '阅读', loading: 'viewsLoading' }
|
||||
];
|
||||
|
||||
maps.forEach(function (m) {
|
||||
|
||||
@ -73,7 +73,7 @@ function submitPost(id) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '提交失败');
|
||||
showToast(data.message || '提交失败', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -94,7 +94,7 @@ function deletePost(id) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '删除失败');
|
||||
showToast(data.message || '删除失败', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -52,6 +52,13 @@
|
||||
<span class="stat-card-label">已退回</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-icon">👁</div>
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-value">{{.Overview.TotalViews}}</span>
|
||||
<span class="stat-card-label">总阅读量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态分布 -->
|
||||
|
||||
@ -94,7 +94,7 @@ function deletePost(id) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '删除失败');
|
||||
showToast(data.message || '删除失败', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -96,7 +96,6 @@
|
||||
|
||||
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
|
||||
<script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
<script src="/static/js/studio-editor.js?v={{assetV "/static/js/studio-editor.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -230,11 +230,13 @@ header {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,.75);
|
||||
object-fit: unset;
|
||||
}
|
||||
.nav-avatar-default svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.nav-avatar-wrapper:hover .nav-avatar {
|
||||
border-color: var(--color-accent);
|
||||
@ -279,9 +281,11 @@ header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,.75);
|
||||
}
|
||||
.nav-dropdown-avatar svg {
|
||||
width: 52%;
|
||||
height: 52%;
|
||||
}
|
||||
|
||||
.nav-dropdown-avatar img {
|
||||
|
||||
@ -239,14 +239,15 @@
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--color-accent), #2980b9);
|
||||
color: #fff;
|
||||
color: rgba(255,255,255,.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto .75rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.user-avatar-fallback svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.guest-avatar {
|
||||
|
||||
@ -97,6 +97,16 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.post-detail-meta a {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-detail-meta a:hover {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-detail-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
@ -917,6 +927,17 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.fav-active-tag {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.fav-folder-opt-count {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
@ -1075,6 +1096,12 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* @提及通知进入:持久半透明高亮 */
|
||||
.comment-card.highlight-mention,
|
||||
.comment-reply-card.highlight-mention {
|
||||
background: rgba(255, 248, 225, 0.5) !important;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -1106,6 +1133,18 @@
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* 有效@提及链接 */
|
||||
.mention-link {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mention-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@ -1217,6 +1256,9 @@
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@ -1229,12 +1271,106 @@
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.mention-item .mention-username {
|
||||
font-weight: 500;
|
||||
.mention-item-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.mention-item .mention-uid {
|
||||
.mention-item-avatar-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.mention-item-avatar-placeholder svg {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.mention-item .mention-username {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mention-item .mention-level {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mention-no-result {
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ========== 右下角悬浮操作栏 ========== */
|
||||
.post-float-bar {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 100;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.float-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all .15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.float-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.float-btn.active {
|
||||
color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.float-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.float-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.float-top-btn {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
border-radius: 0 0 8px 8px;
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
@ -75,14 +75,16 @@
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.25);
|
||||
color: #fff;
|
||||
color: rgba(255,255,255,.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
border: 2px solid rgba(255,255,255,.4);
|
||||
}
|
||||
.space-avatar-placeholder svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.space-header-info {
|
||||
color: #fff;
|
||||
@ -377,9 +379,12 @@
|
||||
}
|
||||
.space-user-avatar-placeholder {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: var(--color-accent); color: #fff;
|
||||
background: var(--color-accent); color: rgba(255,255,255,.75);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.15rem; font-weight: 700;
|
||||
}
|
||||
.space-user-avatar-placeholder svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.space-user-info { flex: 1; min-width: 0; }
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
var commentsSection = document.getElementById('comments');
|
||||
if (!commentsSection) return;
|
||||
|
||||
var postId = commentsSection.closest('.container').querySelector('#postReactions').dataset.postId;
|
||||
var postId = commentsSection.closest('.container').querySelector('#postFloatBar').dataset.postId;
|
||||
var commentsList = document.getElementById('commentsList');
|
||||
var commentsTotal = document.getElementById('commentsTotal');
|
||||
var loadMoreBtn = document.getElementById('commentsLoadMore');
|
||||
@ -14,13 +14,28 @@
|
||||
var currentPage = 1;
|
||||
var totalPages = 1;
|
||||
|
||||
// 渲染@提及:有效提及渲染为蓝色链接(显示当前用户名),无效提及保持纯文本
|
||||
// mentions: { original_username: {uid, name} } — name为当前显示名,用户改名后自动更新
|
||||
function renderMentions(body, mentions) {
|
||||
body = escapeHtml(body);
|
||||
if (!mentions || Object.keys(mentions).length === 0) {
|
||||
return body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
|
||||
}
|
||||
return body.replace(/@(\S{1,16})/g, function(match, username) {
|
||||
var info = mentions[username];
|
||||
if (info && info.uid) {
|
||||
return '<a class="mention-link" href="/space/' + info.uid + '">@' + escapeHtml(info.name) + '</a>';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function buildCommentCard(c) {
|
||||
var time = c.created_at ? formatTime(c.created_at) : '';
|
||||
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
|
||||
var body = c.body || '';
|
||||
|
||||
body = escapeHtml(body);
|
||||
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
|
||||
body = renderMentions(body, c.mentions);
|
||||
|
||||
var html = '<div class="comment-card" data-id="' + c.id + '">' +
|
||||
'<div class="comment-header">' +
|
||||
@ -53,8 +68,8 @@
|
||||
var time = r.created_at ? formatTime(r.created_at) : '';
|
||||
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
|
||||
var body = r.body || '';
|
||||
body = escapeHtml(body);
|
||||
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
|
||||
|
||||
body = renderMentions(body, r.mentions);
|
||||
|
||||
var prefix = '';
|
||||
if (r.reply_to_name) {
|
||||
@ -186,7 +201,7 @@
|
||||
var input = document.getElementById('replyInput-' + id);
|
||||
if (!input) return;
|
||||
var body = input.value.trim();
|
||||
if (!body) { alert('请输入回复内容'); return; }
|
||||
if (!body) { showToast('请输入回复内容', 'warning'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
api('POST', '/api/comments/' + id + '/reply', { body: body })
|
||||
@ -221,12 +236,12 @@
|
||||
}
|
||||
updateTotalCount(1);
|
||||
} else {
|
||||
alert(d.message || '回复失败');
|
||||
showToast(d.message || '回复失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
alert('请求失败');
|
||||
showToast('请求失败', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -236,17 +251,42 @@
|
||||
var mentionPos = 0;
|
||||
var searchTimer = null;
|
||||
|
||||
// 计算 textarea 中光标的近似像素位置(相对视口)
|
||||
function getCursorPixelPos(textarea, cursorIdx) {
|
||||
var text = textarea.value.substring(0, cursorIdx);
|
||||
var lines = text.split('\n');
|
||||
var lineCount = lines.length;
|
||||
var lastLine = lines[lineCount - 1];
|
||||
|
||||
var style = window.getComputedStyle(textarea);
|
||||
var lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 21;
|
||||
var paddingTop = parseFloat(style.paddingTop) || 0;
|
||||
var paddingLeft = parseFloat(style.paddingLeft) || 0;
|
||||
|
||||
// 近似字符宽度 (中文字符约 2x 英文字符)
|
||||
var charWidth = parseFloat(style.fontSize) * 0.6 || 8.4;
|
||||
|
||||
var rect = textarea.getBoundingClientRect();
|
||||
var top = rect.top + paddingTop + (lineCount - 1) * lineHeight + lineHeight;
|
||||
var left = rect.left + paddingLeft + lastLine.length * charWidth;
|
||||
return { top: top, left: left, bottom: rect.bottom };
|
||||
}
|
||||
|
||||
function bindMention(input) {
|
||||
input.addEventListener('input', function() {
|
||||
var val = input.value;
|
||||
var cursor = input.selectionStart;
|
||||
var atPos = val.lastIndexOf('@', cursor - 1);
|
||||
|
||||
// 检查 @ 前是否为空格或行首(合法位置)
|
||||
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
|
||||
hideMention();
|
||||
return;
|
||||
}
|
||||
|
||||
var query = val.substring(atPos + 1, cursor);
|
||||
if (query.length === 0 || query.includes(' ')) {
|
||||
// 查询中包含空格 -> 隐藏下拉
|
||||
if (query.includes(' ') || query.includes('\n')) {
|
||||
hideMention();
|
||||
return;
|
||||
}
|
||||
@ -255,6 +295,14 @@
|
||||
mentionPos = atPos;
|
||||
|
||||
clearTimeout(searchTimer);
|
||||
|
||||
// 仅输入 @ 时,显示提示
|
||||
if (query.length === 0) {
|
||||
showEmptyHint(input);
|
||||
return;
|
||||
}
|
||||
|
||||
// 有查询内容时,延时搜索
|
||||
searchTimer = setTimeout(function() {
|
||||
fetch('/api/users/search?q=' + encodeURIComponent(query))
|
||||
.then(function(r) { return r.json(); })
|
||||
@ -262,7 +310,7 @@
|
||||
if (d.success && d.data && d.data.length > 0) {
|
||||
showMention(input, d.data, query);
|
||||
} else {
|
||||
hideMention();
|
||||
showMention(input, [], query);
|
||||
}
|
||||
})
|
||||
.catch(function() { hideMention(); });
|
||||
@ -275,26 +323,64 @@
|
||||
}
|
||||
|
||||
function showMention(input, users, query) {
|
||||
var rect = input.getBoundingClientRect();
|
||||
var dropdown = mentionDropdown;
|
||||
dropdown.innerHTML = '';
|
||||
dropdown.style.display = 'block';
|
||||
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||||
dropdown.style.left = (rect.left + window.scrollX) + 'px';
|
||||
dropdown.style.width = Math.max(rect.width, 200) + 'px';
|
||||
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
(function(u) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mention-item';
|
||||
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
|
||||
item.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
selectMention(u);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
})(users[i]);
|
||||
if (users.length === 0) {
|
||||
// 无匹配结果:显示提示(仅在有查询时)
|
||||
if (query.length > 0) {
|
||||
var emptyItem = document.createElement('div');
|
||||
emptyItem.className = 'mention-no-result';
|
||||
emptyItem.textContent = '未搜索到用户';
|
||||
dropdown.appendChild(emptyItem);
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
(function(u) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mention-item';
|
||||
var avatarHtml = u.avatar
|
||||
? '<img class="mention-item-avatar" src="' + escapeHtml(u.avatar) + '" alt="" />'
|
||||
: '<span class="mention-item-avatar mention-item-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>';
|
||||
item.innerHTML = avatarHtml +
|
||||
'<span class="mention-username">' + escapeHtml(u.username) + '</span>' +
|
||||
'<span class="mention-level">LV' + u.level + '</span>';
|
||||
item.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
selectMention(u);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
})(users[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算光标位置并定位(先渲染内容,再用 offsetHeight 定位)
|
||||
var cursorIdx = input.selectionStart;
|
||||
var pos = getCursorPixelPos(input, cursorIdx);
|
||||
|
||||
dropdown.style.display = 'block';
|
||||
dropdown.style.width = '220px';
|
||||
// 悬浮在光标上方(不遮挡输入内容)
|
||||
var dropHeight = dropdown.offsetHeight || 60;
|
||||
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
|
||||
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
|
||||
}
|
||||
|
||||
function showEmptyHint(input) {
|
||||
var dropdown = mentionDropdown;
|
||||
dropdown.innerHTML = '';
|
||||
var hint = document.createElement('div');
|
||||
hint.className = 'mention-no-result';
|
||||
hint.textContent = '输入以查找用户';
|
||||
dropdown.appendChild(hint);
|
||||
|
||||
var cursorIdx = input.selectionStart;
|
||||
var pos = getCursorPixelPos(input, cursorIdx);
|
||||
dropdown.style.display = 'block';
|
||||
dropdown.style.width = '220px';
|
||||
var dropHeight = dropdown.offsetHeight || 36;
|
||||
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
|
||||
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
|
||||
}
|
||||
|
||||
function hideMention() {
|
||||
@ -320,7 +406,7 @@
|
||||
if (commentSubmitBtn) {
|
||||
commentSubmitBtn.addEventListener('click', function() {
|
||||
var body = commentInput.value.trim();
|
||||
if (!body) { alert('请输入评论内容'); return; }
|
||||
if (!body) { showToast('请输入评论内容', 'warning'); return; }
|
||||
|
||||
commentSubmitBtn.disabled = true;
|
||||
api('POST', '/api/posts/' + postId + '/comments', { body: body })
|
||||
@ -333,12 +419,12 @@
|
||||
loadComments(currentPage);
|
||||
updateTotalCount(1);
|
||||
} else {
|
||||
alert(d.message || '发表失败');
|
||||
showToast(d.message || '发表失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
commentSubmitBtn.disabled = false;
|
||||
alert('请求失败');
|
||||
showToast('请求失败', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
@ -397,14 +483,14 @@
|
||||
input.focus();
|
||||
input.selectionStart = input.selectionEnd = start + imgUrl.length;
|
||||
} else {
|
||||
alert(d.message || '上传失败');
|
||||
showToast(d.message || '上传失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
uploadLabel.style.pointerEvents = 'auto';
|
||||
uploadLabel.style.opacity = '1';
|
||||
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
|
||||
alert('上传失败');
|
||||
showToast('上传失败', 'error');
|
||||
});
|
||||
|
||||
imageUploadInput.value = '';
|
||||
@ -412,21 +498,45 @@
|
||||
}
|
||||
|
||||
// ====== 评论高亮 ======
|
||||
function highlightComment(commentId) {
|
||||
function highlightComment(commentId, persistent) {
|
||||
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
|
||||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
|
||||
if (card) {
|
||||
commentsList.insertBefore(card, commentsList.firstChild);
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
card.style.background = '#fff8e1';
|
||||
card.style.transition = 'background 1.5s';
|
||||
setTimeout(function() { card.style.background = ''; }, 2000);
|
||||
if (persistent) {
|
||||
card.classList.add('highlight-mention');
|
||||
} else {
|
||||
card.style.background = '#fff8e1';
|
||||
card.style.transition = 'background 1.5s';
|
||||
setTimeout(function() { card.style.background = ''; }, 2000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = window.location.hash;
|
||||
|
||||
// @提及通知进入:置顶 + 持久高亮
|
||||
var mentionMatch = hash.match(/^#mention-(\d+)$/);
|
||||
if (mentionMatch) {
|
||||
var mentionCommentId = mentionMatch[1];
|
||||
var mentionInterval = setInterval(function() {
|
||||
if (highlightComment(mentionCommentId, true)) {
|
||||
clearInterval(mentionInterval);
|
||||
}
|
||||
}, 300);
|
||||
setTimeout(function() {
|
||||
clearInterval(mentionInterval);
|
||||
if (!commentsList.querySelector('.comment-card[data-id="' + mentionCommentId + '"]') &&
|
||||
!commentsList.querySelector('.comment-reply-card[data-id="' + mentionCommentId + '"]')) {
|
||||
showToast('该评论已不存在');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 普通评论/回复链接:短暂高亮
|
||||
var commentMatch = hash.match(/^#comment-(\d+)$/);
|
||||
if (commentMatch) {
|
||||
var targetCommentId = commentMatch[1];
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
if (!btn || !overlay) return;
|
||||
|
||||
var postId = btn.dataset.id;
|
||||
var isAuthor = btn.dataset.uid && btn.dataset.authorUid &&
|
||||
btn.dataset.uid === btn.dataset.authorUid;
|
||||
|
||||
function showOverlay() {
|
||||
overlay.style.display = 'flex';
|
||||
@ -18,18 +20,58 @@
|
||||
capHint.style.display = 'none';
|
||||
lightBtn.disabled = false;
|
||||
heavyBtn.disabled = false;
|
||||
heavyBtn.title = '';
|
||||
heavyBtn.style.opacity = '';
|
||||
lightBtn.title = '';
|
||||
lightBtn.style.opacity = '';
|
||||
}
|
||||
|
||||
function disableHeavy() {
|
||||
heavyBtn.disabled = true;
|
||||
heavyBtn.title = '已达单篇上限(1/2)';
|
||||
heavyBtn.style.opacity = '0.4';
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
lightBtn.disabled = true;
|
||||
heavyBtn.disabled = true;
|
||||
lightBtn.title = '已达单篇上限(2/2)';
|
||||
lightBtn.style.opacity = '0.4';
|
||||
heavyBtn.title = '已达单篇上限(2/2)';
|
||||
heavyBtn.style.opacity = '0.4';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
fetch('/api/energy/info')
|
||||
if (isAuthor) {
|
||||
showToast('无法给自己赋能', 'warning');
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
fetch('/api/energy/info?post_id=' + postId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.data && d.data.daily_exp_cap_reached) {
|
||||
btn.disabled = false;
|
||||
if (!d.success || !d.data) {
|
||||
showToast('获取状态失败,请稍后重试', 'error');
|
||||
return;
|
||||
}
|
||||
if (d.data.daily_exp_cap_reached) {
|
||||
capHint.style.display = 'block';
|
||||
}
|
||||
var total = d.data.post_energize_total || 0;
|
||||
if (total >= 20) {
|
||||
showToast('对该文章赋能已达上限', 'warning');
|
||||
return;
|
||||
}
|
||||
if (total >= 10) {
|
||||
disableHeavy();
|
||||
}
|
||||
showOverlay();
|
||||
})
|
||||
.catch(function() { showOverlay(); });
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
showToast('获取状态失败,请稍后重试', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', hideOverlay);
|
||||
@ -52,16 +94,16 @@
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) {
|
||||
alert(d.data.message || '赋能成功!');
|
||||
showToast(d.data && d.data.message ? d.data.message : '赋能成功!', 'success');
|
||||
hideOverlay();
|
||||
} else {
|
||||
alert(d.message || '赋能失败');
|
||||
showToast(d.message || '赋能失败', 'error');
|
||||
lightBtn.disabled = false;
|
||||
heavyBtn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
alert('请求失败');
|
||||
showToast('请求失败', 'error');
|
||||
lightBtn.disabled = false;
|
||||
heavyBtn.disabled = false;
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
(function() {
|
||||
var favBtn = document.getElementById('favBtn');
|
||||
var favCountEl = document.getElementById('favCount');
|
||||
var container = document.getElementById('postReactions');
|
||||
var container = document.getElementById('postFloatBar');
|
||||
var overlay = document.getElementById('favOverlay');
|
||||
var closeBtn = document.getElementById('favClose');
|
||||
var folderListEl = document.getElementById('favFolderCheckList');
|
||||
@ -47,6 +47,7 @@
|
||||
var isActive = currentFolderId === f.id;
|
||||
html += '<div class="fav-folder-option' + (isActive ? ' active' : '') + '" data-id="' + f.id + '">'
|
||||
+ '<span class="fav-folder-opt-name">' + escapeHtml(f.name) + (f.is_default ? ' <small>(默认)</small>' : '') + '</span>'
|
||||
+ (isActive ? '<span class="fav-active-tag">已收藏</span>' : '')
|
||||
+ '<span class="fav-folder-opt-count">' + (f.item_count || 0) + ' 篇</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
@ -110,8 +111,13 @@
|
||||
updateBtn(true);
|
||||
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
|
||||
hideModal();
|
||||
} else if (d.message && d.message.indexOf('已在此收藏夹中') !== -1) {
|
||||
// 后端返回已收藏,直接关闭弹窗(兜底)
|
||||
currentFolderId = folderId;
|
||||
updateBtn(true);
|
||||
hideModal();
|
||||
} else {
|
||||
alert(d.message || '操作失败');
|
||||
showToast(d.message || '操作失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { loading = false; });
|
||||
@ -119,7 +125,7 @@
|
||||
|
||||
function removeFromFolder(folderId) {
|
||||
if (loading) return;
|
||||
showConfirm('确认', '确定取消收藏吗?').then(function(ok) {
|
||||
showConfirm('确认取消收藏', '确定移出该收藏夹吗?').then(function(ok) {
|
||||
if (!ok) return;
|
||||
loading = true;
|
||||
api('DELETE', '/api/folders/' + folderId + '/posts/' + postId)
|
||||
@ -131,7 +137,7 @@
|
||||
if (favCountEl) favCountEl.textContent = Math.max(0, (parseInt(favCountEl.textContent) || 0) - 1);
|
||||
hideModal();
|
||||
} else {
|
||||
alert(d.message || '操作失败');
|
||||
showToast(d.message || '操作失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { loading = false; });
|
||||
@ -149,7 +155,7 @@
|
||||
loading = false;
|
||||
newBtn.disabled = false;
|
||||
newBtn.textContent = '创建并收藏';
|
||||
alert(d.message || '创建失败');
|
||||
showToast(d.message || '创建失败', 'error');
|
||||
return;
|
||||
}
|
||||
var newId = d.data && d.data.id;
|
||||
@ -164,7 +170,7 @@
|
||||
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
|
||||
hideModal();
|
||||
} else {
|
||||
alert(d2.message || '操作失败');
|
||||
showToast(d2.message || '操作失败', 'error');
|
||||
}
|
||||
});
|
||||
})
|
||||
@ -194,7 +200,7 @@
|
||||
if (!option) return;
|
||||
var folderId = parseInt(option.getAttribute('data-id'));
|
||||
if (isNaN(folderId)) return;
|
||||
if (currentFolderId === folderId) {
|
||||
if (option.classList.contains('active')) {
|
||||
removeFromFolder(folderId);
|
||||
} else {
|
||||
addToFolder(folderId);
|
||||
@ -206,8 +212,8 @@
|
||||
if (newBtn && newInput) {
|
||||
newBtn.addEventListener('click', function() {
|
||||
var name = newInput.value.trim();
|
||||
if (!name) { alert('请输入收藏夹名称'); return; }
|
||||
if (name.length > 50) { alert('收藏夹名称不能超过 50 个字符'); return; }
|
||||
if (!name) { showToast('请输入收藏夹名称', 'warning'); return; }
|
||||
if (name.length > 50) { showToast('收藏夹名称不能超过 50 个字符', 'warning'); return; }
|
||||
createAndAdd(name);
|
||||
});
|
||||
newInput.addEventListener('keydown', function(e) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// 赞/踩系统
|
||||
(function() {
|
||||
var container = document.getElementById('postReactions');
|
||||
var container = document.getElementById('postFloatBar');
|
||||
var likeBtn = document.getElementById('likeBtn');
|
||||
var dislikeBtn = document.getElementById('dislikeBtn');
|
||||
var likesCountEl = document.getElementById('likesCount');
|
||||
@ -41,7 +41,7 @@
|
||||
if (d.success) {
|
||||
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
|
||||
} else {
|
||||
alert(d.message || '操作失败');
|
||||
showToast(d.message || '操作失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { likeBtn.disabled = false; });
|
||||
@ -63,7 +63,7 @@
|
||||
if (d.success) {
|
||||
updateUI(d.data.disliked ? 'disliked' : 'none');
|
||||
} else {
|
||||
alert(d.message || '操作失败');
|
||||
showToast(d.message || '操作失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { dislikeBtn.disabled = false; });
|
||||
|
||||
@ -41,9 +41,9 @@
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) { window.location.reload(); }
|
||||
else { alert(d.message || '提交失败'); }
|
||||
else { showToast(d.message || '提交失败', 'error'); }
|
||||
})
|
||||
.catch(function() { alert('请求失败'); });
|
||||
.catch(function() { showToast('请求失败', 'error'); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -169,10 +169,10 @@ async function handleSubmit(e) {
|
||||
try {
|
||||
const postId = document.getElementById('postId')?.value
|
||||
const title = document.getElementById('postTitle')?.value.trim()
|
||||
if (!title) { alert('请输入标题'); return }
|
||||
if (!title) { showToast('请输入标题', 'warning'); return }
|
||||
|
||||
const body = vditor ? vditor.getValue() : ''
|
||||
if (!body.trim()) { alert('请输入正文'); return }
|
||||
if (!body.trim()) { showToast('请输入正文', 'warning'); return }
|
||||
|
||||
const isEdit = !!postId
|
||||
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
||||
@ -197,10 +197,10 @@ async function handleSubmit(e) {
|
||||
window.location.href = '/studio/posts'
|
||||
}
|
||||
} else {
|
||||
alert(data.message || '操作失败')
|
||||
showToast(data.message || '操作失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('请求失败,请重试')
|
||||
showToast('请求失败,请重试', 'error')
|
||||
} finally {
|
||||
submitting = false
|
||||
if (submitBtn) {
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 拒绝理由弹窗 -->
|
||||
<div class="modal-overlay" id="rejectModal" style="display:none;">
|
||||
<div class="modal-overlay" id="rejectModal">
|
||||
<div class="modal-box">
|
||||
<h3>拒绝审核</h3>
|
||||
<p>请填写拒绝理由:</p>
|
||||
|
||||
@ -7,19 +7,19 @@
|
||||
<div class="admin-toolbar">
|
||||
<input type="text" id="commentSearch" class="admin-search-input" placeholder="搜索评论内容、作者或文章标题..." />
|
||||
<button id="commentSearchBtn" class="btn btn-secondary">搜索</button>
|
||||
<button id="commentClearBtn" class="btn btn-secondary" style="display:none">清除</button>
|
||||
<button id="commentClearBtn" class="btn btn-secondary admin-hidden">清除</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap" id="commentTableWrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th style="width:100px">作者</th>
|
||||
<th class="th-id">ID</th>
|
||||
<th class="th-author">作者</th>
|
||||
<th>评论内容</th>
|
||||
<th style="width:160px">所属文章</th>
|
||||
<th style="width:140px">时间</th>
|
||||
<th style="width:100px">操作</th>
|
||||
<th class="th-post">所属文章</th>
|
||||
<th class="th-time">时间</th>
|
||||
<th class="th-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="commentTableBody"></tbody>
|
||||
@ -27,7 +27,6 @@
|
||||
</div>
|
||||
|
||||
<div class="admin-pagination" id="commentPagination"></div>
|
||||
<div class="admin-loading" id="commentLoading" style="display:none">加载中...</div>
|
||||
<div class="admin-loading admin-hidden" id="commentLoading">加载中...</div>
|
||||
|
||||
{{template "admin/layout/footer.html" .}}
|
||||
<script src="/admin/static/js/comments.js?v={{assetV "/admin/static/js/comments.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">帖子总数</div>
|
||||
<div class="stat-value">{{.PostTotal}}</div>
|
||||
<div class="stat-desc">已发布 {{.PostApproved}}<span class="stat-badge badge-warn" style="margin-left: 12px;">待审核 {{.PostPending}}</span></div>
|
||||
<div class="stat-desc">已发布 {{.PostApproved}}<span class="stat-badge badge-warn">待审核 {{.PostPending}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@ -23,8 +23,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" style="margin-top: 1rem;">
|
||||
<div class="stat-card" style="grid-column: span 2;">
|
||||
<div class="stats-grid mt-grid">
|
||||
<div class="stat-card span-2">
|
||||
<div class="stat-icon {{if .StoreDegraded}}stat-red{{else if eq .StoreType "redis"}}stat-green{{else}}stat-blue{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<label for="fund-log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
|
||||
<label for="fund-log-type-filter">类型筛选:</label>
|
||||
<select id="fund-log-type-filter">
|
||||
<option value="">全部类型</option>
|
||||
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<label for="log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
|
||||
<label for="log-type-filter">类型筛选:</label>
|
||||
<select id="log-type-filter">
|
||||
<option value="">全部类型</option>
|
||||
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
|
||||
<div class="admin-header-right">
|
||||
<span class="admin-user">{{.Username}}</span>
|
||||
<a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align:-2px;margin-right:3px;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
|
||||
<a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过修订</button>
|
||||
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回修订</button>
|
||||
{{end}}
|
||||
{{if and (not .IsLocked) (ne .Status "pending")}}
|
||||
{{if not .IsLocked}}
|
||||
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
|
||||
{{end}}
|
||||
{{if .IsLocked}}
|
||||
|
||||
@ -136,12 +136,11 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 拒绝弹窗 */
|
||||
/* 拒绝弹窗(显示由 JS 控制,此处不设置 display) */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
|
||||
144
templates/admin/static/css/comments.css
Normal file
144
templates/admin/static/css/comments.css
Normal file
@ -0,0 +1,144 @@
|
||||
/* ========== 管理后台评论管理 ========== */
|
||||
|
||||
/* --- 页面标题 --- */
|
||||
.admin-page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.admin-page-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- 工具栏/搜索 --- */
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.admin-search-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* --- 表格 --- */
|
||||
.admin-table-wrap {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-table th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.admin-table tbody tr:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
/* 列宽 */
|
||||
.th-id { width: 60px; }
|
||||
.th-author { width: 100px; }
|
||||
.th-post { width: 160px; }
|
||||
.th-time { width: 140px; }
|
||||
.th-actions { width: 100px; }
|
||||
|
||||
/* 表格空状态 / 加载失败 */
|
||||
.admin-table-empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.admin-table-error {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 评论内容列 */
|
||||
.comment-body-cell {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- 分页 --- */
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.admin-page-link {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.admin-page-link:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.admin-page-info {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- 加载状态 --- */
|
||||
.admin-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* --- 按钮适配 --- */
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
/* --- 工具类 --- */
|
||||
.admin-hidden {
|
||||
display: none;
|
||||
}
|
||||
@ -66,11 +66,12 @@ a:hover { color: var(--primary-hover); }
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
.admin-user { color: #dfe6e9; font-size: 13px; }
|
||||
.admin-back-btn {
|
||||
.admin-back-btn {
|
||||
font-size: 12px; color: #b2bec3;
|
||||
padding: 4px 10px; border: 1px solid #636e72;
|
||||
border-radius: 4px; transition: all 0.2s;
|
||||
}
|
||||
.admin-back-btn svg { vertical-align: -2px; margin-right: 3px; }
|
||||
.admin-back-btn:hover { color: #fff; border-color: #fff; }
|
||||
|
||||
/* --- 整体布局 --- */
|
||||
@ -123,6 +124,7 @@ a:hover { color: var(--primary-hover); }
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stats-grid.mt-grid { margin-top: 1rem; }
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
@ -130,6 +132,7 @@ a:hover { color: var(--primary-hover); }
|
||||
box-shadow: var(--shadow);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
.stat-card.span-2 { grid-column: span 2; }
|
||||
.stat-icon {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
@ -142,7 +145,7 @@ a:hover { color: var(--primary-hover); }
|
||||
.stat-label { font-size: 12px; color: var(--text-muted); }
|
||||
.stat-value { font-size: 22px; font-weight: 600; color: var(--text); }
|
||||
.stat-desc { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
.stat-badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-left: 8px; vertical-align: middle; }
|
||||
.stat-badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-left: 12px; vertical-align: middle; }
|
||||
.badge-warn { background: #fff3e0; color: var(--warning); }
|
||||
|
||||
/* --- 信息卡片 --- */
|
||||
|
||||
@ -172,6 +172,12 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
|
||||
@ -13,6 +13,34 @@ const pages = {
|
||||
};
|
||||
let pendingRejectId = null;
|
||||
|
||||
// 审核操作按钮事件委托(避免 inline onclick)
|
||||
function handleAuditClick(e) {
|
||||
const btn = e.target.closest('[data-audit-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.auditAction;
|
||||
const id = parseInt(btn.dataset.auditId);
|
||||
if (action === 'approve') approveAudit(id);
|
||||
else if (action === 'reject') openRejectModal(id);
|
||||
}
|
||||
|
||||
// 分页按钮事件委托
|
||||
function handlePaginationClick(e) {
|
||||
const btn = e.target.closest('[data-go-page]');
|
||||
if (!btn) return;
|
||||
goPage(parseInt(btn.dataset.goPage));
|
||||
}
|
||||
|
||||
// 为动态插入的图片绑定 error 处理
|
||||
function attachImageErrorHandlers(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('img[data-fallback]').forEach(img => {
|
||||
img.addEventListener('error', function handler() {
|
||||
this.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="#eee" width="80" height="80"/><text x="40" y="45" text-anchor="middle" fill="#999" font-size="12">加载失败</text></svg>');
|
||||
this.removeEventListener('error', handler);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// TAB 切换
|
||||
document.querySelectorAll('.audit-tab').forEach(tab => {
|
||||
@ -26,6 +54,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 全局事件委托:审核操作 & 分页
|
||||
document.addEventListener('click', handleAuditClick);
|
||||
document.addEventListener('click', handlePaginationClick);
|
||||
|
||||
// 初始加载
|
||||
loadCurrentTab();
|
||||
});
|
||||
@ -55,7 +87,10 @@ function loadTableAudits() {
|
||||
showToast(res.message || '加载失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('网络错误', 'error'));
|
||||
.catch((e) => {
|
||||
console.error('loadTableAudits error:', e);
|
||||
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function loadAvatarAudits() {
|
||||
@ -69,7 +104,10 @@ function loadAvatarAudits() {
|
||||
showToast(res.message || '加载失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('网络错误', 'error'));
|
||||
.catch((e) => {
|
||||
console.error('loadAvatarAudits error:', e);
|
||||
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
@ -83,7 +121,10 @@ function loadHistory() {
|
||||
showToast(res.message || '加载失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('网络错误', 'error'));
|
||||
.catch((e) => {
|
||||
console.error('loadHistory error:', e);
|
||||
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
@ -108,13 +149,13 @@ function renderTable(items, bodyId) {
|
||||
function renderUsernameRow(item) {
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(item.submitter_username || '—')}</td>
|
||||
<td>—</td>
|
||||
<td>${item.user_id || '—'}</td>
|
||||
<td>${escapeHtml(item.current_username || '—')}</td>
|
||||
<td><strong>${escapeHtml(item.new_value)}</strong></td>
|
||||
<td>${formatTime(item.created_at)}</td>
|
||||
<td class="col-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.id})">通过</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.id})">拒绝</button>
|
||||
<button class="btn btn-primary btn-sm" data-audit-action="approve" data-audit-id="${item.id}">通过</button>
|
||||
<button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
@ -124,12 +165,12 @@ function renderBioRow(item) {
|
||||
const display = text.length > 40 ? text.substring(0, 40) + '...' : text;
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(item.submitter_username || '—')}</td>
|
||||
<td>${item.user_id || '—'}</td>
|
||||
<td title="${escapeHtml(text)}">${escapeHtml(display)}</td>
|
||||
<td>${formatTime(item.created_at)}</td>
|
||||
<td class="col-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.id})">通过</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.id})">拒绝</button>
|
||||
<button class="btn btn-primary btn-sm" data-audit-action="approve" data-audit-id="${item.id}">通过</button>
|
||||
<button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
@ -147,19 +188,21 @@ function renderAvatarCards(items) {
|
||||
grid.innerHTML = items.map(item => `
|
||||
<div class="avatar-audit-card">
|
||||
<div class="card-header">
|
||||
<span>提交者: <strong>${escapeHtml(item.submitter_username || '—')}</strong></span>
|
||||
<span>提交者: <strong>${item.user_id || '—'}</strong></span>
|
||||
<span>${formatTime(item.created_at)}</span>
|
||||
</div>
|
||||
<div class="avatar-compare">
|
||||
<span>新头像 →</span>
|
||||
<img src="${escapeHtml(item.new_value)}" alt="新头像" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 80 80%22><rect fill=%22%23eee%22 width=%2280%22 height=%2280%22/><text x=%2240%22 y=%2245%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2212%22>加载失败</text></svg>'">
|
||||
<img src="${escapeHtml(item.new_value)}" alt="新头像" data-fallback="true">
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="approveAudit(${item.id})">通过</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="openRejectModal(${item.id})">拒绝</button>
|
||||
<button class="btn btn-primary btn-sm" data-audit-action="approve" data-audit-id="${item.id}">通过</button>
|
||||
<button class="btn btn-outline btn-sm" data-audit-action="reject" data-audit-id="${item.id}">拒绝</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
// 为动态插入的图片绑定 error 处理器(CSP 不允许 inline onerror)
|
||||
attachImageErrorHandlers(grid);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
@ -181,7 +224,7 @@ function renderHistoryTable(items) {
|
||||
return `
|
||||
<tr>
|
||||
<td>${typeLabel}</td>
|
||||
<td>${escapeHtml(item.submitter_username || '—')}</td>
|
||||
<td>${item.user_id || '—'}</td>
|
||||
<td>${escapeHtml(content)}</td>
|
||||
<td><span class="audit-status-tag ${statusClass}">${statusLabel}</span></td>
|
||||
<td>${escapeHtml(reviewer)}</td>
|
||||
@ -249,9 +292,9 @@ function renderPagination(total, page, barId) {
|
||||
const bar = document.getElementById(barId);
|
||||
if (totalPages <= 1) { bar.innerHTML = ''; return; }
|
||||
|
||||
let html = `<button onclick="goPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>`;
|
||||
let html = `<button data-go-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>`;
|
||||
html += `<span class="page-current">第 ${page} / ${totalPages} 页(共 ${total} 条)</span>`;
|
||||
html += `<button onclick="goPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>`;
|
||||
html += `<button data-go-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}>下一页</button>`;
|
||||
bar.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
@ -11,21 +11,22 @@
|
||||
var pagination = document.getElementById('commentPagination');
|
||||
var loading = document.getElementById('commentLoading');
|
||||
|
||||
// escapeHtml / formatTime / api 由 shared/utils.js 统一提供
|
||||
// escapeHtml / formatTime / showToast / showConfirm / getCSRFToken 由 shared/utils.js 提供
|
||||
// api (对象式) 由 admin/static/js/common.js 提供
|
||||
|
||||
function loadComments(page) {
|
||||
loading.style.display = 'block';
|
||||
var url = '/api/admin/comments?page=' + page + '&page_size=20';
|
||||
if (currentKeyword) url += '&keyword=' + encodeURIComponent(currentKeyword);
|
||||
|
||||
api('GET', url).then(function(d) {
|
||||
api.get(url).then(function(d) {
|
||||
loading.style.display = 'none';
|
||||
if (!d.success) return;
|
||||
if (!d.success) { showToast(d.message || '加载失败', 'error'); return; }
|
||||
totalPages = d.data.total_pages || 1;
|
||||
tbody.innerHTML = '';
|
||||
var items = d.data.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#9ca3af">暂无评论</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="admin-table-empty">暂无评论</td></tr>';
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
@ -46,7 +47,8 @@
|
||||
bindDeleteBtns();
|
||||
}).catch(function() {
|
||||
loading.style.display = 'none';
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#dc2626">加载失败</td></tr>';
|
||||
showToast('加载失败', 'error');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="admin-table-error">加载失败</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@ -55,21 +57,19 @@
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
if (btns[i]._bound) continue;
|
||||
btns[i]._bound = true;
|
||||
btns[i].addEventListener('click', async function() {
|
||||
btns[i].addEventListener('click', function() {
|
||||
var id = this.dataset.id;
|
||||
var ok = await showConfirm('确认', '确认删除此评论?');
|
||||
if (!ok) return;
|
||||
fetch('/api/admin/comments/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) {
|
||||
loadComments(currentPage);
|
||||
} else {
|
||||
alert(d.message || '删除失败');
|
||||
}
|
||||
}).catch(function() { alert('请求失败'); });
|
||||
showConfirm('确认', '确认删除此评论?').then(function(ok) {
|
||||
if (!ok) return;
|
||||
api.del('/api/admin/comments/' + id).then(function(d) {
|
||||
if (d.success) {
|
||||
showToast(d.message, 'success');
|
||||
loadComments(currentPage);
|
||||
} else {
|
||||
showToast(d.message || '删除失败', 'error');
|
||||
}
|
||||
}).catch(function() { showToast('请求失败', 'error'); });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
|
||||
// --- AJAX 封装 (管理后台专用对象式 API) ---
|
||||
// 注意:escapeHtml / formatTime / showToast / getCSRFToken 由 shared/utils.js 统一提供
|
||||
const api = {
|
||||
// 使用变量赋值覆盖 utils.js 的 function api(),避免 const 重复声明冲突
|
||||
var api = {
|
||||
async get(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
@ -34,6 +35,13 @@ const api = {
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
async del(url) {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': getCSRFToken() }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -22,8 +22,8 @@
|
||||
// 审核通过
|
||||
handleClick('.post-approve', '确定审核通过此文章吗?', function(id) {
|
||||
api.post('/api/admin/posts/' + id + '/approve')
|
||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||
.catch(function() { alert('操作失败'); });
|
||||
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||
.catch(function() { showToast('操作失败', 'error'); });
|
||||
});
|
||||
|
||||
// 退回
|
||||
@ -31,8 +31,8 @@
|
||||
showPrompt('请输入退回理由(必填):').then(function(reason) {
|
||||
if (!reason || !reason.trim()) return;
|
||||
api.post('/api/admin/posts/' + id + '/reject', { reason: reason.trim() })
|
||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||
.catch(function() { alert('操作失败'); });
|
||||
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||
.catch(function() { showToast('操作失败', 'error'); });
|
||||
});
|
||||
});
|
||||
|
||||
@ -41,15 +41,15 @@
|
||||
showPrompt('请输入锁定理由(必填):').then(function(reason) {
|
||||
if (!reason || !reason.trim()) return;
|
||||
api.post('/api/admin/posts/' + id + '/lock', { reason: reason.trim() })
|
||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||
.catch(function() { alert('操作失败'); });
|
||||
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||
.catch(function() { showToast('操作失败', 'error'); });
|
||||
});
|
||||
});
|
||||
|
||||
// 解锁
|
||||
handleClick('.post-unlock', '确定解锁此文章吗?', function(id) {
|
||||
api.post('/api/admin/posts/' + id + '/unlock')
|
||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||
.catch(function() { alert('操作失败'); });
|
||||
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||
.catch(function() { showToast('操作失败', 'error'); });
|
||||
});
|
||||
})();
|
||||
|
||||
@ -44,7 +44,8 @@ async function loadUsers() {
|
||||
showToast(res.message || '加载失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('网络错误', 'error');
|
||||
console.error('loadUsers error:', e);
|
||||
showToast('加载失败: ' + (e.message || '网络错误'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -57,13 +57,25 @@ function showToast(message, type) {
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none';
|
||||
container.style.position = 'fixed';
|
||||
container.style.top = '20px';
|
||||
container.style.right = '20px';
|
||||
container.style.zIndex = '9999';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.gap = '8px';
|
||||
container.style.pointerEvents = 'none';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = 'background:' + bg + ';color:#fff;padding:10px 24px;border-radius:6px;font-size:14px;pointer-events:auto';
|
||||
toast.style.background = bg;
|
||||
toast.style.color = '#fff';
|
||||
toast.style.padding = '10px 24px';
|
||||
toast.style.borderRadius = '6px';
|
||||
toast.style.fontSize = '14px';
|
||||
toast.style.pointerEvents = 'auto';
|
||||
container.appendChild(toast);
|
||||
setTimeout(function () {
|
||||
toast.style.opacity = '0';
|
||||
@ -91,6 +103,15 @@ function api(method, url, data) {
|
||||
return fetch(url, opts).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置元素 style 属性(满足 CSP style-src 限制,避免 cssText 被拦截)
|
||||
* @param {HTMLElement} el — 目标元素
|
||||
* @param {object} styles — 键值对 { property: value }
|
||||
*/
|
||||
function setStyles(el, styles) {
|
||||
Object.keys(styles).forEach(function(k) { el.style[k] = styles[k]; });
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认弹窗 — 替代原生 confirm(),统一全站风格
|
||||
* @param {string} title — 弹窗标题
|
||||
@ -102,15 +123,59 @@ function showConfirm(title, message) {
|
||||
var safeTitle = escapeHtml(title);
|
||||
var safeMsg = escapeHtml(message).replace(/\n/g, '<br>');
|
||||
|
||||
// 遮罩层
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;display:flex;align-items:center;justify-content:center;';
|
||||
overlay.innerHTML = '<div style="background:#fff;border-radius:8px;padding:28px 32px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,0.15);">'
|
||||
+ '<h3 style="margin:0 0 12px 0;font-size:17px;color:#2d3436;">' + safeTitle + '</h3>'
|
||||
+ '<p style="margin:0 0 20px 0;font-size:14px;line-height:1.6;color:#636e72;">' + safeMsg + '</p>'
|
||||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;">'
|
||||
+ '<button id="mlbModalCancel" style="padding:8px 20px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font-size:14px;cursor:pointer;">取消</button>'
|
||||
+ '<button id="mlbModalConfirm" style="padding:8px 20px;border:none;border-radius:6px;background:#dc2626;color:#fff;font-size:14px;cursor:pointer;">确认</button>'
|
||||
+ '</div></div>';
|
||||
setStyles(overlay, {
|
||||
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
|
||||
zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
});
|
||||
|
||||
// 弹窗容器
|
||||
var box = document.createElement('div');
|
||||
setStyles(box, {
|
||||
background: '#fff', borderRadius: '8px', padding: '28px 32px',
|
||||
minWidth: '320px', maxWidth: '440px',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)'
|
||||
});
|
||||
|
||||
// 标题
|
||||
var h3 = document.createElement('h3');
|
||||
setStyles(h3, { margin: '0 0 12px 0', fontSize: '17px', color: '#2d3436' });
|
||||
h3.textContent = safeTitle;
|
||||
box.appendChild(h3);
|
||||
|
||||
// 消息
|
||||
var p = document.createElement('p');
|
||||
setStyles(p, { margin: '0 0 20px 0', fontSize: '14px', lineHeight: '1.6', color: '#636e72' });
|
||||
p.innerHTML = safeMsg; // 已转义,安全
|
||||
box.appendChild(p);
|
||||
|
||||
// 按钮容器
|
||||
var btnRow = document.createElement('div');
|
||||
setStyles(btnRow, { display: 'flex', gap: '10px', justifyContent: 'flex-end' });
|
||||
|
||||
// 取消按钮
|
||||
var btnCancel = document.createElement('button');
|
||||
setStyles(btnCancel, {
|
||||
padding: '8px 20px', border: '1px solid #d1d5db', borderRadius: '6px',
|
||||
background: '#fff', color: '#374151', fontSize: '14px', cursor: 'pointer'
|
||||
});
|
||||
btnCancel.textContent = '取消';
|
||||
btnCancel.addEventListener('click', function() { close(false); });
|
||||
btnRow.appendChild(btnCancel);
|
||||
|
||||
// 确认按钮
|
||||
var btnConfirm = document.createElement('button');
|
||||
setStyles(btnConfirm, {
|
||||
padding: '8px 20px', border: 'none', borderRadius: '6px',
|
||||
background: '#dc2626', color: '#fff', fontSize: '14px', cursor: 'pointer'
|
||||
});
|
||||
btnConfirm.textContent = '确认';
|
||||
btnConfirm.addEventListener('click', function() { close(true); });
|
||||
btnRow.appendChild(btnConfirm);
|
||||
|
||||
box.appendChild(btnRow);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function close(val) {
|
||||
@ -118,8 +183,6 @@ function showConfirm(title, message) {
|
||||
resolve(val);
|
||||
}
|
||||
|
||||
overlay.querySelector('#mlbModalCancel').onclick = function() { close(false); };
|
||||
overlay.querySelector('#mlbModalConfirm').onclick = function() { close(true); };
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close(false);
|
||||
});
|
||||
@ -142,26 +205,72 @@ function showPrompt(message, defaultValue) {
|
||||
return new Promise(function(resolve) {
|
||||
var safeMsg = escapeHtml(message);
|
||||
|
||||
// 遮罩层
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;display:flex;align-items:center;justify-content:center;';
|
||||
overlay.innerHTML = '<div style="background:#fff;border-radius:8px;padding:28px 32px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,0.15);">'
|
||||
+ '<p style="margin:0 0 16px 0;font-size:14px;line-height:1.6;color:#2d3436;">' + safeMsg + '</p>'
|
||||
+ '<input id="mlbPromptInput" type="text" style="width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;color:#2d3436;outline:none;box-sizing:border-box;" value="' + escapeHtml(defaultValue || '') + '" placeholder="">'
|
||||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px;">'
|
||||
+ '<button id="mlbPromptCancel" style="padding:8px 20px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#374151;font-size:14px;cursor:pointer;">取消</button>'
|
||||
+ '<button id="mlbPromptConfirm" style="padding:8px 20px;border:none;border-radius:6px;background:#0984e3;color:#fff;font-size:14px;cursor:pointer;">确认</button>'
|
||||
+ '</div></div>';
|
||||
document.body.appendChild(overlay);
|
||||
setStyles(overlay, {
|
||||
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.4)',
|
||||
zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
});
|
||||
|
||||
var input = overlay.querySelector('#mlbPromptInput');
|
||||
// 弹窗容器
|
||||
var box = document.createElement('div');
|
||||
setStyles(box, {
|
||||
background: '#fff', borderRadius: '8px', padding: '28px 32px',
|
||||
minWidth: '320px', maxWidth: '440px',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)'
|
||||
});
|
||||
|
||||
// 消息
|
||||
var p = document.createElement('p');
|
||||
setStyles(p, { margin: '0 0 16px 0', fontSize: '14px', lineHeight: '1.6', color: '#2d3436' });
|
||||
p.innerHTML = safeMsg;
|
||||
box.appendChild(p);
|
||||
|
||||
// 输入框
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = 'mlbPromptInput';
|
||||
setStyles(input, {
|
||||
width: '100%', padding: '10px 12px', border: '1px solid #d1d5db',
|
||||
borderRadius: '6px', fontSize: '14px', color: '#2d3436',
|
||||
outline: 'none', boxSizing: 'border-box'
|
||||
});
|
||||
input.value = escapeHtml(defaultValue || '');
|
||||
box.appendChild(input);
|
||||
|
||||
// 按钮容器
|
||||
var btnRow = document.createElement('div');
|
||||
setStyles(btnRow, { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '16px' });
|
||||
|
||||
// 取消按钮
|
||||
var btnCancel = document.createElement('button');
|
||||
setStyles(btnCancel, {
|
||||
padding: '8px 20px', border: '1px solid #d1d5db', borderRadius: '6px',
|
||||
background: '#fff', color: '#374151', fontSize: '14px', cursor: 'pointer'
|
||||
});
|
||||
btnCancel.textContent = '取消';
|
||||
btnCancel.addEventListener('click', function() { close(null); });
|
||||
btnRow.appendChild(btnCancel);
|
||||
|
||||
// 确认按钮
|
||||
var btnConfirm = document.createElement('button');
|
||||
setStyles(btnConfirm, {
|
||||
padding: '8px 20px', border: 'none', borderRadius: '6px',
|
||||
background: '#0984e3', color: '#fff', fontSize: '14px', cursor: 'pointer'
|
||||
});
|
||||
btnConfirm.textContent = '确认';
|
||||
btnConfirm.addEventListener('click', function() { close(input.value); });
|
||||
btnRow.appendChild(btnConfirm);
|
||||
|
||||
box.appendChild(btnRow);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function close(val) {
|
||||
overlay.remove();
|
||||
resolve(val);
|
||||
}
|
||||
|
||||
overlay.querySelector('#mlbPromptCancel').onclick = function() { close(null); };
|
||||
overlay.querySelector('#mlbPromptConfirm').onclick = function() { close(input.value); };
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user