安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试
后端: - ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试 - 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器 - Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限 - 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB 前端: - EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章) - v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser - 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复 - 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复 - 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"oneblog/internal/config"
|
"oneblog/internal/config"
|
||||||
"oneblog/internal/httpx"
|
"oneblog/internal/httpx"
|
||||||
@@ -19,6 +20,25 @@ type API struct {
|
|||||||
Store *store.Store
|
Store *store.Store
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Sessions *Sessions
|
Sessions *Sessions
|
||||||
|
|
||||||
|
loginOnce sync.Once
|
||||||
|
logins *loginLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPI(st *store.Store, cfg *config.Config, sessions *Sessions) *API {
|
||||||
|
a := &API{Store: st, Cfg: cfg, Sessions: sessions}
|
||||||
|
a.limiter()
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// limiter 惰性初始化,兼容测试里的 &API{...} 零值构造。
|
||||||
|
func (a *API) limiter() *loginLimiter {
|
||||||
|
a.loginOnce.Do(func() {
|
||||||
|
if a.logins == nil {
|
||||||
|
a.logins = newLoginLimiter()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return a.logins
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieName = "one_session"
|
const cookieName = "one_session"
|
||||||
@@ -81,6 +101,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
key := sourceKey(r)
|
||||||
|
if a.limiter().blocked(key) {
|
||||||
|
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
|
||||||
|
return
|
||||||
|
}
|
||||||
var in loginRequest
|
var in loginRequest
|
||||||
if err := httpx.Decode(r, &in); err != nil {
|
if err := httpx.Decode(r, &in); err != nil {
|
||||||
httpx.BadRequest(w, "invalid body")
|
httpx.BadRequest(w, "invalid body")
|
||||||
@@ -89,15 +114,18 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||||
if !userOK || !passOK {
|
if !userOK || !passOK {
|
||||||
|
a.limiter().fail(key)
|
||||||
httpx.Unauthorized(w)
|
httpx.Unauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.limiter().reset(key)
|
||||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: isTLS(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
Expires: exp,
|
Expires: exp,
|
||||||
MaxAge: a.Sessions.TTL(),
|
MaxAge: a.Sessions.TTL(),
|
||||||
@@ -113,6 +141,7 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: isTLS(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oneblog/internal/config"
|
||||||
|
"oneblog/internal/db"
|
||||||
|
"oneblog/internal/model"
|
||||||
|
"oneblog/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestAPI(t *testing.T) (*API, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
d, err := db.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { d.Close() })
|
||||||
|
st, err := store.New(d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store: %v", err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{AdminUser: "admin", AdminPass: "s3cret"}
|
||||||
|
a := NewAPI(st, cfg, NewSessions("test-secret", time.Hour))
|
||||||
|
return a, a.Routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(t *testing.T, h http.Handler, user, pass string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(loginRequest{Username: user, Password: pass})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardRejectsAnonymous(t *testing.T) {
|
||||||
|
_, h := newTestAPI(t)
|
||||||
|
for _, path := range []string{"/api/admin/posts", "/api/admin/settings", "/api/admin/dashboard"} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s: got %d, want 401", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginIssuesUsableSession(t *testing.T) {
|
||||||
|
_, h := newTestAPI(t)
|
||||||
|
rec := login(t, h, "admin", "wrong")
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong password: got %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
rec = login(t, h, "admin", "s3cret")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login: got %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" {
|
||||||
|
t.Fatalf("no token in response: %v", err)
|
||||||
|
}
|
||||||
|
// Bearer 通道
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+out.Token)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bearer posts: got %d, want 200", rec2.Code)
|
||||||
|
}
|
||||||
|
// cookie 通道
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "one_session", Value: out.Token})
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec3, req)
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("cookie posts: got %d, want 200", rec3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimitedAfterRepeatedFailures(t *testing.T) {
|
||||||
|
_, h := newTestAPI(t)
|
||||||
|
var rec *httptest.ResponseRecorder
|
||||||
|
for i := 0; i < maxLoginFails+1; i++ {
|
||||||
|
rec = login(t, h, "admin", "bad")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("after %d failures: got %d, want 429", maxLoginFails+1, rec.Code)
|
||||||
|
}
|
||||||
|
// 限速期间即使口令正确也被拒
|
||||||
|
rec = login(t, h, "admin", "s3cret")
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("locked out login: got %d, want 429", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureCookieBehindTLSProxy(t *testing.T) {
|
||||||
|
_, h := newTestAPI(t)
|
||||||
|
body, _ := json.Marshal(loginRequest{Username: "admin", Password: "s3cret"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login: %d", rec.Code)
|
||||||
|
}
|
||||||
|
cookie := rec.Header().Get("Set-Cookie")
|
||||||
|
if !strings.Contains(cookie, "Secure") {
|
||||||
|
t.Fatalf("expected Secure flag over https proxy, got: %s", cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?order= 直接来自查询串并拼进 SQL,注入串必须被白名单挡掉且不影响数据。
|
||||||
|
func TestOrderParamInjectionIsNeutralized(t *testing.T) {
|
||||||
|
a, h := newTestAPI(t)
|
||||||
|
if _, err := a.Store.Create(model.PostInput{Title: "one", ContentMd: "a", Status: model.StatusPublished}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := login(t, h, "admin", "s3cret")
|
||||||
|
var out struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(token.Body).Decode(&out)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/posts?order="+
|
||||||
|
strings.ReplaceAll("published_at DESC; DROP TABLE posts; --", " ", "%20"), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+out.Token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("injected order: got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// 表若被 DROP,这条查询会报错
|
||||||
|
if _, err := a.Store.List(store.ListOptions{Status: "any"}); err != nil {
|
||||||
|
t.Fatalf("posts table damaged: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
|
||||||
|
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
|
||||||
|
type loginLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hits map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLoginFails = 10
|
||||||
|
loginWindow = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLoginLimiter() *loginLimiter {
|
||||||
|
return &loginLimiter{hits: make(map[string][]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) blocked(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return len(l.recent(key, time.Now())) >= maxLoginFails
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) fail(key string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
l.hits[key] = append(l.recent(key, now), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) reset(key string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.hits, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recent 返回窗口内的失败时间;调用方必须持有 l.mu。
|
||||||
|
func (l *loginLimiter) recent(key string, now time.Time) []time.Time {
|
||||||
|
hs := l.hits[key]
|
||||||
|
cut := now.Add(-loginWindow)
|
||||||
|
i := 0
|
||||||
|
for ; i < len(hs); i++ {
|
||||||
|
if hs[i].After(cut) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
hs = hs[i:]
|
||||||
|
l.hits[key] = hs
|
||||||
|
}
|
||||||
|
if len(hs) == 0 {
|
||||||
|
delete(l.hits, key)
|
||||||
|
}
|
||||||
|
return hs
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceKey(r *http.Request) string {
|
||||||
|
// 只信连接层地址;X-Forwarded-For 可被伪造,不作为限速键。
|
||||||
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
|
||||||
|
func isTLS(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ type API struct {
|
|||||||
func (a *API) Routes() http.Handler {
|
func (a *API) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := a.Store.Ping(r.Context()); err != nil {
|
||||||
|
httpx.Error(w, http.StatusServiceUnavailable, "database unreachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
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/site", a.site)
|
mux.HandleFunc("/api/site", a.site)
|
||||||
|
|||||||
@@ -13,20 +13,22 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
ContentMd string `json:"content_md,omitempty"`
|
ContentMd string `json:"content_md,omitempty"`
|
||||||
ContentHTML string `json:"content_html"`
|
ContentHTML string `json:"content_html"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PublishedAt string `json:"published_at"`
|
PublishedAt string `json:"published_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
ReadingMinutes int `json:"reading_minutes"`
|
ReadingMinutes int `json:"reading_minutes"`
|
||||||
Tags []string `json:"tags"`
|
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
|
||||||
|
ContentLen int64 `json:"content_len"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostInput struct {
|
type PostInput struct {
|
||||||
@@ -70,16 +72,16 @@ type Page struct {
|
|||||||
|
|
||||||
// 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"`
|
||||||
PublishedPosts int `json:"published_posts"`
|
PublishedPosts int `json:"published_posts"`
|
||||||
DraftPosts int `json:"draft_posts"`
|
DraftPosts int `json:"draft_posts"`
|
||||||
ShortPosts int `json:"short_posts"`
|
ShortPosts int `json:"short_posts"`
|
||||||
LongPosts int `json:"long_posts"`
|
LongPosts int `json:"long_posts"`
|
||||||
TotalTags int `json:"total_tags"`
|
TotalTags int `json:"total_tags"`
|
||||||
TotalWords int `json:"total_words"`
|
TotalWords int `json:"total_words"`
|
||||||
RecentPosts []Post `json:"recent_posts"`
|
RecentPosts []Post `json:"recent_posts"`
|
||||||
RecentDrafts []Post `json:"recent_drafts"`
|
RecentDrafts []Post `json:"recent_drafts"`
|
||||||
TopTags []Tag `json:"top_tags"`
|
TopTags []Tag `json:"top_tags"`
|
||||||
PublishedByMonth []MonthBucket `json:"published_by_month"`
|
PublishedByMonth []MonthBucket `json:"published_by_month"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+118
-59
@@ -3,6 +3,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -33,6 +34,9 @@ func New(d *db.DB) (*Store, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ping 供 /api/health 探活数据库连接。
|
||||||
|
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||||
|
|
||||||
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
@@ -244,13 +248,40 @@ type ListOptions struct {
|
|||||||
OrderBy string
|
OrderBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrderBy 来自查询参数,必须白名单校验后才能拼进 SQL。
|
||||||
|
var allowedOrder = map[string]bool{
|
||||||
|
"published_at desc": true,
|
||||||
|
"published_at asc": true,
|
||||||
|
"updated_at desc": true,
|
||||||
|
"updated_at asc": true,
|
||||||
|
"created_at desc": true,
|
||||||
|
"reading_minutes desc": true,
|
||||||
|
"reading_minutes asc": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeOrder(o string) string {
|
||||||
|
key := strings.ToLower(strings.Join(strings.Fields(o), " "))
|
||||||
|
if allowedOrder[key] {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return "published_at desc"
|
||||||
|
}
|
||||||
|
|
||||||
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`
|
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
|
||||||
|
|
||||||
|
// listCols 用于列表/时间线:不传 content_md(前端不用),
|
||||||
|
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
|
||||||
|
const listCols = `id, kind, title, slug, summary, cover_url,
|
||||||
|
'' AS content_md,
|
||||||
|
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
|
||||||
|
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
|
||||||
|
|
||||||
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
|
||||||
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.ReadingMinutes)
|
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.ReadingMinutes, &p.ContentLen)
|
||||||
p.Tags = []string{}
|
p.Tags = []string{}
|
||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
@@ -290,17 +321,14 @@ func (s *Store) List(o ListOptions) (model.Page, error) {
|
|||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
w = "WHERE " + strings.Join(where, " AND ")
|
w = "WHERE " + strings.Join(where, " AND ")
|
||||||
}
|
}
|
||||||
order := "published_at DESC"
|
order := sanitizeOrder(o.OrderBy)
|
||||||
if o.OrderBy != "" {
|
|
||||||
order = o.OrderBy
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
|
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
|
||||||
return model.Page{}, err
|
return model.Page{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, postCols, w, order))
|
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, listCols, w, order))
|
||||||
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
|
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Page{}, err
|
return model.Page{}, err
|
||||||
@@ -383,7 +411,8 @@ 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 {
|
||||||
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.ReadingMinutes)
|
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.ReadingMinutes, &p.ContentLen)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -583,51 +612,77 @@ func NormalizeStatus(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Delete(id int64) error {
|
func (s *Store) Delete(id int64) error {
|
||||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
|
return s.tx(func(tx *sql.Tx) error {
|
||||||
return err
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
|
||||||
}
|
return err
|
||||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
|
}
|
||||||
|
res, err := tx.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tx 把多语句写包进一个事务:中途失败整体回滚,不留半截状态。
|
||||||
|
func (s *Store) tx(fn func(tx *sql.Tx) error) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
n, err := res.RowsAffected()
|
if err := fn(tx); err != nil {
|
||||||
if err == nil && n == 0 {
|
_ = tx.Rollback()
|
||||||
return ErrNotFound
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- tags ----------
|
// ---------- tags ----------
|
||||||
|
|
||||||
func (s *Store) setTags(postID int64, names []string) error {
|
func (s *Store) setTags(postID int64, names []string) error {
|
||||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
|
return s.tx(func(tx *sql.Tx) error {
|
||||||
return err
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, raw := range names {
|
|
||||||
name := strings.TrimSpace(raw)
|
|
||||||
if name == "" || seen[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[name] = true
|
|
||||||
tagID, err := s.upsertTag(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
|
seen := map[string]bool{}
|
||||||
return err
|
for _, raw := range names {
|
||||||
|
name := strings.TrimSpace(raw)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
tagID, err := s.upsertTagOn(tx, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return nil
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// execer 让 upsertTag 在普通连接和事务里都能跑。
|
||||||
|
type execer interface {
|
||||||
|
Exec(query string, args ...any) (sql.Result, error)
|
||||||
|
QueryRow(query string, args ...any) *sql.Row
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) upsertTag(name string) (int64, error) {
|
func (s *Store) upsertTag(name string) (int64, error) {
|
||||||
|
return s.upsertTagOn(s.db, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) upsertTagOn(x execer, name string) (int64, error) {
|
||||||
slug := Slugify(name)
|
slug := Slugify(name)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = "tag"
|
slug = "tag"
|
||||||
}
|
}
|
||||||
var id int64
|
var id int64
|
||||||
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
|
err := x.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
@@ -635,10 +690,10 @@ func (s *Store) upsertTag(name string) (int64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if s.db.Dialect == db.Postgres {
|
if s.db.Dialect == db.Postgres {
|
||||||
err = s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
|
err = x.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
|
res, err := x.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -770,41 +825,45 @@ func (s *Store) MergeTags(fromID, toID int64) (model.Tag, error) {
|
|||||||
}
|
}
|
||||||
// De-duplicate before the move so we don't end up with two rows pointing
|
// De-duplicate before the move so we don't end up with two rows pointing
|
||||||
// at the same post.
|
// at the same post.
|
||||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags
|
if err := s.tx(func(tx *sql.Tx) error {
|
||||||
WHERE post_id IN (SELECT post_id FROM post_tags WHERE tag_id = ?)
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags
|
||||||
AND tag_id = ?`), toID, toID); err != nil {
|
WHERE post_id IN (SELECT post_id FROM post_tags WHERE tag_id = ?)
|
||||||
return model.Tag{}, err
|
AND tag_id = ?`), toID, toID); err != nil {
|
||||||
}
|
return err
|
||||||
if _, err := s.db.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil {
|
}
|
||||||
return model.Tag{}, err
|
if _, err := tx.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil {
|
||||||
}
|
return err
|
||||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID); err != nil {
|
}
|
||||||
|
_, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
return model.Tag{}, err
|
return model.Tag{}, err
|
||||||
}
|
}
|
||||||
return s.getTag(toID)
|
return s.getTag(toID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteTag(id int64) error {
|
func (s *Store) DeleteTag(id int64) error {
|
||||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
|
return s.tx(func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id)
|
||||||
return err
|
return err
|
||||||
}
|
})
|
||||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- archive ----------
|
// ---------- archive ----------
|
||||||
|
|
||||||
func (s *Store) Archive() ([]model.ArchiveYear, error) {
|
func (s *Store) Archive() ([]model.ArchiveYear, error) {
|
||||||
page, err := s.List(ListOptions{Status: model.StatusPublished, Page: 1, Size: 500})
|
var items []model.Post
|
||||||
if err != nil {
|
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
|
||||||
|
WHERE status = ? ORDER BY published_at DESC`), []any{model.StatusPublished}, &items); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
years := []model.ArchiveYear{}
|
years := []model.ArchiveYear{}
|
||||||
yearIdx := map[string]int{}
|
yearIdx := map[string]int{}
|
||||||
monthIdx := map[string]int{}
|
monthIdx := map[string]int{}
|
||||||
for _, p := range page.Items {
|
for _, p := range items {
|
||||||
y, m := splitDate(p.PublishedAt)
|
y, m := splitDate(p.PublishedAt)
|
||||||
if y == "" {
|
if y == "" {
|
||||||
continue
|
continue
|
||||||
@@ -840,10 +899,10 @@ func splitDate(rfc3339 string) (string, string) {
|
|||||||
// Dashboard produces the snapshot rendered on the admin home page.
|
// Dashboard produces the snapshot rendered on the admin home page.
|
||||||
func (s *Store) Dashboard() (model.Dashboard, error) {
|
func (s *Store) Dashboard() (model.Dashboard, error) {
|
||||||
d := model.Dashboard{
|
d := model.Dashboard{
|
||||||
RecentPosts: []model.Post{},
|
RecentPosts: []model.Post{},
|
||||||
RecentDrafts: []model.Post{},
|
RecentDrafts: []model.Post{},
|
||||||
TopTags: []model.Tag{},
|
TopTags: []model.Tag{},
|
||||||
PublishedByMonth: []model.MonthBucket{},
|
PublishedByMonth: []model.MonthBucket{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// status / kind counts --------------------------------------------------
|
// status / kind counts --------------------------------------------------
|
||||||
@@ -885,13 +944,13 @@ func (s *Store) Dashboard() (model.Dashboard, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recent published posts (5) --------------------------------------------
|
// recent published posts (5) --------------------------------------------
|
||||||
if err := s.scanPostsInto(s.db.Q(`SELECT `+postCols+` FROM posts
|
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
|
||||||
WHERE status=? ORDER BY published_at DESC LIMIT 5`), []any{model.StatusPublished}, &d.RecentPosts); err != nil {
|
WHERE status=? ORDER BY published_at DESC LIMIT 5`), []any{model.StatusPublished}, &d.RecentPosts); err != nil {
|
||||||
return d, err
|
return d, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// recent drafts (5) -----------------------------------------------------
|
// recent drafts (5) -----------------------------------------------------
|
||||||
if err := s.scanPostsInto(s.db.Q(`SELECT `+postCols+` FROM posts
|
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
|
||||||
WHERE status=? ORDER BY updated_at DESC LIMIT 5`), []any{model.StatusDraft}, &d.RecentDrafts); err != nil {
|
WHERE status=? ORDER BY updated_at DESC LIMIT 5`), []any{model.StatusDraft}, &d.RecentDrafts); err != nil {
|
||||||
return d, err
|
return d, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ func TestPostInputDefaults(t *testing.T) {
|
|||||||
|
|
||||||
func TestNormalizeStatus(t *testing.T) {
|
func TestNormalizeStatus(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"": model.StatusDraft,
|
"": model.StatusDraft,
|
||||||
"draft": model.StatusDraft,
|
"draft": model.StatusDraft,
|
||||||
"published": model.StatusPublished,
|
"published": model.StatusPublished,
|
||||||
" Published ": model.StatusPublished,
|
" Published ": model.StatusPublished,
|
||||||
"pending": model.StatusDraft, // unknown → draft
|
"pending": model.StatusDraft, // unknown → draft
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := NormalizeStatus(in); got != want {
|
if got := NormalizeStatus(in); got != want {
|
||||||
@@ -150,9 +150,9 @@ func TestStoreSQLite(t *testing.T) {
|
|||||||
// the value is preserved across updates.
|
// the value is preserved across updates.
|
||||||
upd, err := s.Update(p1.ID, model.PostInput{
|
upd, err := s.Update(p1.ID, model.PostInput{
|
||||||
Kind: model.KindLong, Title: "第一篇",
|
Kind: model.KindLong, Title: "第一篇",
|
||||||
CoverURL: p1.CoverURL,
|
CoverURL: p1.CoverURL,
|
||||||
ContentMd: "# hi\n这是新版本。",
|
ContentMd: "# hi\n这是新版本。",
|
||||||
Tags: []string{"Go"},
|
Tags: []string{"Go"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("update: %v", err)
|
t.Fatalf("update: %v", err)
|
||||||
@@ -264,3 +264,30 @@ func openTestStore(t *testing.T) *Store {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeOrder(t *testing.T) {
|
||||||
|
if got := sanitizeOrder("published_at ASC"); got != "published_at asc" {
|
||||||
|
t.Errorf("whitelisted order rejected: %q", got)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{
|
||||||
|
"published_at DESC; DROP TABLE posts; --",
|
||||||
|
"(SELECT 1) DESC",
|
||||||
|
"1 DESC",
|
||||||
|
"published_at DESC, (SELECT COUNT(*) FROM sqlite_master) ASC",
|
||||||
|
} {
|
||||||
|
if got := sanitizeOrder(bad); got != "published_at desc" {
|
||||||
|
t.Errorf("sanitizeOrder(%q) = %q, want fallback", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWithMaliciousOrderByFailsSafe(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
page, err := s.List(ListOptions{Status: "any", OrderBy: "published_at DESC; DROP TABLE posts; --"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
if page.Total != 0 {
|
||||||
|
t.Errorf("unexpected total %d", page.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public := &api.API{Store: st, Cfg: cfg}
|
public := &api.API{Store: st, Cfg: cfg}
|
||||||
adminAPI := &admin.API{Store: st, Cfg: cfg, Sessions: admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)}
|
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
|
||||||
|
|
||||||
root := http.NewServeMux()
|
root := http.NewServeMux()
|
||||||
root.Handle("/api/admin/", adminAPI.Routes())
|
root.Handle("/api/admin/", adminAPI.Routes())
|
||||||
|
|||||||
@@ -7,15 +7,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"typecheck": "vue-tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@egoist/tailwindcss-icons": "^1.8.0",
|
|
||||||
"@iconify-json/mingcute": "^1.1.17",
|
|
||||||
"@milkdown/crepe": "^7.22.1",
|
"@milkdown/crepe": "^7.22.1",
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.6",
|
||||||
"marked": "^12.0.2",
|
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0"
|
||||||
},
|
},
|
||||||
@@ -23,10 +19,8 @@
|
|||||||
"@vitejs/plugin-vue": "^5.1.0",
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0"
|
||||||
"vue-tsc": "^2.1.0"
|
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
Generated
-729
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 会话过期时(请求返回 401)统一跳回登录页
|
// 会话过期时(请求返回 401)统一跳回登录页
|
||||||
window.addEventListener('one:unauthorized', () => {
|
function onUnauthorized() {
|
||||||
session.clear()
|
session.clear()
|
||||||
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
|
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
|
||||||
})
|
}
|
||||||
|
onMounted(() => window.addEventListener('one:unauthorized', onUnauthorized))
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('one:unauthorized', onUnauthorized))
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
@@ -149,7 +151,8 @@ watch(() => route.path, () => loadCounts())
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="admin-content">
|
<main class="admin-content">
|
||||||
<RouterView />
|
<!-- 编辑器在 /admin/:id 间切换会复用组件:按 id 重建实例,防止串写 -->
|
||||||
|
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ function onKey(e) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const it = flat.value[idx.value]
|
const it = flat.value[idx.value]
|
||||||
if (it) run(it.item)
|
if (it) run(it.item)
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
// 面板内只有一个输入框,Tab 不外逃
|
||||||
|
e.preventDefault()
|
||||||
|
inputEl.value?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +111,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
v-model="query"
|
v-model="query"
|
||||||
placeholder="输入命令、文章标题…"
|
placeholder="输入命令、文章标题…"
|
||||||
aria-label="搜索命令和文章"
|
aria-label="搜索命令和文章"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="cmd-listbox"
|
||||||
|
:aria-activedescendant="flat.length ? 'cmd-opt-' + idx : undefined"
|
||||||
@input="idx = 0"
|
@input="idx = 0"
|
||||||
/>
|
/>
|
||||||
<div class="cmd-list">
|
<div class="cmd-list" id="cmd-listbox" role="listbox">
|
||||||
<template v-for="(sec, si) in sections" :key="si">
|
<template v-for="(sec, si) in sections" :key="si">
|
||||||
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
|
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
|
||||||
{{ sec.name }}
|
{{ sec.name }}
|
||||||
@@ -119,6 +127,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
:key="it.id"
|
:key="it.id"
|
||||||
class="row"
|
class="row"
|
||||||
:class="{ on: idx === it._idx }"
|
:class="{ on: idx === it._idx }"
|
||||||
|
role="option"
|
||||||
|
:id="'cmd-opt-' + it._idx"
|
||||||
|
:aria-selected="idx === it._idx"
|
||||||
@click="run(it)"
|
@click="run(it)"
|
||||||
@mouseenter="idx = it._idx"
|
@mouseenter="idx = it._idx"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { adminApi } from '../api'
|
|||||||
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
||||||
import '@milkdown/crepe/theme/common/style.css'
|
import '@milkdown/crepe/theme/common/style.css'
|
||||||
import '@milkdown/crepe/theme/frame.css'
|
import '@milkdown/crepe/theme/frame.css'
|
||||||
import { marked } from 'marked'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -136,7 +135,8 @@ async function initEditor() {
|
|||||||
root: editorEl.value,
|
root: editorEl.value,
|
||||||
defaultValue: form.content_md,
|
defaultValue: form.content_md,
|
||||||
features: {
|
features: {
|
||||||
[CrepeFeature.AI]: false
|
[CrepeFeature.AI]: false,
|
||||||
|
[CrepeFeature.Latex]: false
|
||||||
},
|
},
|
||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[CrepeFeature.Placeholder]: {
|
[CrepeFeature.Placeholder]: {
|
||||||
@@ -173,6 +173,8 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (stopWatch) stopWatch()
|
||||||
if (crepe) {
|
if (crepe) {
|
||||||
crepe.destroy()
|
crepe.destroy()
|
||||||
crepe = null
|
crepe = null
|
||||||
@@ -195,7 +197,7 @@ async function switchMode(next) {
|
|||||||
crepe = new Crepe({
|
crepe = new Crepe({
|
||||||
root: editorEl.value,
|
root: editorEl.value,
|
||||||
defaultValue: form.content_md,
|
defaultValue: form.content_md,
|
||||||
features: { [CrepeFeature.AI]: false },
|
features: { [CrepeFeature.AI]: false, [CrepeFeature.Latex]: false },
|
||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[CrepeFeature.Placeholder]: {
|
[CrepeFeature.Placeholder]: {
|
||||||
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
|
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
|
||||||
@@ -213,13 +215,6 @@ async function switchMode(next) {
|
|||||||
mode.value = next
|
mode.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(mode, (n) => {
|
|
||||||
// ensure form is always the source of truth
|
|
||||||
if (n === 'md' && crepe) {
|
|
||||||
// last sync already happened via listener
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- 自动保存 ----------
|
// ---------- 自动保存 ----------
|
||||||
|
|
||||||
let timer
|
let timer
|
||||||
@@ -270,6 +265,21 @@ function applySaved(saved) {
|
|||||||
publishedLocal.value = isoToLocal(saved.published_at)
|
publishedLocal.value = isoToLocal(saved.published_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /admin/1 → /admin/2 时组件被复用(随后 :key 触发重建)。
|
||||||
|
// 必须 sync:父级换 key 会先销毁本实例,pre-flush 回调来不及跑;
|
||||||
|
// 这里取消旧定时器并把未落盘改动写回原文章 id,防止串写
|
||||||
|
watch(
|
||||||
|
id,
|
||||||
|
(next, prev) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (stopWatch) stopWatch()
|
||||||
|
if (prev > 0 && Number.isFinite(next) && next > 0 && dirty.value) {
|
||||||
|
adminApi.updatePost(prev, buildPayload()).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
)
|
||||||
|
|
||||||
function buildPayload() {
|
function buildPayload() {
|
||||||
return {
|
return {
|
||||||
kind: form.kind,
|
kind: form.kind,
|
||||||
@@ -373,9 +383,6 @@ function clearCover() {
|
|||||||
form.cover_url = ''
|
form.cover_url = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// preview mode — render markdown to HTML for the side panel
|
|
||||||
const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks: true }))
|
|
||||||
|
|
||||||
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
|
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
|
||||||
|
|
||||||
const mdPane = ref(null)
|
const mdPane = ref(null)
|
||||||
@@ -578,7 +585,7 @@ const tbGroups = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p v-if="error" style="color: #b4553f; font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
|
<p v-if="error" style="color: var(--admin-danger); font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
|
||||||
|
|
||||||
<div class="editor-grid">
|
<div class="editor-grid">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { adminApi, session } from '../api'
|
|||||||
import { site } from '../site'
|
import { site } from '../site'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const username = ref('admin')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ const orderMap = {
|
|||||||
longest: 'reading_minutes DESC'
|
longest: 'reading_minutes DESC'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速切换筛选/翻页时只采纳最新一次请求的结果(序号防竞态覆盖)
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const my = ++seq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -41,13 +45,15 @@ async function load() {
|
|||||||
if (tagFilter.value) params.tag = tagFilter.value
|
if (tagFilter.value) params.tag = tagFilter.value
|
||||||
if (q.value.trim()) params.q = q.value.trim()
|
if (q.value.trim()) params.q = q.value.trim()
|
||||||
const data = await adminApi.posts(params)
|
const data = await adminApi.posts(params)
|
||||||
|
if (my !== seq) return
|
||||||
items.value = data.items || []
|
items.value = data.items || []
|
||||||
total.value = data.total || 0
|
total.value = data.total || 0
|
||||||
selected.value = new Set()
|
selected.value = new Set()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (my !== seq) return
|
||||||
error.value = e.message || '加载失败'
|
error.value = e.message || '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (my === seq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +270,7 @@ function clearFilters() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stats" aria-hidden="true">
|
<div class="stats" aria-hidden="true">
|
||||||
<div>{{ p.reading_minutes || 1 }}′</div>
|
<div>{{ p.reading_minutes || 1 }}′</div>
|
||||||
<div>{{ (p.content_md || '').length }} 字</div>
|
<div>{{ p.content_len || 0 }} 字</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="#" @click.prevent.stop="edit(p.id)">编辑</a>
|
<a href="#" @click.prevent.stop="edit(p.id)">编辑</a>
|
||||||
|
|||||||
+20
-1
@@ -1,5 +1,23 @@
|
|||||||
const base = ''
|
const base = ''
|
||||||
|
|
||||||
|
// 侧栏数据(标签 / 最近更新)每页都会重新挂载请求,这里做 30s 的
|
||||||
|
// 模块级缓存 + 并发去重;失败不缓存,下一次调用会重新请求。
|
||||||
|
function cached(fn, ttl = 30000) {
|
||||||
|
let p = null
|
||||||
|
let at = 0
|
||||||
|
return (...args) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (p && now - at < ttl) return p
|
||||||
|
at = now
|
||||||
|
const cur = fn(...args)
|
||||||
|
p = cur
|
||||||
|
cur.catch(() => {
|
||||||
|
if (p === cur) p = null
|
||||||
|
})
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request(path, { method = 'GET', body, auth = false } = {}) {
|
async function request(path, { method = 'GET', body, auth = false } = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' }
|
const headers = { 'Content-Type': 'application/json' }
|
||||||
const res = await fetch(base + path, {
|
const res = await fetch(base + path, {
|
||||||
@@ -35,7 +53,8 @@ export const publicApi = {
|
|||||||
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
|
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
|
||||||
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
|
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
|
||||||
archive: () => request('/api/archive'),
|
archive: () => request('/api/archive'),
|
||||||
tags: () => request('/api/tags')
|
tags: cached(() => request('/api/tags')),
|
||||||
|
latest: cached(() => request('/api/posts?size=5'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { site } from '../site'
|
import { site } from '../site'
|
||||||
import { relativeDate, stripTags, minutesLabel } from '../utils'
|
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
post: { type: Object, required: true }
|
post: { type: Object, required: true }
|
||||||
@@ -12,7 +12,7 @@ const isShort = computed(() => props.post.kind === 'short')
|
|||||||
// 时间线里短文不铺全文,超过 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 || '')
|
||||||
return props.post.content_html || ''
|
return sanitizeHtml(props.post.content_html)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 长文没写摘要时,从正文里截一段纯文本
|
// 长文没写摘要时,从正文里截一段纯文本
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ const tags = ref([])
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [posts, tagData] = await Promise.all([
|
const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()])
|
||||||
publicApi.posts({ size: 5 }),
|
|
||||||
publicApi.tags()
|
|
||||||
])
|
|
||||||
latest.value = posts.items || []
|
latest.value = posts.items || []
|
||||||
tags.value = (tagData.tags || []).slice(0, 12)
|
tags.value = (tagData.tags || []).slice(0, 12)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+8
-501
@@ -534,504 +534,6 @@ h4 {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 主题:前台视图响应 site.theme_id ----------
|
|
||||||
后台自己的 token(--admin-*)保持米白纸感不变。
|
|
||||||
切换 [data-theme] 即可换肤;默认 paper 不写在选择器里。 */
|
|
||||||
|
|
||||||
:root[data-theme='ink'] {
|
|
||||||
--paper: #1f1d1a;
|
|
||||||
--card: #25221e;
|
|
||||||
--paper-sunken: #2b2722;
|
|
||||||
--ink: #ece5d2;
|
|
||||||
--ink-soft: #c3b58f;
|
|
||||||
--muted: #8c8270;
|
|
||||||
--faint: #6a6155;
|
|
||||||
--line: #3a352e;
|
|
||||||
--line-soft: #2f2b25;
|
|
||||||
--accent: #c3a972;
|
|
||||||
--accent-soft: rgba(195, 169, 114, 0.14);
|
|
||||||
--accent-line: rgba(195, 169, 114, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='sage'] {
|
|
||||||
--paper: #eef0e6;
|
|
||||||
--card: #f6f7ef;
|
|
||||||
--paper-sunken: #e2e6d6;
|
|
||||||
--ink: #2a3127;
|
|
||||||
--ink-soft: #475042;
|
|
||||||
--muted: #6f7a68;
|
|
||||||
--faint: #94a08d;
|
|
||||||
--line: #d6dcc9;
|
|
||||||
--line-soft: #e6e9d9;
|
|
||||||
--accent: #5f7b5c;
|
|
||||||
--accent-soft: rgba(95, 123, 92, 0.12);
|
|
||||||
--accent-line: rgba(95, 123, 92, 0.38);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='rose'] {
|
|
||||||
--paper: #f7eee6;
|
|
||||||
--card: #fdf5ee;
|
|
||||||
--paper-sunken: #ecdfd3;
|
|
||||||
--ink: #3a2922;
|
|
||||||
--ink-soft: #5e463d;
|
|
||||||
--muted: #9a7c6f;
|
|
||||||
--faint: #b89c91;
|
|
||||||
--line: #ead9cb;
|
|
||||||
--line-soft: #f0e2d5;
|
|
||||||
--accent: #a55a4a;
|
|
||||||
--accent-soft: rgba(165, 90, 74, 0.1);
|
|
||||||
--accent-line: rgba(165, 90, 74, 0.38);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 后台主题:跟随访客 mode 切换明暗 ----------
|
|
||||||
data-admin-theme 与 data-theme 是两套轴:data-theme 控制前台皮肤,
|
|
||||||
data-admin-theme 只切后台的明/暗。后台暗色用深棕墨感,跟前台 ink 协调。 */
|
|
||||||
|
|
||||||
:root[data-admin-theme='dark'] {
|
|
||||||
--admin-bg: #1a1815;
|
|
||||||
--admin-paper: var(--admin-bg);
|
|
||||||
--admin-card: #25221e;
|
|
||||||
--admin-paper-sunken: #2f2b25;
|
|
||||||
--admin-ink: #ece5d2;
|
|
||||||
--admin-ink-soft: #c3b58f;
|
|
||||||
--admin-muted: #9a8f7e;
|
|
||||||
--admin-faint: #6e6557;
|
|
||||||
--admin-line: #3a352e;
|
|
||||||
--admin-line-soft: #2f2b25;
|
|
||||||
--admin-accent: #c3a972;
|
|
||||||
--admin-accent-soft: rgba(195, 169, 114, 0.16);
|
|
||||||
--admin-accent-line: rgba(195, 169, 114, 0.42);
|
|
||||||
--admin-on-accent: #1a1815;
|
|
||||||
--admin-danger: #d68d75;
|
|
||||||
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
|
||||||
--admin-overlay: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主题切换时让背景渐变,避免硬切 */
|
|
||||||
body {
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
.btn,
|
|
||||||
.tag-chip,
|
|
||||||
.pill,
|
|
||||||
.editor-mode button,
|
|
||||||
.chip,
|
|
||||||
input.input,
|
|
||||||
input.select,
|
|
||||||
textarea.textarea {
|
|
||||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--paper);
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: var(--lh);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
/* 移动端不留横向滚动 */
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: rgba(61, 127, 156, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 通用容器 ---------- */
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
max-width: 1238px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--col-left) minmax(0, var(--col-main)) var(--col-right);
|
|
||||||
gap: var(--gutter);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
|
||||||
.layout {
|
|
||||||
grid-template-columns: var(--col-left) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.layout > .rail-right {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.layout {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.layout > .rail-left {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 小组件 ---------- */
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--line);
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-chip {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-chip:hover {
|
|
||||||
border-color: var(--accent-line);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 7px 18px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill:hover {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-ghost:hover {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 表单 ---------- */
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field > label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input,
|
|
||||||
.textarea,
|
|
||||||
.select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus,
|
|
||||||
.textarea:focus,
|
|
||||||
.select:focus {
|
|
||||||
border-color: var(--accent-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 18px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
border-color: var(--accent-line);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
border-color: #b4553f;
|
|
||||||
color: #b4553f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 正文排版(长文:首字下沉 + 1.95 行高) ---------- */
|
|
||||||
|
|
||||||
.prose {
|
|
||||||
font-size: 16.5px;
|
|
||||||
line-height: var(--lh);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose > :first-child {
|
|
||||||
margin-top: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose p {
|
|
||||||
margin: 0 0 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 首段首字下沉 */
|
|
||||||
.prose > p:first-of-type::first-letter {
|
|
||||||
float: left;
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-size: 52px;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 6px 10px 0 0;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h1,
|
|
||||||
.prose h2,
|
|
||||||
.prose h3,
|
|
||||||
.prose h4 {
|
|
||||||
font-family: var(--serif);
|
|
||||||
color: var(--ink);
|
|
||||||
margin: 1.8em 0 0.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h1 {
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h2 {
|
|
||||||
font-size: 22px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h3::after {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 2px;
|
|
||||||
margin-top: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h4 {
|
|
||||||
font-size: 16.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 引言用衬线,和正文区分开 */
|
|
||||||
.prose blockquote {
|
|
||||||
margin: 1.4em 0;
|
|
||||||
padding: 2px 0 2px 18px;
|
|
||||||
border-left: 2px solid var(--accent-line);
|
|
||||||
font-family: var(--serif);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose blockquote p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose ul,
|
|
||||||
.prose ol {
|
|
||||||
margin: 0 0 1.1em;
|
|
||||||
padding-left: 1.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose li {
|
|
||||||
margin-bottom: 0.35em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose a {
|
|
||||||
color: var(--accent);
|
|
||||||
border-bottom: 1px solid var(--accent-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose a:hover {
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose code {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13.5px;
|
|
||||||
background: var(--paper-sunken);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose pre {
|
|
||||||
background: var(--paper-sunken);
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose pre code {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 13.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose img {
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose hr {
|
|
||||||
height: 1px;
|
|
||||||
border: 0;
|
|
||||||
background: var(--line);
|
|
||||||
margin: 2em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1.4em 0;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose th,
|
|
||||||
.prose td {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
padding: 7px 10px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose th {
|
|
||||||
background: var(--paper-sunken);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 短文:放大正文,不做首字下沉 */
|
|
||||||
.prose-short {
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose-short > p:first-of-type::first-letter {
|
|
||||||
float: none;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
margin: 0;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 空状态 / 加载 ---------- */
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 48px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
padding: 48px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
ADMIN — 后台管理界面(侧边栏布局 + 卡片化)
|
ADMIN — 后台管理界面(侧边栏布局 + 卡片化)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -1273,14 +775,19 @@ h4 {
|
|||||||
.admin-side .group {
|
.admin-side .group {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.admin-side .footer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
padding: 20px 16px 60px;
|
padding: 20px 16px 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* footer(含主题切换)到 780px 才隐藏,与全局浮动按钮的出现断点对齐 */
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.admin-side .footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- card / panel ---------- */
|
/* ---------- card / panel ---------- */
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
|||||||
+6
-12
@@ -1,16 +1,11 @@
|
|||||||
import { marked } from 'marked'
|
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
marked.setOptions({ breaks: true, gfm: true })
|
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
|
||||||
|
// 所有 v-html 出口必须经过它。
|
||||||
// 编辑器预览与前台渲染共用这一个入口:先 marked 渲染,再过 DOMPurify
|
export function sanitizeHtml(html) {
|
||||||
export function renderMarkdown(text) {
|
return DOMPurify.sanitize(html || '')
|
||||||
return DOMPurify.sanitize(marked.parse(text || '', { async: false }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑器按 `md.render(...)` 的用法调用
|
|
||||||
export const md = { render: renderMarkdown }
|
|
||||||
|
|
||||||
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
|
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
|
||||||
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
|
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -50,9 +45,8 @@ export function relativeDate(iso) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stripTags(html) {
|
export function stripTags(html) {
|
||||||
const div = document.createElement('div')
|
// 不用 div.innerHTML:那会真正解析并触发 <img onerror> 之类的事件
|
||||||
div.innerHTML = html || ''
|
return new DOMParser().parseFromString(html || '', 'text/html').body.textContent || ''
|
||||||
return div.textContent || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 短文在列表里没有标题,用正文首句当索引
|
// 短文在列表里没有标题,用正文首句当索引
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ const tabs = [
|
|||||||
{ label: '短文', value: 'short' }
|
{ label: '短文', value: 'short' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 快速翻页/切标签时旧响应可能后到并覆盖新结果,用递增序号只采纳最新一次
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const my = ++seq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -47,13 +51,15 @@ async function load() {
|
|||||||
page: page.value,
|
page: page.value,
|
||||||
size: size.value
|
size: size.value
|
||||||
})
|
})
|
||||||
|
if (my !== seq) return
|
||||||
items.value = data.items || []
|
items.value = data.items || []
|
||||||
total.value = data.total || 0
|
total.value = data.total || 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (my !== seq) return
|
||||||
error.value = e.message || '加载失败'
|
error.value = e.message || '加载失败'
|
||||||
items.value = []
|
items.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (my === seq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import LeftNav from '../components/LeftNav.vue'
|
|||||||
import RightRail from '../components/RightRail.vue'
|
import RightRail from '../components/RightRail.vue'
|
||||||
import { publicApi } from '../api'
|
import { publicApi } from '../api'
|
||||||
import { site, applyDocTitle } from '../site'
|
import { site, applyDocTitle } from '../site'
|
||||||
import { formatDate, minutesLabel } from '../utils'
|
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const post = ref(null)
|
const post = ref(null)
|
||||||
@@ -65,7 +65,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
|||||||
<p v-if="!isShort && post.summary" class="lede">{{ post.summary }}</p>
|
<p v-if="!isShort && post.summary" class="lede">{{ post.summary }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="prose" :class="{ 'prose-short': isShort }" v-html="post.content_html"></div>
|
<div class="prose" :class="{ 'prose-short': isShort }" v-html="sanitizeHtml(post.content_html)"></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">
|
||||||
|
|||||||
@@ -15,28 +15,32 @@ const error = ref('')
|
|||||||
const size = 20
|
const size = 20
|
||||||
|
|
||||||
const name = computed(() => route.params.slug)
|
const name = computed(() => route.params.slug)
|
||||||
|
const page = computed(() => Number(route.query.page || 1))
|
||||||
|
|
||||||
|
// 快速翻页时旧响应可能后到并覆盖新结果,用递增序号只采纳最新一次
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const my = ++seq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const data = await publicApi.posts({ tag: name.value, page: 1, size })
|
const data = await publicApi.posts({ tag: name.value, page: page.value, size })
|
||||||
|
if (my !== seq) return
|
||||||
items.value = data.items || []
|
items.value = data.items || []
|
||||||
total.value = data.total || 0
|
total.value = data.total || 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (my !== seq) return
|
||||||
error.value = e.message || '加载失败'
|
error.value = e.message || '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (my === seq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
watch(name, load)
|
watch([name, page], load)
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
||||||
const page = computed(() => Number(route.query.page || 1))
|
|
||||||
|
|
||||||
watch(page, load)
|
|
||||||
|
|
||||||
applyDocTitle('标签')
|
applyDocTitle('标签')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
darkMode: ['class', 'html.dark'],
|
|
||||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: 'var(--border-color)',
|
|
||||||
accent: 'var(--theme-color)',
|
|
||||||
hover: 'var(--hover-color)',
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
sidebar: '240px',
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
modal: 'rgb(0 0 0 / 20%) 0px 0px 1px, rgb(0 0 0 / 20%) 0px 20px 40px',
|
|
||||||
'card-hover':
|
|
||||||
'rgb(0 0 0 / 5%) 0px 0px 1px, rgb(0 0 0 / 12%) 0px 15px 30px',
|
|
||||||
card: 'rgb(0 0 0 / 5%) 0px 0px 1px, rgb(0 0 0 / 4%) 0px 15px 30px',
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
mono: 'Roboto Mono, Monaco, monospace',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
iconsPlugin({
|
|
||||||
collections: getIconCollections(['mingcute']),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user