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:
+38
-2
@@ -1,6 +1,13 @@
|
|||||||
# Bilibili 配置
|
# Bilibili 配置
|
||||||
|
|
||||||
账号类型: `bilibili`,规则类型: `bilibili-dynamic`。
|
账号类型: `bilibili`。
|
||||||
|
|
||||||
|
支持的规则类型:
|
||||||
|
|
||||||
|
| 规则类型 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `bilibili-dynamic` | 发布一条纯文本动态 |
|
||||||
|
| `bilibili-private-message` | 向指定用户发送私信 |
|
||||||
|
|
||||||
## 账号配置 (Account Config)
|
## 账号配置 (Account Config)
|
||||||
|
|
||||||
@@ -14,7 +21,7 @@
|
|||||||
|
|
||||||
在 WebUI 的账号页面可以点击“测试”验证 Cookie 是否有效。
|
在 WebUI 的账号页面可以点击“测试”验证 Cookie 是否有效。
|
||||||
|
|
||||||
## 规则配置 (Rule Config)
|
## 动态规则配置 (`bilibili-dynamic`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -23,3 +30,32 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
定时器到期后会以该账号发布一条纯文本动态。
|
定时器到期后会以该账号发布一条纯文本动态。
|
||||||
|
|
||||||
|
## 私信规则配置 (`bilibili-private-message`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"receiver_uids": [2, 123456],
|
||||||
|
"msg": "要发送的私信内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`receiver_uids` 是接收者的 UID 列表,即对方空间地址 `space.bilibili.com/` 后面的那串数字。
|
||||||
|
|
||||||
|
发送者 UID 由程序自动从账号 Cookie 中读取,不需要填写。
|
||||||
|
|
||||||
|
注意:B 站对私信有频率限制与陌生人私信限制,如果对方设置了“仅关注的人可以给我发私信”,发送会失败。失败信息可以在执行日志里看到。
|
||||||
|
|
||||||
|
## 关于视频投稿
|
||||||
|
|
||||||
|
当前使用的 SDK [`CuteReimu/bilibili`](https://github.com/CuteReimu/bilibili) **没有提供视频投稿(上传稿件)接口**,因此暂不支持“定时投稿视频”这类规则。
|
||||||
|
|
||||||
|
该 SDK 与上传相关的能力只有 `UploadDynamicBfs`(为图片动态上传图片),不包含投稿所需的 `preupload` / 分片上传 / `archive/add` 流程。
|
||||||
|
|
||||||
|
若要支持视频投稿,需要:
|
||||||
|
|
||||||
|
1. 自行实现 B 站投稿协议(预上传 → 分片上传 → 合并 → 提交稿件),且该协议未公开、变动频繁;
|
||||||
|
2. 给 goodBaby 增加文件上传与存储能力(当前只存 JSON 配置,不保存任何文件);
|
||||||
|
3. 处理大文件的断点续传与磁盘占用。
|
||||||
|
|
||||||
|
这是一个独立的功能模块,不在现有驱动的改造范围内。
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const (
|
|||||||
AccountType = "bilibili"
|
AccountType = "bilibili"
|
||||||
// RuleTypeDynamic 发送 B 站动态的规则类型标识
|
// RuleTypeDynamic 发送 B 站动态的规则类型标识
|
||||||
RuleTypeDynamic = "bilibili-dynamic"
|
RuleTypeDynamic = "bilibili-dynamic"
|
||||||
|
// RuleTypePrivateMessage 发送 B 站私信的规则类型标识
|
||||||
|
RuleTypePrivateMessage = "bilibili-private-message"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BiliAccount struct {
|
type BiliAccount struct {
|
||||||
@@ -14,3 +16,10 @@ type BiliAccount struct {
|
|||||||
type BiliDynamicConfig struct {
|
type BiliDynamicConfig struct {
|
||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BiliPrivateMessageConfig 私信规则配置
|
||||||
|
type BiliPrivateMessageConfig struct {
|
||||||
|
// 接收者的 UID 列表
|
||||||
|
ReceiverUids []int64 `json:"receiver_uids"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package bilibili
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/CuteReimu/bilibili/v2"
|
||||||
|
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||||
|
"github.com/ssdomei232/goodBaby/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 私信消息类型:1 为文字
|
||||||
|
const privateMsgTypeText = 1
|
||||||
|
|
||||||
|
// SendBiliPrivateMessage 向配置中的每个 UID 发送一条 B 站私信
|
||||||
|
func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
|
||||||
|
client, err := getBiliClient(rule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := getPrivateMessageConfig(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发私信需要带上自己的 UID,从账号信息里取
|
||||||
|
account, err := client.GetAccountInformation()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取B站账号信息失败(cookie 可能已失效): %w", err)
|
||||||
|
}
|
||||||
|
if account == nil || account.Mid == 0 {
|
||||||
|
return fmt.Errorf("B站 cookie 无效或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
var fails []string
|
||||||
|
for _, uid := range config.ReceiverUids {
|
||||||
|
err := retry.Do(ctx, func() error {
|
||||||
|
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||||
|
SenderUid: account.Mid,
|
||||||
|
ReceiverId: int(uid),
|
||||||
|
ReceiverType: 1,
|
||||||
|
MsgType: privateMsgTypeText,
|
||||||
|
Timestamp: int(time.Now().Unix()),
|
||||||
|
Content: buildTextContent(config.Msg),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fails = append(fails, fmt.Sprintf("UID %d: %v", uid, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fails) > 0 {
|
||||||
|
return fmt.Errorf("%d/%d 条私信发送失败: %s",
|
||||||
|
len(fails), len(config.ReceiverUids), strings.Join(fails, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
|
||||||
|
func buildTextContent(msg string) string {
|
||||||
|
payload, err := json.Marshal(map[string]string{"content": msg})
|
||||||
|
if err != nil {
|
||||||
|
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
|
||||||
|
return `{"content":""}`
|
||||||
|
}
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrivateMessageConfig(rule *model.Rule) (*BiliPrivateMessageConfig, error) {
|
||||||
|
var config BiliPrivateMessageConfig
|
||||||
|
if err := json.Unmarshal([]byte(rule.ConfigJson), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析B站私信规则配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
@@ -31,6 +31,57 @@ func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BilibiliPrivateMessageRuleValidator B站私信规则验证器
|
||||||
|
type BilibiliPrivateMessageRuleValidator struct{}
|
||||||
|
|
||||||
|
func (v *BilibiliPrivateMessageRuleValidator) GetType() string {
|
||||||
|
return RuleTypePrivateMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliPrivateMessageRuleValidator) Validate(configJSON string) error {
|
||||||
|
var config BiliPrivateMessageConfig
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Msg == "" {
|
||||||
|
return fmt.Errorf("B站私信规则配置中 msg 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.ReceiverUids) == 0 {
|
||||||
|
return fmt.Errorf("B站私信规则配置中 receiver_uids 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range config.ReceiverUids {
|
||||||
|
if uid <= 0 {
|
||||||
|
return fmt.Errorf("B站私信规则配置中存在非法的 UID: %d", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *BilibiliPrivateMessageRuleValidator) Meta() meta.RuleMeta {
|
||||||
|
return meta.RuleMeta{
|
||||||
|
Type: RuleTypePrivateMessage,
|
||||||
|
Label: "发送 B 站私信",
|
||||||
|
Description: "触发时以关联的 B 站账号向指定用户发送私信。对方需要能接收你的私信。",
|
||||||
|
Docs: "docs/bilibili-config.md",
|
||||||
|
AccountType: AccountType,
|
||||||
|
Fields: []meta.Field{
|
||||||
|
{
|
||||||
|
Key: "receiver_uids",
|
||||||
|
Label: "接收者 UID",
|
||||||
|
Type: meta.FieldNumberList,
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "2",
|
||||||
|
Help: "对方空间地址 space.bilibili.com/ 后面的那串数字",
|
||||||
|
},
|
||||||
|
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
|
func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
|
||||||
return meta.RuleMeta{
|
return meta.RuleMeta{
|
||||||
Type: RuleTypeDynamic,
|
Type: RuleTypeDynamic,
|
||||||
|
|||||||
@@ -19,3 +19,16 @@ func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule)
|
|||||||
|
|
||||||
return SendBiliDynamicMsg(ctx, rule)
|
return SendBiliDynamicMsg(ctx, rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BilibiliPrivateMessageExecutor B站私信执行器
|
||||||
|
type BilibiliPrivateMessageExecutor struct{}
|
||||||
|
|
||||||
|
func (e *BilibiliPrivateMessageExecutor) GetType() string {
|
||||||
|
return RuleTypePrivateMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||||
|
log.Printf("执行B站私信规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||||
|
|
||||||
|
return SendBiliPrivateMessage(ctx, rule)
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func InitExecutorRegistry() *ExecutorRegistry {
|
|||||||
|
|
||||||
// 注册所有规则执行器
|
// 注册所有规则执行器
|
||||||
registry.Register(&bilibili.BilibiliDynamicExecutor{})
|
registry.Register(&bilibili.BilibiliDynamicExecutor{})
|
||||||
|
registry.Register(&bilibili.BilibiliPrivateMessageExecutor{})
|
||||||
registry.Register(&email.EmailExecutor{})
|
registry.Register(&email.EmailExecutor{})
|
||||||
registry.Register(&github.GithubMakeRepoPublicExecutor{})
|
registry.Register(&github.GithubMakeRepoPublicExecutor{})
|
||||||
registry.Register(&onebot.OneBotExecutor{})
|
registry.Register(&onebot.OneBotExecutor{})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func InitValidatorRegistry() *ValidatorRegistry {
|
|||||||
|
|
||||||
// 注册所有规则验证器
|
// 注册所有规则验证器
|
||||||
r.Register(&bilibili.BilibiliDynamicRuleValidator{})
|
r.Register(&bilibili.BilibiliDynamicRuleValidator{})
|
||||||
|
r.Register(&bilibili.BilibiliPrivateMessageRuleValidator{})
|
||||||
r.Register(&email.EmailRuleValidator{})
|
r.Register(&email.EmailRuleValidator{})
|
||||||
r.Register(&onebot.OneBotRuleValidator{})
|
r.Register(&onebot.OneBotRuleValidator{})
|
||||||
r.Register(&dingtalk.DingTalkRuleValidator{})
|
r.Register(&dingtalk.DingTalkRuleValidator{})
|
||||||
|
|||||||
@@ -79,6 +79,28 @@ body,
|
|||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
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 外观微调 ---------- */
|
/* ---------- Element Plus 外观微调 ---------- */
|
||||||
|
|
||||||
.el-card {
|
.el-card {
|
||||||
@@ -186,6 +208,62 @@ body,
|
|||||||
animation: gb-pulse 1.6s ease-in-out infinite;
|
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 {
|
::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Fold,
|
Fold,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import LogoMark from '@/components/LogoMark.vue'
|
import LogoMark from '@/components/LogoMark.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -43,18 +44,8 @@ const menus = [
|
|||||||
|
|
||||||
const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`)
|
const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`)
|
||||||
|
|
||||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
// 主题状态是全局单例,初始化在 main.ts 里完成
|
||||||
|
const { isDark, toggleTheme } = useTheme()
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await userStore.logout()
|
await userStore.logout()
|
||||||
@@ -108,8 +99,15 @@ async function handleLogout() {
|
|||||||
<div class="header-title">{{ route.meta.title ?? '' }}</div>
|
<div class="header-title">{{ route.meta.title ?? '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="icon-btn" :title="isDark ? '切换到亮色' : '切换到暗色'" @click="toggleDark">
|
<button
|
||||||
<el-icon :size="17"><Moon v-if="!isDark" /><Sunny v-else /></el-icon>
|
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>
|
</button>
|
||||||
<el-dropdown>
|
<el-dropdown>
|
||||||
<span class="user-chip">
|
<span class="user-chip">
|
||||||
@@ -329,6 +327,37 @@ async function handleLogout() {
|
|||||||
background: var(--el-fill-color);
|
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 {
|
.user-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
|
|||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { initTheme } from './composables/useTheme'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
|
// 挂载前先定好主题,避免登录页闪一下白底
|
||||||
|
initTheme()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
+20
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { ApiError } from '@/api/client'
|
|||||||
import type { DashboardOverview } from '@/api/types'
|
import type { DashboardOverview } from '@/api/types'
|
||||||
import { formatDateTime, formatDuration } from '@/utils/format'
|
import { formatDateTime, formatDuration } from '@/utils/format'
|
||||||
import { useIsMobile } from '@/composables/useBreakpoint'
|
import { useIsMobile } from '@/composables/useBreakpoint'
|
||||||
|
import CountUp from '@/components/CountUp.vue'
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ onUnmounted(() => {
|
|||||||
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
|
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<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="stat-label">{{ stat.label }}</div>
|
||||||
<div class="muted">{{ stat.sub }}</div>
|
<div class="muted">{{ stat.sub }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user