评论表情 - 发布 / 回复 / 编辑三处输入框挂上表情面板(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 与主题浮钮不重叠 / 顶栏隐藏与筛选条跟随同步
209 lines
7.3 KiB
JavaScript
209 lines
7.3 KiB
JavaScript
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]
|
||
}) |