feat: 管理员专享昵称(发布需验证后台账号 + 前台标识)
11 个文件变更
+288
-21
fangqihang1717@163.com
| •README.md | +2 -1 |
| •internal/database/database.go | +9 -1 |
| •internal/handlers/admin.go | +2 -2 |
| •internal/handlers/api.go | +21 -2 |
| •internal/handlers/auth.go | +5 -0 |
| •internal/handlers/front.go | +89 -10 |
| •internal/models/models.go | +35 -0 |
| •web/static/app.css | +14 -0 |
| •web/static/app.js | +74 -2 |
| •web/templates/admin.html | +4 -0 |
| •web/templates/front.html | +33 -3 |
变更内容
diff --git a/README.md b/README.md
index 020db2a..630c255 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ ClearLove 表白墙是一套面向个人与小型社区的**表白墙 / 匿名
| 发帖验证 | 点击发布后弹出 4 位数字图形验证码(SVG,一次性消费),上传区支持拖拽与点击 |
| 互动 | 点赞(按浏览器指纹去重,支持匿名)、评论、举报 |
| 账号 | 注册 / 登录(可配置邮箱验证码),个人主页可编辑、删除自己的帖子 |
+| 管理员专享昵称 | 后台配置的保留昵称(如「官方」),发布时须验证后台账号密码,帖子显示「管理员」标识,且该昵称禁止被注册 |
| 展示 | 公告每日一次提示、社区守则、主题背景图、捐赠二维码 |
### 后台(`/admin`)
@@ -55,7 +56,7 @@ ClearLove 表白墙是一套面向个人与小型社区的**表白墙 / 匿名
| 帖子管理 | 编辑、删除、按 IP 或浏览器指纹封禁发帖者 |
| 举报管理 | 人工处理(保留 / 隐藏 / 删除),AI 每 10 分钟自动巡查并留痕 |
| 用户 / 管理员 | 用户启用禁用;管理员支持 `super` 与自定义权限组(8 个权限粒度) |
-| 网站设置 | 站名、主题与背景、注册与发帖开关、SMTP、AI 接入、插件商店云端地址 |
+| 网站设置 | 站名、主题与背景、注册与发帖开关、SMTP、AI 接入、插件商店云端地址、管理员专享昵称 |
| 内容运营 | 公告、话题、社区守则 |
| 插件 | zip 上传安装、启用 / 禁用 / 卸载,**插件商店一键安装云端插件** |
| 开放能力 | API Key 管理 + `/api/v1/*` RESTful 接口 |
diff --git a/internal/database/database.go b/internal/database/database.go
index 0e572df..2f941ed 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -69,7 +69,8 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS posts (
id {PK}, user_id {I} DEFAULT 0, nickname VARCHAR(64) DEFAULT '匿名', content {LT},
topic_id {I} DEFAULT 0, ip VARCHAR(64) DEFAULT '', fingerprint VARCHAR(128) DEFAULT '',
- status {I} DEFAULT 1, like_count {I} DEFAULT 0, comment_count {I} DEFAULT 0, created_at VARCHAR(40)
+ status {I} DEFAULT 1, like_count {I} DEFAULT 0, comment_count {I} DEFAULT 0,
+ is_admin {I} DEFAULT 0, created_at VARCHAR(40)
);
CREATE TABLE IF NOT EXISTS medias (
@@ -125,6 +126,13 @@ func Migrate() error {
}
}
}
+ // 兼容旧库:posts 增加 is_admin 列(列已存在时报错,忽略即可)
+ alter := "ALTER TABLE posts ADD COLUMN is_admin INTEGER DEFAULT 0"
+ if Driver == "mysql" {
+ alter = "ALTER TABLE posts ADD COLUMN is_admin INT DEFAULT 0"
+ }
+ _, _ = DB.Exec(alter)
+
// 常用索引(忽略已存在错误)
indexes := []string{
"CREATE INDEX idx_posts_created ON posts (created_at)",
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index 6bb37fc..d45b2d9 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -190,7 +190,7 @@ func AdminSettings(w http.ResponseWriter, r *http.Request) {
keys := []string{"site_name", "theme", "theme_bg", "allow_register", "require_login_post",
"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from",
"ai_enabled", "ai_base", "ai_key", "ai_model", "ai_autopatrol", "ai_precheck",
- "update_url", "donate_img", "cloud_api"}
+ "update_url", "donate_img", "cloud_api", "admin_nicknames"}
s := map[string]string{}
for _, k := range keys {
s[k] = models.GetSetting(k)
@@ -206,7 +206,7 @@ func AdminSettingsSave(w http.ResponseWriter, r *http.Request) {
return
}
textKeys := []string{"site_name", "theme_bg", "smtp_host", "smtp_port", "smtp_user",
- "smtp_pass", "smtp_from", "ai_base", "ai_key", "ai_model", "update_url", "donate_img", "cloud_api"}
+ "smtp_pass", "smtp_from", "ai_base", "ai_key", "ai_model", "update_url", "donate_img", "cloud_api", "admin_nicknames"}
for _, k := range textKeys {
if v := r.FormValue(k); v != "" || k == "theme_bg" || k == "smtp_pass" {
_ = models.SetSetting(k, strings.TrimSpace(v))
diff --git a/internal/handlers/api.go b/internal/handlers/api.go
index 1110dc5..5800415 100644
--- a/internal/handlers/api.go
+++ b/internal/handlers/api.go
@@ -157,6 +157,9 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
Topic string `json:"topic"`
Images []string `json:"images"`
Videos []string `json:"videos"`
+ // 使用管理员专享昵称时必填
+ AdminUser string `json:"admin_user"`
+ AdminPass string `json:"admin_pass"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
fail(w, 400, "JSON 格式错误")
@@ -181,13 +184,29 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
if nickname == "" {
nickname = "匿名"
}
+ // 管理员专享昵称:校验后台账号密码
+ isAdmin := 0
+ if models.IsAdminNickname(nickname) {
+ ip := util.ClientIP(r)
+ if adminTryBlocked(ip) {
+ fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
+ return
+ }
+ if !verifyAdminCreds(strings.TrimSpace(body.AdminUser), body.AdminPass) {
+ adminTryFail(ip)
+ fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
+ return
+ }
+ adminTryReset(ip)
+ isAdmin = 1
+ }
topicID := int64(0)
if t := util.StripHTML(body.Topic); t != "" {
topicID = ensureTopic(t)
}
res, err := database.DB.Exec(
- "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,created_at) VALUES(?,?,?,?,?,?,1,?)",
- userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"), models.Now())
+ "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
+ userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"), isAdmin, models.Now())
if err != nil {
fail(w, 500, "发布失败")
return
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 078130a..2dc5a67 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -88,6 +88,11 @@ func RegisterSubmit(w http.ResponseWriter, r *http.Request) {
q("用户名需为 3-20 位字母、数字、下划线或中文")
return
}
+ // 管理员专享昵称不允许被注册,避免冒充官方身份
+ if models.IsAdminNickname(username) {
+ q("该名称为管理员专享,请更换用户名")
+ return
+ }
if _, err := mail.ParseAddress(email); err != nil {
q("邮箱格式不正确")
return
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index 778a0bc..2a6b504 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -6,6 +6,8 @@ import (
"net/http"
"path"
"strings"
+ "sync"
+ "time"
"clearlove/internal/ai"
"clearlove/internal/database"
@@ -15,6 +17,61 @@ import (
"clearlove/internal/util"
)
+// ---------- 管理员专享昵称:凭证校验与限流 ----------
+
+// adminTry 单个 IP 的失败计数(防止在线爆破后台密码)
+type adminTry struct {
+ Count int
+ Until time.Time
+}
+
+var (
+ adminTryMu sync.Mutex
+ adminTries = map[string]adminTry{}
+)
+
+// verifyAdminCreds 校验后台管理员账号密码(仅校验,不建立会话)
+func verifyAdminCreds(username, password string) bool {
+ if username == "" || password == "" {
+ return false
+ }
+ var hash string
+ if err := database.DB.QueryRow("SELECT password FROM admins WHERE username=?", username).Scan(&hash); err != nil {
+ return false
+ }
+ return util.CheckPassword(hash, password)
+}
+
+// adminTryBlocked 是否因失败次数过多被暂时锁定(5 次 / 10 分钟)
+func adminTryBlocked(ip string) bool {
+ adminTryMu.Lock()
+ defer adminTryMu.Unlock()
+ t, ok := adminTries[ip]
+ if !ok {
+ return false
+ }
+ if time.Now().After(t.Until) {
+ delete(adminTries, ip)
+ return false
+ }
+ return t.Count >= 5
+}
+
+func adminTryFail(ip string) {
+ adminTryMu.Lock()
+ defer adminTryMu.Unlock()
+ t := adminTries[ip]
+ t.Count++
+ t.Until = time.Now().Add(10 * time.Minute)
+ adminTries[ip] = t
+}
+
+func adminTryReset(ip string) {
+ adminTryMu.Lock()
+ defer adminTryMu.Unlock()
+ delete(adminTries, ip)
+}
+
// CommentItem 评论预览项
type CommentItem struct {
Nickname string `json:"nickname"`
@@ -33,6 +90,7 @@ type Card struct {
Videos []string `json:"videos"`
Likes int64 `json:"likes"`
CommentCount int64 `json:"comment_count"`
+ IsAdmin int `json:"is_admin"`
Comments []CommentItem `json:"comments"`
CreatedAt string `json:"created_at"`
}
@@ -54,7 +112,7 @@ func latestNotice() (int64, string, string) {
func fetchCards(before int64, limit int, topic string) []Card {
q := `
SELECT p.id,p.user_id,IFNULL(p.nickname,'匿名'),IFNULL(p.content,''),IFNULL(t.name,''),
- p.like_count,p.comment_count,p.created_at
+ p.like_count,p.comment_count,IFNULL(p.is_admin,0),p.created_at
FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
WHERE p.status=1 AND (? <= 0 OR p.id < ?)`
args := []any{before, before}
@@ -75,7 +133,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
for rows.Next() {
c := Card{Images: []string{}, Videos: []string{}, Comments: []CommentItem{}}
if err := rows.Scan(&c.ID, &c.UserID, &c.Nickname, &c.Content, &c.Topic,
- &c.Likes, &c.CommentCount, &c.CreatedAt); err == nil {
+ &c.Likes, &c.CommentCount, &c.IsAdmin, &c.CreatedAt); err == nil {
order = append(order, c.ID)
byID[c.ID] = &c
}
@@ -210,8 +268,9 @@ func singleCard(id int64) *Card {
}
c := &Card{
ID: p.ID, UserID: p.UserID, Nickname: p.Nickname, Content: p.Content,
- Likes: p.LikeCount, CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
- Images: []string{}, Videos: []string{}, Comments: []CommentItem{},
+ Likes: p.LikeCount, CommentCount: p.CommentCount, IsAdmin: p.IsAdmin,
+ CreatedAt: p.CreatedAt,
+ Images: []string{}, Videos: []string{}, Comments: []CommentItem{},
}
c.Topic = models.QueryStr("SELECT name FROM topics WHERE id=?", p.TopicID)
rows, err := database.DB.Query("SELECT type,path FROM medias WHERE post_id=? ORDER BY id", id)
@@ -267,6 +326,8 @@ func ComposePage(w http.ResponseWriter, r *http.Request) {
"maxImages": 15, "maxVideos": 3,
// AI 发帖预审是否生效(开启且模型已接入)——前端据此展示审核动画
"aiPrecheck": models.GetSetting("ai_precheck") == "1" && ai.Enabled(),
+ // 管理员专享昵称(逗号连接),前端据此在提交时弹出后台账号验证弹窗
+ "adminNicks": strings.Join(models.AdminNicknames(), ","),
})
}
@@ -309,6 +370,23 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
nickname = "匿名"
}
+ // 管理员专享昵称:必须校验后台账号密码,通过后才允许发布并标记为管理员帖
+ isAdmin := 0
+ if models.IsAdminNickname(nickname) {
+ ip := util.ClientIP(r)
+ if adminTryBlocked(ip) {
+ fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
+ return
+ }
+ if !verifyAdminCreds(strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass")) {
+ adminTryFail(ip)
+ fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
+ return
+ }
+ adminTryReset(ip)
+ isAdmin = 1
+ }
+
// 先全量校验文件(数量/大小/格式),再落盘,避免部分文件写入后失败
imgs := r.MultipartForm.File["images"]
vids := r.MultipartForm.File["videos"]
@@ -385,8 +463,8 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
// 入库
res, err := database.DB.Exec(
- "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,created_at) VALUES(?,?,?,?,?,?,1,?)",
- userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, models.Now())
+ "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
+ userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin, models.Now())
if err != nil {
fail(w, 500, "发布失败,请稍后重试")
return
@@ -517,8 +595,8 @@ func PostEditSave(w http.ResponseWriter, r *http.Request) {
func loadPost(id int64) *models.Post {
p := &models.Post{}
err := database.DB.QueryRow(
- "SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(created_at,'') FROM posts WHERE id=?", id,
- ).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.CreatedAt)
+ "SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE id=?", id,
+ ).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
if err != nil {
return nil
}
@@ -570,7 +648,7 @@ func Profile(w http.ResponseWriter, r *http.Request) {
where = "user_id=?"
}
rows, err := database.DB.Query(
- "SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
+ "SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
if err != nil {
http.Error(w, "查询失败", http.StatusInternalServerError)
return
@@ -582,12 +660,13 @@ func Profile(w http.ResponseWriter, r *http.Request) {
Content string
LikeCount int64
CommentCount int64
+ IsAdmin int
CreatedAt string
}
var posts []own
for rows.Next() {
var p own
- _ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount, &p.CreatedAt)
+ _ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
posts = append(posts, p)
}
Render(w, r, "layout.html", "pg_profile", map[string]any{
diff --git a/internal/models/models.go b/internal/models/models.go
index 470ecad..26bb491 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -5,6 +5,7 @@ package models
import (
"database/sql"
"encoding/json"
+ "strings"
"sync"
"time"
@@ -71,9 +72,43 @@ type Post struct {
Status int // 1 可见 0 隐藏(AI/管理员处理)
LikeCount int64
CommentCount int64
+ IsAdmin int // 1 = 管理员专享昵称发布(已通过后台账号验证)
CreatedAt string
}
+// AdminNicknames 后台配置的「管理员专享昵称」列表。
+// 支持中英文逗号、分号与换行分隔,用于防止他人冒充官方身份。
+func AdminNicknames() []string {
+ raw := GetSetting("admin_nicknames")
+ if raw == "" {
+ return nil
+ }
+ fields := strings.FieldsFunc(raw, func(r rune) bool {
+ return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\r'
+ })
+ out := make([]string, 0, len(fields))
+ for _, f := range fields {
+ if s := strings.TrimSpace(f); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+// IsAdminNickname 昵称是否属于管理员专享(忽略大小写与首尾空白)
+func IsAdminNickname(name string) bool {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return false
+ }
+ for _, n := range AdminNicknames() {
+ if strings.EqualFold(n, name) {
+ return true
+ }
+ }
+ return false
+}
+
var (
setCache sync.Map // 设置项内存缓存
setCacheMu sync.Mutex
diff --git a/web/static/app.css b/web/static/app.css
index aa62660..1ff36f2 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -255,6 +255,20 @@ textarea { resize: vertical; }
.modal-captcha input { width: 130px; text-align: center; letter-spacing: 4px; font-size: 16px; }
.captcha-modal-box .form-err { margin: 10px 0 0; }
.captcha-modal-box .btn-row { margin-top: 14px; }
+
+/* ---------------- 管理员专享昵称 ---------------- */
+.nick-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
+.admin-badge {
+ display: inline-flex; align-items: center; flex: none;
+ padding: 1px 8px; border-radius: 999px;
+ font-size: 11px; font-weight: 600; letter-spacing: .5px;
+ background: linear-gradient(135deg, var(--clv-accent), var(--clv-accent-2));
+ color: #fff;
+}
+.admin-verify-box { width: min(360px, 100%); }
+.admin-verify-box h3 { margin-bottom: 6px; }
+.admin-verify-box > .muted { font-size: 13px; margin: 0 0 6px; }
+.admin-verify-box > .muted b { color: var(--clv-accent); }
.profile-head { display: flex; align-items: center; gap: 14px; }
.profile-head .meta { flex: 1; }
.profile-head h2 { margin: 0; font-size: 18px; }
diff --git a/web/static/app.js b/web/static/app.js
index 4aa4e29..7583f71 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -130,7 +130,11 @@
} else {
nick = el('span', 'nickname', p.nickname || '匿名');
}
- meta.appendChild(nick);
+ var nickRow = el('div', 'nick-row');
+ nickRow.appendChild(nick);
+ // 管理员专享昵称发布的帖子:显示「管理员」标识
+ if (p.is_admin) nickRow.appendChild(el('span', 'admin-badge', '管理员'));
+ meta.appendChild(nickRow);
var t = el('time', '', timeText(p.created_at));
meta.appendChild(t);
head.appendChild(meta);
@@ -347,6 +351,16 @@
else toast(msg);
}
+ /* ---- 管理员专享昵称:命中时需验证后台账号 ---- */
+ var adminNicks = (form.dataset.adminNicks || '').split(',').map(function (s) {
+ return s.trim().toLowerCase();
+ }).filter(function (s) { return s; });
+ var adminAuth = { user: '', pass: '' }; // 本次发布携带的后台凭证
+
+ function isReservedNick(name) {
+ return adminNicks.indexOf(String(name || '').trim().toLowerCase()) >= 0;
+ }
+
/* ---- 媒体上传:拖拽(PC)+ 点击选择(移动端/PC) ---- */
var dropZone = document.getElementById('drop-zone');
var input = document.getElementById('media-input');
@@ -482,6 +496,54 @@
if (e.key === 'Escape' && modal && !modal.hidden) closeCaptcha();
});
+ /* ---- 管理员身份验证弹窗 ---- */
+ var avModal = document.getElementById('admin-verify-modal');
+ var avNick = document.getElementById('admin-verify-nick');
+ var avUser = document.getElementById('admin-user');
+ var avPass = document.getElementById('admin-pass');
+ var avErr = document.getElementById('admin-verify-err');
+ var avOk = document.getElementById('admin-verify-ok');
+ var avCancel = document.getElementById('admin-verify-cancel');
+
+ function closeAdminVerify() { if (avModal) avModal.hidden = true; }
+
+ function openAdminVerify(nick) {
+ if (!avModal) return;
+ if (avNick) avNick.textContent = nick;
+ if (avErr) avErr.hidden = true;
+ if (avUser) avUser.value = '';
+ if (avPass) avPass.value = '';
+ avModal.hidden = false;
+ if (avUser) setTimeout(function () { avUser.focus(); }, 50);
+ }
+
+ if (avCancel) avCancel.addEventListener('click', closeAdminVerify);
+ if (avModal) {
+ var avMask = avModal.querySelector('.modal-mask');
+ if (avMask) avMask.addEventListener('click', closeAdminVerify);
+ }
+ if (avPass) {
+ avPass.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); if (avOk) avOk.click(); }
+ });
+ }
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && avModal && !avModal.hidden) closeAdminVerify();
+ });
+ if (avOk) {
+ avOk.addEventListener('click', function () {
+ var u = (avUser ? avUser.value : '').trim();
+ var p = avPass ? avPass.value : '';
+ if (!u || !p) {
+ if (avErr) { avErr.textContent = '请输入后台账号与密码'; avErr.hidden = false; }
+ return;
+ }
+ adminAuth = { user: u, pass: p };
+ closeAdminVerify();
+ openCaptcha(); // 验证通过后继续走验证码流程
+ });
+ }
+
// 验证码通过后真正提交(XHR 带上传进度 + AI 预审动画)
function doSubmit(code) {
if (captchaErr) captchaErr.hidden = true;
@@ -497,6 +559,9 @@
fd.set('captcha_id', captchaBox ? (captchaBox.dataset.id || '') : '');
fd.set('captcha_code', code);
fd.set('fingerprint', fingerprint());
+ // 管理员专享昵称:后台账号凭证(服务端强制校验)
+ fd.set('admin_user', adminAuth.user || '');
+ fd.set('admin_pass', adminAuth.pass || '');
var xhr = new XMLHttpRequest();
xhr.open('POST', '/compose');
@@ -535,12 +600,19 @@
xhr.send(fd);
}
- // 点击「发布」:先做基础校验,再弹出验证码
+ // 点击「发布」:基础校验 → (管理员专享昵称时)验证后台账号 → 验证码
form.addEventListener('submit', function (ev) {
ev.preventDefault();
if (errBox) errBox.hidden = true;
var content = form.querySelector('[name=content]');
if (content && !content.value.trim()) { showErr('请填写内容'); return; }
+ adminAuth = { user: '', pass: '' };
+ var nickInput = form.querySelector('[name=nickname]');
+ var nick = nickInput ? nickInput.value : '';
+ if (isReservedNick(nick)) {
+ openAdminVerify(String(nick).trim() || '匿名');
+ return;
+ }
openCaptcha();
});
diff --git a/web/templates/admin.html b/web/templates/admin.html
index affae35..f35b2cf 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -149,6 +149,10 @@
<label>检查更新接口地址<input type="text" name="update_url" value="{{.s.update_url}}"></label>
<label>插件商店云端地址<input type="text" name="cloud_api" value="{{.s.cloud_api}}" placeholder="https://clearlove.kazx.top"></label>
<p class="muted">插件商店从该地址拉取插件市场数据,默认使用官方云端(https://clearlove.kazx.top)。</p>
+ <label>管理员专享昵称(多个用逗号或换行分隔)
+ <textarea name="admin_nicknames" rows="3" placeholder="例如:官方, 管理员, ClearLove">{{.s.admin_nicknames}}</textarea>
+ </label>
+ <p class="muted">使用这些昵称发帖时必须验证后台管理员账号与密码,通过后帖子会显示「管理员」标识;这些昵称同时禁止被注册。</p>
<label>捐赠图片地址<input type="text" name="donate_img" value="{{.s.donate_img}}"></label>
</div>
diff --git a/web/templates/front.html b/web/templates/front.html
index 41ef458..0f53bad 100644
--- a/web/templates/front.html
+++ b/web/templates/front.html
@@ -18,7 +18,10 @@
<div class="card-head">
<span class="avatar">{{substr0 .Nickname}}</span>
<div class="meta">
- {{if .UserID}}<a class="nickname" href="/u/{{.UserID}}">{{.Nickname}}</a>{{else}}<span class="nickname">{{.Nickname}}</span>{{end}}
+ <div class="nick-row">
+ {{if .UserID}}<a class="nickname" href="/u/{{.UserID}}">{{.Nickname}}</a>{{else}}<span class="nickname">{{.Nickname}}</span>{{end}}
+ {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+ </div>
<time>{{datefmt .CreatedAt}}</time>
</div>
{{if $.canEdit}}
@@ -79,7 +82,8 @@
{{/* ============ 发帖页 ============ */}}
{{define "pg_compose"}}
-<form class="compose card" id="compose-form" method="post" action="/compose" enctype="multipart/form-data">
+<form class="compose card" id="compose-form" method="post" action="/compose" enctype="multipart/form-data"
+ data-admin-nicks="{{.adminNicks}}">
<h2>发布新帖</h2>
<input type="hidden" name="_csrf" value="{{.csrf}}">
<label>昵称
@@ -138,6 +142,26 @@
</div>
</div>
+{{/* 管理员专享昵称:点击发布后弹出后台账号验证 */}}
+<div class="modal" id="admin-verify-modal" hidden>
+ <div class="modal-mask"></div>
+ <div class="modal-box admin-verify-box">
+ <h3>管理员身份验证</h3>
+ <p class="muted">昵称「<b id="admin-verify-nick"></b>」为管理员专享,请验证后台账号后发布</p>
+ <label>后台账号
+ <input type="text" id="admin-user" maxlength="64" autocomplete="off" placeholder="管理员用户名">
+ </label>
+ <label>后台密码
+ <input type="password" id="admin-pass" maxlength="128" autocomplete="off" placeholder="管理员密码">
+ </label>
+ <p class="form-err" id="admin-verify-err" hidden></p>
+ <div class="btn-row">
+ <button type="button" class="btn btn-ghost btn-block" id="admin-verify-cancel">取消</button>
+ <button type="button" class="btn btn-primary btn-block" id="admin-verify-ok">验证并继续</button>
+ </div>
+ </div>
+</div>
+
{{if .aiPrecheck}}
{{/* 发帖 AI 预审加载动画 */}}
<div class="scan-overlay" id="ai-scan" hidden>
@@ -232,7 +256,13 @@
<article class="card post-card">
<div class="card-head">
<span class="avatar">{{substr0 .Nickname}}</span>
- <div class="meta"><span class="nickname">{{.Nickname}}</span><time>{{datefmt .CreatedAt}}</time></div>
+ <div class="meta">
+ <div class="nick-row">
+ <span class="nickname">{{.Nickname}}</span>
+ {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+ </div>
+ <time>{{datefmt .CreatedAt}}</time>
+ </div>
{{if or $.self $.admin}}
<div class="row-actions">
<a class="mini-btn" href="/post/{{.ID}}/edit">编辑</a>