feat: 深色模式圆形揭示动画 + B 站私信规则

主题切换:
- 抽出 useTheme 全局单例,主题在挂载前初始化,登录页不再闪白底
- 用 View Transitions API 以点击点为圆心做圆形揭示;不支持的浏览器
  加 .no-vt 类退化为整页淡变,两者互斥避免揭示区露出过渡中的颜色
- 尊重 prefers-reduced-motion;过渡被浏览器跳过时静默降级为直接切换
- 主题按钮图标旋转切换

新规则:
- bilibili-private-message:向指定 UID 发送 B 站私信,
  发送者 UID 自动从账号 Cookie 读取

视觉打磨:
- 统计数字改为缓出计数动画,等宽数字避免跳动时抖动
- 补 :focus-visible 焦点环

说明:当前 SDK 未提供视频投稿接口,无法支持定时投稿视频,
详见 docs/bilibili-config.md。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mei
2026-07-26 23:15:02 +08:00
co-authored by Claude Fable 5
parent f8c6115737
commit 54d4fbe24a
14 changed files with 499 additions and 17 deletions
+78
View File
@@ -79,6 +79,28 @@ body,
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* 统计数字等场景用等宽数字,跳动时宽度不抖 */
.stat-value,
.countdown-num,
.ring-pct {
font-variant-numeric: tabular-nums;
}
/* ---------- 键盘可达性 ---------- */
/* 只在键盘导航时显示焦点环,鼠标点击不显示 */
:focus-visible {
outline: 2px solid var(--gb-primary);
outline-offset: 2px;
border-radius: 6px;
}
.el-button:focus-visible,
.icon-btn:focus-visible {
outline: 2px solid var(--gb-primary);
outline-offset: 2px;
}
/* ---------- Element Plus 外观微调 ---------- */
.el-card {
@@ -186,6 +208,62 @@ body,
animation: gb-pulse 1.6s ease-in-out infinite;
}
/* ---------- 明暗切换的圆形揭示动画 ---------- */
/*
* 默认的 view transition 是新旧画面交叉淡入淡出,会和我们的圆形裁剪打架,
* 这里关掉默认动画,改由 useTheme 里的 Web Animations 驱动 clip-path。
*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* 切到深色:新画面盖在上面扩散进来 */
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
/* 切回浅色:反过来,让旧的深色画面盖在上面收缩出去 */
html.dark::view-transition-old(root) {
z-index: 2147483646;
}
html.dark::view-transition-new(root) {
z-index: 1;
}
/*
* 不支持 View Transitions 的浏览器(Firefox 等)退化为整页淡变。
* 只在 .no-vt 下生效:支持圆形揭示时再叠一层背景淡变,
* 会让揭示区域露出"过渡到一半"的颜色,反而更脏。
*/
html.no-vt body,
html.no-vt .el-card,
html.no-vt .el-table,
html.no-vt .el-table tr,
html.no-vt .el-table th.el-table__cell {
transition:
background-color 0.3s var(--gb-ease),
border-color 0.3s var(--gb-ease);
}
/* 用户要求减少动效时,整体禁用过渡 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---------- 滚动条 ---------- */
::-webkit-scrollbar {
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{ value?: number | null; duration?: number }>(), {
duration: 700,
})
const display = ref(0)
let frame: number | undefined
let fallback: number | undefined
/** 缓出曲线,末尾减速,观感比线性自然 */
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3)
function clearTimers() {
if (frame !== undefined) cancelAnimationFrame(frame)
if (fallback !== undefined) clearTimeout(fallback)
frame = undefined
fallback = undefined
}
function animateTo(target: number) {
clearTimers()
// 这些情况下不做动画,直接显示真实数字:
// 系统要求减少动效、页面在后台(rAF 会被冻结)、环境没有 rAF
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduceMotion || document.hidden || typeof requestAnimationFrame !== 'function') {
display.value = target
return
}
const from = display.value
const delta = target - from
if (delta === 0) return
const start = performance.now()
const step = (now: number) => {
const progress = Math.min(1, (now - start) / props.duration)
display.value = Math.round(from + delta * easeOut(progress))
if (progress < 1) {
frame = requestAnimationFrame(step)
} else {
clearTimers()
}
}
frame = requestAnimationFrame(step)
// 安全网:rAF 被节流或根本不回调时,兜底把真实值显示出来。
// 数字展示的正确性优先于动画效果。
fallback = window.setTimeout(() => {
display.value = target
clearTimers()
}, props.duration + 300)
}
watch(
() => props.value,
(v) => animateTo(typeof v === 'number' ? v : 0),
{ immediate: true },
)
onUnmounted(clearTimers)
</script>
<template>
<span>{{ props.value === null || props.value === undefined ? '-' : display }}</span>
</template>
+91
View File
@@ -0,0 +1,91 @@
import { ref } from 'vue'
const STORAGE_KEY = 'gb-theme'
/** 全局共享的主题状态,模块级单例,任何组件读到的都是同一份 */
const isDark = ref(false)
/** 把主题应用到 <html> 上 */
function applyTheme(dark: boolean) {
document.documentElement.classList.toggle('dark', dark)
isDark.value = dark
}
/**
* 在应用启动时调用一次:恢复上次选择,没有记录时跟随系统。
*/
export function initTheme() {
const saved = localStorage.getItem(STORAGE_KEY)
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
applyTheme(saved ? saved === 'dark' : prefersDark)
// 标记浏览器是否支持圆形揭示,供 CSS 决定要不要退化成淡变
if (typeof document.startViewTransition !== 'function') {
document.documentElement.classList.add('no-vt')
}
// 用户没有手动选择过时,跟随系统变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem(STORAGE_KEY)) {
applyTheme(e.matches)
}
})
}
export function useTheme() {
/**
* 切换明暗主题。
*
* 支持 View Transitions 时,以点击处为圆心做一次圆形揭示动画;
* 不支持(Firefox / Safari 旧版)或用户要求减少动效时直接切换。
*/
async function toggleTheme(event?: MouseEvent) {
const next = !isDark.value
const persist = () => {
applyTheme(next)
localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light')
}
// 绑定后再判空,TS 才能正确收窄这个可选 API
const startViewTransition = document.startViewTransition?.bind(document)
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!startViewTransition || reduceMotion) {
persist()
return
}
// 以点击点为圆心;没有事件对象时从右上角(切换按钮所在位置)扩散
const x = event?.clientX ?? window.innerWidth - 60
const y = event?.clientY ?? 30
// 半径取到最远的那个角,保证能覆盖整个视口
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)
const transition = startViewTransition(persist)
try {
await transition.ready
} catch {
// 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。
// 此时主题已经通过 persist() 应用好了,只是没有揭示动画,直接返回。
return
}
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
document.documentElement.animate(
// 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去
{ clipPath: next ? clipPath : [...clipPath].reverse() },
{
duration: 480,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
pseudoElement: next ? '::view-transition-new(root)' : '::view-transition-old(root)',
},
)
}
return { isDark, toggleTheme }
}
+43 -14
View File
@@ -15,6 +15,7 @@ import {
Fold,
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { useTheme } from '@/composables/useTheme'
import LogoMark from '@/components/LogoMark.vue'
const route = useRoute()
@@ -43,18 +44,8 @@ const menus = [
const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`)
const isDark = ref(document.documentElement.classList.contains('dark'))
function toggleDark() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('gb-theme', isDark.value ? 'dark' : 'light')
}
// 恢复上次的主题选择
if (localStorage.getItem('gb-theme') === 'dark' && !isDark.value) {
toggleDark()
}
// 主题状态是全局单例,初始化在 main.ts 里完成
const { isDark, toggleTheme } = useTheme()
async function handleLogout() {
await userStore.logout()
@@ -108,8 +99,15 @@ async function handleLogout() {
<div class="header-title">{{ route.meta.title ?? '' }}</div>
</div>
<div class="header-actions">
<button class="icon-btn" :title="isDark ? '切换到亮色' : '切换到暗色'" @click="toggleDark">
<el-icon :size="17"><Moon v-if="!isDark" /><Sunny v-else /></el-icon>
<button
class="icon-btn theme-btn"
:title="isDark ? '切换到亮色' : '切换到暗色'"
@click="toggleTheme"
>
<Transition name="theme-icon" mode="out-in">
<el-icon v-if="isDark" :size="17" key="sun"><Sunny /></el-icon>
<el-icon v-else :size="17" key="moon"><Moon /></el-icon>
</Transition>
</button>
<el-dropdown>
<span class="user-chip">
@@ -329,6 +327,37 @@ async function handleLogout() {
background: var(--el-fill-color);
}
/* 主题按钮:hover 时图标轻微旋转,点击有回弹 */
.theme-btn .el-icon {
transition: transform 0.4s var(--gb-ease);
}
.theme-btn:hover .el-icon {
transform: rotate(25deg) scale(1.1);
}
.theme-btn:active {
transform: scale(0.9);
}
/* 图标切换:旧图标转出,新图标转入 */
.theme-icon-enter-active,
.theme-icon-leave-active {
transition:
opacity 0.2s var(--gb-ease),
transform 0.28s var(--gb-ease);
}
.theme-icon-enter-from {
opacity: 0;
transform: rotate(-90deg) scale(0.5);
}
.theme-icon-leave-to {
opacity: 0;
transform: rotate(90deg) scale(0.5);
}
.user-chip {
display: inline-flex;
align-items: center;
+4
View File
@@ -7,8 +7,12 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import { initTheme } from './composables/useTheme'
import './assets/main.css'
// 挂载前先定好主题,避免登录页闪一下白底
initTheme()
const app = createApp(App)
app.use(createPinia())
app.use(router)
+20
View File
@@ -0,0 +1,20 @@
/**
* View Transitions API 的类型声明。
*
* 该 API 目前还没进入 TypeScript 的内置 DOM lib,这里补一份最小定义。
* startViewTransition 声明为可选,调用前必须做能力检测。
*/
export {}
declare global {
interface ViewTransition {
readonly ready: Promise<void>
readonly finished: Promise<void>
readonly updateCallbackDone: Promise<void>
skipTransition(): void
}
interface Document {
startViewTransition?: (callback: () => void | Promise<void>) => ViewTransition
}
}
+2 -1
View File
@@ -15,6 +15,7 @@ import { ApiError } from '@/api/client'
import type { DashboardOverview } from '@/api/types'
import { formatDateTime, formatDuration } from '@/utils/format'
import { useIsMobile } from '@/composables/useBreakpoint'
import CountUp from '@/components/CountUp.vue'
const isMobile = useIsMobile()
@@ -228,7 +229,7 @@ onUnmounted(() => {
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
</div>
<div>
<div class="stat-value">{{ stat.value ?? '-' }}</div>
<div class="stat-value"><CountUp :value="stat.value" /></div>
<div class="stat-label">{{ stat.label }}</div>
<div class="muted">{{ stat.sub }}</div>
</div>