Files
ONE/frontend/src/site.js
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

209 lines
7.3 KiB
JavaScript
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.
import { reactive, watch } from 'vue'
import { publicApi } from './api'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink'
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
const LIGHT_SKINS = ['paper', 'sage', 'rose']
// 客户端显示模式 —— 持久化在 localStorage
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 {
const v = localStorage.getItem(MODE_KEY)
return VALID_MODES.includes(v) ? v : 'auto'
} catch (_) {
return 'auto'
}
}
function systemPrefersDark() {
if (typeof window === 'undefined' || !window.matchMedia) return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
// 模式 → 实际 data-theme 值
//
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
// dark → ink(强制暗色)
//
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
// 这样访客无论是手动锁浅色、还是系统亮色跟随自动,都能感受到站主定下的气质。
// 暗色端固定为 ink,等后续真要加暗色变体再扩字段。
function effectiveTheme(mode, lightSkin) {
if (mode === 'dark') return 'ink'
if (mode === 'auto') {
// auto 模式:系统暗 → ink;系统亮 → 站主选的亮色皮肤
return systemPrefersDark() ? 'ink' : (lightSkin || 'paper')
}
// light → 站主选的亮色皮肤(让访客也能感受到站主的气质)
return lightSkin || 'paper'
}
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: '长文与短文,同一种节奏。',
author_name: 'ONE',
author_bio: '写点长的,也写点短的。',
footer_note: '© ONE · 一个博客',
icp: '',
// 社交 / 源码链接(后台「高级 → 社交链接」填写),[{ label, url }],
// 图标由前端按 url 域名推导(socialIcons.js)
social_links: [],
posts_per_page: 10,
light_skin_id: 'paper',
// theme_id 是老字段,保留读取兼容用;新代码不要再写。
theme_id: 'paper',
// ui_id 是站点级设置,服务端为准;本地缓存只用于首屏不闪。
ui_id: readUICache(),
// 按分区存放的站主自定义 CSS(仅 vivid 生效,且不在 /admin 注入)
custom_css: {},
// 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入
custom_js: '',
themeMode: readMode(),
loaded: false
})
// 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') {
const el = document.documentElement
el.classList.add('theme-anim')
setTimeout(() => el.classList.remove('theme-anim'), 300)
}
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
// 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()
// auto 模式下系统切到夜间时实时跟随
if (typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onSystemChange = () => {
if (site.themeMode === 'auto') applyTheme()
}
if (mq.addEventListener) mq.addEventListener('change', onSystemChange)
else if (mq.addListener) mq.addListener(onSystemChange)
}
export async function loadSite() {
try {
const data = await publicApi.site()
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
}
applyTheme()
return site
}
// 访客切模式 → 落 localStorage + 立刻应用(带交叉过渡)
watch(
() => site.themeMode,
() => {
try {
localStorage.setItem(MODE_KEY, site.themeMode)
} catch (_) {
/* private mode 等场景静默 */
}
applyTheme(true)
}
)
// 站主改 light_skin_id → 仅在 auto 模式下立即可见;
// 否则尊重访客的显式选择。
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
}
export const SKIN_CONSTANTS = Object.freeze({
DARK_SKIN,
LIGHT_SKINS: [...LIGHT_SKINS]
})
export const UI_CONSTANTS = Object.freeze({
DEFAULT: UI_DEFAULT,
UIS: [...VALID_UIS]
})