diff --git a/docs/bilibili-config.md b/docs/bilibili-config.md index 4b4e84d..4bc7440 100644 --- a/docs/bilibili-config.md +++ b/docs/bilibili-config.md @@ -1,6 +1,13 @@ # Bilibili 配置 -账号类型: `bilibili`,规则类型: `bilibili-dynamic`。 +账号类型: `bilibili`。 + +支持的规则类型: + +| 规则类型 | 说明 | +| --- | --- | +| `bilibili-dynamic` | 发布一条纯文本动态 | +| `bilibili-private-message` | 向指定用户发送私信 | ## 账号配置 (Account Config) @@ -14,7 +21,7 @@ 在 WebUI 的账号页面可以点击“测试”验证 Cookie 是否有效。 -## 规则配置 (Rule Config) +## 动态规则配置 (`bilibili-dynamic`) ```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. 处理大文件的断点续传与磁盘占用。 + +这是一个独立的功能模块,不在现有驱动的改造范围内。 diff --git a/drivers/bilibili/model.go b/drivers/bilibili/model.go index d2eca89..734a3f2 100644 --- a/drivers/bilibili/model.go +++ b/drivers/bilibili/model.go @@ -5,6 +5,8 @@ const ( AccountType = "bilibili" // RuleTypeDynamic 发送 B 站动态的规则类型标识 RuleTypeDynamic = "bilibili-dynamic" + // RuleTypePrivateMessage 发送 B 站私信的规则类型标识 + RuleTypePrivateMessage = "bilibili-private-message" ) type BiliAccount struct { @@ -14,3 +16,10 @@ type BiliAccount struct { type BiliDynamicConfig struct { Msg string `json:"msg"` } + +// BiliPrivateMessageConfig 私信规则配置 +type BiliPrivateMessageConfig struct { + // 接收者的 UID 列表 + ReceiverUids []int64 `json:"receiver_uids"` + Msg string `json:"msg"` +} diff --git a/drivers/bilibili/privateMessage.go b/drivers/bilibili/privateMessage.go new file mode 100644 index 0000000..5f14d25 --- /dev/null +++ b/drivers/bilibili/privateMessage.go @@ -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 +} diff --git a/drivers/bilibili/ruleConfigChecker.go b/drivers/bilibili/ruleConfigChecker.go index 8bdfd0a..df98f70 100644 --- a/drivers/bilibili/ruleConfigChecker.go +++ b/drivers/bilibili/ruleConfigChecker.go @@ -31,6 +31,57 @@ func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error { 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 { return meta.RuleMeta{ Type: RuleTypeDynamic, diff --git a/drivers/bilibili/runner.go b/drivers/bilibili/runner.go index 4596bd7..ef78c63 100644 --- a/drivers/bilibili/runner.go +++ b/drivers/bilibili/runner.go @@ -19,3 +19,16 @@ func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.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) +} diff --git a/handler/runner/interface.go b/handler/runner/interface.go index 0587f3d..6ad53ef 100644 --- a/handler/runner/interface.go +++ b/handler/runner/interface.go @@ -71,6 +71,7 @@ func InitExecutorRegistry() *ExecutorRegistry { // 注册所有规则执行器 registry.Register(&bilibili.BilibiliDynamicExecutor{}) + registry.Register(&bilibili.BilibiliPrivateMessageExecutor{}) registry.Register(&email.EmailExecutor{}) registry.Register(&github.GithubMakeRepoPublicExecutor{}) registry.Register(&onebot.OneBotExecutor{}) diff --git a/internal/ruleConfigChecker/reg.go b/internal/ruleConfigChecker/reg.go index 4a6a74b..a2af8b6 100644 --- a/internal/ruleConfigChecker/reg.go +++ b/internal/ruleConfigChecker/reg.go @@ -22,6 +22,7 @@ func InitValidatorRegistry() *ValidatorRegistry { // 注册所有规则验证器 r.Register(&bilibili.BilibiliDynamicRuleValidator{}) + r.Register(&bilibili.BilibiliPrivateMessageRuleValidator{}) r.Register(&email.EmailRuleValidator{}) r.Register(&onebot.OneBotRuleValidator{}) r.Register(&dingtalk.DingTalkRuleValidator{}) diff --git a/web/frontend/src/assets/main.css b/web/frontend/src/assets/main.css index 80f7439..0c82e91 100644 --- a/web/frontend/src/assets/main.css +++ b/web/frontend/src/assets/main.css @@ -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 { diff --git a/web/frontend/src/components/CountUp.vue b/web/frontend/src/components/CountUp.vue new file mode 100644 index 0000000..83b18b7 --- /dev/null +++ b/web/frontend/src/components/CountUp.vue @@ -0,0 +1,68 @@ + + + + {{ props.value === null || props.value === undefined ? '-' : display }} + diff --git a/web/frontend/src/composables/useTheme.ts b/web/frontend/src/composables/useTheme.ts new file mode 100644 index 0000000..0a00431 --- /dev/null +++ b/web/frontend/src/composables/useTheme.ts @@ -0,0 +1,91 @@ +import { ref } from 'vue' + +const STORAGE_KEY = 'gb-theme' + +/** 全局共享的主题状态,模块级单例,任何组件读到的都是同一份 */ +const isDark = ref(false) + +/** 把主题应用到 上 */ +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 } +} diff --git a/web/frontend/src/layouts/MainLayout.vue b/web/frontend/src/layouts/MainLayout.vue index eb8f160..638a4cd 100644 --- a/web/frontend/src/layouts/MainLayout.vue +++ b/web/frontend/src/layouts/MainLayout.vue @@ -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() {