余白 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:
@@ -9,6 +9,27 @@
|
||||
<meta name="description" content="长文与短文,同一种节奏。" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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>
|
||||
<body>
|
||||
<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
@@ -3,9 +3,17 @@ import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TopBar from './components/TopBar.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 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,
|
||||
// focus-visible 等样式跟着切 token。
|
||||
@@ -15,16 +23,21 @@ watch(
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.classList.toggle('admin-shell-active', v)
|
||||
}
|
||||
// 同一个开关也用于把 data-ui / data-theme 收回 classic
|
||||
setAdminScope(v)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TopBar v-if="!isAdmin" />
|
||||
<YohakuTop v-if="isVivid" />
|
||||
<TopBar v-else-if="!isAdmin" />
|
||||
<RouterView />
|
||||
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底 -->
|
||||
<ThemeSwitcher variant="floating" class="theme-floating-only" />
|
||||
<YohakuFoot v-if="isVivid" />
|
||||
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
|
||||
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
|
||||
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup>
|
||||
import { site } from '../site'
|
||||
import { publicApi } from '../api'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import ThemeSwitcher from './ThemeSwitcher.vue'
|
||||
|
||||
// vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染,顺带省掉一次
|
||||
// 标签请求 —— 视图本身不用知道 UI 版本。
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
const tags = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isVivid.value) return
|
||||
try {
|
||||
const data = await publicApi.tags()
|
||||
tags.value = (data.tags || []).slice(0, 8)
|
||||
@@ -17,7 +22,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail-left">
|
||||
<aside v-if="!isVivid" class="rail-left">
|
||||
<div class="inner">
|
||||
<RouterLink to="/" class="brand">
|
||||
<span class="mark">○</span>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site } from '../site'
|
||||
import { relativeDate } from '../utils'
|
||||
|
||||
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
const latest = ref([])
|
||||
const tags = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isVivid.value) return
|
||||
try {
|
||||
const [posts, tagData] = await Promise.all([publicApi.latest(), publicApi.tags()])
|
||||
latest.value = posts.items || []
|
||||
@@ -20,7 +24,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail-right">
|
||||
<aside v-if="!isVivid" class="rail-right">
|
||||
<section class="card">
|
||||
<p class="eyebrow">关于这里</p>
|
||||
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,19 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { loadSite } from './site'
|
||||
import { installCustomCss } from './customCss'
|
||||
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(() => {
|
||||
installCustomCss(router)
|
||||
createApp(App).use(router).mount('#app')
|
||||
})
|
||||
|
||||
+75
-2
@@ -10,6 +10,31 @@ const LIGHT_SKINS = ['paper', 'sage', 'rose']
|
||||
const MODE_KEY = 'one.themeMode'
|
||||
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() {
|
||||
if (typeof localStorage === 'undefined') return 'auto'
|
||||
try {
|
||||
@@ -47,6 +72,10 @@ function isValidLightSkin(id) {
|
||||
return LIGHT_SKINS.includes(id)
|
||||
}
|
||||
|
||||
function isValidUI(id) {
|
||||
return VALID_UIS.includes(id)
|
||||
}
|
||||
|
||||
export const site = reactive({
|
||||
site_title: 'ONE · 一个博客',
|
||||
site_desc: '长文与短文,同一种节奏。',
|
||||
@@ -58,12 +87,34 @@ export const site = reactive({
|
||||
light_skin_id: 'paper',
|
||||
// theme_id 是老字段,保留读取兼容用;新代码不要再写。
|
||||
theme_id: 'paper',
|
||||
// ui_id 是站点级设置,服务端为准;本地缓存只用于首屏不闪。
|
||||
ui_id: readUICache(),
|
||||
// 按分区存放的站主自定义 CSS(仅 vivid 生效,且不在 /admin 注入)
|
||||
custom_css: {},
|
||||
themeMode: readMode(),
|
||||
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-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) {
|
||||
// 亮度突变是 Apple 明确点名的反模式:用户主动切换时给一次 250ms 交叉过渡
|
||||
if (animate && typeof document !== 'undefined') {
|
||||
@@ -72,8 +123,15 @@ function applyTheme(animate = false) {
|
||||
setTimeout(() => el.classList.remove('theme-anim'), 300)
|
||||
}
|
||||
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-ui', ui)
|
||||
}
|
||||
applyTheme()
|
||||
|
||||
@@ -93,6 +151,10 @@ export async function loadSite() {
|
||||
Object.assign(site, data)
|
||||
// 防御性:服务端返回的 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
|
||||
} catch (e) {
|
||||
site.loaded = false
|
||||
@@ -120,6 +182,12 @@ watch(() => site.light_skin_id, () => {
|
||||
if (site.themeMode === 'auto') applyTheme()
|
||||
})
|
||||
|
||||
// 站主切 UI → 立刻换整套(自定义 CSS 的重新注入由 customCss.js 自己监听)
|
||||
watch(() => site.ui_id, () => {
|
||||
writeUICache(site.ui_id)
|
||||
applyTheme()
|
||||
})
|
||||
|
||||
export function applyDocTitle(sub) {
|
||||
const base = site.site_title || 'ONE'
|
||||
document.title = sub ? `${sub} · ${base}` : base
|
||||
@@ -128,4 +196,9 @@ export function applyDocTitle(sub) {
|
||||
export const SKIN_CONSTANTS = Object.freeze({
|
||||
DARK_SKIN,
|
||||
LIGHT_SKINS: [...LIGHT_SKINS]
|
||||
})
|
||||
|
||||
export const UI_CONSTANTS = Object.freeze({
|
||||
DEFAULT: UI_DEFAULT,
|
||||
UIS: [...VALID_UIS]
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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’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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,10 +4,34 @@ import RightRail from '../components/RightRail.vue'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
|
||||
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>
|
||||
|
||||
<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">
|
||||
<LeftNav />
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
import YohakuArchive from '../ui/yohaku/YohakuArchive.vue'
|
||||
import { applyDocTitle, site } from '../site'
|
||||
import { formatDateShort } from '../utils'
|
||||
|
||||
const years = ref([])
|
||||
// vivid(余白)是另一套 DOM,数据来源不变,只在视图层分叉
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
@@ -29,7 +33,9 @@ applyDocTitle('归档')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<YohakuArchive v-if="isVivid" :years="years" />
|
||||
|
||||
<div v-else class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
|
||||
@@ -4,12 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import PostCard from '../components/PostCard.vue'
|
||||
import YohakuHome from '../ui/yohaku/YohakuHome.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
|
||||
const route = useRoute()
|
||||
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 total = ref(0)
|
||||
const loading = ref(true)
|
||||
@@ -67,8 +75,34 @@ async function load() {
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(loadYohakuExtras) // 余白的附加数据(标签 / 作品 / 全站统计)只在 vivid 下取一次
|
||||
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) {
|
||||
router.push({ path: '/', query: { ...(value ? { kind: value } : {}), ...(tag.value ? { tag: tag.value } : {}) } })
|
||||
}
|
||||
@@ -86,7 +120,28 @@ applyDocTitle('')
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<LeftNav />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
|
||||
@@ -11,13 +12,35 @@ const route = useRoute()
|
||||
const post = ref(null)
|
||||
const loading = ref(true)
|
||||
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() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
post.value = null
|
||||
contentHtml.value = ''
|
||||
headings.value = []
|
||||
try {
|
||||
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)
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
@@ -34,10 +57,18 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
<YohakuArticle
|
||||
v-if="isVivid"
|
||||
: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">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import ProjectCard from '../components/ProjectCard.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
import YohakuProjects from '../ui/yohaku/YohakuProjects.vue'
|
||||
import { applyDocTitle, site } from '../site'
|
||||
|
||||
const projects = ref([])
|
||||
// vivid(余白)是另一套 DOM,数据来源不变,只在视图层分叉
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
@@ -25,7 +29,9 @@ applyDocTitle('作品')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<YohakuProjects v-if="isVivid" :projects="projects" />
|
||||
|
||||
<div v-else class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
import YohakuTags from '../ui/yohaku/YohakuTags.vue'
|
||||
import { applyDocTitle, site } from '../site'
|
||||
|
||||
const tags = ref([])
|
||||
// vivid(余白)是另一套 DOM,数据来源不变,只在视图层分叉
|
||||
const isVivid = computed(() => site.ui_id === 'vivid')
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
@@ -26,7 +30,9 @@ applyDocTitle('标签')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<YohakuTags v-if="isVivid" :tags="tags" />
|
||||
|
||||
<div v-else class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user