Files
ONE/frontend/src/views/HomeView.vue
T
Sakurasan bc1b0ccce3 交互:表情面板 + 短文快发盒 + 双 UI 手机导航重做 + 社交链接与统计脚本接入
评论表情
- 发布 / 回复 / 编辑三处输入框挂上表情面板(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 与主题浮钮不重叠 /
  顶栏隐藏与筛选条跟随同步
2026-09-27 03:35:28 +08:00

330 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>