余白 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
+67 -4
View File
@@ -49,6 +49,65 @@ ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start
| `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 | | `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 |
| `ONE_DATA_DIR` | `./data` | SQLite 数据目录 | | `ONE_DATA_DIR` | `./data` | SQLite 数据目录 |
## 界面:classic / vivid
前台有两套完整界面,都保留,在后台 **设置 → 界面与自定义** 里整站切换(站点级设置,所有访客同款)。
后台自身的外观不受影响。
| `ui_id` | 说明 |
| --- | --- |
| `classic`(默认) | 原来的极简版:三栏骨架(左导航 / 正文 / 右栏)、纸感分区线、衬线标题、首字下沉 |
| `vivid` | 顶部粘性导航 + 单栏卡片流、渐变配色与背景网格、悬浮卡片、首页 hero;支持站主自定义 CSS |
实现要点,改这套东西前先读:
- `ui_id` 写在 `<html data-ui>` 上,由 `frontend/src/site.js` 的 `applyTheme()` 设置。
`index.html` 里有一小段 inline script 先读 `localStorage['one.ui']` 设好,避免首帧闪一下 classic。
- vivid 的皮肤**不复用** paper/sage/rose,而是让 `data-theme` 取 `vivid` / `vivid-dark`
(明暗仍由访客的 themeMode 决定)。这样两套调色板不会互相打架。
- `frontend/src/ui/vivid.css` 是**纯覆盖层**,在 `main.ts` 里排在 `styles.css` 之后。
**每个选择器都必须以 `html[data-ui="vivid"]` 开头**:scoped 样式编译后是 `.foo[data-v-HASH]`
(特指度 0,2,0),而 `html[data-ui="vivid"] .foo` 是 0,2,1 才能稳定取胜;scoped 样式是懒加载 chunk,
在入口 CSS 之后才到位,漏了前缀会变成「有时生效有时不生效」。复合选择器要**整条链**镜像
(如 `.card:hover .layer-front` 要写全,不能只前缀第一段)。该文件里不允许出现 `!important`。
- vivid 把 classic 那 12 个 token 重新赋值(`--paper/--card/--ink/--accent/...`),
所以读 token 的共享组件零改动就换了皮。vivid 自己的变量以 `--v-` 开头。
- 顶部导航与首页 hero 是新增组件(`VividNav.vue` / `VividHero.vue`),由 `App.vue` 在
`ui_id === 'vivid' && !isAdmin` 时渲染;`LeftNav.vue` / `RightRail.vue` 在 vivid 下自身不渲染,
7 个公开视图没有任何改动。
### 站主自定义 CSS
按分区分段写,存在 settings 的 `custom_css`(一行 JSON,`{分区: CSS}`)。
```
global 全局 —— 每个前台页面都会加载,放变量和通用微调
home 首页(含标签筛选)
post 文章详情(含短文)
archive 归档
tags 标签云 + 单个标签下的列表
projects 作品
about 关于
```
分区 id 必须与 `backend/internal/store/store.go` 的 `ValidCSSSections`
和 `frontend/src/ui/sections.js` 的 `SECTIONS` 三处保持一致,否则会出现「保存了但不生效」。
- **只在 `ui_id = vivid` 时注入**,并且 `/admin` 下一律清空 —— 写坏样式影响不到后台,classic 也完全不受影响。
- 注入方式是两个 `<style>`(`#one-css-global` 常驻、`#one-css-section` 跟路由换),只写 `textContent`,
不是 `innerHTML`,所以 CSS 里写 `</style>` 也逃不出去,不需要 CSS 清洗库。
- 上限:单个分区 16 KiB,全部合计 64 KiB。超限的分区会被**静默丢弃**(写的时候就不落库)。
- 后台保存是**整对象 PUT**:请求里没带的字段会变成零值。用 curl 写的时候记得把其他字段一起带上。
可覆盖的变量(写进「全局」段即可整站生效):`--v-accent`、`--v-accent-2`、`--v-accent-3`、
`--v-bg`、`--v-surface`、`--v-surface-2`、`--v-text`、`--v-text-soft`、`--v-muted`、`--v-line`、
`--v-radius`、`--v-radius-pill`、`--v-content-width`、`--v-measure`、`--v-motion`(0 = 关掉全部动效)。
后台「界面与自定义」里内置了几个一键插入的片段(换主色 / 方正圆角 / 关渐变背景 / 首页单列),
它们只是往文本框里塞文本,不落库,插进去还能接着改。
> 注意:目前若将来给站点加 CSP,运行时的 `<style>` 注入需要 `style-src 'unsafe-inline'` 或一个 nonce。
## 目录结构 ## 目录结构
``` ```
@@ -63,10 +122,14 @@ backend/ Go 后端(模块名 oneblog)
internal/admin 后台接口与登录鉴权(/api/admin/*) internal/admin 后台接口与登录鉴权(/api/admin/*)
internal/httpx JSON 读写助手 internal/httpx JSON 读写助手
frontend/ Vue 3 前端(pnpm) frontend/ Vue 3 前端(pnpm)
src/styles.css 设计 token 与正文排版(07 风格) src/styles.css classic 的设计 token 与正文排版(07 风格)
src/views 前台:时间线 / 详情 / 归档 / 标签 / 关于 src/ui/vivid.css 第二套 UI(vivid)的覆盖层,全部以 html[data-ui="vivid"] 前缀收敛
src/admin 后台:登录 / 列表 / 编辑器 / 标签 / 设置 src/ui/sections.js 自定义 CSS 的分区清单(与后端 ValidCSSSections 对应)
src/components 左栏导航、右栏卡片、时间线行 src/customCss.js 自定义 CSS 的注入管线(按路由切分区,/admin 下清空)
src/site.js 站点设置 + 明暗 / 皮肤 / UI 三个轴的运行时
src/views 前台:时间线 / 详情 / 归档 / 标签 / 作品 / 关于
src/admin 后台:登录 / 列表 / 编辑器 / 标签 / 作品 / 设置
src/components 左栏导航(classic)、右栏卡片(classic)、时间线行、vivid 导航与 hero
``` ```
原有那一版 Vue 前端(07 风格重写之前)保留在 git 历史的第一个 commit `edee708` 里, 原有那一版 Vue 前端(07 风格重写之前)保留在 git 历史的第一个 commit `edee708` 里,
+64
View File
@@ -141,3 +141,67 @@ func TestOrderParamInjectionIsNeutralized(t *testing.T) {
t.Fatalf("posts table damaged: %v", err) 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) httpx.NotFound(w)
return 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) { 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 // only know about a single skin. settingsFromMap falls back to it
// when LightSkinID is empty. // when LightSkinID is empty.
ThemeID string `json:"theme_id,omitempty"` 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 ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"regexp" "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 // Mirror the value into ThemeID so legacy API consumers still see it
// in the JSON response. // in the JSON response.
st.ThemeID = st.LightSkinID 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 { if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n st.PostsPerPage = n
} }
@@ -215,6 +218,107 @@ var ValidLightSkins = map[string]bool{
"rose": true, "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 { func (s *Store) UpdateSettings(st model.Settings) error {
if st.PostsPerPage <= 0 { if st.PostsPerPage <= 0 {
st.PostsPerPage = 10 st.PostsPerPage = 10
@@ -222,6 +326,7 @@ func (s *Store) UpdateSettings(st model.Settings) error {
if !ValidLightSkins[st.LightSkinID] { if !ValidLightSkins[st.LightSkinID] {
st.LightSkinID = "paper" st.LightSkinID = "paper"
} }
st.UIID = sanitizeUI(st.UIID)
sets := map[string]string{ sets := map[string]string{
"site_title": st.SiteTitle, "site_title": st.SiteTitle,
"site_desc": st.SiteDesc, "site_desc": st.SiteDesc,
@@ -233,6 +338,9 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"light_skin_id": st.LightSkinID, "light_skin_id": st.LightSkinID,
// Mirror to theme_id so any older client still sees something. // Mirror to theme_id so any older client still sees something.
"theme_id": st.LightSkinID, "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 { for k, v := range sets {
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
@@ -410,6 +518,51 @@ func (s *Store) Get(id int64) (model.Post, error) {
return items[0], nil 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) { func (s *Store) GetBySlug(slug string) (model.Post, error) {
var p model.Post var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug) 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 return model.Tag{}, err
} }
if err == nil { 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 { if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil {
return model.Tag{}, err return model.Tag{}, err
} }
@@ -1152,7 +1305,7 @@ func (s *Store) Dashboard() (model.Dashboard, error) {
} }
tagRows.Close() 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 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 FROM posts WHERE status=? AND length(published_at) >= 7
GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished) 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 // 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 // database to exercise the new fields (cover_url, tag color) and the merge
// + bulk + dashboard paths. // + bulk + dashboard paths.
+11 -1
View File
@@ -70,7 +70,7 @@ func main() {
log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser) log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser)
} }
if !dirExists(cfg.WebDist) { if !dirExists(cfg.WebDist) {
log.Printf("frontend build not found at %s — run `make web` or `make dev` (API still available)", cfg.WebDist) log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist)
} }
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err) log.Fatalf("listen: %v", err)
@@ -105,9 +105,19 @@ func spaHandler(dist string) http.Handler {
p = "index.html" p = "index.html"
} }
if _, err := fs.Open(p); err == nil && hasExt(p) { if _, err := fs.Open(p); err == nil && hasExt(p) {
// 缓存策略分两类:
// assets/ 下的文件名带内容 hash(Vite 产物),内容变了文件名就变,
// 所以可以长期强缓存;其余(主要是 index.html)必须每次回源校验,
// 否则新构建不会生效 : 页面「改了但看着还是旧的」通常就是它。
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
}
fileServer.ServeHTTP(w, r) fileServer.ServeHTTP(w, r)
return return
} }
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
http.ServeFile(w, r, index) http.ServeFile(w, r, index)
}) })
} }
+21
View File
@@ -9,6 +9,27 @@
<meta name="description" content="长文与短文,同一种节奏。" /> <meta name="description" content="长文与短文,同一种节奏。" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" /> <link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<!--
UI 版本在 <html> 上以 data-ui 表达,服务端设置要等 /api/site 才知道。
这里先读本地缓存设上,避免首帧闪一下 classic 再跳到 vivid。
站点级设置(非访客偏好),缓存是安全的。值与 site.js 的 UI_KEY 保持一致。
-->
<script>
(function () {
try {
// 后台一律 classic:vivid.css 也会命中 .btn / .input 这类全局类,
// 而后台表单正在用,所以这里就要把路径也判断掉,别等 App 挂载再纠正。
var isAdmin = location.pathname.indexOf('/admin') === 0
var v = localStorage.getItem('one.ui')
document.documentElement.setAttribute(
'data-ui',
!isAdmin && v === 'vivid' ? 'vivid' : 'classic'
)
} catch (e) {
document.documentElement.setAttribute('data-ui', 'classic')
}
})()
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+16 -3
View File
@@ -3,9 +3,17 @@ 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 YohakuTop from './ui/yohaku/YohakuTop.vue'
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
import { site, setAdminScope } from './site'
const route = useRoute() const route = useRoute()
const isAdmin = computed(() => route.path.startsWith('/admin')) const isAdmin = computed(() => route.path.startsWith('/admin'))
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
// 所以必须在 data-ui 轴上退回 classic,而不是只靠 --admin-* 区分。
// 余白(vivid)只在公开前台生效:后台永远走 classic 那套。
const isVivid = computed(() => site.ui_id === 'vivid' && !isAdmin.value)
// body hook:让全局 CSS 知道当前是 admin, // body hook:让全局 CSS 知道当前是 admin,
// focus-visible 等样式跟着切 token。 // focus-visible 等样式跟着切 token。
@@ -15,16 +23,21 @@ watch(
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.body.classList.toggle('admin-shell-active', v) document.body.classList.toggle('admin-shell-active', v)
} }
// 同一个开关也用于把 data-ui / data-theme 收回 classic
setAdminScope(v)
}, },
{ immediate: true } { immediate: true }
) )
</script> </script>
<template> <template>
<TopBar v-if="!isAdmin" /> <YohakuTop v-if="isVivid" />
<TopBar v-else-if="!isAdmin" />
<RouterView /> <RouterView />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底 --> <YohakuFoot v-if="isVivid" />
<ThemeSwitcher variant="floating" class="theme-floating-only" /> <!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
</template> </template>
<style scoped> <style scoped>
+276 -2
View File
@@ -1,7 +1,8 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { adminApi } from '../api' import { adminApi } from '../api'
import { SKIN_CONSTANTS } from '../site' import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
import { SECTIONS } from '../ui/sections'
const tab = ref('basic') const tab = ref('basic')
const settings = ref(null) const settings = ref(null)
@@ -19,6 +20,98 @@ const lightSkins = [
] ]
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' } const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
// ---------- 界面与自定义(第二套 UI) ----------
// 两套 UI 都保留,这里整站切换。后台自身外观不受影响。
const uiOptions = [
{
id: 'classic',
label: '经典',
desc: '经典(极简):三栏布局、纸感分区线,内容是主角',
bg: '#faf7f1',
fg: '#3d7f9c'
},
{
id: 'vivid',
label: 'vivid',
desc: 'vivid(余白):顶部导航 + 纸张详情三栏、单强调色、可写自定义 CSS',
bg: '#f6f5fb',
fg: '#5b3df5'
}
]
// 分区 CSS 编辑器当前选中的分区
const cssSection = ref('global')
// vivid 暴露给站主的 CSS 变量。写进「全局」段即可覆盖整站。
const styleTokens = [
{ name: '--v-accent', desc: '主色(唯一强调色)' },
{ name: '--v-accent-2', desc: '主色 hover / 深一档' },
{ name: '--v-accent-3', desc: '状态点缀(在线点等)' },
{ name: '--v-bg', desc: '页面底色' },
{ name: '--v-surface', desc: '卡片/面板底色' },
{ name: '--v-surface-2', desc: '次级底色(代码块等)' },
{ name: '--v-text', desc: '正文颜色' },
{ name: '--v-text-soft', desc: '次级文字' },
{ name: '--v-muted', desc: '弱化文字' },
{ name: '--v-line', desc: '描边颜色' },
{ name: '--v-radius', desc: '圆角' },
{ name: '--v-radius-pill', desc: '胶囊圆角' },
{ name: '--v-content-width', desc: '内容区最大宽度' },
{ name: '--v-measure', desc: '文章正文行宽' },
{ name: '--v-motion', desc: '动效强度倍数(0 = 全关)' },
{ name: '--v-spring', desc: '弹簧缓动曲线' },
{ name: '--v-noise-opacity', desc: '噪点透明度' },
{ name: '--v-grad', desc: '同色微渐变(一般只读取)' }
]
// 一键插入的片段:不落库,只是把文本塞进当前分区的文本框,站主可以接着改。
const snippets = [
{
label: '换一套主色',
hint: '整站强调色改成青绿',
css: `:root {
--v-accent: #0d9488;
--v-accent-2: #0f766e;
}`
},
{
label: '方正的卡片',
hint: '圆角收小,气质更硬朗',
css: `:root {
--v-radius: 4px;
--v-radius-sm: 2px;
}`
},
{
label: '关掉噪点与晕染',
hint: '纯色底 + 更大的正文行宽',
css: `body {
background-image: none;
}
body::before {
content: none;
}
:root {
--v-measure: 820px;
}`
},
{
label: '首页单列',
hint: '时间线不再挤成多列',
css: `.main:has(> .row-feed) {
grid-template-columns: minmax(0, 1fr);
}`
}
]
function applySnippet(css) {
const cur = settings.value.custom_css[cssSection.value] || ''
settings.value.custom_css[cssSection.value] = cur.trim() ? cur.replace(/\s*$/, '\n\n') + css : css
}
const currentSection = computed(() => SECTIONS.find((s) => s.id === cssSection.value) || SECTIONS[0])
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -26,6 +119,19 @@ async function load() {
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) { if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
settings.value.light_skin_id = 'paper' settings.value.light_skin_id = 'paper'
} }
if (!UI_CONSTANTS.UIS.includes(settings.value.ui_id)) {
settings.value.ui_id = UI_CONSTANTS.DEFAULT
}
// 服务端保证非 null,这里再兜一层,并预建分区键,
// 这样 v-model 绑定的文本框一开始就有值可写。
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
settings.value.custom_css = {}
}
for (const s of SECTIONS) {
if (typeof settings.value.custom_css[s.id] !== 'string') {
settings.value.custom_css[s.id] = ''
}
}
} catch (e) { } catch (e) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
} finally { } finally {
@@ -41,6 +147,15 @@ async function save() {
try { try {
const s = await adminApi.saveSettings(settings.value) const s = await adminApi.saveSettings(settings.value)
settings.value = s settings.value = s
// 保存后服务端会丢掉空的/超长的分区,重新补齐本地键,免得输入框失焦
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
settings.value.custom_css = {}
}
for (const sec of SECTIONS) {
if (typeof settings.value.custom_css[sec.id] !== 'string') {
settings.value.custom_css[sec.id] = ''
}
}
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) { } catch (e) {
error.value = e.message || '保存失败' error.value = e.message || '保存失败'
@@ -60,6 +175,7 @@ async function save() {
<div class="settings-tabs"> <div class="settings-tabs">
<button :class="{ on: tab === 'basic' }" @click="tab = 'basic'">基础信息</button> <button :class="{ on: tab === 'basic' }" @click="tab = 'basic'">基础信息</button>
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button> <button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
<button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button>
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button> <button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
</div> </div>
@@ -137,6 +253,97 @@ async function save() {
</div> </div>
</div> </div>
<!-- ui:第二套 UI + 分区自定义 CSS -->
<div v-if="tab === 'ui'" class="panel">
<div class="panel-title"><h2>界面</h2></div>
<div class="field">
<label>整站使用的界面</label>
<div class="theme-swatches">
<div
v-for="u in uiOptions"
:key="u.id"
class="sw"
:class="{ on: settings.ui_id === u.id }"
:style="{ background: u.bg, borderColor: settings.ui_id === u.id ? u.fg : 'var(--admin-line)' }"
@click="settings.ui_id = u.id"
>
<span :style="{ background: u.fg }">{{ u.label }}</span>
</div>
</div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted); line-height: 1.7;">
<span v-for="u in uiOptions" :key="u.id">
<template v-if="settings.ui_id === u.id">{{ u.desc }}</template>
</span>
<br />
两套界面都保留,随时可以换回来。后台界面不受这里影响。
</p>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<label>自定义 CSS(仅 vivid 生效)</label>
<p style="margin: 0 0 10px; font-size: 12.5px; color: var(--admin-muted); line-height: 1.7;">
按分区写样式,「全局」每页都会加载。只在选择了 vivid 时生效,
并且永远不会注入到后台,所以写坏样式不会影响管理界面。
</p>
<div class="css-tabs">
<button
v-for="s in SECTIONS"
:key="s.id"
:class="{ on: cssSection === s.id }"
@click="cssSection = s.id"
>
{{ s.label }}
<span v-if="(settings.custom_css[s.id] || '').trim()" class="dot" aria-hidden="true"></span>
</button>
</div>
<textarea
v-model="settings.custom_css[cssSection]"
class="textarea"
rows="12"
spellcheck="false"
style="font-family: var(--mono); font-size: 13px; line-height: 1.7; margin-top: 10px;"
:placeholder="`/* ${currentSection.hint} */`"
/>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
{{ currentSection.hint }}
</p>
</div>
<div class="field">
<label>一键插入片段</label>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<button
v-for="sn in snippets"
:key="sn.label"
class="btn"
style="font-size: 12.5px; padding: 5px 12px;"
type="button"
:title="sn.hint"
@click="applySnippet(sn.css)"
>
{{ sn.label }}
</button>
</div>
<p style="margin: 8px 0 0; font-size: 12px; color: var(--admin-muted);">
插入到当前分区({{ currentSection.label }})的末尾,插入后仍可自由编辑。
</p>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<label>可用的 CSS 变量</label>
<div class="token-list">
<div v-for="t in styleTokens" :key="t.name" class="token-row">
<code>{{ t.name }}</code>
<span>{{ t.desc }}</span>
</div>
</div>
</div>
</div>
<!-- advanced --> <!-- advanced -->
<div v-if="tab === 'advanced'" class="panel"> <div v-if="tab === 'advanced'" class="panel">
<div class="panel-title"><h2>高级</h2></div> <div class="panel-title"><h2>高级</h2></div>
@@ -160,3 +367,70 @@ async function save() {
</div> </div>
</section> </section>
</template> </template>
<style scoped>
/* 「界面与自定义」里新增的三个小组件。沿用后台既有的 token 与观感,
不引入新的视觉语言,也不动 styles.css。 */
.css-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.css-tabs button {
position: relative;
padding: 5px 12px;
border: 0;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--admin-ink-soft);
}
.css-tabs button:hover {
background: var(--admin-paper-sunken);
}
.css-tabs button.on {
background: var(--admin-accent-soft);
color: var(--admin-accent);
font-weight: 500;
}
/* 有内容的分区标一个小点,一眼看出哪几段写过东西 */
.css-tabs .dot {
display: inline-block;
width: 5px;
height: 5px;
margin-left: 5px;
border-radius: 50%;
background: var(--admin-accent);
vertical-align: 1px;
}
.token-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 6px 18px;
}
.token-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.token-row code {
font-family: var(--mono);
font-size: 12px;
color: var(--admin-accent);
white-space: nowrap;
}
.token-row span {
font-size: 12px;
color: var(--admin-muted);
}
</style>
+7 -2
View File
@@ -1,12 +1,17 @@
<script setup> <script setup>
import { site } from '../site' import { site } from '../site'
import { publicApi } from '../api' import { publicApi } from '../api'
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import ThemeSwitcher from './ThemeSwitcher.vue' import ThemeSwitcher from './ThemeSwitcher.vue'
// vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染,顺带省掉一次
// 标签请求 —— 视图本身不用知道 UI 版本。
const isVivid = computed(() => site.ui_id === 'vivid')
const tags = ref([]) const tags = ref([])
onMounted(async () => { onMounted(async () => {
if (isVivid.value) return
try { try {
const data = await publicApi.tags() const data = await publicApi.tags()
tags.value = (data.tags || []).slice(0, 8) tags.value = (data.tags || []).slice(0, 8)
@@ -17,7 +22,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<aside class="rail-left"> <aside v-if="!isVivid" class="rail-left">
<div class="inner"> <div class="inner">
<RouterLink to="/" class="brand"> <RouterLink to="/" class="brand">
<span class="mark">○</span> <span class="mark">○</span>
+6 -2
View File
@@ -1,13 +1,17 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { publicApi } from '../api' import { publicApi } from '../api'
import { site } from '../site' import { site } from '../site'
import { relativeDate } from '../utils' import { relativeDate } from '../utils'
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
const isVivid = computed(() => site.ui_id === 'vivid')
const latest = ref([]) const latest = ref([])
const tags = ref([]) const tags = ref([])
onMounted(async () => { onMounted(async () => {
if (isVivid.value) return
try { try {
const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()]) const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()])
latest.value = posts.items || [] latest.value = posts.items || []
@@ -20,7 +24,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<aside class="rail-right"> <aside v-if="!isVivid" class="rail-right">
<section class="card"> <section class="card">
<p class="eyebrow">关于这里</p> <p class="eyebrow">关于这里</p>
<p class="bio">{{ site.author_bio || site.site_desc }}</p> <p class="bio">{{ site.author_bio || site.site_desc }}</p>
+80
View File
@@ -0,0 +1,80 @@
import { watch } from 'vue'
import { site } from './site'
// 站主自定义 CSS 的注入管线。
//
// 两个 <style>:global 常驻,section 跟着路由换,这样切页面不用重新解析全局那段。
// 只在 vivid 下注入,并且 /admin 下一律清空 —— 站主写坏样式也影响不到后台。
//
// 安全:只写 textContent,绝不用 innerHTML。textContent 不会被 HTML 解析,
// 所以站主的 CSS 里即使写了 </style> 也逃不出去,因此不需要引入 CSS 清洗库。
// (能写这两段的只有管理员,本身也不是权限边界。)
const GLOBAL_ID = 'one-css-global'
const SECTION_ID = 'one-css-section'
// 路由名 → 分区 id。tag 归到 tags;not-found / 其他一律只吃 global。
const ROUTE_SECTION = {
home: 'home',
post: 'post',
archive: 'archive',
tags: 'tags',
tag: 'tags',
projects: 'projects',
about: 'about'
}
function ensureStyle(id) {
if (typeof document === 'undefined') return null
// 先按 id 找再建:dev 热更新会重复执行模块,不查会堆出一串重复 <style>
let el = document.getElementById(id)
if (!el) {
el = document.createElement('style')
el.id = id
document.head.appendChild(el)
}
return el
}
export function sectionForRoute(route) {
if (!route || !route.name) return ''
return ROUTE_SECTION[route.name] || ''
}
function cssFor(section) {
if (!section) return ''
const map = site.custom_css
if (!map || typeof map !== 'object') return ''
const v = map[section]
return typeof v === 'string' ? v : ''
}
export function refreshCustomCss(route) {
const globalEl = ensureStyle(GLOBAL_ID)
const sectionEl = ensureStyle(SECTION_ID)
if (!globalEl || !sectionEl) return
const isAdmin = !!(route && route.path && route.path.startsWith('/admin'))
if (site.ui_id !== 'vivid' || isAdmin) {
// 清空而不是跳过:避免从公开页跳到后台时残留上一页的分区样式
if (globalEl.textContent) globalEl.textContent = ''
if (sectionEl.textContent) sectionEl.textContent = ''
return
}
const globalCss = cssFor('global')
const sectionCss = cssFor(sectionForRoute(route))
if (globalEl.textContent !== globalCss) globalEl.textContent = globalCss
if (sectionEl.textContent !== sectionCss) sectionEl.textContent = sectionCss
}
export function installCustomCss(router) {
refreshCustomCss(router.currentRoute.value)
router.afterEach((to) => refreshCustomCss(to))
// 站主切 UI 时立刻重新注入/清空。保存 CSS 后需要重新加载页面才会生效
// (site 只在启动时取一次),这是当前有意的简化。
watch(
() => site.ui_id,
() => refreshCustomCss(router.currentRoute.value)
)
}
+11
View File
@@ -2,8 +2,19 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { router } from './router' import { router } from './router'
import { loadSite } from './site' import { loadSite } from './site'
import { installCustomCss } from './customCss'
import './styles.css' import './styles.css'
// vivid(余白)是第二套 UI,全部规则都以 html[data-ui="vivid"] 前缀收敛;
// 必须排在 styles.css 之后,才能在同特指度时按源码顺序取胜。
// 顺序即层叠顺序:令牌 → 骨架 → 各视图。不要把 tokens 挪到后面。
import './ui/yohaku/tokens.css'
import './ui/yohaku/chrome.css'
import './ui/yohaku/home.css'
import './ui/yohaku/panels.css'
import './ui/yohaku/article.css'
import './ui/yohaku/pages.css'
loadSite().then(() => { loadSite().then(() => {
installCustomCss(router)
createApp(App).use(router).mount('#app') createApp(App).use(router).mount('#app')
}) })
+75 -2
View File
@@ -10,6 +10,31 @@ const LIGHT_SKINS = ['paper', 'sage', 'rose']
const MODE_KEY = 'one.themeMode' const MODE_KEY = 'one.themeMode'
const VALID_MODES = ['light', 'dark', 'auto'] const VALID_MODES = ['light', 'dark', 'auto']
// 整站 UI 版本 —— 站点级设置(后台切换),不属于访客偏好。
// classic 是原来那套极简版;vivid 是另一套更外放、且支持自定义 CSS 的版本。
const UI_KEY = 'one.ui'
const VALID_UIS = ['classic', 'vivid']
const UI_DEFAULT = 'classic'
function readUICache() {
if (typeof localStorage === 'undefined') return UI_DEFAULT
try {
const v = localStorage.getItem(UI_KEY)
return VALID_UIS.includes(v) ? v : UI_DEFAULT
} catch (_) {
return UI_DEFAULT
}
}
function writeUICache(id) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(UI_KEY, id)
} catch (_) {
/* private mode 等场景静默 */
}
}
function readMode() { function readMode() {
if (typeof localStorage === 'undefined') return 'auto' if (typeof localStorage === 'undefined') return 'auto'
try { try {
@@ -47,6 +72,10 @@ function isValidLightSkin(id) {
return LIGHT_SKINS.includes(id) return LIGHT_SKINS.includes(id)
} }
function isValidUI(id) {
return VALID_UIS.includes(id)
}
export const site = reactive({ export const site = reactive({
site_title: 'ONE · 一个博客', site_title: 'ONE · 一个博客',
site_desc: '长文与短文,同一种节奏。', site_desc: '长文与短文,同一种节奏。',
@@ -58,12 +87,34 @@ export const site = reactive({
light_skin_id: 'paper', light_skin_id: 'paper',
// theme_id 是老字段,保留读取兼容用;新代码不要再写。 // theme_id 是老字段,保留读取兼容用;新代码不要再写。
theme_id: 'paper', theme_id: 'paper',
// ui_id 是站点级设置,服务端为准;本地缓存只用于首屏不闪。
ui_id: readUICache(),
// 按分区存放的站主自定义 CSS(仅 vivid 生效,且不在 /admin 注入)
custom_css: {},
themeMode: readMode(), themeMode: readMode(),
loaded: false loaded: false
}) })
// data-theme → 前台皮肤(paper / sage / rose / ink) // data-theme → 前台皮肤。classic: paper/sage/rose/ink;vivid: vivid/vivid-dark
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步 // data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
// data-ui → 整站 UI 版本(classic / vivid)
//
// 后台一律强制回 classic:vivid.css 是覆盖层,它也会命中 .btn / .input 这类
// 全局类,而后台表单就在用这些类。所以后台不仅要走 --admin-*,还要在
// data-ui / data-theme 两个轴上退回原路径,才能保证后台观感零变化。
// App.vue 在路由变化时调用 setAdminScope 维护这个开关。
// 初值从当前路径推断:直接刷新 /admin 时,模块加载阶段就要退回 classic,
// 否则这里会覆盖掉 index.html 里那段预置脚本的结果,闪一下 vivid。
let adminScope =
typeof location !== 'undefined' && location.pathname.indexOf('/admin') === 0
export function setAdminScope(on) {
const next = !!on
if (next === adminScope) return
adminScope = next
applyTheme()
}
function applyTheme(animate = false) { function applyTheme(animate = false) {
// 亮度突变是 Apple 明确点名的反模式:用户主动切换时给一次 250ms 交叉过渡 // 亮度突变是 Apple 明确点名的反模式:用户主动切换时给一次 250ms 交叉过渡
if (animate && typeof document !== 'undefined') { if (animate && typeof document !== 'undefined') {
@@ -72,8 +123,15 @@ function applyTheme(animate = false) {
setTimeout(() => el.classList.remove('theme-anim'), 300) setTimeout(() => el.classList.remove('theme-anim'), 300)
} }
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark()) const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
document.documentElement.setAttribute('data-theme', effectiveTheme(site.themeMode, site.light_skin_id)) // vivid 自带亮/暗两套调色板,不复用 paper/sage/rose —— 否则两套 token 会互相打架。
const vivid = !adminScope && site.ui_id === 'vivid'
const theme = vivid
? (isDark ? 'vivid-dark' : 'vivid')
: effectiveTheme(site.themeMode, site.light_skin_id)
const ui = vivid && isValidUI(site.ui_id) ? site.ui_id : UI_DEFAULT
document.documentElement.setAttribute('data-theme', theme)
document.documentElement.setAttribute('data-admin-theme', isDark ? 'dark' : 'light') document.documentElement.setAttribute('data-admin-theme', isDark ? 'dark' : 'light')
document.documentElement.setAttribute('data-ui', ui)
} }
applyTheme() applyTheme()
@@ -93,6 +151,10 @@ export async function loadSite() {
Object.assign(site, data) Object.assign(site, data)
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper // 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper' if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
if (!isValidUI(site.ui_id)) site.ui_id = UI_DEFAULT
if (!site.custom_css || typeof site.custom_css !== 'object') site.custom_css = {}
writeUICache(site.ui_id)
site.loaded = true site.loaded = true
} catch (e) { } catch (e) {
site.loaded = false site.loaded = false
@@ -120,6 +182,12 @@ watch(() => site.light_skin_id, () => {
if (site.themeMode === 'auto') applyTheme() if (site.themeMode === 'auto') applyTheme()
}) })
// 站主切 UI → 立刻换整套(自定义 CSS 的重新注入由 customCss.js 自己监听)
watch(() => site.ui_id, () => {
writeUICache(site.ui_id)
applyTheme()
})
export function applyDocTitle(sub) { export function applyDocTitle(sub) {
const base = site.site_title || 'ONE' const base = site.site_title || 'ONE'
document.title = sub ? `${sub} · ${base}` : base document.title = sub ? `${sub} · ${base}` : base
@@ -129,3 +197,8 @@ export const SKIN_CONSTANTS = Object.freeze({
DARK_SKIN, DARK_SKIN,
LIGHT_SKINS: [...LIGHT_SKINS] LIGHT_SKINS: [...LIGHT_SKINS]
}) })
export const UI_CONSTANTS = Object.freeze({
DEFAULT: UI_DEFAULT,
UIS: [...VALID_UIS]
})
+15
View File
@@ -0,0 +1,15 @@
// 自定义 CSS 的分区清单。
//
// 必须与后端 store.ValidCSSSections(backend/internal/store/store.go)保持一致 ——
// 后端会丢掉不在白名单里的分区,两边不一致会出现「保存了但不生效」。
export const SECTIONS = [
{ id: 'global', label: '全局', hint: '每个前台页面都会加载,适合放变量和通用微调' },
{ id: 'home', label: '首页', hint: '时间线 / 标签筛选页' },
{ id: 'post', label: '文章页', hint: '文章详情,含短文' },
{ id: 'archive', label: '归档', hint: '' },
{ id: 'tags', label: '标签', hint: '标签云与单个标签下的列表' },
{ id: 'projects', label: '作品', hint: '' },
{ id: 'about', label: '关于', hint: '' }
]
export const SECTION_IDS = SECTIONS.map((s) => s.id)
+37
View File
@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue'
import { site } from '../../site'
// 余白关于页。对应设计稿 viewAbout():一张 about-card(kicker + 姓名 + 身份 + 简介 + 四个事实)。
// 四个数字与首页头版同源,由调用方传入,避免两处各算一遍。
const props = defineProps({
postCount: { type: Number, default: 0 },
tagCount: { type: Number, default: 0 },
projectCount: { type: Number, default: 0 },
longCount: { type: Number, default: 0 }
})
const facts = computed(() => [
{ n: props.postCount, label: '篇文章' },
{ n: props.tagCount, label: '个标签' },
{ n: props.projectCount, label: '件作品' },
{ n: props.longCount, label: '长文' }
])
</script>
<template>
<div class="wrap wrap-lead">
<div class="about-card">
<div class="kicker kicker-block">About</div>
<h1>{{ site.author_name }}</h1>
<div class="role">Writer · Builder</div>
<p class="bio">{{ site.author_bio }}</p>
<div class="about-facts">
<div v-for="f in facts" :key="f.label">
<b>{{ f.n }}</b><span>{{ f.label }}</span>
</div>
</div>
</div>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import { RouterLink } from 'vue-router'
// 余白归档。对应设计稿 viewArchive():page-head + 年块(年 + 篇数)+ 月分组。
// 短文没有标题,列表里用正文开头(后端对 short 返回全文 content_html,够用)。
const props = defineProps({ years: { type: Array, default: () => [] } })
const fmt = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
const fmtDate = (iso) => fmt.format(new Date(iso)).replace(/\//g, '-')
const kindLabel = (k) => (k === 'long' ? '长文' : '短文')
const plain = (html) => {
if (!html) return ''
const el = document.createElement('div')
el.innerHTML = html
return (el.textContent || '').trim()
}
const titleOf = (p) => (p.kind === 'long' ? p.title : plain(p.content_html).slice(0, 48))
</script>
<template>
<div class="page-head">
<div class="kicker">Archive</div>
<h1>沿着时间<em>往回走</em></h1>
<p>按年 → 月分组,来自 <code>GET /api/archive</code>。</p>
</div>
<div class="wrap wrap-flush">
<div v-for="y in props.years" :key="y.year" class="year-block">
<div class="year-head">
<span class="y">{{ y.year }}</span>
<span class="c">{{ y.count }} 篇</span>
</div>
<div v-for="m in y.months" :key="m.month" class="month">
<h3>{{ y.year }} / {{ m.month }}</h3>
<ul>
<li v-for="p in m.posts" :key="p.id || p.slug">
<RouterLink :to="`/post/${encodeURIComponent(p.slug)}`">
<span class="d">{{ fmtDate(p.published_at).slice(5) }}</span>
<span class="kd" :class="p.kind">{{ kindLabel(p.kind) }}</span>
<span class="t">{{ titleOf(p) }}</span>
</RouterLink>
</li>
</ul>
</div>
</div>
</div>
</template>
+193
View File
@@ -0,0 +1,193 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
// 中 main : back + article.paper(元信息 / 标题 / 导语 / 正文 / 标签)
// 右 side-r : 目录(正文 h2)+ 阅读进度
// 数据与消毒都由 PostView 负责(contentHtml 已注入 h2 id),这里只做呈现与交互。
const props = defineProps({
post: { type: Object, default: null },
headings: { type: Array, default: () => [] },
contentHtml: { type: String, default: '' },
neighbors: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
const fmtDate = (iso) => {
const d = new Date(iso)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const nf = new Intl.NumberFormat('zh-CN')
const plain = (html) => {
const el = document.createElement('div')
el.innerHTML = html || ''
return (el.textContent || '').trim()
}
const isShort = computed(() => props.post?.kind === 'short')
const title = computed(() =>
isShort.value ? plain(props.post?.content_html).slice(0, 40) + '…' : props.post?.title || ''
)
/* 左栏时间线:后端已按时间线顺序给好(最旧在前、最新在后),
* 找到当前篇,按相对位置打标签 : 与设计稿的 更早/上一篇/当前/下一篇/更新 一致。 */
const tl = computed(() => {
const nb = props.neighbors || []
const idx = nb.findIndex((x) => x.slug === props.post?.slug)
if (idx < 0) return []
const label = (d) => (d === 0 ? '当前' : d === -1 ? '下一篇' : d === -2 ? '更新' : d === 1 ? '上一篇' : '更早')
return nb.map((p, i) => ({
label: label(i - idx),
now: i === idx,
date: fmtDate(p.published_at),
text: p.kind === 'long' ? p.title : plain(p.content_html).slice(0, 20) + '…',
slug: p.slug
}))
})
/* ---------- 阅读进度 + 目录高亮 + 右栏 meter ---------- */
const pbar = ref(null)
const ppct = ref(null)
const meterFill = ref(null)
const meterNum = ref(null)
const tocEl = ref(null)
let ticking = false
function onScroll() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
ticking = false
const h = document.documentElement.scrollHeight - window.innerHeight
const p = h > 0 ? Math.min(100, Math.max(0, (window.scrollY / h) * 100)) : 0
if (pbar.value) pbar.value.querySelector('i').style.width = p.toFixed(1) + '%'
if (ppct.value) ppct.value.querySelector('b').textContent = String(Math.round(p))
if (meterFill.value) meterFill.value.style.width = p.toFixed(1) + '%'
if (meterNum.value) meterNum.value.textContent = Math.round(p) + '%'
// 目录高亮:视口 120px 以上最后一个 h2
let active = null
document.querySelectorAll('.prose h2').forEach((h) => {
if (h.getBoundingClientRect().top < 120) active = h
})
if (tocEl.value) {
tocEl.value.querySelectorAll('a').forEach((a) => {
a.classList.toggle('on', !!active && a.getAttribute('href') === '#' + active.id)
})
}
})
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
/* 弹簧滚动:TOC 点击用(设计稿 springScrollTo 的同款手感) */
function springScrollTo(y) {
const start = window.scrollY
const dist = y - start
if (Math.abs(dist) < 2) return
const dur = Math.min(700, Math.max(350, Math.abs(dist) * 0.4))
const t0 = performance.now()
const overshoot = 1.15
function frame(t) {
const p = Math.min(1, (t - t0) / dur)
const e = 1 - Math.pow(1 - p, 3)
const wobble = p < 1 ? Math.sin(p * Math.PI) * overshoot * 0.06 * (1 - p) : 0
window.scrollTo(0, start + dist * (e + wobble))
if (p < 1) requestAnimationFrame(frame)
else window.scrollTo(0, y)
}
requestAnimationFrame(frame)
}
function onTocClick(e) {
const a = e.target.closest('a')
if (!a) return
const id = a.getAttribute('href')
if (!id || !id.startsWith('#h-')) return
e.preventDefault()
const el = document.getElementById(id.slice(1))
if (el) springScrollTo(el.getBoundingClientRect().top + window.scrollY - 80)
}
</script>
<template>
<!-- 顶部阅读进度:只在这类长页出现 -->
<div class="progress" ref="pbar"><i /></div>
<div class="progress-pct">READ <b>0</b>%</div>
<div v-if="props.loading" class="wrap"><div class="empty"><h3>载入中…</h3></div></div>
<div v-else-if="props.error" class="wrap">
<div class="empty"><h3>出错了</h3><p>{{ props.error }}</p><a class="chip on" href="/">回时间线</a></div>
</div>
<div v-else-if="!props.post" class="wrap">
<div class="empty"><h3>没有这篇</h3><p>链接可能过期了。</p><a class="chip on" href="/">回时间线</a></div>
</div>
<div v-else class="article-layout">
<aside class="side-l">
<div class="tl">
<div
v-for="x in tl"
:key="x.slug + x.label"
class="tl-item"
:class="{ now: x.now }"
>
<b>{{ x.label }}</b>
<time>{{ x.date }}</time>
<div>{{ x.text }}</div>
</div>
</div>
</aside>
<main>
<a class="back" href="/">← 返回时间线</a>
<article class="paper">
<div class="meta">
<span class="badge" :class="props.post.kind">{{ props.post.kind.toUpperCase() }}</span>
<time :datetime="props.post.published_at">{{ fmtDate(props.post.published_at) }}</time>
<span>· 约 {{ props.post.reading_minutes }} 分钟</span>
<span>· {{ nf.format(props.post.content_len || 0) }} 字</span>
</div>
<template v-if="!isShort">
<h1 class="serif">{{ props.post.title }}</h1>
<p v-if="props.post.summary" class="dek">{{ props.post.summary }}</p>
</template>
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
<div class="prose" v-html="sanitizeHtml(props.contentHtml)" />
<div class="end">
<span class="lbl">标签</span>
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div>
</article>
</main>
<aside class="side-r">
<nav class="toc" ref="tocEl" aria-label="本文目录">
<h4>目录</h4>
<ol>
<li v-for="h in props.headings" :key="h.id">
<a :href="`#${h.id}`" @click="onTocClick">{{ h.text }}</a>
</li>
</ol>
<ol v-if="!props.headings.length"><li><a>(本文无小标题)</a></li></ol>
</nav>
<div class="read-meter">
<div class="bar"><i ref="meterFill" /></div>
<div class="num"><span>阅读进度</span><b ref="meterNum">0%</b></div>
</div>
</aside>
</div>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
import { RouterLink } from 'vue-router'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 元信息 + 标题 + 摘要 + 标签),
// 短文是纸条(直接铺正文)。两种密度混在同一条时间线里,这是设计稿的主张。
const props = defineProps({
items: { type: Array, default: () => [] }
})
const fmt = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
const fmtDate = (iso) => fmt.format(new Date(iso)).replace(/\//g, '-')
const fmtDateTime = (iso) => {
const d = new Date(iso)
const p = (n) => String(n).padStart(2, '0')
return `${fmtDate(iso)} ${p(d.getHours())}:${p(d.getMinutes())}`
}
const nf = new Intl.NumberFormat('zh-CN')
// 短文没有标题,列表里用正文开头当标题(设计稿同此处理)
const plain = (html) => {
if (!html) return ''
const el = document.createElement('div')
el.innerHTML = html
return (el.textContent || '').trim()
}
const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
</script>
<template>
<div class="feed">
<template v-for="p in props.items" :key="p.id || p.slug">
<!-- 长文:卡片 -->
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
</div>
<div class="body">
<div class="meta">
<span class="badge long">LONG</span>
<time :datetime="p.published_at">{{ fmtDate(p.published_at) }}</time>
<span>· 约 {{ p.reading_minutes }} 分钟</span>
<span>· {{ nf.format(p.content_len || 0) }} 字</span>
</div>
<h2>{{ p.title }}</h2>
<p class="sum">{{ p.summary }}</p>
<div class="tags">
<span v-for="t in p.tags || []" :key="t" class="tg">{{ t }}</span>
</div>
</div>
</RouterLink>
<!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="meta">
<span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
</div>
<div class="body" v-html="p.content_html" />
<div class="tags">
<span v-for="t in p.tags || []" :key="t" class="tg">{{ t }}</span>
</div>
</RouterLink>
</template>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup>
import { RouterLink } from 'vue-router'
import { site } from '../../site'
// 余白页脚。对应设计稿的 <footer class="foot">。
//
// 设计稿底部正中还有一枚 <div class="switch">「余白 / 当前主题」的药丸,
// 按站主要求去掉了(它只标识当前主题名,本站没有多套主题,属于纯装饰,
// 而且固定在底部会挡住内容)。对应的 .switch 样式也一并从
// chrome.css / pages.css 里删除,避免留下死规则。
const links = [
{ to: '/', label: '时间线' },
{ to: '/archive', label: '归档' },
{ to: '/tags', label: '标签' },
{ to: '/projects', label: '作品' },
{ to: '/about', label: '关于' }
]
</script>
<template>
<footer class="foot">
<div class="quote">What&rsquo;s left unsaid holds the most weight.</div>
<div>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></div>
<div class="foot-nav">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="sep">·</span>
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
</template>
<span class="sep">·</span>
<a href="/rss.xml">RSS</a>
</div>
</footer>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue'
import { site } from '../../site'
// 余白头版。对应设计稿的 <section class="hero">。
// 四个数字全部由传入的数据算出来,不写死 : 设计稿里也是算的。
const props = defineProps({
postCount: { type: Number, default: 0 },
charCount: { type: Number, default: 0 },
tagCount: { type: Number, default: 0 },
projectCount: { type: Number, default: 0 }
})
const nf = new Intl.NumberFormat('zh-CN')
const stats = computed(() => [
{ n: props.postCount, label: '篇文章' },
{ n: props.charCount, label: '累计字数' },
{ n: props.tagCount, label: '个标签' },
{ n: props.projectCount, label: '件作品' }
])
</script>
<template>
<section class="hero">
<div class="kicker">Personal Site · 在余白处书写</div>
<h1>把想法写下来,<br><span class="hl">留白也是内容</span>。</h1>
<p class="lead">
{{ site.site_desc }}<br>
<strong>全部内容自托管</strong>,没有推荐算法,只有时间线。
</p>
<div class="stats">
<div v-for="s in stats" :key="s.label" class="stat">
<b>{{ nf.format(s.n) }}</b><span>{{ s.label }}</span>
</div>
</div>
</section>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup>
import { computed } from 'vue'
import YohakuHero from './YohakuHero.vue'
import YohakuToolbar from './YohakuToolbar.vue'
import YohakuFeed from './YohakuFeed.vue'
import YohakuPager from './YohakuPager.vue'
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
// hero → wrap(toolbar + feed + empty + pager) → panels。
// 数据与筛选状态由 HomeView 持有(它读路由 query),这里只做呈现。
const props = defineProps({
items: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
page: { type: Number, default: 1 },
maxPage: { type: Number, default: 1 },
kind: { type: String, default: '' },
tag: { type: String, default: '' },
query: { type: String, default: '' },
tags: { type: Array, default: () => [] },
projects: { type: Array, default: () => [] },
statPosts: { type: Number, default: 0 },
statChars: { type: Number, default: 0 },
statTags: { type: Number, default: 0 },
statProjects: { type: Number, default: 0 }
})
// 展示区只放前 3 件(diygod.cc 的做法:部分 + 查看所有)
const showcase = computed(() => (props.projects || []).slice(0, 3))
const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
</script>
<template>
<YohakuHero
:post-count="statPosts"
:char-count="statChars"
:tag-count="statTags"
:project-count="statProjects"
/>
<div class="wrap">
<YohakuToolbar
:kind="kind"
:tag="tag"
:query="query"
:tags="tags"
@update:kind="emit('update:kind', $event)"
@update:tag="emit('update:tag', $event)"
@update:query="emit('update:query', $event)"
/>
<YohakuFeed :items="items" />
<div v-if="!items.length" class="empty">
<h3>这里还没有落笔</h3>
<p>换个筛选或搜索词试试。</p>
<button class="chip on" type="button" @click="emit('update:kind', ''), emit('update:tag', ''), emit('update:query', '')">看全部</button>
</div>
<YohakuPager :page="page" :max-page="maxPage" :total="total" @go="emit('go', $event)" />
</div>
<section class="showcase">
<div class="show-head">
<h2 class="serif">做过的东西</h2>
<span class="en mono">Projects</span>
<a class="push mono" href="/projects">全部作品 →</a>
</div>
<div class="proj-grid">
<RouterLink
v-for="p in showcase"
:key="p.id || p.slug"
class="proj"
to="/projects"
>
<div class="cov">
<img v-if="p.cover_url" :src="p.cover_url" alt="" loading="lazy">
<span v-if="p.status" class="st">{{ p.status }}</span>
</div>
<h3>{{ p.title }}</h3>
<p>{{ p.summary }}</p>
</RouterLink>
</div>
<div class="show-more">
<a class="chip on" href="/projects">查看所有作品</a>
</div>
</section>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
// 余白分页。对应设计稿的 <div class="pager">。设计稿只列「上一页 / 当前 / 下一页 / 共 n 篇」,
// 不做页码列表 : 保持原样。
const props = defineProps({
page: { type: Number, default: 1 },
maxPage: { type: Number, default: 1 },
total: { type: Number, default: 0 }
})
const emit = defineEmits(['go'])
</script>
<template>
<div v-if="props.total" class="pager">
<template v-if="props.maxPage > 1">
<button type="button" :disabled="props.page <= 1" @click="emit('go', props.page - 1)">← 上一页</button>
<span class="cur">{{ props.page }} / {{ props.maxPage }}</span>
<button type="button" :disabled="props.page >= props.maxPage" @click="emit('go', props.page + 1)">下一页 →</button>
</template>
<span class="info">共 {{ props.total }} 篇</span>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup>
// 余白作品。对应设计稿 viewProjects():page-head + proj-grid,
// 每件是封面 + 状态角标 + 标题 + 说明 + 外链/仓库两行。
const props = defineProps({ projects: { type: Array, default: () => [] } })
</script>
<template>
<div class="page-head">
<div class="kicker">Projects</div>
<h1>做过的<em>东西</em></h1>
<p>来自 <code>GET /api/projects</code>,可挂外链与仓库。</p>
</div>
<div class="wrap wrap-flush">
<div class="proj-grid">
<article v-for="p in props.projects" :key="p.id || p.slug" class="proj">
<!-- 封面块只在有图时渲染,否则会留下 150px 的空色块 -->
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<span v-if="p.status" class="st">{{ p.status }}</span>
</div>
<div class="body">
<h3>
{{ p.title }}
<span v-if="!p.cover_url && p.status" class="st st-plain">{{ p.status }}</span>
</h3>
<p>{{ p.summary }}</p>
<div class="acts">
<a v-if="p.url" :href="p.url" target="_blank" rel="noopener noreferrer">↗ 主页</a>
<a v-if="p.repo_url" :href="p.repo_url" target="_blank" rel="noopener noreferrer">⌥ 仓库</a>
</div>
</div>
</article>
</div>
</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup>
import { useRouter } from 'vue-router'
// 余白标签云。对应设计稿 viewTags():page-head + tag-grid,每格是一个 tag-tile :
// 大号计数 + 色块(颜色来自接口的 color 字段)+ 名字 + 「n 篇 · /tag/slug」。
// 点格子回首页并按该标签筛选(设计稿就是这么做的)。
const props = defineProps({ tags: { type: Array, default: () => [] } })
const router = useRouter()
function open(t) {
router.push({ path: '/', query: { tag: t.name } })
}
</script>
<template>
<div class="page-head">
<div class="kicker">Tags</div>
<h1>标签<em>如常</em></h1>
<p>颜色来自接口的 <code>color</code>,计数来自 <code>count</code>。</p>
</div>
<div class="wrap wrap-flush">
<div class="tag-grid">
<a
v-for="t in props.tags"
:key="t.slug || t.name"
class="tag-tile"
href="#"
:style="{ '--tc': t.color || 'var(--accent)' }"
@click.prevent="open(t)"
>
<span class="big">{{ t.count }}</span>
<div class="sw" />
<span class="nm">{{ t.name }}</span>
<span class="ct">{{ t.count }} 篇 · /tag/{{ t.slug }}</span>
</a>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import { ref } from 'vue'
// 余白筛选条。对应设计稿的 <div class="toolbar">:类型分段 + 标签 chips + 搜索。
// 状态不在组件里 : 全部走路由 query(kind / tag / q / page),
// 这样筛选结果可以直接分享,也和后端分页对得上。
const props = defineProps({
kind: { type: String, default: '' },
tag: { type: String, default: '' },
query: { type: String, default: '' },
tags: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:kind', 'update:tag', 'update:query'])
const kinds = [
{ value: '', label: '全部' },
{ value: 'long', label: '长文' },
{ value: 'short', label: '短文' }
]
const draft = ref(props.query)
function submit() {
emit('update:query', draft.value.trim())
}
function pickKind(v) {
// 设计稿里点类型会清掉标签:「全部」是真正的复位
emit('update:tag', '')
emit('update:kind', v)
}
</script>
<template>
<div class="toolbar">
<div class="seg" role="tablist" aria-label="按类型筛选">
<button
v-for="k in kinds"
:key="k.label"
type="button"
role="tab"
:aria-selected="props.kind === k.value && !props.tag"
:class="{ on: props.kind === k.value && !props.tag }"
@click="pickKind(k.value)"
>{{ k.label }}</button>
</div>
<div class="chips">
<button
v-for="t in props.tags"
:key="t.slug || t.name"
type="button"
class="chip"
:class="{ on: props.tag === t.name }"
@click="emit('update:tag', props.tag === t.name ? '' : t.name)"
>{{ t.name }}</button>
</div>
<label class="search">
<input
v-model="draft"
type="search"
placeholder="搜索…"
aria-label="搜索文章"
@keydown.enter.prevent="submit"
>
<button type="button" aria-label="搜索" @click="submit">⌕</button>
</label>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { site } from '../../site'
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
// 样式在 ui/yohaku/chrome.css,选择器已收敛到 html[data-ui="vivid"] 下。
const route = useRoute()
const scrolled = ref(false)
const links = [
{ to: '/', label: '时间线' },
{ to: '/archive', label: '归档' },
{ to: '/tags', label: '标签' },
{ to: '/projects', label: '作品' },
{ to: '/about', label: '关于' }
]
const initial = () => (site.author_name || site.site_title || 'O').trim().slice(0, 1).toUpperCase()
function onScroll() {
scrolled.value = (window.scrollY || 0) > 8
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
// 设计稿里主题键是 ◐ / ◑:亮色时显示 ◐(点它去暗),暗色时显示 ◑。
function toggleTheme() {
// site.themeMode 是站点级偏好(auto / light / dark),site.js 里有 watcher 负责落盘与写 data-theme。
// 这里直接改它,不自己碰 document : 单一数据源。
const dark = document.documentElement.getAttribute('data-theme') === 'vivid-dark'
site.themeMode = dark ? 'light' : 'dark'
}
</script>
<template>
<header class="top" :class="{ scrolled }">
<div class="top-in">
<RouterLink class="brand" to="/">
<span class="av">{{ initial() }}</span>
<span>{{ site.site_title?.split(/[·|]/)[0]?.trim() || 'ONE' }}</span>
</RouterLink>
<nav class="nav" aria-label="站点导航">
<RouterLink
v-for="l in links"
:key="l.to"
:to="l.to"
:class="{ on: l.to === '/' ? route.path === '/' : route.path.startsWith(l.to) }"
>{{ l.label }}</RouterLink>
</nav>
<div class="top-acts">
<button class="icon-btn" type="button" title="切换明暗" aria-label="切换明暗" @click="toggleTheme">◐</button>
</div>
</div>
</header>
</template>
+182
View File
@@ -0,0 +1,182 @@
/* ONE · 余白(yohaku)· 由设计稿按节拆分 / 窄屏
*
* 由设计稿(仓库根 index.html 的 <style>)按它自己的分节原样拆分。
* 只做了一件事:给每条规则加 html[data-ui='vivid'] 前缀(@media 内部的
* 规则也逐条加,at-rule 本身不加),并把 :root[data-theme="dark"] 映射到
* 本站的 vivid-dark。数值一律未改,规则也没有被拆开过。
*
* 前缀是硬要求:组件 scoped 样式编译后是 .foo[data-v-HASH](0,2,0),
* html[data-ui='vivid'] .foo 是(0,2,1)才能稳定取胜;scoped 是懒加载
* chunk,入口 CSS 之后才到位,平手时按源码顺序反而会赢。
*/
/* ---- 设计稿分节:详情:三栏「纸」 ---- */
/* ============================================================
详情:三栏「纸」· 左时间线 / 中纸张 / 右目录+进度
============================================================ */
html[data-ui='vivid'] .article-layout{
max-width:1100px;margin:0 auto;padding:36px 28px 60px;
display:grid;
grid-template-columns:140px minmax(0,1fr) 180px;
gap:32px;align-items:start;
}
@media (max-width:960px) {
html[data-ui='vivid'] .article-layout{grid-template-columns:1fr;gap:0}
html[data-ui='vivid'] .side-l, html[data-ui='vivid'] .side-r{display:none}
html[data-ui='vivid'] .progress-pct{display:none}
}
/* 左:时间线 */
html[data-ui='vivid'] .side-l{position:sticky;top:80px;padding-top:8px}
html[data-ui='vivid'] .tl{position:relative;padding-left:14px}
html[data-ui='vivid'] .tl::before{
content:"";position:absolute;left:3px;top:6px;bottom:6px;width:1.5px;
background:linear-gradient(180deg,var(--accent),transparent);
border-radius:2px;
}
html[data-ui='vivid'] .tl-item{
position:relative;padding:0 0 18px 0;font-size:12px;color:var(--muted);
}
html[data-ui='vivid'] .tl-item::before{
content:"";position:absolute;left:-14px;top:5px;
width:7px;height:7px;border-radius:50%;
background:var(--card);border:2px solid var(--accent);
}
html[data-ui='vivid'] .tl-item.now::before{
background:var(--accent);
box-shadow:0 0 0 4px var(--accent-soft);
}
html[data-ui='vivid'] .tl-item b{display:block;color:var(--soft);font-weight:700;font-size:12px}
html[data-ui='vivid'] .tl-item.now b{color:var(--accent)}
/* 中:纸张 */
html[data-ui='vivid'] .paper{
background:var(--card);
border:1px solid var(--line);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-lg);
padding:44px 48px 52px;
position:relative;
overflow:hidden;
animation:paperIn .7s var(--spring) both;
}
@keyframes paperIn {
html[data-ui='vivid'] from{opacity:0;transform:translateY(24px) scale(.985)}
html[data-ui='vivid'] to{opacity:1;transform:none}
}
/* 纸的纹理感:右上角一抹强调色 */
html[data-ui='vivid'] .paper::before{
content:"";position:absolute;top:0;right:0;
width:120px;height:120px;
background:radial-gradient(circle at 100% 0,var(--accent-soft),transparent 70%);
pointer-events:none;
}
html[data-ui='vivid'] .paper .meta{
display:flex;gap:12px;align-items:center;flex-wrap:wrap;
font-size:12.5px;color:var(--muted);font-weight:600;margin-bottom:18px;
}
html[data-ui='vivid'] .paper h1{
margin:0 0 20px;font-size:clamp(28px,4vw,40px);font-weight:800;
letter-spacing:-.03em;line-height:1.25;color:var(--ink);
}
html[data-ui='vivid'] .paper .dek{
margin:0 0 32px;font-size:17px;line-height:1.8;color:var(--soft);
padding-left:16px;border-left:3px solid var(--accent);
font-family:var(--serif);
}
html[data-ui='vivid'] .prose{font-size:16.5px;line-height:1.95;color:var(--text);max-width:var(--measure)}
html[data-ui='vivid'] .prose p{margin:0 0 1.35em}
html[data-ui='vivid'] .prose h2{
font-size:21px;font-weight:800;margin:2.2em 0 .8em;
letter-spacing:-.02em;color:var(--ink);
padding-bottom:10px;border-bottom:1px solid var(--line);
}
html[data-ui='vivid'] .prose a{color:var(--accent);font-weight:600;border-bottom:1.5px solid var(--accent-soft);transition:border-color .25s}
html[data-ui='vivid'] .prose a:hover{border-bottom-color:var(--accent)}
html[data-ui='vivid'] .prose code{
font-family:var(--mono);font-size:.88em;
background:var(--accent-soft);color:var(--accent);
padding:2px 7px;border-radius:6px;
}
html[data-ui='vivid'] .prose pre{
background:color-mix(in srgb,var(--ink) 92%,var(--accent));
color:#e8e8f0;padding:18px 20px;border-radius:12px;
overflow-x:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);
}
html[data-ui='vivid'] .prose pre code{background:none;padding:0;color:inherit;font-size:inherit}
html[data-ui='vivid'] .prose blockquote{
margin:1.8em 0;padding:16px 20px;
background:var(--accent-soft);
border-left:3px solid var(--accent);
border-radius:0 12px 12px 0;
font-family:var(--serif);font-size:1.05em;
color:var(--soft);font-style:italic;
}
html[data-ui='vivid'] .prose ul{padding-left:1.3em}
html[data-ui='vivid'] .prose li{margin-bottom:.55em}
html[data-ui='vivid'] .prose hr{border:0;border-top:1px dashed var(--line-strong);margin:2.5em 0}
html[data-ui='vivid'] .paper .end{
margin-top:40px;padding-top:24px;border-top:1px dashed var(--line-strong);
display:flex;gap:8px;flex-wrap:wrap;align-items:center;
}
html[data-ui='vivid'] .paper .end .lbl{font-size:12px;color:var(--muted);font-weight:700;margin-right:4px}
html[data-ui='vivid'] .back{
display:inline-flex;align-items:center;gap:6px;
font-size:13px;font-weight:700;color:var(--accent);
margin-bottom:20px;padding:6px 0;
transition:gap .4s var(--spring);
}
html[data-ui='vivid'] .back:hover{gap:12px}
/* 右:目录 + 进度 */
html[data-ui='vivid'] .side-r{position:sticky;top:80px;padding-top:8px}
html[data-ui='vivid'] .toc h4{
margin:0 0 12px;font-size:11px;font-weight:800;
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);
}
html[data-ui='vivid'] .toc ol{list-style:none;margin:0;padding:0;counter-reset:toc}
html[data-ui='vivid'] .toc li{counter-increment:toc;margin-bottom:2px}
html[data-ui='vivid'] .toc a{
display:flex;gap:8px;font-size:12.5px;color:var(--soft);
padding:6px 8px;border-radius:8px;line-height:1.4;
transition:all .4s var(--spring);
}
html[data-ui='vivid'] .toc a::before{
content:counter(toc,decimal-leading-zero);
font-family:var(--mono);font-size:10px;color:var(--muted);
flex:none;padding-top:2px;
}
html[data-ui='vivid'] .toc a:hover, html[data-ui='vivid'] .toc a.on{
background:var(--accent-soft);color:var(--accent);transform:translateX(4px);
}
html[data-ui='vivid'] .toc a:hover::before, html[data-ui='vivid'] .toc a.on::before{color:var(--accent)}
html[data-ui='vivid'] .read-meter{
margin-top:20px;padding-top:16px;border-top:1px solid var(--line);
}
html[data-ui='vivid'] .read-meter .bar{
height:3px;background:color-mix(in srgb,var(--ink) 8%,transparent);
border-radius:999px;overflow:hidden;
}
html[data-ui='vivid'] .read-meter .bar i{
display:block;height:100%;width:0%;
background:linear-gradient(90deg,var(--accent),var(--accent-deep));
border-radius:999px;transition:width .15s linear;
}
html[data-ui='vivid'] .read-meter .num{
display:flex;justify-content:space-between;
font-family:var(--mono);font-size:11px;color:var(--muted);
margin-top:8px;font-weight:600;
}
html[data-ui='vivid'] .read-meter .num b{color:var(--accent)}
/* 视觉隐藏但保留可访问性:短文详情用它放一个文档级 h1(站主要求短文不显示标题) */
html[data-ui='vivid'] .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+138
View File
@@ -0,0 +1,138 @@
/* ONE · 余白(yohaku)· 由设计稿按节拆分 / 窄屏
*
* 由设计稿(仓库根 index.html 的 <style>)按它自己的分节原样拆分。
* 只做了一件事:给每条规则加 html[data-ui='vivid'] 前缀(@media 内部的
* 规则也逐条加,at-rule 本身不加),并把 :root[data-theme="dark"] 映射到
* 本站的 vivid-dark。数值一律未改,规则也没有被拆开过。
*
* 前缀是硬要求:组件 scoped 样式编译后是 .foo[data-v-HASH](0,2,0),
* html[data-ui='vivid'] .foo 是(0,2,1)才能稳定取胜;scoped 是懒加载
* chunk,入口 CSS 之后才到位,平手时按源码顺序反而会赢。
*/
/* ---- 设计稿分节:顶部导航 ---- */
/* ---- 顶部导航:四态里的第一态(悬浮常规条) ---- */
html[data-ui='vivid'] .top{
position:sticky;top:0;z-index:100;
backdrop-filter:blur(16px) saturate(1.3);
-webkit-backdrop-filter:blur(16px) saturate(1.3);
background:color-mix(in srgb,var(--paper) 78%,transparent);
border-bottom:1px solid transparent;
transition:border-color .3s var(--ease),background .3s var(--ease),box-shadow .3s var(--ease);
}
html[data-ui='vivid'] .top.scrolled{
border-bottom-color:var(--line);
box-shadow:0 1px 0 var(--line);
}
html[data-ui='vivid'] .top-in{
max-width:1100px;margin:0 auto;
padding:0 28px;height:60px;
display:flex;align-items:center;gap:20px;
}
html[data-ui='vivid'] .brand{
display:flex;align-items:center;gap:10px;
font-weight:700;font-size:15px;letter-spacing:.02em;
transition:transform .5s var(--spring);
}
html[data-ui='vivid'] .brand:hover{transform:scale(1.04)}
html[data-ui='vivid'] .brand .av{
width:30px;height:30px;border-radius:50%;
background:linear-gradient(135deg,var(--accent),var(--accent-deep));
display:grid;place-items:center;color:#fff;font-size:13px;font-weight:800;
box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);
position:relative;
}
html[data-ui='vivid'] .brand .av::after{
content:"";position:absolute;right:-1px;bottom:-1px;
width:9px;height:9px;border-radius:50%;
background:#34c759;border:2px solid var(--paper);
}
html[data-ui='vivid'] .nav{display:flex;gap:2px;flex:1;justify-content:center}
html[data-ui='vivid'] .nav a{
position:relative;font-size:13.5px;font-weight:600;color:var(--soft);
padding:8px 14px;border-radius:999px;
transition:color .25s var(--ease),background .25s var(--ease),transform .5s var(--spring);
}
html[data-ui='vivid'] .nav a:hover{color:var(--ink);background:var(--accent-soft);transform:translateY(-1px)}
html[data-ui='vivid'] .nav a.on{color:var(--accent)}
html[data-ui='vivid'] .nav a.on::after{
content:"";position:absolute;left:50%;bottom:4px;
width:4px;height:4px;border-radius:50%;
background:var(--accent);transform:translateX(-50%);
}
html[data-ui='vivid'] .top-acts{display:flex;align-items:center;gap:6px}
html[data-ui='vivid'] .icon-btn{
width:36px;height:36px;border-radius:50%;
display:grid;place-items:center;color:var(--soft);font-size:15px;
transition:background .25s var(--ease),color .25s var(--ease),transform .5s var(--spring);
}
html[data-ui='vivid'] .icon-btn:hover{background:var(--accent-soft);color:var(--accent);transform:scale(1.08)}
@media (max-width:720px) {
html[data-ui='vivid'] .nav{display:none}
}
/* ---- 设计稿分节:阅读进度(详情页) ---- */
/* ---- 阅读进度(详情页) ---- */
html[data-ui='vivid'] .progress{
position:fixed;top:0;left:0;right:0;height:2px;z-index:200;
background:transparent;pointer-events:none;
}
html[data-ui='vivid'] .progress i{
display:block;height:100%;width:0%;
background:linear-gradient(90deg,var(--accent),var(--accent-deep));
transition:width .1s linear;
}
html[data-ui='vivid'] .progress-pct{
position:fixed;right:22px;top:50%;transform:translateY(-50%);
z-index:90;font-family:var(--mono);font-size:11px;font-weight:700;
color:var(--muted);letter-spacing:.06em;
writing-mode:vertical-rl;
opacity:0;transition:opacity .4s var(--ease);
pointer-events:none;
}
html[data-ui='vivid'] .progress-pct.show{opacity:1}
html[data-ui='vivid'] .progress-pct b{color:var(--accent);font-weight:800}
/* ---- 设计稿分节:页脚 ---- */
/* ---- 页脚 ---- */
html[data-ui='vivid'] .foot{
max-width:1100px;margin:40px auto 0;padding:28px 28px 120px;
border-top:1px solid var(--line);
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;
}
html[data-ui='vivid'] .foot a{color:var(--accent);font-weight:600;transition:opacity .25s}
html[data-ui='vivid'] .foot a:hover{opacity:.75}
html[data-ui='vivid'] .foot .sep{margin:0 8px;opacity:.4}
html[data-ui='vivid'] .foot .quote{
font-family:var(--serif);font-style:italic;
color:var(--soft);margin-bottom:8px;font-size:13.5px;
}
/* ---- 设计稿分节:切换器 / 窄屏 ---- */
/* ---- 切换器 ---- */
html[data-ui='vivid'] .switch{
position:fixed;z-index:99;left:50%;bottom:18px;transform:translateX(-50%);
display:flex;gap:6px;padding:6px;border-radius:999px;
background:rgba(28,28,30,.88);backdrop-filter:blur(14px);
box-shadow:0 10px 36px rgba(0,0,0,.35);
border:1px solid rgba(255,255,255,.08);
}
html[data-ui='vivid'] .switch button{
padding:9px 15px;border-radius:999px;font-size:12.5px;font-weight:700;
color:rgba(255,255,255,.55);transition:.2s;
}
html[data-ui='vivid'] .switch button:hover{color:#fff;background:rgba(255,255,255,.1)}
html[data-ui='vivid'] .switch button.on{background:#fff;color:#111}
html[data-ui='vivid'] .switch .lbl{color:rgba(255,255,255,.35);font-size:11px;align-self:center;padding:0 8px;letter-spacing:.08em}
@media (max-width:640px) {
html[data-ui='vivid'] .hero{padding:48px 20px 32px}
html[data-ui='vivid'] .wrap{padding:8px 18px 36px}
html[data-ui='vivid'] .paper{padding:28px 22px 36px;border-radius:16px}
html[data-ui='vivid'] .about-card{padding:32px 22px}
html[data-ui='vivid'] .panels{padding:16px 18px 12px}
html[data-ui='vivid'] .toolbar{flex-direction:column;align-items:stretch}
html[data-ui='vivid'] .search{width:100%}
html[data-ui='vivid'] .search input{flex:1;width:auto}
html[data-ui='vivid'] .top-in{padding:0 16px;gap:12px}
}
+243
View File
@@ -0,0 +1,243 @@
/* ONE · 余白(yohaku)· 由设计稿按节拆分 / 窄屏
*
* 由设计稿(仓库根 index.html 的 <style>)按它自己的分节原样拆分。
* 只做了一件事:给每条规则加 html[data-ui='vivid'] 前缀(@media 内部的
* 规则也逐条加,at-rule 本身不加),并把 :root[data-theme="dark"] 映射到
* 本站的 vivid-dark。数值一律未改,规则也没有被拆开过。
*
* 前缀是硬要求:组件 scoped 样式编译后是 .foo[data-v-HASH](0,2,0),
* html[data-ui='vivid'] .foo 是(0,2,1)才能稳定取胜;scoped 是懒加载
* chunk,入口 CSS 之后才到位,平手时按源码顺序反而会赢。
*/
/* ---- 设计稿分节:Hero ---- */
/* ---- Hero ---- */
html[data-ui='vivid'] .hero{
max-width:1100px;margin:0 auto;padding:72px 28px 48px;
position:relative;
}
html[data-ui='vivid'] .hero .kicker{
display:inline-flex;align-items:center;gap:8px;
font-size:12px;font-weight:700;letter-spacing:.14em;
color:var(--accent);text-transform:uppercase;
margin-bottom:20px;
}
html[data-ui='vivid'] .hero .kicker::before{
content:"";width:20px;height:1.5px;background:var(--accent);border-radius:2px;
}
html[data-ui='vivid'] .hero h1{
margin:0;font-size:clamp(36px,5.5vw,56px);font-weight:800;
letter-spacing:-.03em;line-height:1.15;max-width:16ch;
color:var(--ink);
}
html[data-ui='vivid'] .hero h1 .hl{
color:var(--accent);
position:relative;
}
html[data-ui='vivid'] .hero h1 .hl::after{
content:"";position:absolute;left:0;right:0;bottom:4px;height:8px;
background:var(--accent-soft);z-index:-1;border-radius:2px;
}
html[data-ui='vivid'] .hero .lead{
margin:18px 0 0;max-width:480px;font-size:16px;
color:var(--soft);line-height:1.8;
}
html[data-ui='vivid'] .hero .lead strong{color:var(--text);font-weight:700}
html[data-ui='vivid'] .stats{display:flex;gap:28px;margin-top:32px;flex-wrap:wrap}
html[data-ui='vivid'] .stat b{
display:block;font-size:22px;font-weight:800;letter-spacing:-.02em;
color:var(--ink);font-variant-numeric:tabular-nums;
}
html[data-ui='vivid'] .stat span{font-size:12px;color:var(--muted);font-weight:600}
/* ---- 设计稿分节:筛选 ---- */
/* ---- 筛选 ---- */
html[data-ui='vivid'] .wrap{max-width:1100px;margin:0 auto;padding:8px 28px 48px}
html[data-ui='vivid'] .toolbar{
display:flex;gap:10px;flex-wrap:wrap;align-items:center;
padding-bottom:20px;margin-bottom:8px;
border-bottom:1px solid var(--line);
}
html[data-ui='vivid'] .seg{
display:inline-flex;background:color-mix(in srgb,var(--ink) 5%,transparent);
border-radius:999px;padding:3px;gap:2px;
}
html[data-ui='vivid'] .seg button{
font-size:13px;font-weight:600;color:var(--soft);
padding:7px 16px;border-radius:999px;
transition:background .3s var(--ease),color .3s var(--ease),transform .5s var(--spring);
}
html[data-ui='vivid'] .seg button:hover{color:var(--ink)}
html[data-ui='vivid'] .seg button.on{
background:var(--card);color:var(--accent);
box-shadow:var(--shadow);
transform:scale(1.02);
}
html[data-ui='vivid'] .chips{display:flex;gap:6px;flex-wrap:wrap;flex:1}
html[data-ui='vivid'] .chip{
font-size:12.5px;font-weight:600;color:var(--soft);
padding:7px 14px;border-radius:999px;
border:1px solid var(--line);
transition:all .35s var(--spring);
}
html[data-ui='vivid'] .chip:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)}
html[data-ui='vivid'] .chip.on{
background:var(--accent);border-color:var(--accent);color:#fff;
box-shadow:0 6px 16px var(--accent-glow);
transform:translateY(-1px);
}
html[data-ui='vivid'] .search{
display:flex;align-items:center;gap:6px;
background:color-mix(in srgb,var(--ink) 4%,transparent);
border-radius:999px;padding:2px 4px 2px 14px;
border:1px solid transparent;
transition:border-color .3s,background .3s,box-shadow .3s;
}
html[data-ui='vivid'] .search:focus-within{
border-color:var(--accent);background:var(--card);
box-shadow:0 0 0 3px var(--accent-soft);
}
html[data-ui='vivid'] .search input{
border:0;background:transparent;outline:none;font:inherit;font-size:13.5px;
width:150px;padding:7px 0;color:var(--ink);
}
html[data-ui='vivid'] .search input::placeholder{color:var(--muted)}
html[data-ui='vivid'] .search button{
width:32px;height:32px;border-radius:50%;
background:var(--accent);color:#fff;font-size:13px;
transition:transform .5s var(--spring),box-shadow .3s;
}
html[data-ui='vivid'] .search button:hover{transform:scale(1.1);box-shadow:0 4px 12px var(--accent-glow)}
/* ---- 设计稿分节:列表:长文卡片 / 短文纸条 ---- */
/* ---- 列表:长文卡片 / 短文纸条 ---- */
html[data-ui='vivid'] .feed{display:flex;flex-direction:column;gap:18px;padding-top:24px}
/* 长文:干净卡片 */
html[data-ui='vivid'] .card{
display:block;background:var(--card);
border:1px solid var(--line);
border-radius:var(--radius-lg);
overflow:hidden;
box-shadow:var(--shadow);
transition:transform .55s var(--spring),box-shadow .45s var(--ease),border-color .3s;
position:relative;
}
html[data-ui='vivid'] .card:hover{
transform:translateY(-4px) scale(1.005);
box-shadow:var(--shadow-lg);
border-color:color-mix(in srgb,var(--accent) 35%,transparent);
}
html[data-ui='vivid'] .card:active{transform:translateY(-1px) scale(.998);transition-duration:.15s}
html[data-ui='vivid'] .card .cov{
height:180px;background:linear-gradient(
135deg,
color-mix(in srgb,var(--accent) 25%,var(--paper)),
color-mix(in srgb,var(--accent) 8%,var(--paper))
);
position:relative;overflow:hidden;
}
html[data-ui='vivid'] .card .cov img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)}
html[data-ui='vivid'] .card:hover .cov img{transform:scale(1.04)}
html[data-ui='vivid'] .card .body{padding:22px 26px 24px}
html[data-ui='vivid'] .card .meta{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;
font-size:12px;color:var(--muted);font-weight:600;margin-bottom:10px;
}
html[data-ui='vivid'] .badge{
font-size:10.5px;font-weight:800;letter-spacing:.06em;
padding:3px 9px;border-radius:999px;
}
html[data-ui='vivid'] .badge.long{background:var(--accent-soft);color:var(--accent)}
html[data-ui='vivid'] .badge.short{background:rgba(52,199,89,.14);color:#28a745}
html[data-ui='vivid'] .card h2{
margin:0 0 10px;font-size:clamp(19px,2.2vw,23px);font-weight:800;
letter-spacing:-.02em;line-height:1.35;color:var(--ink);
transition:color .25s;
}
html[data-ui='vivid'] .card:hover h2{color:var(--accent)}
html[data-ui='vivid'] .card .sum{
margin:0;font-size:14.5px;line-height:1.75;color:var(--soft);
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;
}
html[data-ui='vivid'] .card .tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:14px}
html[data-ui='vivid'] .tg{
font-size:11.5px;font-weight:700;color:var(--soft);
padding:4px 11px;border-radius:999px;
background:color-mix(in srgb,var(--ink) 5%,transparent);
transition:all .4s var(--spring);
}
html[data-ui='vivid'] .tg:hover{background:var(--accent-soft);color:var(--accent);transform:translateY(-2px)}
/* 短文:纸条,无框更轻 */
html[data-ui='vivid'] .note{
display:block;background:transparent;
border-left:3px solid var(--accent);
border-radius:0 var(--radius) var(--radius) 0;
padding:18px 24px;
margin-left:12px;
position:relative;
transition:background .35s var(--ease),transform .55s var(--spring),border-color .3s;
}
html[data-ui='vivid'] .note:hover{
background:var(--card);
transform:translateX(6px);
box-shadow:var(--shadow);
}
html[data-ui='vivid'] .note .meta{
display:flex;gap:10px;align-items:center;
font-size:12px;color:var(--muted);font-weight:600;margin-bottom:8px;
}
html[data-ui='vivid'] .note .body{
margin:0;font-size:15.5px;line-height:1.8;color:var(--text);
}
html[data-ui='vivid'] .note .body a{
color:var(--accent);font-weight:600;
border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent);
transition:border-color .25s;
}
html[data-ui='vivid'] .note .body a:hover{border-bottom-color:var(--accent)}
html[data-ui='vivid'] .note .tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:12px}
/* ---- 设计稿分节:分页 ---- */
/* ---- 分页 ---- */
html[data-ui='vivid'] .pager{
display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;
}
html[data-ui='vivid'] .pager button{
font-size:13px;font-weight:700;padding:9px 16px;border-radius:999px;
color:var(--soft);border:1px solid var(--line);background:var(--card);
transition:all .45s var(--spring);
}
html[data-ui='vivid'] .pager button:hover:not(:disabled){
border-color:var(--accent);color:var(--accent);transform:translateY(-2px);
box-shadow:0 6px 16px var(--accent-soft);
}
html[data-ui='vivid'] .pager button:disabled{opacity:.4;cursor:not-allowed}
html[data-ui='vivid'] .pager .cur{
background:var(--accent);border-color:var(--accent);color:#fff;
box-shadow:0 6px 16px var(--accent-glow);
}
html[data-ui='vivid'] .pager .info{border:0;background:none;color:var(--muted);font-weight:600;font-size:12.5px}
/* ---- 首页作品展示区(参照 diygod.cc:只展示部分 + 查看所有) ---- */
html[data-ui='vivid'] .showcase{
max-width:1100px;margin:56px auto 0;padding:0 28px;
}
html[data-ui='vivid'] .show-head{
display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:20px;
}
html[data-ui='vivid'] .show-head h2{
margin:0;font-size:1.4rem;font-weight:650;letter-spacing:0.01em;
}
html[data-ui='vivid'] .show-head .en{color:var(--muted)}
html[data-ui='vivid'] .show-head .push{margin-left:auto;color:var(--muted)}
html[data-ui='vivid'] .show-head .push:hover{color:var(--accent-deep)}
html[data-ui='vivid'] .show-more{display:flex;justify-content:center;margin-top:26px}
html[data-ui='vivid'] .show-more .chip{font-size:13px;padding:9px 22px}
@media (max-width:860px){
html[data-ui='vivid'] .showcase{
padding-left:max(18px, env(safe-area-inset-left));
padding-right:max(18px, env(safe-area-inset-right));
}
}
+160
View File
@@ -0,0 +1,160 @@
/* ONE · 余白(yohaku)· 由设计稿按节拆分 / 窄屏
*
* 由设计稿(仓库根 index.html 的 <style>)按它自己的分节原样拆分。
* 只做了一件事:给每条规则加 html[data-ui='vivid'] 前缀(@media 内部的
* 规则也逐条加,at-rule 本身不加),并把 :root[data-theme="dark"] 映射到
* 本站的 vivid-dark。数值一律未改,规则也没有被拆开过。
*
* 前缀是硬要求:组件 scoped 样式编译后是 .foo[data-v-HASH](0,2,0),
* html[data-ui='vivid'] .foo 是(0,2,1)才能稳定取胜;scoped 是懒加载
* chunk,入口 CSS 之后才到位,平手时按源码顺序反而会赢。
*/
/* ---- 设计稿分节:归档 / 标签 / 作品 / 关于 ---- */
/* ---- 归档 / 标签 / 作品 / 关于 ---- */
html[data-ui='vivid'] .page-head{
max-width:1100px;margin:0 auto;padding:56px 28px 32px;
}
html[data-ui='vivid'] .page-head code{font-family:var(--mono);font-size:.9em}
.page-head .kicker{
display:inline-flex;align-items:center;gap:8px;
font-size:12px;font-weight:700;letter-spacing:.14em;
color:var(--accent);text-transform:uppercase;margin-bottom:16px;
}
html[data-ui='vivid'] .page-head .kicker::before{content:"";width:20px;height:1.5px;background:var(--accent)}
html[data-ui='vivid'] .page-head h1{
margin:0;font-size:clamp(32px,5vw,48px);font-weight:800;
letter-spacing:-.03em;color:var(--ink);
}
html[data-ui='vivid'] .page-head h1 em{font-style:normal;color:var(--accent)}
html[data-ui='vivid'] .page-head p{margin:12px 0 0;max-width:480px;color:var(--soft);font-size:15px;line-height:1.75}
html[data-ui='vivid'] .year-block{margin-bottom:36px}
html[data-ui='vivid'] .year-head{display:flex;align-items:baseline;gap:14px;margin-bottom:14px}
html[data-ui='vivid'] .year-head .y{
font-size:clamp(36px,5vw,52px);font-weight:800;letter-spacing:-.03em;
color:var(--accent);font-variant-numeric:tabular-nums;
}
html[data-ui='vivid'] .year-head .c{
font-size:12px;font-weight:700;color:var(--muted);
padding:5px 12px;border-radius:999px;
background:color-mix(in srgb,var(--ink) 5%,transparent);
}
html[data-ui='vivid'] .month{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);margin-bottom:12px;overflow:hidden;
box-shadow:var(--shadow);
transition:box-shadow .35s,transform .55s var(--spring);
}
html[data-ui='vivid'] .month:hover{box-shadow:var(--shadow-lg);transform:translateY(-2px)}
html[data-ui='vivid'] .month h3{
margin:0;padding:14px 22px 10px;font-size:12px;font-weight:800;
letter-spacing:.1em;color:var(--accent);
}
html[data-ui='vivid'] .month ul{list-style:none;margin:0;padding:0 10px 10px}
html[data-ui='vivid'] .month li a{
display:flex;gap:14px;padding:10px 14px;border-radius:10px;
font-size:14px;align-items:baseline;
transition:background .3s,transform .5s var(--spring);
}
html[data-ui='vivid'] .month li a:hover{background:var(--accent-soft);transform:translateX(6px)}
html[data-ui='vivid'] .month .d{font-family:var(--mono);font-size:12px;color:var(--muted);flex:none;font-weight:600}
html[data-ui='vivid'] .month .kd{font-size:10.5px;font-weight:800;padding:2px 8px;border-radius:999px;flex:none}
html[data-ui='vivid'] .month .kd.s{background:rgba(52,199,89,.14);color:#28a745}
html[data-ui='vivid'] .month .kd.l{background:var(--accent-soft);color:var(--accent)}
html[data-ui='vivid'] .month .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
html[data-ui='vivid'] .month li a:hover .t{color:var(--ink)}
html[data-ui='vivid'] .tag-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
html[data-ui='vivid'] .tag-tile{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);padding:22px;
box-shadow:var(--shadow);position:relative;overflow:hidden;
transition:all .55s var(--spring);
}
html[data-ui='vivid'] .tag-tile:hover{
transform:translateY(-6px);
box-shadow:var(--shadow-lg);
border-color:color-mix(in srgb,var(--tc,var(--accent)) 40%,transparent);
}
html[data-ui='vivid'] .tag-tile .sw{
width:32px;height:32px;border-radius:10px 10px 10px 4px;
background:var(--tc,var(--accent));margin-bottom:14px;
box-shadow:0 6px 14px -4px var(--tc,var(--accent));
transition:transform .55s var(--spring);
}
html[data-ui='vivid'] .tag-tile:hover .sw{transform:rotate(-8deg) scale(1.1)}
html[data-ui='vivid'] .tag-tile .nm{font-size:18px;font-weight:800;display:block;margin-bottom:4px;color:var(--ink)}
html[data-ui='vivid'] .tag-tile .ct{font-size:12.5px;color:var(--muted);font-weight:600}
html[data-ui='vivid'] .tag-tile .big{
position:absolute;right:14px;top:10px;font-size:42px;font-weight:800;
color:color-mix(in srgb,var(--tc,var(--accent)) 12%,transparent);
letter-spacing:-.04em;line-height:1;
}
html[data-ui='vivid'] .proj-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px}
html[data-ui='vivid'] .proj{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);overflow:hidden;
box-shadow:var(--shadow);display:flex;flex-direction:column;
transition:all .55s var(--spring);
}
html[data-ui='vivid'] .proj:hover{transform:translateY(-6px);box-shadow:var(--shadow-lg);border-color:var(--accent-soft)}
html[data-ui='vivid'] .proj .cov{height:150px;position:relative;overflow:hidden;background:var(--accent-soft)}
html[data-ui='vivid'] .proj .cov img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)}
html[data-ui='vivid'] .proj:hover .cov img{transform:scale(1.05)}
html[data-ui='vivid'] .proj .st{
position:absolute;top:12px;left:12px;font-size:10.5px;font-weight:800;
background:color-mix(in srgb,var(--card) 90%,transparent);
backdrop-filter:blur(8px);color:var(--accent);
padding:5px 11px;border-radius:999px;
}
html[data-ui='vivid'] .proj .body{padding:18px 20px 20px;flex:1;display:flex;flex-direction:column;gap:8px}
html[data-ui='vivid'] .proj h3{margin:0;font-size:17px;font-weight:800;color:var(--ink);letter-spacing:-.01em}
html[data-ui='vivid'] .proj p{margin:0;font-size:13.5px;line-height:1.7;color:var(--soft);flex:1}
html[data-ui='vivid'] .proj .acts{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
html[data-ui='vivid'] .proj .acts span{
font-size:11.5px;font-weight:700;padding:6px 12px;border-radius:999px;
background:color-mix(in srgb,var(--ink) 5%,transparent);color:var(--soft);
transition:all .45s var(--spring);
}
html[data-ui='vivid'] .proj:hover .acts span{background:var(--accent-soft);color:var(--accent)}
html[data-ui='vivid'] .about-card{
max-width:680px;margin:0 auto;background:var(--card);
border:1px solid var(--line);border-radius:var(--radius-lg);
padding:48px 44px;box-shadow:var(--shadow-lg);
animation:paperIn .7s var(--spring) both;
}
html[data-ui='vivid'] .about-card h1{margin:0 0 8px;font-size:clamp(32px,5vw,44px);font-weight:800;letter-spacing:-.03em;color:var(--ink)}
html[data-ui='vivid'] .about-card .role{
font-size:13px;font-weight:700;letter-spacing:.12em;color:var(--accent);
text-transform:uppercase;margin-bottom:22px;
}
html[data-ui='vivid'] .about-card .bio{
font-size:16.5px;line-height:1.95;color:var(--soft);
max-width:46ch;margin:0 0 32px;font-family:var(--serif);
}
html[data-ui='vivid'] .about-facts{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
html[data-ui='vivid'] .about-facts div{
background:color-mix(in srgb,var(--accent) 8%,transparent);
border-radius:var(--radius);padding:16px;
}
html[data-ui='vivid'] .about-facts b{
display:block;font-size:24px;font-weight:800;color:var(--accent);
letter-spacing:-.02em;font-variant-numeric:tabular-nums;
}
html[data-ui='vivid'] .about-facts span{font-size:12px;color:var(--muted);font-weight:700}
html[data-ui='vivid'] .empty{
max-width:480px;margin:40px auto;background:var(--card);
border:1px dashed var(--line-strong);border-radius:var(--radius-lg);
padding:48px 32px;text-align:center;box-shadow:var(--shadow);
}
html[data-ui='vivid'] .empty h3{margin:0 0 8px;font-size:20px;font-weight:800;color:var(--ink)}
html[data-ui='vivid'] .empty p{margin:0 0 18px;color:var(--muted);font-size:14px}
/* 无封面作品卡:状态角标内联到标题旁(设计稿 mock 全有封面,此场景是补的) */
html[data-ui='vivid'] .proj .st-plain{
position:static;display:inline-block;margin-left:9px;vertical-align:2px;
font-size:10px;font-weight:800;letter-spacing:.06em;
padding:2px 9px;border-radius:999px;
background:color-mix(in srgb,var(--card) 90%,transparent);
color:var(--muted);box-shadow:inset 0 0 0 1px var(--line-strong);
}
+79
View File
@@ -0,0 +1,79 @@
/* ONE · 余白(yohaku)· 由设计稿按节拆分 / 窄屏
*
* 由设计稿(仓库根 index.html 的 <style>)按它自己的分节原样拆分。
* 只做了一件事:给每条规则加 html[data-ui='vivid'] 前缀(@media 内部的
* 规则也逐条加,at-rule 本身不加),并把 :root[data-theme="dark"] 映射到
* 本站的 vivid-dark。数值一律未改,规则也没有被拆开过。
*
* 前缀是硬要求:组件 scoped 样式编译后是 .foo[data-v-HASH](0,2,0),
* html[data-ui='vivid'] .foo 是(0,2,1)才能稳定取胜;scoped 是懒加载
* chunk,入口 CSS 之后才到位,平手时按源码顺序反而会赢。
*/
/* ---- 设计稿分节:底部:标签 / 作品 / 关于 ---- */
/* ---- 底部:标签 / 作品 / 关于 ---- */
html[data-ui='vivid'] .panels{
max-width:1100px;margin:0 auto;padding:20px 28px 20px;
display:grid;/* 设计稿此处是 1.2fr 1fr 1fr;站主要求标签云卡明显大一圈,改为 1.6fr(实测 444 vs 277px)。
高度策略:align-items:start —— 每张卡自适应内容,顶边齐平,不互相拉伸。
(试过等高拉伸 / 跨行加高,标签云内容少时会空出一大块,站主否了。) */
grid-template-columns:1.6fr 1fr 1fr;align-items:start;
}
@media (max-width:860px) {
html[data-ui='vivid'] .panels{grid-template-columns:1fr}
}
html[data-ui='vivid'] .panel{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);padding:24px;
box-shadow:var(--shadow);
transition:transform .55s var(--spring),box-shadow .4s;
}
/* 标签云卡比另外两张高出一截(站主要求):三张**顶边齐平**,
作品 / 关于两卡通过负 bottom margin 各自收短 40px ——
于是标签云的底边比它们低出 40px。栅格行高由标签云决定,无需补偿。 */
html[data-ui='vivid'] .panel:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
html[data-ui='vivid'] .panel h3{
margin:0 0 16px;font-size:11px;font-weight:800;
letter-spacing:.16em;text-transform:uppercase;color:var(--muted);
display:flex;align-items:center;gap:10px;
}
html[data-ui='vivid'] .panel h3::after{content:"";flex:1;height:1px;background:var(--line)}
html[data-ui='vivid'] .cloud{display:flex;flex-wrap:wrap;gap:8px}
html[data-ui='vivid'] .cloud a{
font-size:13px;font-weight:700;padding:7px 14px;border-radius:999px;
background:color-mix(in srgb,var(--accent) 10%,transparent);
color:var(--accent);
transition:all .5s var(--spring);
}
html[data-ui='vivid'] .cloud a:hover{
background:var(--accent);color:#fff;
transform:translateY(-3px) scale(1.05);
box-shadow:0 8px 20px var(--accent-glow);
}
html[data-ui='vivid'] .cloud a span{opacity:.7;font-size:11px;margin-left:4px;font-weight:600}
html[data-ui='vivid'] .plist{display:flex;flex-direction:column;gap:10px}
html[data-ui='vivid'] .plist a{
display:block;padding:13px 15px;border-radius:var(--radius);
background:color-mix(in srgb,var(--ink) 3%,transparent);
transition:all .5s var(--spring);
}
html[data-ui='vivid'] .plist a:hover{
background:var(--accent-soft);transform:translateX(6px);
}
html[data-ui='vivid'] .plist b{display:block;font-size:14px;font-weight:700;color:var(--ink)}
html[data-ui='vivid'] .plist small{color:var(--muted);font-size:12px}
html[data-ui='vivid'] .about-box .name{
font-size:18px;font-weight:800;color:var(--ink);margin:0 0 8px;letter-spacing:-.02em;
}
html[data-ui='vivid'] .about-box p{margin:0;font-size:13.5px;line-height:1.8;color:var(--soft)}
/* 卡变大了一圈,chip 也相应放大一号,避免大卡里塞小字显得空 */
html[data-ui='vivid'] .panels .cloud{gap:11px}
html[data-ui='vivid'] .panels .cloud a{
font-size:15px;padding:10px 19px;
}
html[data-ui='vivid'] .panels .cloud a span{font-size:12.5px;margin-left:5px}
+249
View File
@@ -0,0 +1,249 @@
/* ONE · 余白(yohaku)· 令牌层
*
* 这一层是整套 UI 的地基,做两件事:
*
* 1. 定义**站主接口** --v-*(28 个),值是设计稿的原始数值。
* 站主在后台「自定义 CSS」里写 :root{--v-accent:…} 就能覆盖 :
* 不需要去背 html[data-ui='vivid'] 这种选择器。
*
* 2. 把**设计稿的令牌名**(--paper / --ink / --accent / …)全部指回 --v-*。
* 设计稿里 700 多行规则读的都是这些名字;接上之后站主只改一处,
* 整套 UI 连同 classic 的共享组件(PostCard / RightRail / .prose / .btn)
* 一起换皮。
*
* 为什么放进 @layer:未分层的规则永远赢过分层的规则(层级顺序先于特指度)。
* 站主的自定义 CSS 是未分层的普通 <style>,所以写进 @layer 是**让出**优先级,
* 而不是去压别人。这是刻意的。
*
* 数值来源:仓库根 index.html(设计稿)的 :root 与 :root[data-theme="dark"],
* 一个数都没有改。
*/
@layer one-yohaku {
/* ==========================================================================
1. 站主接口 + 设计稿令牌(亮色)
========================================================================== */
html[data-ui='vivid'] {
/* ---- 站主接口:颜色 ---- */
--v-bg: #faf7f0;
--v-surface: #fffefb;
--v-surface-2: #f4f1ea;
--v-text: #1c1c1e;
--v-text-2: #2c2c2e;
--v-text-soft: #5c5c66;
--v-muted: #8e8e99;
--v-line: rgba(28, 28, 30, 0.1);
--v-line-strong: rgba(28, 28, 30, 0.16);
--v-accent: #69a6cc;
--v-accent-2: #4a8bb3;
--v-accent-3: color-mix(in srgb, var(--v-accent) 70%, var(--v-accent-2));
--v-accent-soft: rgba(105, 166, 204, 0.14);
--v-accent-glow: rgba(105, 166, 204, 0.35);
/* ---- 站主接口:形状与尺寸 ---- */
--v-radius: 14px;
--v-radius-sm: 10px;
--v-radius-lg: 20px;
--v-radius-pill: 999px;
--v-content-width: 1100px;
--v-measure: 680px;
--v-nav-h: 64px;
--v-motion: 1; /* 动效强度倍数;置 0 即全部即时生效 */
/* ---- 站主接口:阴影与动效 ---- */
--v-shadow: 0 1px 2px rgba(28, 28, 30, 0.04), 0 8px 28px -8px rgba(28, 28, 30, 0.08);
--v-shadow-sm: 0 1px 2px rgba(28, 28, 30, 0.04), 0 4px 14px -6px rgba(28, 28, 30, 0.08);
--v-shadow-lg: 0 2px 4px rgba(28, 28, 30, 0.05), 0 20px 50px -16px rgba(28, 28, 30, 0.14);
--v-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--v-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
/* ---- 站主接口:字体与噪点 ---- */
--v-font-display: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', system-ui,
-apple-system, sans-serif;
--v-font-serif: 'Songti SC', 'Noto Serif SC', 'SimSun', Georgia, serif;
--v-noise-opacity: 0.45;
--v-noise-blend: overlay;
/* 单一强调色下的「渐变」= 同色微变,不再三色跳 */
--v-grad: linear-gradient(
135deg,
var(--v-accent),
color-mix(in srgb, var(--v-accent) 86%, var(--v-accent-2))
);
}
/* ==========================================================================
2. 暗色:只覆盖 --v-* 的默认值,上面的别名自动跟随
========================================================================== */
html[data-ui='vivid'][data-theme='vivid-dark'] {
--v-bg: #1c1c1e;
--v-surface: #232326;
--v-surface-2: #2a2a2d;
--v-text: #f2f2f7;
--v-text-2: #e8e8ed;
--v-text-soft: #a1a1aa;
--v-muted: #6e6e78;
--v-line: rgba(242, 242, 247, 0.1);
--v-line-strong: rgba(242, 242, 247, 0.16);
--v-accent: #a0a7d4;
--v-accent-2: #b8bee6;
--v-accent-soft: rgba(160, 167, 212, 0.16);
--v-accent-glow: rgba(160, 167, 212, 0.3);
--v-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 8px 28px -8px rgba(0, 0, 0, 0.35);
--v-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2), 0 4px 14px -6px rgba(0, 0, 0, 0.3);
--v-shadow-lg: 0 2px 4px rgba(0, 0, 0, 0.25), 0 20px 50px -16px rgba(0, 0, 0, 0.5);
--v-noise-opacity: 0.3;
--v-noise-blend: soft-light;
}
}
/* ==========================================================================
2. 设计稿的令牌名 → 全部指回 --v-*
--------------------------------------------------------------------------
⚠ 这一段**必须留在 @layer 外面**。
层级顺序优先于特指度:未分层的规则永远赢过分层的规则。styles.css 里
`:root { --accent: #3d7f9c }` 是未分层的,如果这里放进 @layer,
设计稿的配色就会被 classic 的令牌整片压掉 : 页面「看着像,细节全不对」。
(--v-* 的默认值留在层里是为了让站主的自定义 CSS 能覆盖,这一段不是。)
========================================================================== */
html[data-ui='vivid'] {
/* ---- 设计稿的令牌名 → 全部指回 --v-* ----
* 下面这些名字是设计稿 700 多行规则在用的。接上之后,
* 站主改 --v-* 就等于改整站;classic 的共享组件也一起换皮。 */
--paper: var(--v-bg);
--card: var(--v-surface);
--paper-sunken: var(--v-surface-2);
--ink: var(--v-text);
--text: var(--v-text-2);
--soft: var(--v-text-soft);
--ink-soft: var(--v-text-soft);
--muted: var(--v-muted);
--line: var(--v-line);
--line-soft: var(--v-line);
--line-strong: var(--v-line-strong);
--accent: var(--v-accent);
--accent-2: var(--v-accent-2);
--accent-deep: var(--v-accent-2);
--accent-line: var(--v-accent-2);
--accent-soft: var(--v-accent-soft);
--accent-glow: var(--v-accent-glow);
--shadow: var(--v-shadow);
--shadow-lg: var(--v-shadow-lg);
--radius: var(--v-radius);
--radius-sm: var(--v-radius-sm);
--radius-lg: var(--v-radius-lg);
--spring: var(--v-spring);
--spring-soft: cubic-bezier(0.22, 1.2, 0.36, 1);
--ease: var(--v-ease);
--sans: var(--v-font-display);
--serif: var(--v-font-serif);
--mono: 'SF Mono', 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
--measure: var(--v-measure);
}
/* ==========================================================================
3. 复位与肌理
--------------------------------------------------------------------------
设计稿里这几条是全局的。必须逐条前缀收敛到 vivid,
否则 a{text-decoration:none} / *{box-sizing} 会连后台一起改掉。
========================================================================== */
html[data-ui='vivid'] * {
box-sizing: border-box;
}
html[data-ui='vivid'] {
scroll-behavior: auto;
}
html[data-ui='vivid'] body {
margin: 0;
font-family: var(--sans);
color: var(--text);
background: var(--paper);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
transition: background 0.4s var(--ease), color 0.4s var(--ease);
}
html[data-ui='vivid'] a {
color: inherit;
text-decoration: none;
}
html[data-ui='vivid'] button {
font: inherit;
cursor: pointer;
border: 0;
background: none;
color: inherit;
}
html[data-ui='vivid'] ::selection {
background: var(--accent-glow);
color: var(--ink);
}
/* 噪点:强调色染色的细颗粒 */
html[data-ui='vivid'] body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: var(--v-noise-opacity);
mix-blend-mode: var(--v-noise-blend);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0.42 0 0 0 0 0.65 0 0 0 0 0.8 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 180px 180px;
}
/* ==========================================================================
4. 与 classic / 后台样式共存的防线
--------------------------------------------------------------------------
设计稿是一个独立单页,它默认「没人动过这些属性」。但真站里 styles.css
(含后台那一段)是全局、未分层加载的,它的
· h1,h2,h3,h4 { font-family: var(--serif) }
· body { line-height: 1.8 }
· .stat / .toolbar / .chip(后台那套带卡片的)
会漏进余白的元素里 : 凡是设计稿「没声明、靠继承或默认值」的属性都会被改掉。
所以这里把设计稿的期望值**显式写死**。
为什么这样不会误伤设计稿自己的衬线:详情页那 4 处 `font-family: var(--serif)`
是 `.xxx .yyy` 形式,前缀后特指度 (0,2,1),高于这里的 (0,1,1) : 它们照样赢。
这里只负责挡掉裸选择器。
⚠ 这一段必须留在 @layer 外面:未分层才能压住 styles.css 的未分层规则。
========================================================================== */
html[data-ui='vivid'] body {
line-height: normal;
}
html[data-ui='vivid'] h1,
html[data-ui='vivid'] h2,
html[data-ui='vivid'] h3,
html[data-ui='vivid'] h4,
html[data-ui='vivid'] h5,
html[data-ui='vivid'] h6 {
font-family: inherit;
/* styles.css 还给 h1..h4 设了 line-height:1.4;设计稿对这些标题是「靠继承」,
所以这里一并交还给继承(设计稿自己声明了行高的标题特指度更高,照样赢)。 */
line-height: inherit;
}
/* 后台有同名类且带卡片外观,逐个还原成设计稿里的裸元素 */
html[data-ui='vivid'] .stat {
background: none;
border: 0;
border-radius: 0;
padding: 0;
gap: normal;
display: block;
}
html[data-ui='vivid'] .toolbar {
background: none;
border-top: 0;
border-radius: 0;
padding: 0 0 20px;
}
html[data-ui='vivid'] .chip {
display: inline-block;
gap: normal;
}
/* styles.css 里有 .panel + .panel { margin-top: 16px }(classic 的纵向排布用),
在余白的三栏栅格里它会把第 2、3 张卡往下压 16px,顶边就不齐了 —— 中和掉。 */
html[data-ui='vivid'] .panels > .panel + .panel {
margin-top: 0;
}
+25 -1
View File
@@ -4,10 +4,34 @@ import RightRail from '../components/RightRail.vue'
import { site, applyDocTitle } from '../site' import { site, applyDocTitle } from '../site'
applyDocTitle('关于') applyDocTitle('关于')
import { computed, onMounted, ref } from 'vue'
import YohakuAbout from '../ui/yohaku/YohakuAbout.vue'
import { publicApi } from '../api'
// 余白版关于页的四个事实。只在 vivid 下取,取不到就显示 0,不挡页面。
const isVivid = computed(() => site.ui_id === 'vivid')
const stat = ref({ posts: 0, tags: 0, projects: 0, long: 0 })
onMounted(async () => {
if (!isVivid.value) return
try {
const [tg, pj, ar] = await Promise.all([publicApi.tags(), publicApi.projects(), publicApi.archive()])
const flat = []
;(ar?.years || []).forEach((y) => (y.months || []).forEach((m) => (m.posts || []).forEach((x) => flat.push(x))))
stat.value = {
posts: flat.length,
tags: (tg?.tags || []).length,
projects: (pj?.projects || []).length,
long: flat.filter((x) => x.kind === 'long').length
}
} catch (e) { /* 统计失败不影响关于页 */ }
})
</script> </script>
<template> <template>
<div class="shell"> <YohakuAbout v-if="isVivid" :post-count="stat.posts" :tag-count="stat.tags" :project-count="stat.projects" :long-count="stat.long" />
<div v-else class="shell">
<div class="layout"> <div class="layout">
<LeftNav /> <LeftNav />
+8 -2
View File
@@ -3,10 +3,14 @@ import { computed, onMounted, ref } from 'vue'
import LeftNav from '../components/LeftNav.vue' 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 { applyDocTitle } from '../site' import YohakuArchive from '../ui/yohaku/YohakuArchive.vue'
import { applyDocTitle, site } from '../site'
import { formatDateShort } from '../utils' import { formatDateShort } from '../utils'
const years = ref([]) const years = ref([])
// vivid(余白)是另一套 DOM,数据来源不变,只在视图层分叉
const isVivid = computed(() => site.ui_id === 'vivid')
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
@@ -29,7 +33,9 @@ applyDocTitle('归档')
</script> </script>
<template> <template>
<div class="shell"> <YohakuArchive v-if="isVivid" :years="years" />
<div v-else class="shell">
<div class="layout"> <div class="layout">
<LeftNav /> <LeftNav />
+56 -1
View File
@@ -4,12 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
import LeftNav from '../components/LeftNav.vue' import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue' import RightRail from '../components/RightRail.vue'
import PostCard from '../components/PostCard.vue' import PostCard from '../components/PostCard.vue'
import YohakuHome from '../ui/yohaku/YohakuHome.vue'
import { publicApi } from '../api' import { publicApi } from '../api'
import { site, applyDocTitle } from '../site' import { site, applyDocTitle } from '../site'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// vivid(余白)走的是另一套 DOM:hero + toolbar + feed + pager + panels。
// 数据来源和 classic 完全一样,只是呈现不同 —— 所以分支放在视图层,不动取数逻辑。
const isVivid = computed(() => site.ui_id === 'vivid')
const yTags = ref([])
const yProjects = ref([])
const yStats = ref({ posts: 0, chars: 0 })
const items = ref([]) const items = ref([])
const total = ref(0) const total = ref(0)
const loading = ref(true) const loading = ref(true)
@@ -67,8 +75,34 @@ async function load() {
} }
onMounted(load) onMounted(load)
onMounted(loadYohakuExtras) // 余白的附加数据(标签 / 作品 / 全站统计)只在 vivid 下取一次
watch([kind, tag, page], load) watch([kind, tag, page], load)
// 余白头版要四个数字:文章 / 字数 / 标签 / 作品。
// 这些是「全站统计」,不能用当前筛选后的 total,所以单独取一次;
// 取不到就不显示统计,绝不挡住首页。
async function loadYohakuExtras() {
if (!isVivid.value) return
try {
const [tg, pj, ar] = await Promise.all([
publicApi.tags(),
publicApi.projects(),
publicApi.archive()
])
// 三个接口都返回包装对象:{tags:[]} / {projects:[]} / {years:[]}
yTags.value = tg?.tags || []
yProjects.value = pj?.projects || []
const flat = []
;(ar?.years || []).forEach((y) => (y.months || []).forEach((m) => (m.posts || []).forEach((x) => flat.push(x))))
yStats.value = {
posts: flat.length,
chars: flat.reduce((n, x) => n + (x.content_len || 0), 0)
}
} catch (e) {
yStats.value = { posts: total.value, chars: 0 }
}
}
function setKind(value) { function setKind(value) {
router.push({ path: '/', query: { ...(value ? { kind: value } : {}), ...(tag.value ? { tag: tag.value } : {}) } }) router.push({ path: '/', query: { ...(value ? { kind: value } : {}), ...(tag.value ? { tag: tag.value } : {}) } })
} }
@@ -86,7 +120,28 @@ applyDocTitle('')
</script> </script>
<template> <template>
<div class="shell"> <YohakuHome
v-if="isVivid"
:items="items"
:total="total"
:page="page"
:max-page="pageCount"
:kind="kind"
:tag="tag"
:query="String(route.query.q || '')"
:tags="yTags"
:projects="yProjects"
:stat-posts="yStats.posts"
:stat-chars="yStats.chars"
:stat-tags="yTags.length"
:stat-projects="yProjects.length"
@update:kind="setKind"
@update:tag="(v) => router.push({ path: '/', query: v ? { tag: v } : {} })"
@update:query="(v) => router.push({ path: '/', query: v ? { q: v } : {} })"
@go="(n) => router.push({ path: '/', query: { ...route.query, page: n } })"
/>
<div v-else class="shell">
<div class="layout"> <div class="layout">
<LeftNav /> <LeftNav />
+39 -5
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import LeftNav from '../components/LeftNav.vue' 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 { 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'
@@ -11,13 +12,35 @@ const route = useRoute()
const post = ref(null) const post = ref(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const isVivid = computed(() => site.ui_id === 'vivid')
// 正文 h2 加 id,供 vivid 右栏目录;classic 不用但无害
function withHeadingIds(html) {
const doc = new DOMParser().parseFromString(html || '', 'text/html')
const heads = [...doc.querySelectorAll('h2')]
heads.forEach((h, i) => {
if (!h.id) h.id = 'h-' + i
})
return {
html: doc.body.innerHTML,
headings: heads.map((h) => ({ id: h.id, text: h.textContent || '' }))
}
}
const contentHtml = ref('')
const headings = ref([])
async function load() { async function load() {
loading.value = true loading.value = true
error.value = '' error.value = ''
post.value = null post.value = null
contentHtml.value = ''
headings.value = []
try { try {
post.value = await publicApi.post(route.params.slug) post.value = await publicApi.post(route.params.slug)
const prepared = withHeadingIds(post.value.content_html)
contentHtml.value = prepared.html
headings.value = prepared.headings
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title) applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
} catch (e) { } catch (e) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
@@ -34,10 +57,18 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
</script> </script>
<template> <template>
<div class="shell"> <YohakuArticle
<div class="layout"> v-if="isVivid"
<LeftNav /> :post="post"
:headings="headings"
:content-html="contentHtml"
:neighbors="post?.neighbors || []"
:loading="loading"
:error="error"
/>
<div v-else class="shell">
<div class="layout">
<main class="main"> <main class="main">
<div v-if="loading" class="loading">载入中…</div> <div v-if="loading" class="loading">载入中…</div>
@@ -65,7 +96,11 @@ 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="sanitizeHtml(post.content_html)"></div> <div
class="prose"
:class="{ 'prose-short': isShort }"
v-html="sanitizeHtml(contentHtml)"
></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">
@@ -83,7 +118,6 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
</article> </article>
</main> </main>
<RightRail />
</div> </div>
</div> </div>
</template> </template>
+9 -3
View File
@@ -1,12 +1,16 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import LeftNav from '../components/LeftNav.vue' import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue' import RightRail from '../components/RightRail.vue'
import ProjectCard from '../components/ProjectCard.vue' import ProjectCard from '../components/ProjectCard.vue'
import { publicApi } from '../api' import { publicApi } from '../api'
import { applyDocTitle } from '../site' import YohakuProjects from '../ui/yohaku/YohakuProjects.vue'
import { applyDocTitle, site } from '../site'
const projects = ref([]) const projects = ref([])
// vivid(余白)是另一套 DOM,数据来源不变,只在视图层分叉
const isVivid = computed(() => site.ui_id === 'vivid')
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
@@ -25,7 +29,9 @@ applyDocTitle('作品')
</script> </script>
<template> <template>
<div class="shell"> <YohakuProjects v-if="isVivid" :projects="projects" />
<div v-else class="shell">
<div class="layout"> <div class="layout">
<LeftNav /> <LeftNav />
+9 -3
View File
@@ -1,11 +1,15 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import LeftNav from '../components/LeftNav.vue' 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 { applyDocTitle } from '../site' import YohakuTags from '../ui/yohaku/YohakuTags.vue'
import { applyDocTitle, site } from '../site'
const tags = ref([]) const tags = ref([])
// vivid(余白)是另一套 DOM,数据来源不变,只在视图层分叉
const isVivid = computed(() => site.ui_id === 'vivid')
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
@@ -26,7 +30,9 @@ applyDocTitle('标签')
</script> </script>
<template> <template>
<div class="shell"> <YohakuTags v-if="isVivid" :tags="tags" />
<div v-else class="shell">
<div class="layout"> <div class="layout">
<LeftNav /> <LeftNav />
+1396
View File
File diff suppressed because it is too large Load Diff