Compare commits
6
Commits
08250b58ef
...
439133b4b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439133b4b5 | ||
|
|
1a2db5ddac | ||
|
|
0929f880e3 | ||
|
|
8e9d98e261 | ||
|
|
cfd6948987 | ||
|
|
740d47bbf6 |
@@ -3,3 +3,11 @@ SecretAccessKey=
|
||||
S3Api="https://<account_id>.r2.cloudflarestorage.com"
|
||||
PublicURL=
|
||||
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=
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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.fileByID))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -772,3 +791,103 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
@@ -23,6 +24,14 @@ type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
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 {
|
||||
@@ -34,6 +43,14 @@ func (a *API) Routes() http.Handler {
|
||||
}
|
||||
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/posts", a.listPosts)
|
||||
mux.HandleFunc("/api/posts/", a.getPost)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -21,6 +21,10 @@ type Config struct {
|
||||
SiteURL string
|
||||
InsecureDev bool
|
||||
|
||||
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
|
||||
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
|
||||
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
|
||||
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ type Post struct {
|
||||
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
|
||||
ContentLen int64 `json:"content_len"`
|
||||
Tags []string `json:"tags"`
|
||||
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
|
||||
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
@@ -42,6 +45,8 @@ type PostInput struct {
|
||||
PublishedAt string `json:"published_at"`
|
||||
Tags []string `json:"tags"`
|
||||
ReadingMinutes *int `json:"reading_minutes"`
|
||||
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
@@ -123,6 +128,14 @@ type FilePage struct {
|
||||
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).
|
||||
type Dashboard struct {
|
||||
TotalPosts int `json:"total_posts"`
|
||||
@@ -161,6 +174,10 @@ type Settings struct {
|
||||
// SocialLinks 以 JSON 数组形式存在 settings KV 里(key: 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)
|
||||
// prefers light. Valid values: paper / sage / rose.
|
||||
// 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.
|
||||
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"`
|
||||
}
|
||||
|
||||
+391
-11
@@ -96,6 +96,30 @@ func (s *Store) migrate() error {
|
||||
store TEXT NOT NULL DEFAULT 'local',
|
||||
created_at TEXT NOT NULL
|
||||
)`, 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 {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
||||
@@ -109,6 +133,8 @@ func (s *Store) migrate() error {
|
||||
columnAdds := []string{
|
||||
`ALTER TABLE posts ADD COLUMN cover_url 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 {
|
||||
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_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_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 {
|
||||
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
@@ -145,6 +174,8 @@ func (s *Store) seedSettings() error {
|
||||
"footer_note": "© ONE · 一个博客",
|
||||
"icp": "",
|
||||
"posts_per_page": "10",
|
||||
"comments_enabled": "1",
|
||||
"comments_review": "0",
|
||||
}
|
||||
for k, v := range defs {
|
||||
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.CustomJS = m["custom_js"]
|
||||
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 {
|
||||
st.PostsPerPage = n
|
||||
}
|
||||
@@ -389,8 +423,11 @@ func (s *Store) UpdateSettings(st model.Settings) error {
|
||||
"custom_css": encodeCSSMap(st.CustomCSS),
|
||||
// 原样存:站主自己的代码,不做任何转义/清洗。
|
||||
"custom_js": st.CustomJS,
|
||||
// 空数组存空串:KV 里不留 "null"。
|
||||
// 社交 / 源码链接:JSON 数组,空数组存 "[]"。
|
||||
"social_links": encodeSocialLinks(st.SocialLinks),
|
||||
// 开关统一存 '1' / '0'。
|
||||
"comments_enabled": b2s(st.CommentsEnabled),
|
||||
"comments_review": b2s(st.CommentsReview),
|
||||
}
|
||||
for k, v := range sets {
|
||||
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,
|
||||
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(前端不用),
|
||||
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
|
||||
const listCols = `id, kind, title, slug, summary, cover_url,
|
||||
'' AS content_md,
|
||||
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) {
|
||||
var p model.Post
|
||||
var imgs string
|
||||
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.ReadingMinutes, &p.ContentLen)
|
||||
&p.ReadingMinutes, &p.ContentLen, &imgs)
|
||||
p.Tags = []string{}
|
||||
p.Images = decodeImages(imgs)
|
||||
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 {
|
||||
var imgs string
|
||||
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.ReadingMinutes, &p.ContentLen)
|
||||
&p.ReadingMinutes, &p.ContentLen, &imgs)
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -637,6 +700,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
|
||||
return err
|
||||
}
|
||||
p.Tags = []string{}
|
||||
p.Images = decodeImages(imgs)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -881,17 +945,17 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
|
||||
|
||||
var id int64
|
||||
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)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
published_at,created_at,updated_at,reading_minutes,images)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
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,
|
||||
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 {
|
||||
return p, err
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
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
|
||||
// whatever the user last saved.
|
||||
p.CoverURL = strings.TrimSpace(in.CoverURL)
|
||||
// Images 同理:nil = 不修改(快发盒只发新帖,后台编辑器全量回传)
|
||||
if in.Images != nil {
|
||||
p.Images = in.Images
|
||||
}
|
||||
p.UpdatedAt = now()
|
||||
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
||||
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=?,
|
||||
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.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
|
||||
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if in.Tags != nil {
|
||||
@@ -1508,3 +1576,315 @@ func (s *Store) DeleteFile(id int64) (model.File, error) {
|
||||
}
|
||||
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
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"oneblog/internal/admin"
|
||||
"oneblog/internal/api"
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/storage"
|
||||
@@ -60,8 +61,21 @@ func main() {
|
||||
blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads"))
|
||||
}
|
||||
|
||||
public := &api.API{Store: st, Cfg: cfg, Blobs: blobs}
|
||||
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
|
||||
// 评论区的读者会话(cookie one_reader),30 天有效
|
||||
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
|
||||
|
||||
root := http.NewServeMux()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TopBar from './components/TopBar.vue'
|
||||
import ThemeSwitcher from './components/ThemeSwitcher.vue'
|
||||
import ImgLightbox from './components/ImgLightbox.vue'
|
||||
import YohakuTop from './ui/yohaku/YohakuTop.vue'
|
||||
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
|
||||
import { site, setAdminScope } from './site'
|
||||
@@ -36,6 +37,8 @@ watch(
|
||||
<RouterView />
|
||||
<YohakuFoot v-if="isVivid" />
|
||||
<!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) -->
|
||||
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
|
||||
<ImgLightbox />
|
||||
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
|
||||
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
|
||||
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
|
||||
|
||||
@@ -60,6 +60,7 @@ const crumb = computed(() => {
|
||||
if (p.endsWith('/new')) return '写新的'
|
||||
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
|
||||
if (p.endsWith('/files')) return '文件'
|
||||
if (p.endsWith('/comments')) return '评论'
|
||||
if (p.endsWith('/tags')) return '标签'
|
||||
if (p.endsWith('/settings')) return '设置'
|
||||
if (p === '/admin' || p === '/admin/') return '总览'
|
||||
@@ -124,6 +125,10 @@ watch(() => route.path, () => loadCounts())
|
||||
<span class="ic">▣</span>
|
||||
<span>文件</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/comments" class="item">
|
||||
<span class="ic">❝</span>
|
||||
<span>评论</span>
|
||||
</RouterLink>
|
||||
<div class="group">操作</div>
|
||||
<RouterLink to="/admin/new" class="item">
|
||||
<span class="ic">✎</span>
|
||||
|
||||
@@ -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>
|
||||
@@ -147,6 +147,8 @@ async function load() {
|
||||
settings.value.ui_id = UI_CONSTANTS.DEFAULT
|
||||
}
|
||||
socialText.value = socialToText(settings.value.social_links)
|
||||
settings.value.comments_enabled = !!settings.value.comments_enabled
|
||||
settings.value.comments_review = !!settings.value.comments_review
|
||||
// 服务端保证非 null,这里再兜一层,并预建分区键,
|
||||
// 这样 v-model 绑定的文本框一开始就有值可写。
|
||||
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
|
||||
@@ -382,6 +384,17 @@ async function save() {
|
||||
<label>ICP 备案号</label>
|
||||
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
|
||||
</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">
|
||||
<label>社交链接(每行一条:名称 | 链接)</label>
|
||||
<textarea
|
||||
|
||||
@@ -83,6 +83,14 @@ export const adminApi = {
|
||||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
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)),
|
||||
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
|
||||
|
||||
@@ -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>
|
||||
@@ -1,14 +1,25 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { site } from '../site'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const isShort = computed(() => props.post.kind === 'short')
|
||||
|
||||
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
|
||||
// 用户在选中文字时不跳转(复制内容是第一意图)。
|
||||
function openPost() {
|
||||
if (window.getSelection()?.toString()) return
|
||||
router.push(`/post/${props.post.slug}`)
|
||||
}
|
||||
|
||||
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
|
||||
const body = computed(() => {
|
||||
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>
|
||||
|
||||
<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="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-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">
|
||||
<RouterLink :to="`/post/${post.slug}`" class="read">
|
||||
{{ isShort ? '查看' : '继续阅读' }} →
|
||||
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
|
||||
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
|
||||
</RouterLink>
|
||||
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
|
||||
<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"
|
||||
:to="`/tag/${encodeURIComponent(t)}`"
|
||||
class="tag-chip"
|
||||
@click.stop
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
@@ -76,6 +105,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
padding: 14px 30px;
|
||||
margin: 0 -16px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -154,6 +184,51 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -164,7 +239,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
|
||||
.read {
|
||||
color: var(--faint);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.read:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,40 @@ const publishing = ref(false)
|
||||
const error = ref('')
|
||||
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 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 () => {
|
||||
try {
|
||||
@@ -53,9 +85,11 @@ async function publish() {
|
||||
await adminApi.createPost({
|
||||
kind: 'short',
|
||||
content_md: draft.value.trim(),
|
||||
status: 'published'
|
||||
status: 'published',
|
||||
images: images.value.map((i) => i.url)
|
||||
})
|
||||
draft.value = ''
|
||||
images.value = []
|
||||
sheetOpen.value = false
|
||||
emit('published')
|
||||
} catch (e) {
|
||||
@@ -79,13 +113,30 @@ async function publish() {
|
||||
<span class="qs-av">{{ initial }}</span>
|
||||
<div class="qs-body">
|
||||
<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">
|
||||
<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-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
|
||||
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
|
||||
{{ publishing ? '发布中…' : '发布' }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,13 +153,30 @@ async function publish() {
|
||||
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
|
||||
</div>
|
||||
<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">
|
||||
<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-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
|
||||
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
|
||||
{{ publishing ? '发布中…' : '发布' }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -201,6 +269,76 @@ async function publish() {
|
||||
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 */
|
||||
.qs-fab {
|
||||
display: none;
|
||||
|
||||
@@ -25,14 +25,14 @@ const props = defineProps({
|
||||
const s = props.api.s
|
||||
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 isOwner = computed(() => !!(props.c.author && props.c.author.is_owner))
|
||||
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id))
|
||||
const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin'))
|
||||
const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id))
|
||||
|
||||
// 外链:优先作者自填的 url,其次 GitHub 主页
|
||||
const authorHref = computed(() => {
|
||||
const a = props.c.author
|
||||
const a = props.c.user
|
||||
if (!a) return ''
|
||||
if (a.url) return a.url
|
||||
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
|
||||
@@ -41,12 +41,11 @@ const authorHref = computed(() => {
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (props.c.is_deleted) return false
|
||||
if (isOwner.value) return true
|
||||
if (!isMine.value) return false
|
||||
const t = new Date(props.c.created_at).getTime()
|
||||
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 editing = computed(() => s.editing && s.editing.id === props.c.id)
|
||||
@@ -118,7 +117,7 @@ const atInline = computed(
|
||||
function replyNameOf(r) {
|
||||
if (!r.parent_id || r.parent_id === props.c.id) return ''
|
||||
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>
|
||||
|
||||
@@ -126,15 +125,15 @@ function replyNameOf(r) {
|
||||
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
|
||||
<div class="cm-av" :class="{ 'is-owner': isOwner }">
|
||||
<img
|
||||
v-if="c.author && c.author.avatar_url"
|
||||
:src="c.author.avatar_url"
|
||||
v-if="c.user && c.user.avatar_url"
|
||||
:src="c.user.avatar_url"
|
||||
:alt="`${name} 的头像`"
|
||||
loading="lazy"
|
||||
>
|
||||
<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">
|
||||
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path :d="BRANDS[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.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path :d="BRANDS[c.user.provider]" />
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡 / 输入框 -->
|
||||
<!-- 登录卡 / 禁言 / 输入框 -->
|
||||
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
|
||||
<div v-else-if="!reader.user" class="cm-gate">
|
||||
<p class="cm-gate-t">使用社交账号登录</p>
|
||||
@@ -141,6 +141,10 @@ onMounted(() => {
|
||||
<!-- Telegram 官方按钮落到这里 -->
|
||||
<div ref="tgBox" class="cm-tg" />
|
||||
</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 class="cm-av cm-av-me">
|
||||
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const routes = [
|
||||
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
|
||||
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.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: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
|
||||
|
||||
@@ -304,6 +304,32 @@ html.topbar-hidden {--topbar-h: 0px;}
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
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;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { sanitizeHtml } from '../../utils'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
import CommentSection from '../../components/comments/CommentSection.vue'
|
||||
|
||||
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
|
||||
@@ -168,6 +169,23 @@ function onTocClick(e) {
|
||||
|
||||
<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">
|
||||
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
|
||||
// 余白列表。对应设计稿的 <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)}`">
|
||||
<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">
|
||||
<span class="badge short">SHORT</span>
|
||||
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
|
||||
|
||||
@@ -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;}
|
||||
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 挡回去
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
@@ -128,6 +128,19 @@ html[data-ui='vivid'] .note .body a {color:var(--accent);font-weight:600;
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
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'] .pager {display:flex;gap:8px;align-items:center;justify-content:center;
|
||||
margin-top:36px;flex-wrap:wrap;}
|
||||
|
||||
@@ -5,6 +5,7 @@ import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
||||
import CommentSection from '../components/comments/CommentSection.vue'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
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)"
|
||||
></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">
|
||||
<div v-if="post.tags && post.tags.length" class="tags">
|
||||
<RouterLink
|
||||
|
||||
Reference in New Issue
Block a user