【0.0.5】活动期限/周本通知器 (#2618)

* feat(ActivitySwitchNotice): 支持解析包含分钟的时间文本

- 添加对分钟单位的解析支持,使用正则表达式匹配"分钟"
- 将天数、小时数、分钟数都改为浮点数解析以支持小数
- 添加数值非负验证,确保解析结果不为负数
- 将分钟转换为小时进行统一计算
- 对结果进行四舍五入取整
- 修复OCR识别结果不完整的问题,自动补全"小时"和"分钟"单位

* feat(activity): 添加活动黑名单条件过滤功能

- 新增 parseWhiteActivity 和 parseBlackActivity 函数,支持解析黑白名单格式
- 实现黑名单条件匹配机制,支持活动名-条件1,条件2的语法格式
- 添加 getMapByKey 函数支持反向匹配功能
- 更新配置初始化流程,添加 init 函数处理黑名单配置
- 改进活动过滤逻辑,支持条件匹配检查
- 更新 README.md 文档说明新的黑名单条件语法
- 修改设置项名称从 blackActivityNameList 到 blackActivity
- 优化配置项标签说明,添加条件语法使用说明
- 添加版本历史记录 0.0.5 版本更新内容

* refactor: 重构黑名单过滤机制,新增条件匹配功能

* feat(ActivitySwitchNotice): 添加manifest.json读取和版本日志功能

- 添加manifest.json文件读取功能
- 将初始化日志从info级别调整为debug级别
- 在启动时输出版本信息日志
- 初始化manifest变量以存储应用清单数据

* chore(docs): 更新版本发布日期

- 修正版本 0.0.5 的发布日期从 2026-01-03 为 2026-01-04

* chore(ActivitySwitchNotice): 更新版本号并清理manifest配置

- 将版本号从 0.0.4 更新到 0.0.5
- 移除了 http_allowed_urls 中多余的逗号,修复了JSON格式
- 保持了原有的依赖配置和URL访问权限设置

* fix(ActivitySwitchNotice): 修复OCR键函数调用参数缺失问题

- 修复OcrKey函数调用时缺少activityName参数的问题
- 确保黑名单条件检查时传递正确的活动名称参数
- 解决因参数缺失可能导致的条件匹配错误

* docs(ActivitySwitchNotice): 更新README文档添加逻辑流程图

- 添加了详细的逻辑流程说明
- 使用mermaid图表展示组件间交互流程
- 补充了配置初始化到活动过滤的完整流程
- 说明了黑名单匹配和OCR校验的具体逻辑
- 更新了用户使用指南章节结构

* docs(ActivitySwitchNotice): 更新活动过滤器流程图文档

- 修正了活动过滤器流程图中的条件判断逻辑描述
- 更新了黑名单匹配条件的处理流程说明
This commit is contained in:
云端客
2026-01-04 11:04:59 +08:00
committed by GitHub
parent 36c8687b79
commit 37ff6a6e5b
5 changed files with 250 additions and 44 deletions

View File

@@ -14,13 +14,77 @@
- ✅ 智能解析剩余时间(支持"22天14小时"等格式)
- ✅ 可配置的通知阈值默认8760小时内结束的活动
- ✅ 支持指定特定活动进行监控
- ✅ 支持活动黑名单过滤功能
- ✅ 支持活动黑名单过滤功能0.0.5版本,新增支持为黑名单活动设置特定条件,只有满足条件时才过滤,注:特定条件为空默认没有条件)
- ✅ 防重复检测机制
- ✅ 异常处理和错误恢复
- ✅ 自动提醒征讨领域减半剩余次数(默认`周日`提醒可配置)
- ✅ 支持独立通知功能(`0.0.4`版本新增 因BGI不支持WebSocket,需搭配WsProxy+开启JS HTTP
权限使用)[前往WsProxy部署](https://github.com/Kirito520Asuna/WsProxy)
## 逻辑流程
```mermaid
sequenceDiagram
autonumber
participant Config as 配置初始化
participant Parser as 解析器
participant ActivityMgr as 活动管理
participant OCRSvc as OCR 服务
participant Filter as 过滤决策
participant Notification as 通知服务
Config->>Parser: 读取 settings.whiteActivityNameList
Parser->>Parser: parseWhiteActivity(text)
Parser-->>Config: 返回 whiteActivityNameList
Config->>Parser: 读取 settings.blackActivity
Parser->>Parser: parseBlackActivity(text, excludeList)
Parser-->>Config: 返回 blackActivityMap
Note over ActivityMgr: 遍历所有候选活动
loop 每个候选活动
Note over ActivityMgr: 检查白名单匹配
alt 白名单不为空
ActivityMgr->>ActivityMgr: 检查活动名是否包含白名单关键词
alt 包含关键词
ActivityMgr-->>ActivityMgr: 标记为白名单活动
else 不包含关键词
alt relationship为false或关系
ActivityMgr-->>ActivityMgr: 继续处理
else relationship为true与关系
ActivityMgr-->>ActivityMgr: 跳过该活动
end
end
end
Note over ActivityMgr: 检查黑名单匹配
alt 黑名单不为空
ActivityMgr->>Filter: 是否匹配黑名单?
Filter->>Parser: getMapByKey(blackActivityMap, 活动名, reverseMatch=true)
Parser-->>Filter: 返回条件列表
Filter->>OCRSvc: 用 OCR 校验条件(如剩余时间、文本等)
OCRSvc-->>Filter: 返回条件满足情况
alt 任一条件满足
Filter-->>ActivityMgr: 跳过该活动
else 所有条件均不满足
Filter-->>ActivityMgr: 保留该活动
end
else 匹配但无条件
Filter-->>ActivityMgr: 直接跳过该活动
else 未匹配黑名单
Filter-->>ActivityMgr: 保留该活动
end
end
Note over ActivityMgr: 活动筛选完成后
ActivityMgr->>ActivityMgr: 根据whiteActivityNameList和relationship进行二次筛选
alt relationship为true与关系
ActivityMgr->>ActivityMgr: 同时满足剩余时间阈值和白名单条件
else relationship为false或关系
ActivityMgr->>ActivityMgr: 满足剩余时间阈值或白名单任一条件
end
ActivityMgr->>ActivityMgr: 检查剩余时间阈值
ActivityMgr->>Notification: 发送符合条件的活动通知
Notification-->> ActivityMgr: 通知发送完成
```
---
## 用户使用指南
@@ -43,7 +107,7 @@
| `noticeType` | 通知模式默认BGI通知-使用独立通知需要开启JS HTTP权限 | BGI通知 | v |
| `relationship` | 剩余时间与白名单启用`和`关系(默认`或`关系) | false | v |
| `whiteActivityNameList` | 白名单活动名称(用\|分隔) | 空(监控所有活动) | v |
| `blackActivityNameList` | 黑名单活动名称(用\|分隔) | 空(无黑名单活动) | v |
| `blackActivity` | 黑名单活动名称(用|分隔)- 支持条件语法:活动名-条件1,条件2 | 空(无黑名单活动) | v |
| `notifyHoursThreshold` | 通知时间阈值(小时) | 8760365天 | v |
| `activityKey` | 打开活动页面的快捷键 | F5 | v |
| `toTopCount` | 滑动到顶最大尝试次数 | 10 | x |
@@ -83,7 +147,15 @@
- **全部活动监控**`whiteActivityNameList` 保持空值,监控所有有剩余时间的活动
- **指定活动监控**:填写活动关键词,如 `海灯节\|盛典`,只监控包含这些关键词的活动
- **黑名单过滤**`blackActivityNameList` 可以设置不想接收提醒的活动名称,多个活动用`|`分隔
- **黑名单过滤**blackActivity 可以设置不想接收提醒的活动名称,多个活动用|分隔
- **条件黑名单过滤**:支持条件语法 活动名-条件1,条件2只有当活动满足指定条件时才过滤
##### 使用示例
```text
普通黑名单: "活动A|活动B"
条件黑名单: "活动A-已完成|活动B-条件1,条件2"
混合使用: "活动A|活动C-已完成,已领取"
```
#### 时间通知机制
@@ -270,7 +342,7 @@ ActivitySwitchNotice/
| `noticeType` | String | 通知模式BGI通知/独立通知/两者都发送) |
| `relationship` | Boolean | 剩余时间与白名单启用`和`关系(默认`或`关系) |
| `whiteActivityNameList` | String | 白名单活动名称(用\|分隔) |
| `blackActivityNameList` | String | 黑名单活动名称(用\|分隔) |
| `blackActivity` | String | 黑名单活动名称(用\|分隔)- 支持条件语法:活动名-条件1,条件2 |
| `notifyHoursThreshold` | Number | 通知阈值(小时) |
| `activityKey` | String | 打开活动页面的快捷键 |
| `toTopCount` | Number | 滑动到顶最大尝试次数 |
@@ -280,9 +352,9 @@ ActivitySwitchNotice/
| `ws_proxy_url` | String | WebSocket代理URL独立通知配置 |
| `ws_url` | String | WebSocket客户端 URL独立通知配置 |
| `ws_token` | String | WebSocket客户端 token独立通知配置 |
| `action` | String | 发送类型(私聊/群聊)(独立通知配置) |
| `send_id` | String | 发送ID群号或QQ号对应发送类型 (独立通知配置) |
| `at_list` | String | @某人列表使用,隔开QQ号 (独立通知配置) |
| `action` | String | 发送类型(私聊/群聊)(独立通知配置) |
| `send_id` | String | 发送ID群号或QQ号对应发送类型 (独立通知配置) |
| `at_list` | String | @某人列表使用,隔开QQ号 (独立通知配置) |
---
@@ -310,6 +382,17 @@ ActivitySwitchNotice/
## 版本历史
### 0.0.5 (2026-01-04)
- **性能优化**:优化滚动到顶部算法,减少页面滚动次数,提升初始化效率
- **功能增强**:新增条件黑名单过滤机制,支持基于活动状态的动态过滤策略
- **代码重构**:新增 `parseBlackActivity` 函数,实现黑名单配置的结构化解析
- **架构改进**:重构黑名单匹配逻辑,引入条件匹配引擎,支持多条件复合判断
- **数据结构优化**:引入 `blackActivityMap` 配置项,使用 Map 数据结构提升查找性能
- **逻辑优化**:增强活动过滤算法,集成条件匹配验证机制
- **初始化流程**:重构配置加载流程,新增 [init]() 函数统一处理配置项初始化
- **文档完善**:更新配置项文档,补充条件黑名单语法说明
### 0.0.4 (2026-01-01)
- 新增 独立通知配置功能,支持通过 WebSocket 发送通知

