Compare commits

..
6 Commits
Author SHA1 Message Date
Sakurasan 439133b4b5 .env.example:补 GitHub OAuth 凭据模板(评论登录) 2026-09-28 00:50:03 +08:00
Sakurasan 1a2db5ddac 管理员免登录直接评论:后台会话映射站主身份(免审核、不可被禁言)+ 评论作者字段对齐后端 user
- 公开 API 解析顺序:读者 cookie → 管理员 cookie(provider=admin 站主读者,名字取站点作者名)
- 站主发评论跳过禁言与审核;后台禁言接口拒绝站主身份
- CommentItem 读 c.author 改为 c.user(后端实际字段),站主徽章按 provider 判断
2026-09-28 00:43:25 +08:00
Sakurasan 0929f880e3 评论区:禁言卡判断挪到匿名判断之后——匿名访客读 user.banned 抛错导致整个评论区消失 2026-09-28 00:27:41 +08:00
Sakurasan 8e9d98e261 OAuth 登录记住发起页:回调后回到登录前所在的前台 origin(开发时 3000/8080 分离不再落错站) 2026-09-28 00:15:17 +08:00
Sakurasan cfd6948987 评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀
- 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑
- 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换
- 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
2026-09-27 23:41:40 +08:00
Sakurasan 740d47bbf6 短文配图:快发盒上传 + Twitter 式网格 + 全站 lightbox 预览;整卡点击进详情、时间线短文豁免首字 2026-09-27 23:41:34 +08:00
27 changed files with 2004 additions and 47 deletions
+8
View File
@@ -3,3 +3,11 @@ SecretAccessKey=
S3Api="https://<account_id>.r2.cloudflarestorage.com" S3Api="https://<account_id>.r2.cloudflarestorage.com"
PublicURL= PublicURL=
Bucket= Bucket=
ONE_GITHUB_CLIENT_ID=Iv1.xxxxxxxx
ONE_GITHUB_CLIENT_SECRET=ghp_xxxxxxxx
# GitHub OAuth(评论登录):https://github.com/settings/developers
# 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名)
ONE_GITHUB_CLIENT_ID=
ONE_GITHUB_CLIENT_SECRET=
+119
View File
@@ -15,6 +15,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -72,6 +73,24 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/files", a.guard(a.files)) mux.HandleFunc("/api/admin/files", a.guard(a.files))
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID)) mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) {
// 路径形如 /api/admin/readers/{id}/ban
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/")
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[1] != "ban" {
httpx.NotFound(w)
return
}
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || id <= 0 {
httpx.BadRequest(w, "bad reader id")
return
}
a.adminReaderBan(w, r, id)
}))
return mux return mux
} }
@@ -772,3 +791,103 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required") httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
} }
} }
// ---------- comments(评论审核与管理) ----------
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
status := httpx.QueryString(r, "status")
page := httpx.QueryInt(r, "page", 1)
size := httpx.QueryInt(r, "size", 20)
fp, err := a.Store.ListCommentsAdmin(status, page, size)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, fp)
}
func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/")
id, err := parseInt(rest)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
// 审核动作:{"status": "visible" | "pending"}
var in struct {
Status string `json:"status"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if in.Status != "visible" && in.Status != "pending" {
httpx.BadRequest(w, "status 只支持 visible / pending")
return
}
if err := a.Store.SetCommentStatus(id, in.Status); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true})
case http.MethodDelete:
// 后台删除同样走软删(墓碑保楼层)
if err := a.Store.DeleteComment(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
// ---------- readers(评论用户与禁言) ----------
func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
readers, err := a.Store.ListReaders()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"readers": readers})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
}
}
func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) {
var in struct {
Banned bool `json:"banned"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
// 站主身份(管理员会话映射出来的读者)不允许禁言
if rd, err := a.Store.GetReader(id); err == nil && rd.Provider == "admin" {
httpx.BadRequest(w, "不能禁言站主身份")
return
}
if err := a.Store.SetReaderBanned(id, in.Banned); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned})
}
+20 -3
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"oneblog/internal/auth"
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/httpx" "oneblog/internal/httpx"
"oneblog/internal/model" "oneblog/internal/model"
@@ -20,9 +21,17 @@ import (
) )
type API struct { type API struct {
Store *store.Store Store *store.Store
Cfg *config.Config Cfg *config.Config
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享) Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
// 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions
GH auth.GitHub
// AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。
AdminSessions interface {
Verify(token string) (string, error)
}
} }
func (a *API) Routes() http.Handler { func (a *API) Routes() http.Handler {
@@ -34,6 +43,14 @@ func (a *API) Routes() http.Handler {
} }
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver}) httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
}) })
// 读者登录与评论
mux.HandleFunc("/api/auth/providers", a.authProviders)
mux.HandleFunc("/api/auth/me", a.authMe)
mux.HandleFunc("/api/auth/logout", a.authLogout)
mux.HandleFunc("/api/auth/github/login", a.githubLogin)
mux.HandleFunc("/api/auth/callback/github", a.githubCallback)
mux.HandleFunc("/api/comments", a.comments)
mux.HandleFunc("/api/comments/", a.commentSub)
mux.HandleFunc("/api/site", a.site) mux.HandleFunc("/api/site", a.site)
mux.HandleFunc("/api/posts", a.listPosts) mux.HandleFunc("/api/posts", a.listPosts)
mux.HandleFunc("/api/posts/", a.getPost) mux.HandleFunc("/api/posts/", a.getPost)
+429
View File
@@ -0,0 +1,429 @@
// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转;
// 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。
//
// 审核:设置里开了「先审后显」时,新评论 status=pending——
// 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/render"
"oneblog/internal/store"
)
const (
readerCookie = "one_reader"
oauthStateCook = "one_oauth_state"
oauthBackCook = "one_oauth_back"
maxCommentLen = 500
editWindow = 10 * time.Minute
)
// readerID 从会话 cookie 解出读者 ID;匿名返回 false。
// 后台管理员已登录(one_session)时直接映射为站主读者身份——
// 站主发评论不必再走一遍 GitHub 登录。
func (a *API) readerID(r *http.Request) (int64, bool) {
rd, ok, err := a.resolveReader(r)
if err != nil || !ok {
return 0, false
}
return rd.ID, true
}
// resolveReader 解出当前访客的读者身份:读者会话优先,
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
rd, gerr := a.Store.GetReader(id)
if gerr == nil {
return rd, true, nil
}
}
}
if a.AdminSessions != nil {
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
rd, oerr := a.ownerReader()
if oerr == nil {
return rd, true, nil
}
}
}
}
return model.Reader{}, false, nil
}
// ownerReader 取(或创建)站主评论身份:provider=admin,名字用站点作者名。
func (a *API) ownerReader() (model.Reader, error) {
name := "站主"
if st, err := a.Store.GetSettings(); err == nil && st.AuthorName != "" {
name = st.AuthorName
}
return a.Store.UpsertReader(model.Reader{
Provider: "admin", Handle: a.Cfg.AdminUser, Name: name,
})
}
func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
providers := []map[string]any{}
if a.GH.Enabled() {
providers = append(providers, map[string]any{
"id": "github", "label": "GitHub", "kind": "redirect",
})
}
httpx.OK(w, map[string]any{"providers": providers})
}
func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
var user any // 匿名时 {user: null},前端判空即「未登录」
if reader, ok, err := a.resolveReader(r); err == nil && ok {
user = map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL,
"provider": reader.Provider, "is_owner": reader.Provider == "admin", "banned": reader.Banned,
}
}
httpx.OK(w, map[string]any{"user": user})
}
func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: "", Path: "/", MaxAge: -1})
httpx.OK(w, map[string]any{"ok": true})
}
// githubLogin 跳转 GitHub 授权页。state 防 CSRF 存短命 cookie;
// 授权完成回到 callback 后必须带上同一个值。
// 同时把发起登录的前台 origin 记下来(one_oauth_back),
// callback 用它跳回去——开发时前端 3000 / 后端 8080 分离才不会落错站。
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
state := randHex(16)
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
HttpOnly: true, MaxAge: 600})
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
}
}
http.Redirect(w, r, a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state), http.StatusFound)
}
// githubCallback 用 code 换身份:GitHub 用户 → upsert 读者 → 发会话 →
// 回到首页。
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
httpx.BadRequest(w, "state 不匹配,请重新登录")
return
}
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github")
if err != nil {
httpx.ServerError(w, err)
return
}
u, err := a.GH.FetchUser(r.Context(), gh)
if err != nil {
httpx.ServerError(w, err)
return
}
name := u.Name
if name == "" {
name = u.Login
}
reader, err := a.Store.UpsertReader(model.Reader{
Provider: "github", Handle: u.Login, Name: name,
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
})
if err != nil {
httpx.ServerError(w, err)
return
}
token, _ := a.ReaderSessions.Issue(reader.ID)
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
// 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根
back := a.Cfg.SiteURL
if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
back = u.Scheme + "://" + u.Host
}
}
http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, back, http.StatusFound)
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ---------- comments(评论的读取与发表) ----------
func (a *API) comments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.listComments(w, r)
case http.MethodPost:
a.createComment(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
func (a *API) listComments(w http.ResponseWriter, r *http.Request) {
postID := httpx.QueryInt(r, "post_id", 0)
if postID <= 0 {
httpx.BadRequest(w, "post_id required")
return
}
viewer, _ := a.readerID(r)
newest := httpx.QueryString(r, "sort") == "newest"
roots, err := a.Store.ListCommentsByPost(int64(postID), viewer, newest)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{
"items": roots, "total": len(roots),
"page": 1, "size": len(roots),
})
}
// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着;
// 审核开关开着时新评论进「待审」。站主身份(管理员会话)不受禁言与审核约束。
func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
reader, ok, err := a.resolveReader(r)
if err != nil {
httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录")
return
}
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录后才能评论")
return
}
isOwner := reader.Provider == "admin"
if reader.Banned && !isOwner {
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
return
}
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
if !st.CommentsEnabled {
httpx.Error(w, http.StatusForbidden, "评论未开放")
return
}
var in struct {
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if _, err := a.Store.Get(in.PostID); err != nil {
httpx.BadRequest(w, "文章不存在")
return
}
var parent model.Comment
root := int64(0)
if in.ParentID > 0 {
p, err := a.Store.GetComment(in.ParentID)
if err != nil {
httpx.BadRequest(w, "回复的评论不存在")
return
}
if p.PostID != in.PostID {
httpx.BadRequest(w, "回复的评论不属于这篇文章")
return
}
parent = p
root = parent.RootID
if root == 0 {
root = parent.ID
}
}
status := "visible"
if st.CommentsReview && !isOwner {
status = "pending"
}
c, err := a.Store.CreateComment(model.Comment{
PostID: in.PostID, UserID: reader.ID, ParentID: in.ParentID, RootID: root,
BodyMd: body, BodyHTML: render.Markdown(body), Status: status,
})
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.Created(w, c)
}
// commentSub /api/comments/{id} 与 /api/comments/{root}/thread 的分发
func (a *API) commentSub(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/comments/"), "/")
if rest == "" {
httpx.NotFound(w)
return
}
// {root}/thread:楼内回复翻页(当前实现全量内嵌,这里兜底返回剩余)
if strings.HasSuffix(rest, "/thread") {
rootID, err := strconv.ParseInt(strings.TrimSuffix(rest, "/thread"), 10, 64)
if err != nil {
httpx.BadRequest(w, "bad root id")
return
}
a.commentThread(w, r, rootID)
return
}
id, err := strconv.ParseInt(rest, 10, 64)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
a.editComment(w, r, id)
case http.MethodDelete:
a.deleteComment(w, r, id)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
func (a *API) commentThread(w http.ResponseWriter, r *http.Request, rootID int64) {
root, err := a.Store.GetComment(rootID)
if err != nil {
httpx.ServerError(w, err)
return
}
cursor := httpx.QueryInt(r, "cursor", 0)
items := []model.Comment{}
if cursor >= 0 && cursor < len(root.Replies) {
items = root.Replies[cursor:]
}
httpx.OK(w, map[string]any{"items": items, "cursor": "", "reply_count": root.ReplyCount})
}
// editComment 作者改自己的评论:10 分钟内有效,且未被禁言未删除
func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能编辑自己的评论")
return
}
if c.IsDeleted {
httpx.NotFound(w)
return
}
if time.Since(mustParse(c.CreatedAt)) > editWindow {
httpx.Error(w, http.StatusForbidden, "超过可编辑时间")
return
}
var in struct {
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if err := a.Store.UpdateCommentBody(id, body, render.Markdown(body)); err != nil {
httpx.ServerError(w, err)
return
}
updated, err := a.Store.GetComment(id)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, updated)
}
// deleteComment 作者软删自己的评论(留壳保楼层)
func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能删除自己的评论")
return
}
if err := a.Store.DeleteComment(id); err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true})
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}
+172
View File
@@ -0,0 +1,172 @@
// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader,
// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开)
const ReaderCookie = "one_reader"
// ReaderSessions 签发 / 校验读者会话令牌:
// base64("reader:<readerID>:<expiryUnix>") + "." + HMAC-SHA256。
type ReaderSessions struct {
secret []byte
ttl time.Duration
}
func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return &ReaderSessions{secret: []byte(secret), ttl: ttl}
}
var ErrBadSession = errors.New("invalid reader session")
func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
exp := time.Now().Add(s.ttl)
payload := readerPayload(readerID, exp)
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
return enc + "." + s.sign(payload), exp
}
func (s *ReaderSessions) Verify(token string) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return 0, ErrBadSession
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return 0, ErrBadSession
}
// 必须以 reader: 开头——后台令牌(base64("admin:<exp>"))复制过来也无效
if len(raw) < 10 || string(raw)[:7] != "reader:" {
return 0, ErrBadSession
}
// 余下是 "<id>:<exp>"
rest := string(raw)[7:]
i := strings.IndexByte(rest, ':')
if i <= 0 {
return 0, ErrBadSession
}
id, err := strconv.ParseInt(rest[:i], 10, 64)
if err != nil {
return 0, ErrBadSession
}
expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64)
if err != nil {
return 0, ErrBadSession
}
if time.Now().After(time.Unix(expUnix, 0)) {
return 0, ErrBadSession
}
// 签名放在最后验证:payload 与 exp 都验过再比对 MAC
if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) {
return 0, ErrBadSession
}
return id, nil
}
func readerPayload(id int64, exp time.Time) string {
return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10)
}
func (s *ReaderSessions) sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// ---------- GitHub OAuth ----------
// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放,
// 前台登录卡自动不显示该入口。
type GitHub struct {
ClientID string
ClientSecret string
}
func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
const (
githubAuthURL = "https://github.com/login/oauth/authorize"
githubTokenURL = "https://github.com/login/oauth/access_token"
githubUserURL = "https://api.github.com/user"
)
// LoginURL 生成 GitHub 授权页跳转地址
func (g GitHub) LoginURL(redirectURI, state string) string {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("redirect_uri", redirectURI)
v.Set("scope", "read:user")
v.Set("state", state)
return githubAuthURL + "?" + v.Encode()
}
// GitHubUser 是 GitHub 用户接口里我们关心的字段
type GitHubUser struct {
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
HTMLURL string `json:"html_url"`
}
// Exchange 用授权码换 access token
func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("client_secret", g.ClientSecret)
v.Set("code", code)
v.Set("redirect_uri", redirectURI)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
return "", fmt.Errorf("github: token exchange failed")
}
return out.AccessToken, nil
}
// FetchUser 拉取 GitHub 用户资料
func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil)
if err != nil {
return GitHubUser{}, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return GitHubUser{}, err
}
defer res.Body.Close()
var u GitHubUser
if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" {
return GitHubUser{}, fmt.Errorf("github: fetch user failed")
}
return u, nil
}
+14 -6
View File
@@ -21,6 +21,10 @@ type Config struct {
SiteURL string SiteURL string
InsecureDev bool InsecureDev bool
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
GitHubClientID string
GitHubClientSecret string
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理): // 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com, // S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求 // 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
@@ -28,12 +32,12 @@ type Config struct {
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名 // AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘 // 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
// (DataDir/uploads),并在启动日志提示一句。 // (DataDir/uploads),并在启动日志提示一句。
StorageDriver string // r2 | local StorageDriver string // r2 | local
S3Endpoint string // env: S3Api S3Endpoint string // env: S3Api
R2Bucket string R2Bucket string
R2AccessKey string R2AccessKey string
R2SecretKey string R2SecretKey string
UploadsPublicBase string // env: PublicURL UploadsPublicBase string // env: PublicURL
} }
func getenv(k, def string) string { func getenv(k, def string) string {
@@ -122,6 +126,10 @@ func Load() (*Config, error) {
} }
} }
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "")
return c, nil return c, nil
} }
+52
View File
@@ -29,6 +29,9 @@ type Post struct {
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。 // ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
ContentLen int64 `json:"content_len"` ContentLen int64 `json:"content_len"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
Images []string `json:"images"`
} }
type PostInput struct { type PostInput struct {
@@ -42,6 +45,8 @@ type PostInput struct {
PublishedAt string `json:"published_at"` PublishedAt string `json:"published_at"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
ReadingMinutes *int `json:"reading_minutes"` ReadingMinutes *int `json:"reading_minutes"`
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
Images []string `json:"images"`
} }
type Tag struct { type Tag struct {
@@ -123,6 +128,14 @@ type FilePage struct {
Size int `json:"size"` Size int `json:"size"`
} }
// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
type CommentPage struct {
Items []Comment `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// Dashboard is the snapshot rendered on /admin (homepage). // Dashboard is the snapshot rendered on /admin (homepage).
type Dashboard struct { type Dashboard struct {
TotalPosts int `json:"total_posts"` TotalPosts int `json:"total_posts"`
@@ -161,6 +174,10 @@ type Settings struct {
// SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links), // SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links),
// 解析失败/为空时前台拿到空数组,区块自动隐藏。 // 解析失败/为空时前台拿到空数组,区块自动隐藏。
SocialLinks []SocialLink `json:"social_links"` SocialLinks []SocialLink `json:"social_links"`
// 评论开关(comments_enabled):关着时前台整个评论区不渲染。
CommentsEnabled bool `json:"comments_enabled"`
// 审核开关(comments_review):开着时新评论先进「待审」,站主通过后才公开。
CommentsReview bool `json:"comments_review"`
// LightSkinID is the front-end skin used when the client (or system) // LightSkinID is the front-end skin used when the client (or system)
// prefers light. Valid values: paper / sage / rose. // prefers light. Valid values: paper / sage / rose.
// Dark side is fixed to ink for now — kept implicit so we can add // Dark side is fixed to ink for now — kept implicit so we can add
@@ -186,3 +203,38 @@ type Settings struct {
// sanitizing it would only break the snippet. // sanitizing it would only break the snippet.
CustomJS string `json:"custom_js"` CustomJS string `json:"custom_js"`
} }
// Reader 是评论区的登录用户(GitHub OAuth)。Banned = 禁言中。
type Reader struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
URL string `json:"url"`
Banned bool `json:"banned"`
// CommentCount 是累计评论数(后台用户列表展示用)
CommentCount int64 `json:"comment_count"`
CreatedAt string `json:"created_at"`
}
// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
type Comment struct {
ID int64 `json:"id"`
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
RootID int64 `json:"root_id"`
User *Reader `json:"user"`
UserID int64 `json:"user_id"`
BodyMd string `json:"body_md"`
BodyHTML string `json:"body_html"`
Status string `json:"status"`
IsDeleted bool `json:"is_deleted"`
CreatedAt string `json:"created_at"`
EditedAt string `json:"edited_at"`
Replies []Comment `json:"replies"`
ReplyCount int `json:"reply_count"`
// PostTitle 是文章标题(后台评论列表联表带出,仅管理接口填充)
PostTitle string `json:"post_title"`
}
+398 -18
View File
@@ -96,6 +96,30 @@ func (s *Store) migrate() error {
store TEXT NOT NULL DEFAULT 'local', store TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL created_at TEXT NOT NULL
)`, ai), )`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS users (
id %s,
provider TEXT NOT NULL,
handle TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
banned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(provider, handle)
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS comments (
id %s,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
root_id INTEGER NOT NULL DEFAULT 0,
body_md TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'visible',
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
edited_at TEXT NOT NULL DEFAULT ''
)`, ai),
} }
for _, q := range stmts { for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil { if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -109,6 +133,8 @@ func (s *Store) migrate() error {
columnAdds := []string{ columnAdds := []string{
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`, `ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
// 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
} }
for _, q := range columnAdds { for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") && if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
@@ -126,6 +152,9 @@ func (s *Store) migrate() error {
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`}, {"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
{"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`}, {"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`},
{"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`}, {"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`},
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`},
{"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`},
{"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`},
} }
for _, ix := range indexes { for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") { if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -138,13 +167,15 @@ func (s *Store) migrate() error {
func (s *Store) seedSettings() error { func (s *Store) seedSettings() error {
defs := map[string]string{ defs := map[string]string{
"site_title": "ONE · 一个博客", "site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。", "site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE", "author_name": "ONE",
"author_bio": "写点长的,也写点短的。", "author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客", "footer_note": "© ONE · 一个博客",
"icp": "", "icp": "",
"posts_per_page": "10", "posts_per_page": "10",
"comments_enabled": "1",
"comments_review": "0",
} }
for k, v := range defs { for k, v := range defs {
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
@@ -207,6 +238,9 @@ func settingsFromMap(m map[string]string) model.Settings {
st.CustomCSS = decodeCSSMap(m["custom_css"]) st.CustomCSS = decodeCSSMap(m["custom_css"])
st.CustomJS = m["custom_js"] st.CustomJS = m["custom_js"]
st.SocialLinks = decodeSocialLinks(m["social_links"]) st.SocialLinks = decodeSocialLinks(m["social_links"])
// 开关类:'1' / 'true' 都算开,其余(含空)算关
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true")
st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
if n := atoi(m["posts_per_page"]); n > 0 { if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n st.PostsPerPage = n
} }
@@ -389,8 +423,11 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"custom_css": encodeCSSMap(st.CustomCSS), "custom_css": encodeCSSMap(st.CustomCSS),
// 原样存:站主自己的代码,不做任何转义/清洗。 // 原样存:站主自己的代码,不做任何转义/清洗。
"custom_js": st.CustomJS, "custom_js": st.CustomJS,
// 空数组存空串:KV 里不留 "null"。 // 社交 / 源码链接:JSON 数组,空数组存 "[]"。
"social_links": encodeSocialLinks(st.SocialLinks), "social_links": encodeSocialLinks(st.SocialLinks),
// 开关统一存 '1' / '0'。
"comments_enabled": b2s(st.CommentsEnabled),
"comments_review": b2s(st.CommentsReview),
} }
for k, v := range sets { for k, v := range sets {
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
@@ -440,21 +477,46 @@ func sanitizeOrder(o string) string {
} }
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status, const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)` published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images`
// listCols 用于列表/时间线:不传 content_md(前端不用), // listCols 用于列表/时间线:不传 content_md(前端不用),
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。 // 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
const listCols = `id, kind, title, slug, summary, cover_url, const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md, '' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html, CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)` status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images`
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
func decodeImages(s string) []string {
out := []string{}
if strings.TrimSpace(s) == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}
}
return out
}
func encodeImages(imgs []string) string {
if len(imgs) == 0 {
return "[]"
}
b, err := json.Marshal(imgs)
if err != nil {
return "[]"
}
return string(b)
}
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) { func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post var p model.Post
var imgs string
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen) &p.ReadingMinutes, &p.ContentLen, &imgs)
p.Tags = []string{} p.Tags = []string{}
p.Images = decodeImages(imgs)
return p, err return p, err
} }
@@ -627,9 +689,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
} }
func scanPostInto(row *sql.Row, p *model.Post) error { func scanPostInto(row *sql.Row, p *model.Post) error {
var imgs string
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen) &p.ReadingMinutes, &p.ContentLen, &imgs)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return ErrNotFound return ErrNotFound
} }
@@ -637,6 +700,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
return err return err
} }
p.Tags = []string{} p.Tags = []string{}
p.Images = decodeImages(imgs)
return nil return nil
} }
@@ -881,17 +945,17 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
var id int64 var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status, q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
published_at,created_at,updated_at,reading_minutes) published_at,created_at,updated_at,reading_minutes,images)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id) p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)).Scan(&id)
if err != nil { if err != nil {
return p, err return p, err
} }
} else { } else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes) p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images))
if err != nil { if err != nil {
return p, err return p, err
} }
@@ -939,6 +1003,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
// current value (empty string included), so any update round-trips with // current value (empty string included), so any update round-trips with
// whatever the user last saved. // whatever the user last saved.
p.CoverURL = strings.TrimSpace(in.CoverURL) p.CoverURL = strings.TrimSpace(in.CoverURL)
// Images 同理:nil = 不修改(快发盒只发新帖,后台编辑器全量回传)
if in.Images != nil {
p.Images = in.Images
}
p.UpdatedAt = now() p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 { if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes p.ReadingMinutes = *in.ReadingMinutes
@@ -947,9 +1015,9 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
} }
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?, if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`), content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil { p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil {
return p, err return p, err
} }
if in.Tags != nil { if in.Tags != nil {
@@ -1508,3 +1576,315 @@ func (s *Store) DeleteFile(id int64) (model.File, error) {
} }
return f, nil return f, nil
} }
// b2s 布尔转 KV 开关值
func b2s(b bool) string {
if b {
return "1"
}
return "0"
}
// ---------- readers(评论区的登录用户) ----------
// UpsertReader 按 (provider, handle) 找人:找到就更新资料,找不到就建档。
// banned 是状态位,不随资料更新覆盖。
func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
r.CreatedAt = now()
q := s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
VALUES (?,?,?,?,?,0,?)
ON CONFLICT (provider,handle) DO UPDATE SET
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`)
// SQLite 的 ON CONFLICT 语法 Postgres 也认(现代版);老库退化走下面分支
if s.db.Dialect == db.Postgres {
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
VALUES (?,?,?,?,?,0,?) ON CONFLICT (provider,handle) DO UPDATE SET
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`),
r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
return model.Reader{}, err
}
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
if _, err := s.db.Exec(q, r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
return model.Reader{}, err
}
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
func (s *Store) GetReader(id int64) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return model.Reader{}, err
}
return r, nil
}
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return model.Reader{}, err
}
return r, nil
}
func (s *Store) SetReaderBanned(id int64, banned bool) error {
res, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ?`), b2i(banned), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ListReaders 后台的用户列表:带评论数,禁言中的排前面
func (s *Store) ListReaders() ([]model.Reader, error) {
rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.created_at,
(SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
FROM users u ORDER BY u.banned DESC, u.created_at DESC`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Reader{}
for rows.Next() {
var r model.Reader
var cnt int64
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil {
return nil, err
}
r.CommentCount = cnt
out = append(out, r)
}
return out, rows.Err()
}
func b2i(b bool) int64 {
if b {
return 1
}
return 0
}
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
var r model.Reader
var banned int64
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt)
r.Banned = banned == 1
return r, err
}
// ---------- comments ----------
const commentCols = `c.id, c.post_id, c.parent_id, c.root_id, c.user_id, c.body_md, c.body_html,
c.status, c.is_deleted, c.created_at, c.edited_at,
u.id, u.provider, u.handle, u.name, u.avatar_url, u.url, u.banned, u.created_at`
func scanComment(sc interface{ Scan(...any) error }) (model.Comment, error) {
var c model.Comment
var u model.Reader
var banned int64
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt)
if err != nil {
return c, err
}
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
c.Replies = []model.Comment{}
return c, nil
}
// CreateComment 新建评论;parent/root 归属与审核状态由调用方决定
func (s *Store) CreateComment(c model.Comment) (model.Comment, error) {
c.CreatedAt = now()
q := s.db.Q(`INSERT INTO comments (post_id,user_id,parent_id,root_id,body_md,body_html,status,created_at)
VALUES (?,?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, c.PostID, c.UserID, c.ParentID, c.RootID,
c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt).Scan(&id)
if err != nil {
return model.Comment{}, err
}
} else {
res, err := s.db.Exec(q, c.PostID, c.UserID, c.ParentID, c.RootID, c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt)
if err != nil {
return model.Comment{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.Comment{}, err
}
}
return s.GetComment(id)
}
// GetComment 单条(含用户)
func (s *Store) GetComment(id int64) (model.Comment, error) {
c, err := scanComment(s.db.QueryRow(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id WHERE c.id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Comment{}, ErrNotFound
}
return model.Comment{}, err
}
return c, nil
}
// ListCommentsByPost 一篇文章的公开评论树:顶层可见 + 访客自己的待审;
// 每条顶层内嵌全部可见回复。deleted 的行保留(墓碑:正文清空,楼层不塌)。
func (s *Store) ListCommentsByPost(postID, viewerID int64, newestFirst bool) ([]model.Comment, error) {
order := `ASC`
if newestFirst {
order = `DESC`
}
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.parent_id = 0
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
ORDER BY c.created_at `+order), postID, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
roots := []model.Comment{}
idx := map[int64]int{}
for rows.Next() {
c, err := scanComment(rows)
if err != nil {
return nil, err
}
c.Replies = []model.Comment{}
idx[c.ID] = len(roots)
roots = append(roots, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 回复:可见的(+访客自己的待审),按时间正序挂在各自根下
rows2, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.parent_id <> 0
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
ORDER BY c.created_at ASC`), postID, viewerID)
if err != nil {
return nil, err
}
defer rows2.Close()
for rows2.Next() {
c, err := scanComment(rows2)
if err != nil {
return nil, err
}
if at, ok := idx[c.RootID]; ok {
roots[at].Replies = append(roots[at].Replies, c)
}
}
for i := range roots {
roots[i].ReplyCount = len(roots[i].Replies)
}
return roots, rows2.Err()
}
// ListCommentsAdmin 后台的评论列表(平铺,含用户与文章标题),status 过滤
func (s *Store) ListCommentsAdmin(status string, page, size int) (model.CommentPage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
switch status {
case "pending", "visible":
where = ` WHERE c.status = '` + status + `' AND c.is_deleted = 0`
default:
where = ` WHERE c.is_deleted = 0`
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM comments c`+where), args...).Scan(&total); err != nil {
return model.CommentPage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+`, COALESCE(p.title, '') FROM comments c
JOIN users u ON u.id = c.user_id
JOIN posts p ON p.id = c.post_id`+where+`
ORDER BY c.created_at DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.CommentPage{}, err
}
defer rows.Close()
out := []model.Comment{}
for rows.Next() {
c, title, err := scanCommentAdmin(rows)
if err != nil {
return model.CommentPage{}, err
}
c.PostTitle = title
out = append(out, c)
}
return model.CommentPage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
// CommentPage 后台评论管理的分页容器
type modelCommentPageAlias = struct{}
func scanCommentAdmin(sc interface{ Scan(...any) error }) (model.Comment, string, error) {
var c model.Comment
var u model.Reader
var title string
var banned int64
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt, &title)
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
return c, title, err
}
// SetCommentStatus 审核通过 / 退回待审
func (s *Store) SetCommentStatus(id int64, status string) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET status = ? WHERE id = ?`), status, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteComment 软删:留壳(「该评论已删除」),正文清空
func (s *Store) DeleteComment(id int64) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET is_deleted = 1, body_md = '', body_html = '' WHERE id = ?`), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// UpdateCommentBody 编辑后的正文回写
func (s *Store) UpdateCommentBody(id int64, md, html string) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET body_md = ?, body_html = ?, edited_at = ? WHERE id = ?`),
md, html, now(), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+16 -2
View File
@@ -18,6 +18,7 @@ import (
"oneblog/internal/admin" "oneblog/internal/admin"
"oneblog/internal/api" "oneblog/internal/api"
"oneblog/internal/auth"
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/db" "oneblog/internal/db"
"oneblog/internal/storage" "oneblog/internal/storage"
@@ -60,8 +61,21 @@ func main() {
blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads")) blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads"))
} }
public := &api.API{Store: st, Cfg: cfg, Blobs: blobs} // 评论区的读者会话(cookie one_reader),30 天有效
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)) readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
public := &api.API{
Store: st,
Cfg: cfg,
Blobs: blobs,
ReaderSessions: readerSessions,
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret},
AdminSessions: adminSessions,
}
adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Blobs = blobs
adminAPI.Blobs = blobs adminAPI.Blobs = blobs
root := http.NewServeMux() root := http.NewServeMux()
+3
View File
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import TopBar from './components/TopBar.vue' import TopBar from './components/TopBar.vue'
import ThemeSwitcher from './components/ThemeSwitcher.vue' import ThemeSwitcher from './components/ThemeSwitcher.vue'
import ImgLightbox from './components/ImgLightbox.vue'
import YohakuTop from './ui/yohaku/YohakuTop.vue' import YohakuTop from './ui/yohaku/YohakuTop.vue'
import YohakuFoot from './ui/yohaku/YohakuFoot.vue' import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
import { site, setAdminScope } from './site' import { site, setAdminScope } from './site'
@@ -36,6 +37,8 @@ watch(
<RouterView /> <RouterView />
<YohakuFoot v-if="isVivid" /> <YohakuFoot v-if="isVivid" />
<!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) --> <!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) -->
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
<ImgLightbox />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。 <!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 --> vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" /> <ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
+5
View File
@@ -60,6 +60,7 @@ const crumb = computed(() => {
if (p.endsWith('/new')) return '写新的' if (p.endsWith('/new')) return '写新的'
if (/^\/admin\/\d+/.test(p)) return '编辑文章' if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/files')) return '文件' if (p.endsWith('/files')) return '文件'
if (p.endsWith('/comments')) return '评论'
if (p.endsWith('/tags')) return '标签' if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置' if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览' if (p === '/admin' || p === '/admin/') return '总览'
@@ -124,6 +125,10 @@ watch(() => route.path, () => loadCounts())
<span class="ic">▣</span> <span class="ic">▣</span>
<span>文件</span> <span>文件</span>
</RouterLink> </RouterLink>
<RouterLink to="/admin/comments" class="item">
<span class="ic">❝</span>
<span>评论</span>
</RouterLink>
<div class="group">操作</div> <div class="group">操作</div>
<RouterLink to="/admin/new" class="item"> <RouterLink to="/admin/new" class="item">
<span class="ic">✎</span> <span class="ic">✎</span>
+214
View File
@@ -0,0 +1,214 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate, stripTags } from '../utils'
// 评论管理:待审队列优先,通过 / 删除 / 禁言都在行内完成。
// 删除走软删(墓碑保楼层);禁言按读者维度,禁言中的行上有标记。
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const status = ref(route.query.status === 'any' || route.query.status === 'visible' ? route.query.status : 'pending')
const page = computed(() => Number(route.query.page || 1))
const size = 20
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await adminApi.comments({ status: status.value, page: page.value, size })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
if (my === seq) loading.value = false
}
}
onMounted(load)
watch([status, page], load)
function setStatus(v) {
status.value = v
router.replace({ query: { ...route.query, status: v === 'pending' ? undefined : v, page: 1 } })
}
async function approve(id) {
await adminApi.approveComment(id)
load()
}
async function remove(id) {
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
await adminApi.deleteComment(id)
load()
}
async function banToggle(c) {
const banned = !c.user?.banned
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
await adminApi.setReaderBanned(c.user?.id, banned)
c.user = { ...(c.user || {}), banned }
}
const tabs = [
{ label: '待审', value: 'pending' },
{ label: '已通过', value: 'visible' },
{ label: '全部', value: 'any' }
]
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
评论 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
</h1>
</header>
<div class="posts-toolbar" style="margin-bottom: 16px;">
<button class="chip" :class="{ on: status === 'pending' }" @click="setStatus('pending')">待审</button>
<button class="chip" :class="{ on: status === 'visible' }" @click="setStatus('visible')">已通过</button>
<button class="chip" :class="{ on: status === 'any' }" @click="setStatus('any')">全部</button>
</div>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">
{{ status === 'pending' ? '没有待审的评论。' : '还没有评论。' }}
</div>
<template v-else>
<div v-for="c in items" :key="c.id" class="cm-row">
<div class="cm-ava" aria-hidden="true">{{ (c.user?.name || '?').slice(0, 1) }}</div>
<div class="cm-body-col">
<div class="cm-row-head">
<span class="cm-name" :title="c.user?.handle || ''">{{ c.user?.name || '匿名' }}</span>
<span v-if="c.user?.banned" class="cm-flag">禁言中</span>
<span v-if="c.status === 'pending'" class="cm-flag pend">待审</span>
<span class="cm-time" :title="c.created_at">{{ relativeDate(c.created_at) }}</span>
</div>
<div class="cm-text">{{ stripTags(c.body_html) }}</div>
<div class="cm-post">on {{ c.post_title || '无题' }}</div>
</div>
<div class="cm-acts">
<a v-if="c.status === 'pending'" href="#" @click.prevent="approve(c.id)">通过</a>
<a href="#" class="danger" @click.prevent="remove(c.id)">删除</a>
<a href="#" @click.prevent="banToggle(c)">{{ c.user?.banned ? '解除禁言' : '禁言' }}</a>
</div>
</div>
<nav
v-if="pageCount > 1"
aria-label="分页"
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
>
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
← 上一页
</button>
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
下一页 →
</button>
</nav>
</template>
</section>
</template>
<style scoped>
.cm-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
gap: 12px;
padding: 14px 4px;
border-bottom: 1px solid var(--admin-line-soft);
}
.cm-ava {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--admin-paper-sunken);
color: var(--admin-ink);
display: grid;
place-items: center;
font-family: var(--serif);
font-size: 16px;
user-select: none;
}
.cm-row-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
color: var(--admin-muted);
}
.cm-name {
font-weight: 600;
color: var(--admin-ink);
}
.cm-flag {
padding: 0 5px;
border: 1px solid var(--admin-line);
border-radius: 2px;
font-size: 11px;
color: var(--admin-muted);
}
.cm-flag.pend {
color: #9a5b45;
border-color: #ddcdc2;
}
.cm-time {
color: var(--admin-faint);
}
.cm-text {
margin-top: 4px;
font-size: 14.5px;
line-height: 1.75;
color: var(--admin-ink);
overflow-wrap: anywhere;
}
.cm-post {
margin-top: 4px;
font-size: 12px;
color: var(--admin-faint);
}
.cm-acts {
display: flex;
gap: 12px;
align-items: baseline;
font-size: 12.5px;
}
.cm-acts a {
color: var(--admin-muted);
}
.cm-acts a:hover {
color: var(--admin-accent);
}
.cm-acts a.danger:hover {
color: #b4553f;
}
</style>
+13
View File
@@ -147,6 +147,8 @@ async function load() {
settings.value.ui_id = UI_CONSTANTS.DEFAULT settings.value.ui_id = UI_CONSTANTS.DEFAULT
} }
socialText.value = socialToText(settings.value.social_links) socialText.value = socialToText(settings.value.social_links)
settings.value.comments_enabled = !!settings.value.comments_enabled
settings.value.comments_review = !!settings.value.comments_review
// 服务端保证非 null,这里再兜一层,并预建分区键, // 服务端保证非 null,这里再兜一层,并预建分区键,
// 这样 v-model 绑定的文本框一开始就有值可写。 // 这样 v-model 绑定的文本框一开始就有值可写。
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') { if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
@@ -382,6 +384,17 @@ async function save() {
<label>ICP 备案号</label> <label>ICP 备案号</label>
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" /> <input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
</div> </div>
<div class="field">
<label>评论</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer;">
<input v-model="settings.comments_enabled" type="checkbox" />
开放评论(关着时前台整个评论区不渲染)
</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer; margin-top: 6px;">
<input v-model="settings.comments_review" type="checkbox" />
新评论需审核,通过后公开
</label>
</div>
<div class="field"> <div class="field">
<label>社交链接(每行一条:名称 | 链接)</label> <label>社交链接(每行一条:名称 | 链接)</label>
<textarea <textarea
+8
View File
@@ -83,6 +83,14 @@ export const adminApi = {
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }), deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'), settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }), saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
// ---------- 评论管理 ----------
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
approveComment: (id) =>
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
setReaderBanned: (id, banned) =>
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
// ---------- 文件上传 ---------- // ---------- 文件上传 ----------
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)), files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }), deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { onBeforeUnmount, watch } from 'vue'
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
// 图片预览层:App.vue 全局挂一次。铺满全屏的暗底 + 居中大图,
// 多图时左右箭头 / 方向键切换,角标显示第几张;Esc / 点空白收起。
function onKey(e) {
if (e.key === 'Escape') closeLightbox()
if (e.key === 'ArrowLeft') stepLightbox(-1)
if (e.key === 'ArrowRight') stepLightbox(1)
}
watch(
() => st.open,
(v) => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
}
)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<Transition name="lb">
<div v-if="st.open" class="lb-overlay" @click="closeLightbox">
<img
class="lb-img"
:src="st.list[st.index]"
alt=""
@click.stop
/>
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
<span v-if="st.list.length > 1" class="lb-count">{{ st.index + 1 }} / {{ st.list.length }}</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lb-overlay {
position: fixed;
inset: 0;
z-index: 130;
background: rgba(12, 11, 10, 0.88);
display: flex;
align-items: center;
justify-content: center;
}
.lb-img {
max-width: min(1200px, 92vw);
max-height: 88vh;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
user-select: none;
}
.lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 26px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding-bottom: 4px;
transition: background-color 0.15s, color 0.15s;
}
.lb-nav:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-nav.prev {
left: 22px;
}
.lb-nav.next {
right: 22px;
}
.lb-close {
position: absolute;
top: 18px;
right: 22px;
width: 38px;
height: 38px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 17px;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.lb-close:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-count {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.lb-img {
max-width: 96vw;
max-height: 74vh;
}
.lb-nav {
width: 38px;
height: 38px;
font-size: 22px;
}
.lb-nav.prev {
left: 10px;
}
.lb-nav.next {
right: 10px;
}
.lb-close {
top: 10px;
right: 10px;
}
}
.lb-enter-active {
transition: opacity 0.2s ease;
}
.lb-leave-active {
transition: opacity 0.18s ease;
}
.lb-enter-from,
.lb-leave-to {
opacity: 0;
}
</style>
+84 -3
View File
@@ -1,14 +1,25 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site' import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils' import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
const router = useRouter()
const props = defineProps({ const props = defineProps({
post: { type: Object, required: true } post: { type: Object, required: true }
}) })
const isShort = computed(() => props.post.kind === 'short') const isShort = computed(() => props.post.kind === 'short')
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
// 用户在选中文字时不跳转(复制内容是第一意图)。
function openPost() {
if (window.getSelection()?.toString()) return
router.push(`/post/${props.post.slug}`)
}
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要 // 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
const body = computed(() => { const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '') if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
@@ -25,7 +36,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
</script> </script>
<template> <template>
<article class="row-feed" :class="{ short: isShort }"> <article class="row-feed" :class="{ short: isShort }" @click="openPost">
<div class="avatar" aria-hidden="true">{{ initial }}</div> <div class="avatar" aria-hidden="true">{{ initial }}</div>
<div class="body"> <div class="body">
@@ -46,9 +57,26 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<div v-if="isShort" class="prose short-body" v-html="body"></div> <div v-if="isShort" class="prose short-body" v-html="body"></div>
<div v-else class="summary">{{ summaryText }}</div> <div v-else class="summary">{{ summaryText }}</div>
<!-- 短文配图:Twitter 式网格(1 全宽 / 2 并排 / 3 左大右二 / 4 四宫格);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click.stop="openLightbox(post.images, i)"
/>
</div>
<div class="meta"> <div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read"> <RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
{{ isShort ? '查看' : '继续阅读' }} → <time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
</RouterLink> </RouterLink>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span> <span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<span v-if="post.tags && post.tags.length" class="tags"> <span v-if="post.tags && post.tags.length" class="tags">
@@ -57,6 +85,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
:key="t" :key="t"
:to="`/tag/${encodeURIComponent(t)}`" :to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip" class="tag-chip"
@click.stop
> >
{{ t }} {{ t }}
</RouterLink> </RouterLink>
@@ -76,6 +105,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
padding: 14px 30px; padding: 14px 30px;
margin: 0 -16px; margin: 0 -16px;
border-bottom: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease;
} }
@@ -154,6 +184,51 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
margin: 0; margin: 0;
} }
/* 短文配图:Twitter 式网格。1 张全宽(限高裁切),2/4 等分,3 张左大右二 */
.imgs {
display: grid;
gap: 3px;
margin-top: 10px;
border-radius: 12px;
overflow: hidden;
}
.imgs img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
cursor: zoom-in;
}
.imgs.n1 img {
max-height: 440px;
}
.imgs.n2,
.imgs.n4 {
grid-template-columns: 1fr 1fr;
}
.imgs.n3 {
grid-template-columns: 1fr 1fr;
}
.imgs.n2 img,
.imgs.n4 img {
aspect-ratio: 1;
}
.imgs.n3 img {
aspect-ratio: 1;
}
.imgs.n3 img:first-child {
grid-row: span 2;
aspect-ratio: auto;
height: 100%;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -164,7 +239,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
color: var(--faint); color: var(--faint);
} }
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
.read { .read {
color: var(--faint);
transition: color 0.15s ease;
}
.read:hover {
color: var(--accent); color: var(--accent);
} }
+140 -2
View File
@@ -23,8 +23,40 @@ const publishing = ref(false)
const error = ref('') const error = ref('')
const sheetOpen = ref(false) const sheetOpen = ref(false)
// 配图:选图即上传(走 /api/admin/files),最多 4 张,发布时随帖子带上
const images = ref([]) // [{ url, name }]
const MAX_IMAGES = 4
const imgInput = ref(null)
const imgUploading = ref(false)
function pickImages() {
imgInput.value?.click()
}
async function onImagesPick(e) {
const files = [...(e.target.files || [])].slice(0, MAX_IMAGES - images.value.length)
e.target.value = '' // 允许分批续选
if (!files.length) return
imgUploading.value = true
error.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
const data = await adminApi.uploadFiles(fd)
for (const f of data) images.value.push({ url: f.url, name: f.name })
} catch (e2) {
error.value = e2.message || '图片上传失败'
} finally {
imgUploading.value = false
}
}
function removeImage(i) {
images.value.splice(i, 1)
}
const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase()) const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase())
const canPost = computed(() => draft.value.trim().length > 0 && !publishing.value) const canPost = computed(
() => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value
)
onMounted(async () => { onMounted(async () => {
try { try {
@@ -53,9 +85,11 @@ async function publish() {
await adminApi.createPost({ await adminApi.createPost({
kind: 'short', kind: 'short',
content_md: draft.value.trim(), content_md: draft.value.trim(),
status: 'published' status: 'published',
images: images.value.map((i) => i.url)
}) })
draft.value = '' draft.value = ''
images.value = []
sheetOpen.value = false sheetOpen.value = false
emit('published') emit('published')
} catch (e) { } catch (e) {
@@ -79,13 +113,30 @@ async function publish() {
<span class="qs-av">{{ initial }}</span> <span class="qs-av">{{ initial }}</span>
<div class="qs-body"> <div class="qs-body">
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" /> <textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts"> <div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span> <span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span> <span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish"> <button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }} {{ publishing ? '发布中…' : '发布' }}
</button> </button>
</div> </div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div> </div>
</div> </div>
</div> </div>
@@ -102,13 +153,30 @@ async function publish() {
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button> <button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
</div> </div>
<textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" /> <textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts"> <div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span> <span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span> <span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish"> <button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }} {{ publishing ? '发布中…' : '发布' }}
</button> </button>
</div> </div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -201,6 +269,76 @@ async function publish() {
cursor: default; cursor: default;
} }
/* 配图:缩略图条 + 添加按钮(最多 4 张,Twitter 同款) */
.qs-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qs-thumb {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--line);
}
.qs-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qs-thumb .x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.qs-img-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: none;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
}
.qs-img-btn:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-soft);
}
.qs-img-btn:disabled {
opacity: 0.4;
cursor: default;
}
.qs-img-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* 手机:FAB 常驻右下,点开上拉 sheet */ /* 手机:FAB 常驻右下,点开上拉 sheet */
.qs-fab { .qs-fab {
display: none; display: none;
@@ -25,14 +25,14 @@ const props = defineProps({
const s = props.api.s const s = props.api.s
const EDIT_WINDOW_MS = 10 * 60 * 1000 const EDIT_WINDOW_MS = 10 * 60 * 1000
const name = computed(() => (props.c.author && props.c.author.name) || '已注销用户') const name = computed(() => (props.c.user && props.c.user.name) || '已注销用户')
const relativeTime = computed(() => relativeDate(props.c.created_at)) const relativeTime = computed(() => relativeDate(props.c.created_at))
const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner)) const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin'))
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id)) const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id))
// 外链:优先作者自填的 url,其次 GitHub 主页 // 外链:优先作者自填的 url,其次 GitHub 主页
const authorHref = computed(() => { const authorHref = computed(() => {
const a = props.c.author const a = props.c.user
if (!a) return '' if (!a) return ''
if (a.url) return a.url if (a.url) return a.url
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}` if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
@@ -41,12 +41,11 @@ const authorHref = computed(() => {
const canEdit = computed(() => { const canEdit = computed(() => {
if (props.c.is_deleted) return false if (props.c.is_deleted) return false
if (isOwner.value) return true
if (!isMine.value) return false if (!isMine.value) return false
const t = new Date(props.c.created_at).getTime() const t = new Date(props.c.created_at).getTime()
return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS
}) })
const canDelete = computed(() => !props.c.is_deleted && (isMine.value || isOwner.value)) const canDelete = computed(() => !props.c.is_deleted && isMine.value)
const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id) const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id)
const editing = computed(() => s.editing && s.editing.id === props.c.id) const editing = computed(() => s.editing && s.editing.id === props.c.id)
@@ -118,7 +117,7 @@ const atInline = computed(
function replyNameOf(r) { function replyNameOf(r) {
if (!r.parent_id || r.parent_id === props.c.id) return '' if (!r.parent_id || r.parent_id === props.c.id) return ''
const t = (props.c.replies || []).find((x) => x.id === r.parent_id) const t = (props.c.replies || []).find((x) => x.id === r.parent_id)
return t ? (t.author && t.author.name) || '' : '' return t ? (t.user && t.user.name) || '' : ''
} }
</script> </script>
@@ -126,15 +125,15 @@ function replyNameOf(r) {
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }"> <li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
<div class="cm-av" :class="{ 'is-owner': isOwner }"> <div class="cm-av" :class="{ 'is-owner': isOwner }">
<img <img
v-if="c.author && c.author.avatar_url" v-if="c.user && c.user.avatar_url"
:src="c.author.avatar_url" :src="c.user.avatar_url"
:alt="`${name} 的头像`" :alt="`${name} 的头像`"
loading="lazy" loading="lazy"
> >
<span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span> <span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span>
<i v-if="c.author && c.author.provider" class="cm-pv" :title="PROVIDER_LABEL[c.author.provider] || c.author.provider"> <i v-if="c.user && c.user.provider" class="cm-pv" :title="PROVIDER_LABEL[c.user.provider] || c.user.provider">
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="BRANDS[c.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.author.provider]" /> <path :d="BRANDS[c.user.provider]" />
</svg> </svg>
</i> </i>
</div> </div>
@@ -117,7 +117,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- 登录卡 / 输入框 --> <!-- 登录卡 / 禁言 / 输入框 -->
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" /> <div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
<div v-else-if="!reader.user" class="cm-gate"> <div v-else-if="!reader.user" class="cm-gate">
<p class="cm-gate-t">使用社交账号登录</p> <p class="cm-gate-t">使用社交账号登录</p>
@@ -141,6 +141,10 @@ onMounted(() => {
<!-- Telegram 官方按钮落到这里 --> <!-- Telegram 官方按钮落到这里 -->
<div ref="tgBox" class="cm-tg" /> <div ref="tgBox" class="cm-tg" />
</div> </div>
<div v-else-if="reader.user.banned" class="cm-gate cm-gate-banned">
<p class="cm-gate-t">你已被禁言,暂时无法参与评论。</p>
<p class="cm-gate-hint">如有疑问请联系站主。</p>
</div>
<div v-else class="cm-compose"> <div v-else class="cm-compose">
<div class="cm-av cm-av-me"> <div class="cm-av cm-av-me">
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy"> <img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
+31
View File
@@ -0,0 +1,31 @@
import { reactive } from 'vue'
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
// 打开时锁 body 滚动(和菜单 sheet 同一套做法)。
const state = reactive({
list: [],
index: 0,
open: false
})
export const lightboxState = state
export function openLightbox(list, index = 0) {
if (!list || !list.length) return
state.list = list
state.index = Math.max(0, Math.min(index, list.length - 1))
state.open = true
document.body.style.overflow = 'hidden'
}
export function closeLightbox() {
state.open = false
document.body.style.overflow = ''
}
export function stepLightbox(delta) {
if (!state.open || state.list.length < 2) return
state.index = (state.index + delta + state.list.length) % state.list.length
}
+1
View File
@@ -18,6 +18,7 @@ const routes = [
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') }, { path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') }, { path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') }, { path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') },
{ path: 'comments', name: 'admin-comments', component: () => import('./admin/CommentsView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') }, { path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') }, { path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') }, { path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
+26
View File
@@ -304,6 +304,32 @@ html.topbar-hidden {--topbar-h: 0px;}
line-height: inherit; line-height: inherit;
margin: 0; margin: 0;
color: inherit;} color: inherit;}
/* 时间线里的短文同样不放大首字:一句话的纸条压个大字像截断
(详情页 .prose-short 同此;长文详情的首字下沉保留) */
.prose.short-body > p:first-of-type::first-letter {float: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
margin: 0;
color: inherit;}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;详情页用) */
.post-imgs {display: grid;
gap: 3px;
margin-top: 18px;
border-radius: 6px;
overflow: hidden;}
.post-imgs img {display: block;
width: 100%;
height: 100%;
object-fit: cover;}
.post-imgs.n2, .post-imgs.n4 {grid-template-columns: 1fr 1fr;}
.post-imgs.n3 {grid-template-columns: 1fr 1fr;}
.post-imgs.n2 img, .post-imgs.n4 img {aspect-ratio: 1;}
.post-imgs.n3 img {aspect-ratio: 1;}
.post-imgs.n3 img:first-child {grid-row: span 2;
aspect-ratio: auto;
height: 100%;}
.post-imgs.n1 img {max-height: 560px;}
.empty {padding: 48px 0; .empty {padding: 48px 0;
text-align: center; text-align: center;
color: var(--muted); color: var(--muted);
+18
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils' import { sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue' import CommentSection from '../../components/comments/CommentSection.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」: // 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
@@ -168,6 +169,23 @@ function onTocClick(e) {
<div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" /> <div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && props.post.images && props.post.images.length"
class="post-imgs"
:class="'n' + Math.min(props.post.images.length, 4)"
>
<img
v-for="(u, i) in props.post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(props.post.images, i)"
/>
</div>
<div class="end"> <div class="end">
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink> <RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div> </div>
+17
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { openLightbox } from '../../lightbox'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签), // 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。 // 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
@@ -54,6 +55,22 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 短文:纸条,正文直接铺开 --> <!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`"> <RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="p.content_html" /> <div class="body" v-html="p.content_html" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
<div
v-if="p.images && p.images.length"
class="imgs"
:class="'n' + Math.min(p.images.length, 4)"
>
<img
v-for="(u, i) in p.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click.prevent.stop="openLightbox(p.images, i)"
/>
</div>
<div class="meta"> <div class="meta">
<span class="badge short">SHORT</span> <span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time> <time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
+5
View File
@@ -130,6 +130,11 @@ html[data-ui='vivid'] .paper > .end {margin-top:40px;padding-top:24px;border-top
display:flex;gap:14px;flex-wrap:wrap;align-items:center;} display:flex;gap:14px;flex-wrap:wrap;align-items:center;}
html[data-ui='vivid'] .paper > .end .tg {padding:6px 0} html[data-ui='vivid'] .paper > .end .tg {padding:6px 0}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;跟 prose 同宽,圆角跟 vivid 走) */
html[data-ui='vivid'] .post-imgs {margin-top: 6px;
border-radius: var(--radius);}
html[data-ui='vivid'] .post-imgs.n1 img {max-height: 520px;}
/* ========================================================================== /* ==========================================================================
把 classic 的 .prose 挡回去 把 classic 的 .prose 挡回去
-------------------------------------------------------------------------- --------------------------------------------------------------------------
+13
View File
@@ -128,6 +128,19 @@ html[data-ui='vivid'] .note .body a {color:var(--accent);font-weight:600;
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)} html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)}
} }
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张) */
html[data-ui='vivid'] .note .imgs {display:grid;gap:3px;margin-top:10px;
border-radius:var(--radius);overflow:hidden;}
html[data-ui='vivid'] .note .imgs img {display:block;width:100%;height:100%;
object-fit:cover;cursor:zoom-in;}
html[data-ui='vivid'] .note .imgs.n2,
html[data-ui='vivid'] .note .imgs.n4 {grid-template-columns:1fr 1fr;}
html[data-ui='vivid'] .note .imgs.n3 {grid-template-columns:1fr 1fr;}
html[data-ui='vivid'] .note .imgs.n2 img,
html[data-ui='vivid'] .note .imgs.n4 img {aspect-ratio:1;}
html[data-ui='vivid'] .note .imgs.n3 img {aspect-ratio:1;}
html[data-ui='vivid'] .note .imgs.n3 img:first-child {grid-row:span 2;
aspect-ratio:auto;height:100%;}
html[data-ui='vivid'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px} html[data-ui='vivid'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center; html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;} margin-top:36px;flex-wrap:wrap;}
+18
View File
@@ -5,6 +5,7 @@ import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue' import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue' import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue' import CommentSection from '../components/comments/CommentSection.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api' import { publicApi } from '../api'
import { site, applyDocTitle } from '../site' import { site, applyDocTitle } from '../site'
import { formatDate, minutesLabel, sanitizeHtml } from '../utils' import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
@@ -106,6 +107,23 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
v-html="sanitizeHtml(contentHtml)" v-html="sanitizeHtml(contentHtml)"
></div> ></div>
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="post-imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(post.images, i)"
/>
</div>
<footer class="foot"> <footer class="foot">
<div v-if="post.tags && post.tags.length" class="tags"> <div v-if="post.tags && post.tags.length" class="tags">
<RouterLink <RouterLink