余白 UI:按设计稿模块化还原 + 站主自定义 CSS + 详情 neighbors + 静态缓存头

前端(新增 ui/yohaku/,各视图按 ui_id=vivid 分支渲染;classic 与后台不受影响)
- 令牌层:设计稿数值原样落地,28 个 --v-* 为站主接口(自定义 CSS 可覆盖,
  放 @layer 让出优先级);另加防线,中和 styles.css 裸选择器
  (h1 衬线 / body 行高 / 后台 .stat/.toolbar/.chip 卡片)漏进余白元素
- 顶栏(滚动收缩态)/ 阅读进度(顶部 2px + READ n%)/ 页脚
- 首页:hero(统计条)/ 筛选(类型分段 + 标签 chips + 搜索)/
  长文卡片与短文纸条 / 分页 / 作品展示区(3 件 + 查看所有,参照 diygod.cc);
  面板栅格改 1.6fr 1fr 1fr 且高度自适应内容(标签云明显大一圈)
- 文章页:三栏「纸」—— 左时间线(前后各两篇)/ 中纸张 / 右目录与阅读进度;
  短文详情不显示标题(h1 视觉隐藏保留无障碍)
- 全部规则收敛在 html[data-ui='vivid'] 前缀下;无 !important

后端
- GET /api/posts/:slug 增 neighbors(前后各两篇,详情页侧栏时间线用)
- SPA 缓存头:index.html no-cache,assets/ 带 hash 长期 immutable
  (此前无缓存头,浏览器会一直拿旧构建)

其他
- 站主自定义 CSS 管线(customCss.js + ui/sections.js,按分区注入,仅 vivid)
- 删除旧 vivid 覆盖层实现(VividNav/Hero/Footer/Timeline/Toc、ui/vivid.css)
- 设计稿原型附于仓库根 index.html;作品表清掉外链 diygod.cc 的示例封面,
  改用本地占位图(public/covers/)
