通过浏览器系统推送,接收虎绿林的@消息和内信通知。
支持显示消息内容详情,支持点击通知跳转到消息对应页面。
Windows:Chrome、Edge、FireFox
Android:FireFox(受省电机制等限制,后台时可能收不到通知)
Android:Chrome(似乎需要服务器支持 Service Worker)
其它浏览器暂未测试。
<script>
// 虎绿林消息提醒通知推送,第1版,2025年11月25日
(function(){'use strict';function a(a){if(j.has(a))return j.get(a);const b=fetch(`/q.php/user.info.${a}.json`).then(a=>a.json()).then(b=>b&&b.name?b.name:a).catch(b=>(console.error(`[UserInfo] 获取 uid=${a} 失败`,b),a));return j.set(a,b),b}function b(a){try{const b=JSON.parse(a);if(!Array.isArray(b))return"\u6536\u5230\u4E00\u6761\u65B0\u6D88\u606F";let c="";return b.forEach(a=>{"text"===a.type?c+=a.value||"":"face"===a.type?c+=`[${a.face}]`:"at"===a.type&&(c+=`@${a.tag} `)}),c.replace(/[\r\n\s]+/g," ").trim().substring(0,80)}catch(a){return"\u6536\u5230\u4E00\u6761\u65B0\u6D88\u606F"}}function c(a,b,c,d){if("Notification"in window&&"granted"===Notification.permission)try{const e=new Notification(a,{body:b,icon:"/favicon.ico",tag:c,requireInteraction:!1});e.onclick=function(){window.focus(),d&&window.open(d,"_blank"),this.close()}}catch(a){console.error(a)}}function d(){function d(){h&&(h.readyState===WebSocket.OPEN||h.readyState===WebSocket.CONNECTING)||(h=new WebSocket(f),h.onopen=()=>{console.log("[WS] \u8FDE\u63A5\u5EFA\u7ACB\uFF0C\u5F00\u59CB\u76D1\u542C\u6D88\u606F\u63A8\u9001"),h.send(JSON.stringify({action:"unsub",data:["online","offline"]})),i&&clearInterval(i),i=setInterval(()=>{h.readyState===WebSocket.OPEN&&h.send("{\"action\":\"ping\"}")},6e4)},h.onmessage=async d=>{try{const e=JSON.parse(d.data);if("msg"===e.event&&console.log("[WS] \u6536\u5230\u6D88\u606F\u5BF9\u8C61:",e),"msg"===e.event&&e.data&&(0===e.data.type||1===e.data.type)){const d=e.data,f=d.byuid,g=await a(f);let h,i,j="";const k=JSON.parse(d.content);switch(d.type){case 0:h=`${g} 给您发了一条内信`,i=b(d.content),j=`/q.php/msg.index.chat.${f}.html`;break;case 1:const a=k.find(a=>"atMsg"===a.type);h=`${g} 在${a.pos}@您`,j=a.url,i=b(JSON.stringify(a.msg));break;default:h=`${g} 给您发了一条新消息`,i=b(d.content),j=`/q.php/index.index.html`;}j=j.replace("{$BID}","html"),c(h,i,`hu60_msg_${d.id}`,j)}}catch(a){console.error("[WS] \u5904\u7406\u65B0\u6D88\u606F\u51FA\u9519",a)}},h.onclose=()=>{console.log("[WS] \u8FDE\u63A5\u65AD\u5F00\uFF0C10 \u79D2\u540E\u91CD\u8FDE"),i&&clearInterval(i),setTimeout(d,1e4)},h.onerror=()=>h.close())}console.log("[Lock] \u83B7\u5F97\u9501\uFF0C\u5F53\u524D\u6807\u7B7E\u9875\u8D1F\u8D23\u6D88\u606F\u63A8\u9001"),d()}async function e(){const a=()=>{navigator.locks?navigator.locks.request(g,{mode:"exclusive"},async()=>(d(),new Promise(()=>{}))):d()};if(!("Notification"in window))return void console.warn("[Notify] \u6D4F\u89C8\u5668\u4E0D\u652F\u6301\u901A\u77E5 API\uFF0C\u4E0D\u542F\u52A8 WebSocket \u63A8\u9001\u670D\u52A1\u3002");if("granted"===Notification.permission)a();else if("default"===Notification.permission){console.log("[Notify] \u6743\u9650\u4E3A default\uFF0C\u663E\u793A\u624B\u52A8\u6388\u6743\u63D0\u793A");const b=document.createElement("div");b.style.cssText="position:fixed;top:0;left:0;width:100%;z-index:2147483647;background:#fff3cd;color:#856404;text-align:center;padding:8px 0;border-bottom:1px solid #ffeeba;font-size:14px;line-height:1.5;box-shadow:0 2px 5px rgba(0,0,0,0.1);",b.innerHTML="\u672A\u83B7\u5F97\u901A\u77E5\u6743\u9650\uFF0C\u63A8\u9001\u670D\u52A1\u4E0D\u4F1A\u542F\u52A8\u3002<a href=\"javascript:void(0);\" style=\"color:#533f03;text-decoration:underline;font-weight:bold;cursor:pointer;\">\u70B9\u51FB\u6388\u4E88\u901A\u77E5\u6743\u9650</a>";const c=b.querySelector("a");c.onclick=async c=>{c.preventDefault();try{const c=await Notification.requestPermission();"granted"===c?(b.remove(),a()):(console.warn("[Notify] \u7528\u6237\u62D2\u7EDD\u4E86\u6743\u9650\u8BF7\u6C42"),b.innerHTML="\u5DF2\u62D2\u7EDD\u901A\u77E5\u6743\u9650\uFF0C\u5982\u9700\u5F00\u542F\u8BF7\u5728\u6D4F\u89C8\u5668\u7F51\u7AD9\u8BBE\u7F6E\u4E2D\u6388\u4E88\u3002",setTimeout(()=>b.remove(),3e3))}catch(a){console.error("[Notify] \u8BF7\u6C42\u6743\u9650\u53D1\u751F\u9519\u8BEF",a)}},document.body.prepend(b)}else console.warn("[Notify] \u672A\u83B7\u5F97\u901A\u77E5\u6743\u9650\uFF08\u7528\u6237\u5DF2\u62D2\u7EDD\uFF09\uFF0C\u4E0D\u542F\u52A8 WebSocket \u63A8\u9001\u670D\u52A1\u3002")}const f="wss://"+location.host+"/ws/msg",g="hu60_websocket_leader_lock";let h=null,i=null;const j=new Map;window.addEventListener("load",e)})();
</script>
<script>
// 虎绿林消息提醒通知推送,第1版,2025年11月25日
(function () {
'use strict';
const WS_URL = "wss://" + location.host + "/ws/msg";
// 选主锁,在开启多个标签页时仅维持一个 WebSocket 连接
const LOCK_NAME = "hu60_websocket_leader_lock";
let socket = null;
let keepAliveTimer = null;
// 用户名缓存:Key=uid, Value=Promise<string>
const userNameCache = new Map();
// 获取用户名
function getUserName(uid) {
if (userNameCache.has(uid)) {
return userNameCache.get(uid);
}
const fetchPromise = fetch(`/q.php/user.info.${uid}.json`)
.then(res => res.json())
.then(json => {
return (json && json.name) ? json.name : uid;
})
.catch(err => {
console.error(`[UserInfo] 获取 uid=${uid} 失败`, err);
return uid;
});
userNameCache.set(uid, fetchPromise);
return fetchPromise;
}
// 消息内容解析
function parseMsgContent(contentStr) {
try {
const contentArr = JSON.parse(contentStr);
if (!Array.isArray(contentArr)) {
return "收到一条新消息";
}
let plainText = "";
contentArr.forEach(item => {
if (item.type === 'text') plainText += item.value || "";
else if (item.type === 'face') plainText += `[${item.face}]`;
else if (item.type === 'at') plainText += `@${item.tag} `;
});
// 多行文本转单行、压缩空格、截取前 80 字符
return plainText.replace(/[\r\n\s]+/g, ' ').trim().substring(0, 80);
} catch (e) { return "收到一条新消息"; }
}
// 消息显示
function showNotification(title, body, tag, openUrl) {
if (!("Notification" in window) || Notification.permission !== "granted") return;
try {
const notification = new Notification(title, {
body: body,
icon: '/favicon.ico',
tag: tag,
requireInteraction: false
});
notification.onclick = function () {
window.focus();
if (openUrl) {
window.open(openUrl, '_blank');
}
this.close();
};
} catch (e) { console.error(e); }
}
// WebSocket 服务
function startWebSocketService() {
console.log("[Lock] 获得锁,当前标签页负责消息推送");
function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
socket = new WebSocket(WS_URL);
socket.onopen = () => {
console.log("[WS] 连接建立,开始监听消息推送");
// 不订阅机器人上下线事件
socket.send(JSON.stringify({ "action": "unsub", "data": ["online", "offline"] }));
// 仅监听实时消息,不拉取历史未读
if (keepAliveTimer) clearInterval(keepAliveTimer);
keepAliveTimer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send('{"action":"ping"}');
}, 60000);
};
socket.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
// DEBUG: 打印消息对象
if (msg.event === "msg") {
console.log("[WS] 收到消息对象:", msg);
}
if (msg.event === "msg" && msg.data && (msg.data.type === 0 || msg.data.type === 1)) {
const data = msg.data;
const uid = data.byuid;
const senderName = await getUserName(uid);
let title;
let content;
let targetUrl = "";
const contentArr = JSON.parse(data.content);
switch (data.type) {
case 0: // 内信
title = `${senderName} 给您发了一条内信`;
content = parseMsgContent(data.content);
targetUrl = `/q.php/msg.index.chat.${uid}.html`;
break;
case 1: // @消息
const atNode = contentArr.find(item => item.type === 'atMsg');
title = `${senderName} 在${atNode.pos}@您`;
targetUrl = atNode.url;
content = parseMsgContent(JSON.stringify(atNode.msg));
break;
default:
title = `${senderName} 给您发了一条新消息`;
content = parseMsgContent(data.content);
targetUrl = `/q.php/index.index.html`;
break;
}
targetUrl = targetUrl.replace("{$BID}", "html");
showNotification(title, content, `hu60_msg_${data.id}`, targetUrl);
}
} catch (e) {
console.error("[WS] 处理新消息出错", e);
}
};
socket.onclose = () => {
console.log("[WS] 连接断开,10 秒后重连");
if (keepAliveTimer) clearInterval(keepAliveTimer);
setTimeout(connect, 10000);
};
socket.onerror = () => socket.close();
}
connect();
}
// 初始化
async function init() {
// 获取锁并连接 WebSocket
const runService = () => {
if (navigator.locks) {
navigator.locks.request(LOCK_NAME, { mode: 'exclusive' }, async () => {
startWebSocketService();
return new Promise(() => { });
});
} else {
startWebSocketService();
}
};
if (!("Notification" in window)) {
console.warn("[Notify] 浏览器不支持通知 API,不启动 WebSocket 推送服务。");
return;
}
if (Notification.permission === "granted") {
// 已获权限,直接启动
runService();
} else if (Notification.permission === "default") {
// 权限未定,在页面顶部插入提示条,引导用户手动点击
console.log("[Notify] 权限为 default,显示手动授权提示");
const bar = document.createElement("div");
bar.style.cssText = "position:fixed;top:0;left:0;width:100%;z-index:2147483647;background:#fff3cd;color:#856404;text-align:center;padding:8px 0;border-bottom:1px solid #ffeeba;font-size:14px;line-height:1.5;box-shadow:0 2px 5px rgba(0,0,0,0.1);";
bar.innerHTML = '未获得通知权限,推送服务不会启动。<a href="javascript:void(0);" style="color:#533f03;text-decoration:underline;font-weight:bold;cursor:pointer;">点击授予通知权限</a>';
// 绑定点击事件
const link = bar.querySelector('a');
link.onclick = async (e) => {
e.preventDefault(); // 防止可能的默认行为
try {
const permission = await Notification.requestPermission();
if (permission === "granted") {
bar.remove(); // 授权成功,移除提示条
runService(); // 启动服务
} else {
console.warn("[Notify] 用户拒绝了权限请求");
bar.innerHTML = '已拒绝通知权限,如需开启请在浏览器网站设置中授予。';
setTimeout(() => bar.remove(), 3000);
}
} catch (err) {
console.error("[Notify] 请求权限发生错误", err);
}
};
document.body.prepend(bar);
} else {
// permission === "denied"
console.warn("[Notify] 未获得通知权限(用户已拒绝),不启动 WebSocket 推送服务。");
}
}
window.addEventListener('load', init);
})();
</script>
基于虎绿林 WebSocket 消息推送服务实现。