余白 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
+277 -3
View File
@@ -1,7 +1,8 @@
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
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 settings = ref(null)
@@ -19,6 +20,98 @@ const lightSkins = [
]
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() {
loading.value = true
try {
@@ -26,6 +119,19 @@ async function load() {
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
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) {
error.value = e.message || '加载失败'
} finally {
@@ -41,6 +147,15 @@ async function save() {
try {
const s = await adminApi.saveSettings(settings.value)
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 })
} catch (e) {
error.value = e.message || '保存失败'
@@ -60,6 +175,7 @@ async function save() {
<div class="settings-tabs">
<button :class="{ on: tab === 'basic' }" @click="tab = 'basic'">基础信息</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>
</div>
@@ -137,6 +253,97 @@ async function save() {
</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 -->
<div v-if="tab === 'advanced'" class="panel">
<div class="panel-title"><h2>高级</h2></div>
@@ -159,4 +366,71 @@ async function save() {
</div>
</div>
</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>