View File

@@ -1,3 +1,4 @@
let manifest = {};
async function init() {
let utils=[
"ws",
@@ -8,7 +9,8 @@ async function init() {
for (let util of utils) {
eval(file.readTextSync(`utils/${util}.js`));
}
log.info("main 初始化完成");
manifest = JSON.parse(file.readTextSync("manifest.json"));
log.debug("main 初始化完成");
}
// 判断是否在主界面的函数
const isInMainUI = () => {
@@ -41,6 +43,7 @@ async function toMainUi() {
(async function () {
await init();
log.info(`版本:{version}`,manifest.version)
if (settings.toMainUi){
await toMainUi();
}

View File

@@ -1,6 +1,6 @@
{
"name": "活动期限/周本通知器",
"version": "0.0.4",
"version": "0.0.5",
"description": "",
"settings_ui": "settings.json",
"main": "main.js",
@@ -13,6 +13,6 @@
"dependencies": [],
"http_allowed_urls": [
"https://*",
"http://*",
"http://*"
]
}

View File

@@ -28,9 +28,9 @@
"label": "白名单活动名称(使用|分割)<可不填 默认推送所有有剩余时间的活动>"
},
{
"name": "blackActivityNameList",
"name": "blackActivity",
"type": "input-text",
"label": "黑名单活动名称(使用|分割)<可不填,默认没有不推送的活动>"
"label": "黑名单活动名称(使用|分割)<可不填,默认没有不推送的活动>(新增语法指定条件的黑名单活动1-条件1,条件2|活动2-条件1)"
},
{
"name": "notifyHoursThreshold",

View File

@@ -7,21 +7,71 @@ function settingsParseInt(str, defaultValue) {
}
}
const config = {
/**
* 解析白名单活动文本,将其转换为活动列表
* @param {string} text - 包含活动信息的文本,多个活动用'|'分隔
* @param {Array} defaultList - 当text为空时的默认返回列表默认为空数组
* @returns {Array} 解析后的活动列表,去除空字符串并过滤空白项
*/
function parseWhiteActivity(text, defaultList = []) {
return (text ? text.split('|').filter(s => s.trim()) : defaultList)
}
/**
* 解析黑名单活动文本
* @param {string} text - 包含活动信息的文本,使用'|'分隔多个活动,使用'-'分隔活动名称和条件,使用','分隔多个条件
* @param {Array<string>} excludeList - 需要排除的活动名称列表
* @returns {Map<string, Array<string>>|undefined} 解析后的活动映射键为活动名称值为条件数组如果输入文本为空则返回undefined
*/
function parseBlackActivity(text, excludeList = []) {
if (!text) return undefined;
const result = new Map();
// 使用'|'分割文本为多个活动元素
const splitList = text.split('|');
for (let element of splitList) {
element = element.trim(); // 清理空白字符
if (!element) continue; // 跳过空元素
// 使用'-'分割活动元素的名称和条件
const elementList = element.split('-');
const activityName = elementList[0].trim();
if (!activityName) continue; // 跳过没有名称的活动
// 检查是否在排除列表中
if (excludeList.includes(activityName)) {
continue;
}
// 解析条件如果有条件部分length > 1则分割条件否则为空数组
const conditions = elementList.length > 1
? Array.from(elementList[1].split(',').map(item => item.trim()))
: [];
log.debug(`parseBlackActivity: ${activityName} - ${conditions}`)
// 将解析后的活动对象添加到结果中
result.set(activityName, conditions);
}
return result.size > 0 ? result : undefined;
}
let config = {
//剩余时间,白名单 启用`和`关系(默认`与`关系)
relationship: settings.relationship,
whiteActivityNameList: (settings.whiteActivityNameList ? settings.whiteActivityNameList.split('|').filter(s => s.trim()) : []),
whiteActivityNameList: parseWhiteActivity(settings.whiteActivityNameList),
activityKey: (settings.activityKey ? settings.activityKey : 'F5'),
toTopCount: settingsParseInt(settings.toTopCount, 10),//滑动到顶最大尝试次数
scrollPageCount: settingsParseInt(settings.scrollPageCount, 4),//滑动次数/页
notifyHoursThreshold: settingsParseInt(settings.notifyHoursThreshold, 8760),//剩余时间阈值(默认 8760小时=365天)
// 黑名单活动名称列表,这些活动将被排除在识别和处理之外
// 通过 | 分隔多个活动名称,并过滤掉空白项
blackActivityMap: parseBlackActivity(settings.blackActivity, parseWhiteActivity(settings.whiteActivityNameList)),
// 同时确保黑名单中的活动名称不包含在白名单whiteActivityNameList
blackActivityNameList: (settings.blackActivityNameList ? settings.blackActivityNameList.split('|').filter(s => s.trim())
.filter(
item => !settings.whiteActivityNameList.split('|').filter(s => s.trim()).some(keyword => item.includes(keyword))
) : []),
blackActivityNameList: [],
}
const ocrRegionConfig = {
activity: {x: 267, y: 197, width: 226, height: 616},//活动识别区域坐标和尺寸
@@ -61,12 +111,17 @@ const genshinJson = {
*
* @param {Map} map - 要搜索的Map对象默认为空Map
* @param {string} key - 用于匹配的键名部分字符串
* @param {boolean} reverseMatch - 开启反向匹配
* @returns {*} 匹配键对应的值如果未找到匹配项则返回undefined
*/
function getMapByKey(map = new Map(), key) {
function getMapByKey(map = new Map(), key, reverseMatch = false) {
// 遍历Map的所有键名查找包含指定key的键
log.debug('Map=>size:{size}', map.size)
for (let keyName of map.keys()) {
if (keyName.includes(key)) {
log.debug('Map=>key:{key},keyName:{keyName},value:{value},ok:{ok}', key, keyName, JSON.stringify(map.get(keyName)), keyName.includes(key))
if (keyName.includes(key) && !reverseMatch) {
return map.get(keyName)
} else if (key.includes(keyName) && reverseMatch) {
return map.get(keyName)
}
}
@@ -131,21 +186,26 @@ async function scrollPage(totalDistance, isUp = false, waitCount = 6, stepDistan
await sleep(ms);
}
/**
* 根据活动状态 进行页面滚动
* @param {boolean} isUp - 是否向上滚动,默认为false
* 根据活动页面进行滚动操作
* @param {boolean} isUp - 滚动方向true表示向上滚动false表示向下滚动
* @param {number} total - 滚动总量
* @param {number} waitCount - 等待次数
* @param {number} stepDistance - 每次滚动的步长距离
* @param {number} scrollPageCount - 滚动页面次数默认从config中获取
*/
async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, stepDistance = 30) {
async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, stepDistance = 30, scrollPageCount = config.scrollPageCount) {
// 根据滚动方向设置坐标位置
// 如果是向上滚动,使用顶部坐标;否则使用底部坐标
let x = isUp ? xyConfig.top.x : xyConfig.bottom.x
let y = isUp ? xyConfig.top.y : xyConfig.bottom.y
let x = isUp ? xyConfig.top.x : xyConfig.bottom.x; // 根据滚动方向获取x坐标
let y = isUp ? xyConfig.top.y : xyConfig.bottom.y; // 根据滚动方向获取y坐标
// 记录滑动方向
log.info(`活动页面-${isUp ? '向上' : '向下'}滑动`)
log.info(`活动页面-${isUp ? '向上' : '向下'}滑动`);
// 注释:坐标信息已注释掉,避免日志过多
// log.info(`坐标:${x},${y}`)
// log.info(`坐标:${x},${y}`);
// 根据配置的滑动次数执行循环
for (let i = 0; i < config.scrollPageCount; i++) {
for (let i = 0; i < scrollPageCount; i++) {
// 移动到坐标位置
await moveMouseTo(x, y)
//80 18次滑动偏移量 46次测试未发现偏移
@@ -160,7 +220,7 @@ async function scrollPagesByActivity(isUp = false, total = 90, waitCount = 6, st
* @throws {Error} 如果超过最大尝试次数仍未检测到稳定顶部,则抛出错误
*/
async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity) {
let ms = 800
let ms = 800; // 等待时间,单位毫秒
let topActivityName = null; // 上一次检测到的顶部活动名称
let sameTopCount = 0; // 连续出现相同顶部名称的次数
const requiredSameCount = 1; // 需要连续几次相同才确认到顶(推荐 2~3
@@ -197,7 +257,7 @@ async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity)
log.warn("顶部OCR未识别到任何活动条目可能是页面为空或识别失败");
// 再尝试一次向上滚大距离
// await scrollPagesByActivity(true); // true = 向上
await scrollPagesByActivity(true, 80 * 4, 6, 60);
await scrollPagesByActivity(true, 80 * 4, 6, 60, 1);
await sleep(ms);
continue;
}
@@ -225,8 +285,7 @@ async function scrollPagesByActivityToTop(ocrRegion = ocrRegionConfig.activity)
// 未达到稳定状态,继续向上滚动一页(可根据实际情况调整滚动距离)
// 这里使用更大滚动距离确保能快速回顶
// await scrollPagesByActivity(true); // true = 向上
// 可选:加大单次滚动量(如果你发现默认一页不够)
await scrollPagesByActivity(true, 80 * 4, 6, 60);
await scrollPagesByActivity(true, 80 * 4, 6, 60, 1);
await sleep(ms); // 给页面滚动和渲染留时间
} finally {
@@ -260,24 +319,34 @@ function parseRemainingTimeToHours(timeText) {
return 0;
}
// 提取数字和单位(支持中英文冒号、空格等)
const dayMatch = timeText.match(/(\d+)\s*天/);
const hourMatch = timeText.match(/(\d+)\s*小时/);
let days = 0;
let hours = 0;
let minutes = 0;
// 如果上面的复杂正则有问题,可以使用原来的简化版本
const dayMatch = timeText.match(/(\d+(?:\.\d+)?)\s*天/);
const hourMatch = timeText.match(/(\d+(?:\.\d+)?)\s*小时/);
const minuteMatch = timeText.match(/(\d+(?:\.\d+)?)\s*分钟/);
if (dayMatch) {
days = parseInt(dayMatch[1], 10);
days = parseFloat(dayMatch[1]);
}
if (hourMatch) {
hours = parseInt(hourMatch[1], 10);
hours = parseFloat(hourMatch[1]);
}
if (minuteMatch) {
minutes = parseFloat(minuteMatch[1]);
}
// 天数转小时 + 原有小时
const totalHours = days * 24 + hours;
// 确保数值非负
days = Math.max(0, days);
hours = Math.max(0, hours);
minutes = Math.max(0, minutes);
return totalHours;
// 将分钟转换为小时
const totalHours = days * 24 + hours + minutes / 60;
return Math.round(totalHours); // 四舍五入到整数
}
/**
@@ -359,11 +428,20 @@ async function OcrKey(activityName, key = "剩余时间", ocrRegion = ocrRegionC
}
}
async function init() {
log.debug(`[init-config]-[{config}]`, JSON.stringify(config));
let blackActivityMap = config.blackActivityMap
config.blackActivityNameList = blackActivityMap ? Array.from(blackActivityMap.keys()) : [];
config.blackActivityNameList.length > 0 && log.debug(`[init]-[{blackActivityNameList}]`, config.blackActivityNameList.join('|'));
log.debug(`[init]-[{ket}]`, 'activity');
log.debug(`[init-config-end]-[{config}]`, JSON.stringify(config));
}
/**
* 活动主函数:扫描所有活动页面,识别剩余时间,最后统一发送通知
*/
async function activityMain() {
await init();
const ms = 1000;
await sleep(ms);
@@ -454,13 +532,42 @@ async function activityMain() {
}
}
// 检查当前活动名称是否在黑名单中
if (config.blackActivityNameList.length > 0) {
const matched = config.blackActivityNameList.some(keyword => activityName.includes(keyword));
let matched = config.blackActivityNameList.some(keyword => activityName.includes(keyword));
if (matched) {
continue; // 不关心的活动,跳过不点击
// 获取黑名单活动的条件配置
let blackActivityConditions = getMapByKey(config.blackActivityMap, activityName,true);
log.info(`[黑名单条件检测]blackActivityMap:{blackActivityMap},activityName:{activityName},blackActivityConditions:{blackActivityConditions}`,
config.blackActivityMap, activityName, blackActivityConditions);
if (blackActivityConditions && blackActivityConditions.length > 0) {
log.debug('[黑名单条件检测开始]')
matched = false;
// 遍历所有条件,检查是否满足黑名单条件
for (const blackActivityCondition of blackActivityConditions) {
try {
let condition = await OcrKey(activityName,blackActivityCondition);
if (condition) {
log.info(`满足黑名单条件==>{ac}->{ba}`, activityName, blackActivityCondition);
matched = true;
break;
}
} catch (error) {
log.error(`检查黑名单条件时发生错误: ${error.message}`, error);
// 继续检查下一个条件,不中断整个流程
continue;
}
}
}
// 如果匹配到黑名单活动,则跳过不点击
if (matched) {
continue; // 不关心的活动,跳过不点击
}
}
}
// 避免重复点击同一个活动(防止 OCR 误识别或页面抖动)
if (activityMap.has(activityName)) {
log.info(`活动已记录,跳过重复点击: ${activityName}`);
@@ -470,6 +577,11 @@ async function activityMain() {
let remainingTimeText = await OcrKey(activityName);
if (remainingTimeText) {
if (remainingTimeText.endsWith('小')) {
remainingTimeText += '时'
} else if (remainingTimeText.endsWith('分')) {
remainingTimeText += '钟'
}
const totalHours = parseRemainingTimeToHours(remainingTimeText);
if (totalHours <= 24 && totalHours > 0) {
remainingTimeText += '<即将结束>'
@@ -580,7 +692,15 @@ async function activityMain() {
let blackText = "";
if (config.blackActivityNameList.length > 0) {
blackText += `|==>已开启黑名单: ${config.blackActivityNameList.join(",")}<==|`
let blackAllText = []
for (let en of config.blackActivityNameList) {
let configTextList = config.blackActivityMap.get(en)
if (configTextList) {
en += (configTextList.length > 0 ? "-" : "") + configTextList.join(',')
blackAllText.push(en)
}
}
blackText += `==>{已开启黑名单: ${blackAllText.join("|")}}<==`
}
await noticeUtil.sendNotice(activityMapFilter, `原神活动剩余时间提醒(仅显示 ${titleKey} 的活动)${blackText}`);