评论表情 - 发布 / 回复 / 编辑三处输入框挂上表情面板(EmojiPicker + emoji.js): 「表情 / 颜文字」两个 tab,光标处插入(insertAtCursor,未聚焦追加到 末尾),面板自己管点外 / Esc 收起 - 操作钮 ✎ ✕ ↩ 圆图标改文字钮(回复 / 编辑 / 删除),删除悬停给红, 补 focus-visible;编辑态去掉「正在改定此评:」交给 placeholder 双 UI 手机导航(参考 innei.in 的底部胶囊 + 上拉菜单) - 余白:顶栏手机端整条退场,底部悬浮 dock(品牌 + NavBurger 汉堡), 点开上拉毛玻璃菜单:mono 序号 01-05 + 中文大字 + 英文小字,当前项 强调色 + 左缘短杠,逐项错峰 45ms 入场;dock 在菜单之下原位变 ✕ - classic:顶栏 brand 居中(三列网格)+ NavBurger + 下拉纸片;下滑隐藏 上滑唤回——上滑要累计 40px 才算数(页面高度稳定时浏览器会回弹几十 像素,那种「假上滑」不拉回顶栏);首页 .sticky-head 用 var(--topbar-h) 跟随顶栏一起滑 - NavBurger 两套 UI 共用;经典手机 FAB 避开右下角主题浮钮 sticky 失效修复 - body overflow-x:hidden 把 body 变成滚动容器,页内所有 position:sticky 全部失明(首页筛选条吸顶从来没生效过)→ 改 overflow-x:clip, 只裁剪不建滚动容器 - 余白手机端 .article-layout 裸 1fr 的自动最小尺寸是 min-content, 长 token 把整列顶到 592px 横向溢出 → minmax(0,1fr) + overflow-wrap 短文快发盒(QuickShort,Twitter 式) - 时间线顶部内联发布盒:头像 + 输入框 + 发布(Ctrl/⌘+Enter),手机收进 右下角 FAB + 上拉 sheet;只有管理员会话在场才渲染(/api/admin/me 探测),发布走真实 createPost(kind:short)——标题 / slug / 阅读时长 后端自动生成。读者社交登录是阶段二产物,后端就绪前访客无法登录, 所以天然只有站主可见;要放开给读者发,换探测和发布接口即可 - 经典 FAB 避开右下角主题浮钮(bottom 72),余白贴安全区(bottom 20) 社交链接 + 统计脚本接入 - Settings 的 social_links 前台两处渲染:经典左栏「常读标签」区域改为 「社交账号」图标行(url 域名推导图标:GitHub / Telegram / X / 邮箱 信封,认不出显示名称文字),右栏卡片下方 Twitter 式页脚小字区三排 (导航行 / 社交图标行 / 项目源码 + 版权行)——项目源码是固定入口 (本站 git remote),与版权同为固定元素,后台配置挤不掉 - Settings 加 custom_js:后台贴完整 <script> 片段(GA / Plausible / Umami),DOMParser 解析后逐个重建 script 节点插进 head(innerHTML 塞的 script 不会执行);公开页每页注入一次,/admin 不注入; 站主自己的代码不做清洗(洗 JS 只会把统计脚本洗坏) - 订阅卡删掉:RSS 入口在页脚小字里,一张卡只为一个链接不值得 杂项 - vite dev 加 --host:手机连局域网直接评审 - 评论 mock 的状态浮层窄屏上移,给底部 dock 让位 - 经典页脚在 12px 页边距下的排版对齐 验证 - 后端 go test 全绿;前端构建通过 - 浏览器逐状态过目:双 UI × 亮暗 × 桌面/手机;表情插入 / 短文发布真实 入库(测试内容已删)/ dock 与 sheet 开合 / FAB 与主题浮钮不重叠 / 顶栏隐藏与筛选条跟随同步
330 lines
8.7 KiB
Vue
330 lines
8.7 KiB
Vue
<script setup>
|
||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||
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 QuickShort from '../components/QuickShort.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 + showcase。
|
||
// 数据来源和 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)
|
||
const error = ref('')
|
||
|
||
const kind = computed(() => route.query.kind || '')
|
||
const tag = computed(() => route.query.tag || '')
|
||
const page = computed(() => Number(route.query.page || 1))
|
||
const size = computed(() => site.posts_per_page || 10)
|
||
|
||
// 滚动收缩标题栏:16–56px 区间内连续插值 --sq(0→1),
|
||
// 反馈跟随滚动本身,而不是在阈值处二值跳变
|
||
const headEl = ref(null)
|
||
function onScroll() {
|
||
if (!headEl.value) return
|
||
const p = Math.min(1, Math.max(0, (window.scrollY - 16) / 40))
|
||
headEl.value.style.setProperty('--sq', p.toFixed(3))
|
||
}
|
||
onMounted(() => {
|
||
onScroll()
|
||
window.addEventListener('scroll', onScroll, { passive: true })
|
||
})
|
||
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
|
||
|
||
const tabs = [
|
||
{ label: '全部', value: '' },
|
||
{ label: '长文', value: 'long' },
|
||
{ label: '短文', value: 'short' }
|
||
]
|
||
|
||
// 快速翻页/切标签时旧响应可能后到并覆盖新结果,用递增序号只采纳最新一次
|
||
let seq = 0
|
||
|
||
async function load() {
|
||
const my = ++seq
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const data = await publicApi.posts({
|
||
kind: kind.value,
|
||
tag: tag.value,
|
||
page: page.value,
|
||
size: size.value
|
||
})
|
||
if (my !== seq) return
|
||
items.value = data.items || []
|
||
total.value = data.total || 0
|
||
} catch (e) {
|
||
if (my !== seq) return
|
||
error.value = e.message || '加载失败'
|
||
items.value = []
|
||
} finally {
|
||
if (my === seq) loading.value = false
|
||
}
|
||
}
|
||
|
||
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 } : {}) } })
|
||
}
|
||
|
||
// 短文发布成功:回到第一页让新内容立刻可见(路由变化会自动触发重载)
|
||
function onPublished() {
|
||
if (page.value !== 1) {
|
||
router.replace({ path: '/', query: { ...route.query, page: 1 } })
|
||
} else {
|
||
load()
|
||
}
|
||
}
|
||
|
||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
|
||
|
||
const heading = computed(() => {
|
||
if (tag.value) return `#${tag.value}`
|
||
if (kind.value === 'long') return '长文'
|
||
if (kind.value === 'short') return '短文'
|
||
return '时间线'
|
||
})
|
||
|
||
applyDocTitle('')
|
||
</script>
|
||
|
||
<template>
|
||
<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 } })"
|
||
>
|
||
<template #compose>
|
||
<QuickShort @published="onPublished" />
|
||
</template>
|
||
</YohakuHome>
|
||
|
||
<div v-else class="shell">
|
||
<div class="layout">
|
||
<LeftNav />
|
||
|
||
<main class="main">
|
||
<header ref="headEl" class="sticky-head">
|
||
<h1 class="page-title">{{ heading }}</h1>
|
||
<nav class="tabs">
|
||
<button
|
||
v-for="t in tabs"
|
||
:key="t.value"
|
||
class="tab"
|
||
:class="{ active: kind === t.value }"
|
||
@click="setKind(t.value)"
|
||
>
|
||
{{ t.label }}
|
||
</button>
|
||
<a v-if="tag" class="tab clear" href="#" @click.prevent="router.push({ path: '/' })">
|
||
清除标签 ×
|
||
</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<!-- 短文快发盒:只有管理员会话在场才渲染(见 QuickShort) -->
|
||
<QuickShort @published="onPublished" />
|
||
|
||
<div v-if="loading" class="loading">载入中…</div>
|
||
<div v-else-if="error" class="empty">{{ error }}(后端是否已启动?)</div>
|
||
<div v-else-if="!items.length" class="empty">
|
||
这里还空着。<RouterLink to="/admin">去后台写第一篇 →</RouterLink>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<PostCard v-for="p in items" :key="p.id" :post="p" />
|
||
|
||
<nav v-if="pageCount > 1" class="pager">
|
||
<RouterLink
|
||
v-if="page > 1"
|
||
class="btn"
|
||
:to="{ path: '/', query: { ...route.query, page: page - 1 } }"
|
||
>
|
||
← 上一页
|
||
</RouterLink>
|
||
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||
<RouterLink
|
||
v-if="page < pageCount"
|
||
class="btn"
|
||
:to="{ path: '/', query: { ...route.query, page: page + 1 } }"
|
||
>
|
||
下一页 →
|
||
</RouterLink>
|
||
</nav>
|
||
</template>
|
||
</main>
|
||
|
||
<RightRail />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.main {
|
||
min-width: 0;
|
||
border-left: 1px solid var(--line);
|
||
border-right: 1px solid var(--line);
|
||
padding-bottom: 60px;
|
||
}
|
||
|
||
.sticky-head {
|
||
--sq: 0;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background: color-mix(in srgb, var(--paper) 82%, transparent);
|
||
backdrop-filter: blur(8px) saturate(160%);
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 8px 16px 0;
|
||
}
|
||
|
||
/* 系统要求减少半透明时回退为实心材质 */
|
||
@media (prefers-reduced-transparency: reduce) {
|
||
.sticky-head {
|
||
background: var(--paper);
|
||
backdrop-filter: none;
|
||
}
|
||
}
|
||
|
||
.page-title {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
max-height: calc(26px * (1 - var(--sq)));
|
||
opacity: calc(1 - var(--sq));
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 2px;
|
||
margin-top: calc(4px * (1 - var(--sq)));
|
||
}
|
||
|
||
.tab {
|
||
position: relative;
|
||
padding: 8px 12px 9px;
|
||
background: none;
|
||
border: 0;
|
||
cursor: pointer;
|
||
font-size: 13.5px;
|
||
color: var(--muted);
|
||
transition: transform 100ms ease-out;
|
||
}
|
||
|
||
.tab:hover {
|
||
color: var(--ink);
|
||
background: var(--paper-sunken);
|
||
}
|
||
|
||
.tab.active {
|
||
color: var(--accent);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tab.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 14px;
|
||
right: 14px;
|
||
bottom: 0;
|
||
height: 2px;
|
||
background: var(--accent);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.tab.clear {
|
||
margin-left: auto;
|
||
font-size: 13px;
|
||
color: var(--faint);
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 22px 16px 0;
|
||
}
|
||
|
||
.page-info {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
@media (max-width: 780px) {
|
||
.main {
|
||
border-left: 0;
|
||
border-right: 0;
|
||
}
|
||
/* 移动端 TopBar 也吸顶,标题栏贴其下方(高度在 styles.css 的 --topbar-h)。
|
||
TopBar 下滑隐藏时把 html.topbar-hidden 翻上来,偏移归 0,两层同曲线一起滑 */
|
||
.sticky-head {
|
||
top: var(--topbar-h, 54px);
|
||
transition: top 0.32s var(--ease, ease);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.sticky-head {
|
||
transition: none;
|
||
}
|
||
}
|
||
}
|
||
</style>
|