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"
|
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=
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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>
|
<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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: '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') },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 挡回去
|
||||||
--------------------------------------------------------------------------
|
--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user