This commit is contained in:
Sakurasan
2026-09-23 15:37:42 +08:00
parent 3238175ef0
commit fbda293072
46 changed files with 4266 additions and 35 deletions
+64
View File
@@ -141,3 +141,67 @@ func TestOrderParamInjectionIsNeutralized(t *testing.T) {
t.Fatalf("posts table damaged: %v", err)
}
}
// 设置里的 ui_id / custom_css 必须能经 PUT→GET 往返,且非法分区被挡掉。
func TestSettingsUIRoundTrip(t *testing.T) {
_, h := newTestAPI(t)
token := login(t, h, "admin", "s3cret")
var sess struct {
Token string `json:"token"`
}
_ = json.NewDecoder(token.Body).Decode(&sess)
if sess.Token == "" {
t.Fatal("login failed")
}
put := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sess.Token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
rec := put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper",
"ui_id":"vivid","custom_css":{"home":"body{--v-accent:#f0f}","bogus":"x{}"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put settings: got %d body=%s", rec.Code, rec.Body.String())
}
get := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
get.Header.Set("Authorization", "Bearer "+sess.Token)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, get)
if rec.Code != http.StatusOK {
t.Fatalf("get settings: got %d", rec.Code)
}
var got model.Settings
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.UIID != "vivid" {
t.Errorf("ui_id = %q, want vivid", got.UIID)
}
if got.CustomCSS["home"] != "body{--v-accent:#f0f}" {
t.Errorf("home css lost: %+v", got.CustomCSS)
}
if _, ok := got.CustomCSS["bogus"]; ok {
t.Error("unknown section should not be persisted")
}
// An invalid ui_id falls back rather than being stored verbatim.
rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put invalid ui: got %d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, get)
_ = json.NewDecoder(rec.Body).Decode(&got)
if got.UIID != "classic" {
t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID)
}
if got.CustomCSS == nil {
t.Error("custom_css should be non-nil")
}
}
+9 -1
View File
@@ -97,7 +97,15 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
httpx.NotFound(w)
return
}
httpx.OK(w, p)
// 邻居给详情页侧栏的时间线用;取不到不该让详情页挂掉。
nb, err := a.Store.Neighbors(slug)
if err != nil {
nb = nil
}
httpx.OK(w, struct {
model.Post
Neighbors []model.Post `json:"neighbors"`
}{p, nb})
}
func (a *API) archive(w http.ResponseWriter, r *http.Request) {
+10
View File
@@ -137,4 +137,14 @@ type Settings struct {
// only know about a single skin. settingsFromMap falls back to it
// when LightSkinID is empty.
ThemeID string `json:"theme_id,omitempty"`
// UIID selects which front-end UI the whole site renders. Valid values
// are "classic" (the original minimalist layout) and "vivid" (the
// livelier one). The admin UI is unaffected by this — it always uses the
// --admin-* tokens.
UIID string `json:"ui_id"`
// CustomCSS holds owner-authored stylesheets keyed by page section
// ("global", "home", "post", "archive", "tags", "projects", "about").
// Only injected for the vivid UI, and never on /admin. Always non-nil so
// the JSON response is {} rather than null.
CustomCSS map[string]string `json:"custom_css"`
}
+155 -2
View File
@@ -5,6 +5,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
@@ -190,6 +191,8 @@ func settingsFromMap(m map[string]string) model.Settings {
// Mirror the value into ThemeID so legacy API consumers still see it
// in the JSON response.
st.ThemeID = st.LightSkinID
st.UIID = sanitizeUI(m["ui_id"])
st.CustomCSS = decodeCSSMap(m["custom_css"])
if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n
}
@@ -215,6 +218,107 @@ var ValidLightSkins = map[string]bool{
"rose": true,
}
const (
// UIDefault is the UI used when nothing valid is stored.
UIDefault = "classic"
// UIClassic is the original minimalist front-end.
UIClassic = "classic"
// UIVivid is the livelier front-end, which also owns custom-CSS support.
UIVivid = "vivid"
)
// ValidUIs is the whitelist of front-end UI ids. Anything else falls back to
// "classic" on both read and write.
var ValidUIs = map[string]bool{
UIClassic: true,
UIVivid: true,
}
// ValidCSSSections whitelists the page sections an owner may target with
// custom CSS. Keep in sync with frontend/src/ui/sections.js.
var ValidCSSSections = map[string]bool{
"global": true,
"home": true,
"post": true,
"archive": true,
"tags": true,
"projects": true,
"about": true,
}
const (
// maxCustomCSSBytes caps the whole stylesheet set; maxSectionCSSBytes caps
// any single section. Both are generous for hand-written CSS but keep a
// runaway paste from bloating every /api/site response.
maxCustomCSSBytes = 64 << 10
maxSectionCSSBytes = 16 << 10
)
func sanitizeUI(id string) string {
id = strings.TrimSpace(id)
if ValidUIs[id] {
return id
}
return UIDefault
}
// decodeCSSMap turns the stored JSON blob into a section→CSS map. It never
// returns nil, and it drops unknown sections and over-long values even if the
// row was hand-edited, so callers can trust the result.
func decodeCSSMap(raw string) map[string]string {
out := map[string]string{}
if strings.TrimSpace(raw) == "" {
return out
}
var parsed map[string]string
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return out
}
total := 0
for section, css := range parsed {
if !ValidCSSSections[section] {
continue
}
css = strings.ReplaceAll(css, "\x00", "")
if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes {
continue
}
if total+len(css) > maxCustomCSSBytes {
continue
}
total += len(css)
out[section] = css
}
return out
}
// encodeCSSMap is the write-side counterpart: unknown sections are dropped and
// oversized values are refused, then the map is serialized. A nil or empty map
// encodes to "{}" so a full-replace PUT clears everything deterministically.
func encodeCSSMap(in map[string]string) string {
clean := map[string]string{}
total := 0
for section, css := range in {
if !ValidCSSSections[section] {
continue
}
css = strings.ReplaceAll(css, "\x00", "")
if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes {
continue
}
if total+len(css) > maxCustomCSSBytes {
continue
}
total += len(css)
clean[section] = css
}
b, err := json.Marshal(clean)
if err != nil {
return "{}"
}
return string(b)
}
func (s *Store) UpdateSettings(st model.Settings) error {
if st.PostsPerPage <= 0 {
st.PostsPerPage = 10
@@ -222,6 +326,7 @@ func (s *Store) UpdateSettings(st model.Settings) error {
if !ValidLightSkins[st.LightSkinID] {
st.LightSkinID = "paper"
}
st.UIID = sanitizeUI(st.UIID)
sets := map[string]string{
"site_title": st.SiteTitle,
"site_desc": st.SiteDesc,
@@ -233,6 +338,9 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"light_skin_id": st.LightSkinID,
// Mirror to theme_id so any older client still sees something.
"theme_id": st.LightSkinID,
"ui_id": st.UIID,
// Full replace: an omitted/empty map clears every section's CSS.
"custom_css": encodeCSSMap(st.CustomCSS),
}
for k, v := range sets {
if s.db.Dialect == db.Postgres {
@@ -410,6 +518,51 @@ func (s *Store) Get(id int64) (model.Post, error) {
return items[0], nil
}
// Neighbors 返回某篇已发布文章在时间线上的前后邻居(详情页侧栏用)。
// 返回顺序与时间线一致(published_at DESC):最旧在最前、最新在最后,
// 目标文章夹在中间,前后各取最多 2 篇。
func (s *Store) Neighbors(slug string) ([]model.Post, error) {
rows, err := s.db.Query(s.db.Q(`SELECT ` + listCols + ` FROM posts
WHERE status = ? ORDER BY published_at DESC`), model.StatusPublished)
if err != nil {
return nil, err
}
defer rows.Close()
all := []model.Post{}
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return nil, err
}
all = append(all, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
idx := -1
for i, p := range all {
if p.Slug == slug {
idx = i
break
}
}
if idx < 0 {
return []model.Post{}, nil
}
lo, hi := idx-2, idx+2
if lo < 0 {
lo = 0
}
if hi > len(all)-1 {
hi = len(all) - 1
}
out := all[lo : hi+1]
if err := s.attachTags(out); err != nil {
return nil, err
}
return out, nil
}
func (s *Store) GetBySlug(slug string) (model.Post, error) {
var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
@@ -925,7 +1078,7 @@ func (s *Store) CreateTagFull(name, color string) (model.Tag, error) {
return model.Tag{}, err
}
if err == nil {
// already exists — update color and return
// already exists · update color and return
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil {
return model.Tag{}, err
}
@@ -1152,7 +1305,7 @@ func (s *Store) Dashboard() (model.Dashboard, error) {
}
tagRows.Close()
// published_by_month — last 12 months -----------------------------------
// published_by_month · last 12 months -----------------------------------
monthRows, err := s.db.Query(s.db.Q(`SELECT substr(published_at,1,7) AS m, COUNT(*) AS c
FROM posts WHERE status=? AND length(published_at) >= 7
GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished)
+116
View File
@@ -113,6 +113,122 @@ func TestSettingsLightSkinFallback(t *testing.T) {
}
}
func TestSettingsUIFallback(t *testing.T) {
s := openTestStore(t)
st, err := s.GetSettings()
if err != nil {
t.Fatalf("get: %v", err)
}
if st.UIID != UIClassic {
t.Errorf("default ui_id should be %q, got %q", UIClassic, st.UIID)
}
if st.CustomCSS == nil {
t.Error("CustomCSS should never be nil")
}
// An unknown ui_id is coerced to the default on write.
st.UIID = "bogus"
if err := s.UpdateSettings(st); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := s.GetSettings()
if got.UIID != UIClassic {
t.Errorf("bogus ui_id should fall back to %q, got %q", UIClassic, got.UIID)
}
// A valid one persists.
st.UIID = UIVivid
if err := s.UpdateSettings(st); err != nil {
t.Fatalf("update again: %v", err)
}
got, _ = s.GetSettings()
if got.UIID != UIVivid {
t.Errorf("ui_id should persist, got %q", got.UIID)
}
}
func TestSettingsUIFallbackOnRead(t *testing.T) {
s := openTestStore(t)
// A hand-edited / legacy row must not leak an unknown UI to clients.
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
"ui_id", "neon"); err != nil {
t.Fatalf("seed ui_id: %v", err)
}
st, err := s.GetSettings()
if err != nil {
t.Fatalf("get: %v", err)
}
if st.UIID != UIClassic {
t.Errorf("expected read-side fallback to %q, got %q", UIClassic, st.UIID)
}
}
func TestSettingsCustomCSSRoundTrip(t *testing.T) {
s := openTestStore(t)
// Seeding a raw row with an unknown section + a valid one.
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
"custom_css", `{"home":"body{--v-accent:#f0f}","bogus":"x{}"}`); err != nil {
t.Fatalf("seed custom_css: %v", err)
}
st, _ := s.GetSettings()
if st.CustomCSS["home"] != "body{--v-accent:#f0f}" {
t.Errorf("home css not read back: %+v", st.CustomCSS)
}
if _, ok := st.CustomCSS["bogus"]; ok {
t.Error("unknown section should be dropped on read")
}
// Write side: unknown sections dropped, XML-hostile chars survive, nil clears.
st.CustomCSS = map[string]string{
"home": `a::after{content:"<&>"}`,
"bogus": "x{}",
}
if err := s.UpdateSettings(st); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := s.GetSettings()
if got.CustomCSS["home"] != `a::after{content:"<&>"}` {
t.Errorf("css mangled in round trip: %q", got.CustomCSS["home"])
}
if len(got.CustomCSS) != 1 {
t.Errorf("unknown section should be dropped on write, got %+v", got.CustomCSS)
}
// A nil map is a full clear, and still comes back as a non-nil empty map.
got.CustomCSS = nil
if err := s.UpdateSettings(got); err != nil {
t.Fatalf("clear: %v", err)
}
after, _ := s.GetSettings()
if after.CustomCSS == nil {
t.Error("CustomCSS should be non-nil after clearing")
}
if len(after.CustomCSS) != 0 {
t.Errorf("expected cleared map, got %+v", after.CustomCSS)
}
}
func TestSettingsCustomCSSOverLongSectionDropped(t *testing.T) {
s := openTestStore(t)
st, _ := s.GetSettings()
st.CustomCSS = map[string]string{
"home": strings.Repeat("a", maxSectionCSSBytes+1),
"about": "b{color:red}",
}
if err := s.UpdateSettings(st); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := s.GetSettings()
if _, ok := got.CustomCSS["home"]; ok {
t.Error("over-long section should be dropped")
}
if got.CustomCSS["about"] != "b{color:red}" {
t.Errorf("valid section should survive, got %+v", got.CustomCSS)
}
}
// TestStoreSQLite runs a small integration pass on top of an in-memory SQLite
// database to exercise the new fields (cover_url, tag color) and the merge
// + bulk + dashboard paths.