交互:表情面板 + 短文快发盒 + 双 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 与主题浮钮不重叠 /
  顶栏隐藏与筛选条跟随同步
This commit is contained in:
Sakurasan
2026-09-27 03:35:28 +08:00
parent 5dfa1550fb
commit bc1b0ccce3
23 changed files with 1381 additions and 129 deletions
+3
View File
@@ -50,6 +50,9 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
@update:query="emit('update:query', $event)"
/>
<!-- 短文快发盒(HomeView 传入;只有管理员会话在场才渲染) -->
<slot name="compose" />
<YohakuFeed :items="items" />
<div v-if="!items.length" class="empty">
+46 -35
View File
@@ -2,13 +2,16 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { site } from '../../site'
import NavBurger from '../../components/NavBurger.vue'
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
// 样式在 ui/yohaku/chrome.css,选择器已收敛到 html[data-ui="vivid"] 下。
//
// 窄屏例外:设计稿在窄屏把导航整个 display:none 掉了,手机上会没有任何导航入口。
// 这里补一个 ☰ + 全屏菜单(chrome.css 的 .menu / .menu-btn)。
// 这里补一个汉堡钮 + 全屏菜单(chrome.css 的 .menu)。菜单 z-index 压在顶栏
// 之下:顶栏(品牌 + 汉堡)浮在菜单上不消失,汉堡原位并成 ✕,不再需要一个
// 重复的菜单头。
const route = useRoute()
const scrolled = ref(false)
@@ -35,6 +38,9 @@ function onScroll() {
scrolled.value = (window.scrollY || 0) > 8
}
function toggleMenu() {
menuOpen.value ? closeMenu() : openMenu()
}
function openMenu() {
menuOpen.value = true
// 焦点交给对话框本身(aria-modal 承诺过),Tab 第一下才落到「时间线」。
@@ -44,7 +50,8 @@ function openMenu() {
}
function closeMenu() {
menuOpen.value = false
nextTick(() => burger.value?.focus({ preventScroll: true }))
const b = burger.value?.$el || burger.value
nextTick(() => b?.focus?.({ preventScroll: true }))
}
// 菜单铺满全屏时锁住底层滚动(iOS 上不锁背景会跟着手指滑)
@@ -100,53 +107,57 @@ function toggleTheme() {
</nav>
<div class="top-acts">
<button class="icon-btn theme-btn" type="button" title="切换明暗" aria-label="切换明暗" @click="toggleTheme">◐</button>
<button
ref="burger"
class="icon-btn menu-btn"
type="button"
aria-label="打开菜单"
aria-controls="v-menu"
:aria-expanded="menuOpen"
@click="openMenu"
>☰</button>
</div>
</div>
</header>
<!-- 窄屏全屏菜单。必须待在 .top 外面:.top 有 backdrop-filter,
会给 position:fixed 的后代当包含块,铺不满屏。 -->
<!-- 窄屏:底部悬浮 dock(品牌 + 汉堡),常驻拇指区。桌面 display:none。
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。 -->
<div class="dock">
<RouterLink class="dock-brand" to="/">
<span class="av">{{ initial() }}</span>
<span>{{ title() }}</span>
</RouterLink>
<NavBurger
ref="burger"
:open="menuOpen"
aria-controls="v-menu"
:aria-label="menuOpen ? '关闭菜单' : '打开菜单'"
@toggle="toggleMenu"
/>
</div>
<!-- 窄屏上拉菜单:scrim + 圆角毛玻璃 sheet,从 dock 上方长出来 -->
<Transition name="v-menu">
<div
v-if="menuOpen"
id="v-menu"
ref="menuEl"
class="menu"
class="menu-root"
role="dialog"
aria-modal="true"
aria-label="站点导航"
tabindex="-1"
@click.self="closeMenu"
>
<div class="menu-head">
<span class="m-brand">
<span class="av">{{ initial() }}</span>
<span>{{ title() }}</span>
</span>
<button class="icon-btn" type="button" aria-label="关闭菜单" @click="closeMenu">✕</button>
</div>
<nav class="menu-in">
<RouterLink
v-for="l in links"
:key="l.to"
:to="l.to"
:class="{ on: isOn(l) }"
>
<span>{{ l.label }}</span>
<em>{{ l.en }}</em>
</RouterLink>
</nav>
<div class="menu-foot">
<button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button>
<div class="scrim" @click="closeMenu" />
<div class="menu">
<nav class="menu-in" aria-label="站点导航">
<RouterLink
v-for="(l, i) in links"
:key="l.to"
:to="l.to"
:class="{ on: isOn(l) }"
:style="{ '--i': i }"
>
<span class="idx">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="zh">{{ l.label }}</span>
<em>{{ l.en }}</em>
</RouterLink>
</nav>
<div class="menu-foot">
<button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button>
<p v-if="site.site_desc" class="m-desc">{{ site.site_desc }}</p>
</div>
</div>
</div>
</Transition>
+88 -36
View File
@@ -15,13 +15,13 @@ html[data-ui='vivid'] .brand {display:flex;align-items:center;gap:10px;
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .brand:hover {transform:scale(1.04)}
}
/* .m-brand 是全屏菜单里那个同款标头(菜单铺满屏后顶栏被盖住,得有个续接) */
html[data-ui='vivid'] :is(.brand, .m-brand) .av {width:30px;height:30px;border-radius:50%;
/* .av 品牌标记:顶栏一枚,菜单里那枚随菜单头一起撤了(顶栏浮在菜单上) */
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'] :is(.brand, .m-brand) .av::after {content:"";position:absolute;right:-1px;bottom:-1px;
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}
@@ -50,56 +50,108 @@ html[data-ui='vivid'] .icon-btn {width:36px;height:36px;border-radius:50%;
写在后面会把 .menu-btn {display:grid} 压掉。 */
html[data-ui='vivid'] .menu-btn {display:none}
@media (max-width:720px) {
/* 设计稿在窄屏把导航整个藏了 / 早先的做法是品牌行下方再摊一条横向滚动行,
5 个中文项挤成一条平铺,读起来很碎。改成收进 ☰ 全屏菜单。 */
html[data-ui='vivid'] .nav {display:none}
html[data-ui='vivid'] .menu-btn {display:grid}
/* 主题切换收进 ☰(菜单底部那枚)—— 顶栏和菜单各一个就是重复。
桌面没有 ☰,所以只在窄屏藏:那枚 ◐ 是设计稿顶栏自带的,留着。 */
html[data-ui='vivid'] .theme-btn {display:none}
/* 浏览器标题栏已经在显示站点名,品牌文字在手机上是重复的 —— 只留头像标记 */
html[data-ui='vivid'] .brand > span:last-child {display:none}
/* .nav 原来是 flex:1(把品牌顶左、操作顶右),它一藏起来就得自己撑开 */
html[data-ui='vivid'] .top-in {justify-content:space-between;gap:12px;padding:0 16px}
/* 顶栏整条在手机上退场:品牌和导航都挪到底部悬浮 dock(参考 innei.in 的
底部胶囊),内容区一寸顶栏都不占。桌面照旧走内联导航。
dock 的 display:flex 在下面 .dock 基准规则之后 —— 同特指度后导入者赢,
写在前面会被 display:none 压掉(chrome.css 老坑)。 */
html[data-ui='vivid'] .top {display:none}
}
/* ---- 窄屏全屏菜单 ---- */
html[data-ui='vivid'] .m-brand {display:flex;align-items:center;gap:10px;
font-weight:700;font-size:15px;letter-spacing:.02em;}
html[data-ui='vivid'] .menu {position:fixed;inset:0;z-index:150;
background:var(--paper);
/* ---- 窄屏:底部悬浮 dock + 上拉菜单 ----
dock 常驻拇指区(品牌 + 汉堡),点开一片从底部长出来的圆角毛玻璃菜单。 */
html[data-ui='vivid'] .dock {position:fixed;z-index:90;
left:50%;bottom:max(14px, env(safe-area-inset-bottom));transform:translateX(-50%);
display:none;align-items:center;gap:10px;
padding:6px 8px 6px 14px;border-radius:999px;
border:1px solid var(--line);
background:color-mix(in srgb,var(--card) 82%,transparent);
backdrop-filter:blur(20px) saturate(1.5);
-webkit-backdrop-filter:blur(20px) saturate(1.5);
box-shadow:var(--shadow-lg);}
html[data-ui='vivid'] .dock-brand {display:flex;align-items:center;gap:9px;
font-weight:700;font-size:14px;letter-spacing:.02em;color:var(--ink);}
/* 窄屏才把 dock 摆出来;这条必须压在基准规则(display:none)之后 */
@media (max-width:720px) {
html[data-ui='vivid'] .dock {display:flex}
}
html[data-ui='vivid'] :is(.brand, .dock-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'] :is(.brand, .dock-brand) .av::after {content:"";position:absolute;right:-1px;bottom:-1px;
width:9px;height:9px;border-radius:50%;
background:#34c759;border:2px solid var(--paper);}
/* 上拉菜单:圆角毛玻璃贴着 dock 上方长出来,最高 70vh,内容多就内部滚 */
html[data-ui='vivid'] .menu-root {position:fixed;inset:0;z-index:110;}
html[data-ui='vivid'] .menu-root .scrim {position:absolute;inset:0;
background:color-mix(in srgb,var(--ink) 32%,transparent);}
html[data-ui='vivid'] .menu {position:absolute;left:10px;right:10px;
bottom:max(10px, env(safe-area-inset-bottom));
border-radius:26px;
background:color-mix(in srgb,var(--card) 92%,transparent);
backdrop-filter:blur(28px) saturate(1.6);
-webkit-backdrop-filter:blur(28px) saturate(1.6);
border:1px solid var(--line);
box-shadow:var(--shadow-lg);
display:flex;flex-direction:column;
/* 上下留出刘海 / 小横条的安全区 */
padding:0 max(18px, env(safe-area-inset-left)) max(20px, env(safe-area-inset-bottom));
overscroll-behavior:contain;}
padding:10px 22px max(14px, env(safe-area-inset-bottom));
overscroll-behavior:contain;
max-height:min(70vh, 560px);}
html[data-ui='vivid'] .menu:focus {outline:none}
html[data-ui='vivid'] .menu-head {height:56px;flex:none; /* 对齐顶栏高度,关掉时视觉不跳 */
display:flex;align-items:center;justify-content:space-between;}
html[data-ui='vivid'] .menu-in {flex:1;display:flex;flex-direction:column;
padding-top:10px;overflow-y:auto;}
html[data-ui='vivid'] .menu-in a {display:flex;align-items:baseline;gap:12px;
padding:16px 2px;font-size:26px;font-weight:800;letter-spacing:-.02em;
justify-content:flex-start;
padding-top:6px;overflow-y:auto;
scrollbar-width:none;}
html[data-ui='vivid'] .menu-in a {position:relative;display:flex;align-items:baseline;gap:14px;
padding:15px 2px;font-size:25px;font-weight:800;letter-spacing:-.02em;
line-height:1.25;color:var(--soft);
border-bottom:1px solid var(--line);
transition:color .3s var(--ease);
/* 逐项入场:--i 由模板按序号写进 style,错峰 45ms */
animation:mItem .55s var(--spring) both;
animation-delay:calc(var(--i, 0) * 45ms);}
@keyframes mItem {from {opacity:0;transform:translateY(14px)}}
html[data-ui='vivid'] .menu-in a:last-child {border-bottom:0}
html[data-ui='vivid'] .menu-in a .idx {font-family:var(--mono);font-size:11px;font-weight:700;
color:var(--muted);letter-spacing:.06em;
transition:color .3s var(--ease);}
html[data-ui='vivid'] .menu-in a em {font-style:normal;font-size:11px;font-weight:700;
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
html[data-ui='vivid'] .menu-in a.on,
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-left:auto;
transition:color .3s var(--ease);}
html[data-ui='vivid'] .menu-in a.on {color:var(--accent)}
html[data-ui='vivid'] .menu-in a.on .idx,
html[data-ui='vivid'] .menu-in a.on em {color:var(--accent)}
/* 当前项的左缘短杠:菜单内边距 22px,杠落在离屏边 10px 处 */
html[data-ui='vivid'] .menu-in a.on::before {content:"";position:absolute;left:-14px;top:50%;
width:3px;height:18px;border-radius:2px;background:var(--accent);
transform:translateY(-50%);}
html[data-ui='vivid'] .menu-in a:active {color:var(--accent)}
html[data-ui='vivid'] .menu-foot {flex:none;padding-top:18px}
/* 进:整块淡入 + 菜单项轻推上来;出:只淡出(避免收起时还要等位移) */
html[data-ui='vivid'] .v-menu-enter-active {transition:opacity .26s var(--ease)}
html[data-ui='vivid'] .v-menu-enter-active .menu-in {transition:transform .5s var(--spring),opacity .4s var(--ease)}
html[data-ui='vivid'] .menu-foot {flex:none;padding-top:16px;
display:flex;align-items:center;justify-content:space-between;gap:12px;}
html[data-ui='vivid'] .menu-foot .m-desc {margin:0;font-size:12px;line-height:1.7;
color:var(--muted);text-align:right;}
/* 进:scrim 淡入 + 菜单从底部长出来 + 项逐项推上来;出:整块沉回去 */
html[data-ui='vivid'] .v-menu-enter-active {transition:opacity .3s var(--ease)}
html[data-ui='vivid'] .v-menu-enter-active .menu {transition:transform .55s var(--spring),opacity .35s var(--ease)}
html[data-ui='vivid'] .v-menu-enter-from {opacity:0}
html[data-ui='vivid'] .v-menu-enter-from .menu-in {transform:translateY(12px);opacity:0}
html[data-ui='vivid'] .v-menu-leave-active {transition:opacity .18s var(--ease)}
html[data-ui='vivid'] .v-menu-enter-from .menu {transform:translateY(56px);opacity:0}
html[data-ui='vivid'] .v-menu-leave-active {transition:opacity .22s var(--ease)}
html[data-ui='vivid'] .v-menu-leave-active .menu {transition:transform .22s var(--ease)}
html[data-ui='vivid'] .v-menu-leave-to {opacity:0}
html[data-ui='vivid'] .v-menu-leave-to .menu {transform:translateY(48px)}
/* 全局 reduce-motion 只把 duration 压扁,delay 会留下来 → 单独清掉 */
@media (prefers-reduced-motion: reduce) {
html[data-ui='vivid'] .v-menu-enter-active,
html[data-ui='vivid'] .v-menu-enter-active .menu-in {transition-delay:0s !important}
html[data-ui='vivid'] .v-menu-enter-active .menu {transition-delay:0s !important}
html[data-ui='vivid'] .menu-in a {animation:none}
}
/* 短文快发盒(QuickShort)的 vivid 差异:圆角跟自己的半径档走,
FAB 不用给经典版的主题浮钮让位(vivid 的主题键在菜单里),贴到安全区 */
html[data-ui='vivid'] .qs-card {border-radius:14px;}
html[data-ui='vivid'] .qs-fab {bottom:max(20px, env(safe-area-inset-bottom));}
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%;
+7
View File
@@ -38,6 +38,13 @@ html[data-ui='vivid'] .cm-send {background: var(--accent);color: #fff;
}
html[data-ui='vivid'] .cm-send:active {transform: scale(.97);}
/* 表情面板:基础版的暗色靠 token 别名天然成立,vivid 只把阴影换成自己的档位 */
html[data-ui='vivid'] .cm-emoji {border-color: var(--line);box-shadow: var(--shadow-lg);}
html[data-ui='vivid'] .cm-emoji-btn {border-radius: 8px;transition: opacity .3s var(--spring),background-color .3s,transform .5s var(--spring);}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .cm-emoji-btn:hover {transform: scale(1.08);}
}
/* 列表 */
html[data-ui='vivid'] .cm-list {gap: 24px;}
html[data-ui='vivid'] .cm-av {width: 36px;height: 36px;background: var(--accent-soft);color: var(--accent);}