功能:奖励发放聊天室公告 + 右下角 Toast 通知卡片
后端(AdminCommandController::reward):
- 新增聊天室公开公告消息(系统公告,所有在场用户可见)
- 接收者私信附带 toast_notification 字段触发前端小卡片
- 公告文案:「🪙 [职务人] 向 [目标] 发放了 [N] 枚奖励金币!」
前端:
- 新建 chat/partials/toast-notification.blade.php:
全局右下角 Toast 组件,window.chatToast.show() API
支持 title/message/icon/color/duration/action 配置
多条 Toast 从右下角向上堆叠,独立计时、独立关闭
- chat:message 事件监听中检测 toast_notification 字段,
自动弹出右下角通知卡片(仅接收方可见)
- showFriendToast 迁移至 window.chatToast.show(),
删除 80 行旧实现,代码量净减
- frame.blade.php 引入新 partial
DEVELOPMENT.md:
- 新增 §7.9 chatToast 完整文档(API、使用场景、迁移说明)
- 原 chatBanner 章节编号改为 §7.10
This commit is contained in:
+65
-1
@@ -711,7 +711,71 @@ if (ok) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7.9 全局大卡片通知 `window.chatBanner` ⭐
|
### 7.9 全局右下角 Toast 通知卡片 `window.chatToast` ⭐
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `chatToast` 是固定在**右下角**的轻量通知卡片,适用于实时事件通知(奖励到账、好友动态等)。
|
||||||
|
> 与 `chatDialog` 不同,它不阻断操作流程,自动消失,可堆叠多条。
|
||||||
|
|
||||||
|
#### 文件位置
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/views/chat/partials/toast-notification.blade.php ← Toast 组件 HTML + JS
|
||||||
|
resources/views/chat/frame.blade.php ← 已 @include,全页面可用
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API 说明
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.chatToast.show({
|
||||||
|
title: "标题文字", // 必填
|
||||||
|
message: "正文内容", // 必填,支持 HTML
|
||||||
|
icon: "🪙", // 可选,左侧 Emoji,默认 💬
|
||||||
|
color: "#f59e0b", // 可选,强调色,默认 #336699
|
||||||
|
duration: 6000, // 可选,自动消失毫秒,0 = 不自动消失,默认 6000
|
||||||
|
action: {
|
||||||
|
// 可选,操作按钮
|
||||||
|
label: "操作文字",
|
||||||
|
onClick: () => {
|
||||||
|
/* ... */
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 通过消息字段自动触发(后端控制)
|
||||||
|
|
||||||
|
后端 broadcast 的消息中包含 `toast_notification` 字段,且接收方是当前用户时,前端脚本会自动弹出 Toast:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// AdminCommandController::reward() 示例
|
||||||
|
$msg['toast_notification'] = [
|
||||||
|
'title' => '🪙 奖励金币到账',
|
||||||
|
'message' => "<b>{$admin->username}</b> 向你发放了 <b>{$amount}</b> 枚金币!",
|
||||||
|
'icon' => '🪙',
|
||||||
|
'color' => '#f59e0b',
|
||||||
|
'duration' => 8000,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用场景
|
||||||
|
|
||||||
|
| 场景 | 颜色 | 图标 |
|
||||||
|
| ---------------- | --------------- | ---- |
|
||||||
|
| 奖励金币到账 | `#f59e0b`(橙) | 🪙 |
|
||||||
|
| 好友动态通知 | `#6b7280`(灰) | 👥 |
|
||||||
|
| 礼物收到 | `#e11d48`(玫) | 🎁 |
|
||||||
|
| 系统提示(普通) | `#336699`(蓝) | 💬 |
|
||||||
|
| 等级晋升 | `#7c3aed`(紫) | 🌟 |
|
||||||
|
|
||||||
|
#### 原 `showFriendToast` 迁移说明
|
||||||
|
|
||||||
|
旧函数 `showFriendToast()` 已被 `window.chatToast.show()` 替代,好友删除通知已改用新 API。
|
||||||
|
新增功能只需调用 `window.chatToast.show()`,**勿新增** `showFriendToast` 调用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.10 全局大卡片通知 `window.chatBanner` ⭐
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> `chatBanner` 是居中弹出的沉浸式大卡片通知组件,适用于好友通知、任命公告、系统广播等重要事件。与 `chatDialog` 不同,它**不阻断操作流程**,支持自动消失和自定义按钮。
|
> `chatBanner` 是居中弹出的沉浸式大卡片通知组件,适用于好友通知、任命公告、系统广播等重要事件。与 `chatDialog` 不同,它**不阻断操作流程**,支持自动消失和自定义按钮。
|
||||||
|
|||||||
@@ -545,17 +545,42 @@ class AdminCommandController extends Controller
|
|||||||
'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}",
|
'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 向聊天室发送悄悄话通知接收者
|
// ① 聊天室公开公告(所有在场用户可见)
|
||||||
|
$publicMsg = [
|
||||||
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
|
'room_id' => $roomId,
|
||||||
|
'from_user' => '系统公告',
|
||||||
|
'to_user' => '',
|
||||||
|
'content' => "🪙 <b>{$admin->username}</b>({$positionName})向 <b>{$targetUsername}</b> 发放了 <b>{$amount}</b> 枚奖励金币!",
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#d97706',
|
||||||
|
'action' => '',
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
$this->chatState->pushMessage($roomId, $publicMsg);
|
||||||
|
broadcast(new MessageSent($roomId, $publicMsg));
|
||||||
|
SaveMessageJob::dispatch($publicMsg);
|
||||||
|
|
||||||
|
// ② 接收者私信(含 toast_notification 触发右下角小卡片)
|
||||||
|
$freshJjb = $target->fresh()->jjb;
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $this->chatState->nextMessageId($roomId),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => $roomId,
|
'room_id' => $roomId,
|
||||||
'from_user' => '系统',
|
'from_user' => '系统',
|
||||||
'to_user' => $targetUsername,
|
'to_user' => $targetUsername,
|
||||||
'content' => "🎁 <b>{$admin->username}</b>({$position->name})向你发放了 <b>{$amount}</b> 枚金币奖励!当前金币:{$target->fresh()->jjb} 枚。",
|
'content' => "🎁 <b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币奖励!当前金币:{$freshJjb} 枚。",
|
||||||
'is_secret' => true,
|
'is_secret' => true,
|
||||||
'font_color' => '#d97706',
|
'font_color' => '#d97706',
|
||||||
'action' => '',
|
'action' => '',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
// 前端 toast-notification 组件识别此字段,弹出右下角通知卡片
|
||||||
|
'toast_notification' => [
|
||||||
|
'title' => '🪙 奖励金币到账',
|
||||||
|
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
||||||
|
'icon' => '🪙',
|
||||||
|
'color' => '#f59e0b',
|
||||||
|
'duration' => 8000,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
$this->chatState->pushMessage($roomId, $msg);
|
$this->chatState->pushMessage($roomId, $msg);
|
||||||
broadcast(new MessageSent($roomId, $msg));
|
broadcast(new MessageSent($roomId, $msg));
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
{{-- ═══════════ 全局自定义弹窗(替代原生 alert/confirm,全页面可用) ═══════════ --}}
|
{{-- ═══════════ 全局自定义弹窗(替代原生 alert/confirm,全页面可用) ═══════════ --}}
|
||||||
@include('chat.partials.global-dialog')
|
@include('chat.partials.global-dialog')
|
||||||
|
@include('chat.partials.toast-notification')
|
||||||
{{-- ═══════════ 聊天室交互脚本(独立文件维护) ═══════════ --}}
|
{{-- ═══════════ 聊天室交互脚本(独立文件维护) ═══════════ --}}
|
||||||
@include('chat.partials.user-actions')
|
@include('chat.partials.user-actions')
|
||||||
|
|
||||||
|
|||||||
@@ -494,6 +494,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendMessage(msg);
|
appendMessage(msg);
|
||||||
|
// 若消息携带 toast_notification 字段且当前用户是接收者,弹右下角小卡片
|
||||||
|
if (msg.toast_notification && msg.to_user === window.chatContext.username) {
|
||||||
|
const t = msg.toast_notification;
|
||||||
|
window.chatToast.show({
|
||||||
|
title: t.title || '通知',
|
||||||
|
message: t.message || '',
|
||||||
|
icon: t.icon || '💬',
|
||||||
|
color: t.color || '#336699',
|
||||||
|
duration: t.duration ?? 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('chat:kicked', (e) => {
|
window.addEventListener('chat:kicked', (e) => {
|
||||||
@@ -673,20 +684,39 @@
|
|||||||
.listen('.FriendRemoved', (e) => {
|
.listen('.FriendRemoved', (e) => {
|
||||||
if (e.had_added_back) {
|
if (e.had_added_back) {
|
||||||
// 之前是互相好友,现在对方删除了我 → 提示可以同步删除
|
// 之前是互相好友,现在对方删除了我 → 提示可以同步删除
|
||||||
showFriendToast(
|
window.chatToast.show({
|
||||||
`� <b>${e.from_username}</b> 已将你从好友列表移除。<br>
|
title: '好友通知',
|
||||||
<span style="color:#6b7280; font-size:12px;">你的好友列表中仍保留对方,可点击同步移除。</span>`,
|
message: `<b>${e.from_username}</b> 已将你从好友列表移除。<br><span style="color:#6b7280; font-size:12px;">你的好友列表中仍保留对方,可点击同步移除。</span>`,
|
||||||
'#6b7280', {
|
icon: '👥',
|
||||||
|
color: '#6b7280',
|
||||||
|
duration: 10000,
|
||||||
|
action: {
|
||||||
label: `🗑️ 同步移除 ${e.from_username}`,
|
label: `🗑️ 同步移除 ${e.from_username}`,
|
||||||
username: e.from_username,
|
onClick: async () => {
|
||||||
action: 'remove'
|
const url = `/friend/${encodeURIComponent(e.from_username)}/remove`;
|
||||||
}
|
const csrf = document.querySelector('meta[name="csrf-token"]')
|
||||||
);
|
?.content ?? '';
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrf,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_id: window.chatContext?.roomId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
showFriendToast(
|
window.chatToast.show({
|
||||||
`� <b>${e.from_username}</b> 已将你从他的好友列表移除。`,
|
title: '好友通知',
|
||||||
'#9ca3af'
|
message: `<b>${e.from_username}</b> 已将你从他的好友列表移除。`,
|
||||||
);
|
icon: '👥',
|
||||||
|
color: '#9ca3af',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1002,86 +1032,8 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function showFriendToast(html, color = '#16a34a', action = null) {
|
// showFriendToast 已迁移至 window.chatToast(toast-notification.blade.php)
|
||||||
const toast = document.createElement('div');
|
// 保留空函数作向后兼容(移除时搜索 showFriendToast 确认无残余调用)
|
||||||
toast.style.cssText = `
|
|
||||||
position: fixed; bottom: 24px; right: 24px; z-index: 999999;
|
|
||||||
background: #fff; border-left: 4px solid ${color};
|
|
||||||
border-radius: 8px; padding: 14px 18px; min-width: 260px; max-width: 320px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,.18);
|
|
||||||
font-size: 13px; color: #374151; line-height: 1.6;
|
|
||||||
animation: fdSlideIn .3s ease;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 操作按钮 HTML
|
|
||||||
let actionHtml = '';
|
|
||||||
if (action) {
|
|
||||||
actionHtml = `
|
|
||||||
<div style="margin-top:10px;">
|
|
||||||
<button id="friend-toast-btn-${Date.now()}"
|
|
||||||
style="background:${color}; color:#fff; border:none; border-radius:5px;
|
|
||||||
padding:5px 12px; font-size:12px; font-weight:bold; cursor:pointer;">
|
|
||||||
${action.label}
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.innerHTML = `
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:bold; margin-bottom:4px; color:${color};">💬 好友通知</div>
|
|
||||||
<div>${html}</div>
|
|
||||||
${actionHtml}
|
|
||||||
</div>
|
|
||||||
<button onclick="this.closest('div[style]').remove()"
|
|
||||||
style="background:none; border:none; color:#9ca3af; font-size:18px;
|
|
||||||
cursor:pointer; line-height:1; margin-left:8px; flex-shrink:0;">×</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// 绑定操作按钮事件
|
|
||||||
if (action) {
|
|
||||||
const btn = toast.querySelector('button[id^="friend-toast-btn"]');
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '处理中…';
|
|
||||||
try {
|
|
||||||
const method = action.action === 'add' ? 'POST' : 'DELETE';
|
|
||||||
const url =
|
|
||||||
`/friend/${encodeURIComponent(action.username)}/${action.action === 'add' ? 'add' : 'remove'}`;
|
|
||||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': csrf,
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
room_id: window.chatContext?.roomId
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
btn.textContent = data.status === 'success' ? '✅ 已完成' : '❌ 失败';
|
|
||||||
btn.style.background = data.status === 'success' ? '#16a34a' : '#cc4444';
|
|
||||||
setTimeout(() => toast.remove(), 2000);
|
|
||||||
} catch (e) {
|
|
||||||
btn.textContent = '❌ 网络错误';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5秒后自动消失
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transition = 'opacity .5s';
|
|
||||||
toast.style.opacity = '0';
|
|
||||||
setTimeout(() => toast.remove(), 500);
|
|
||||||
}, 8000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
|
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
|
||||||
window.addEventListener('chat:effect', (e) => {
|
window.addEventListener('chat:effect', (e) => {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{{--
|
||||||
|
文件功能:全局右下角 Toast 小卡片通知组件
|
||||||
|
|
||||||
|
提供全局 JS API:
|
||||||
|
window.chatToast.show({ title, message, icon?, color?, duration? })
|
||||||
|
- title: 卡片标题
|
||||||
|
- message: 卡片内容(支持 HTML)
|
||||||
|
- icon: 左侧 Emoji 图标(可选,默认 💬)
|
||||||
|
- color: 强调色 HEX(可选,默认 #336699)
|
||||||
|
- duration: 自动消失毫秒数(可选,默认 6000;0 = 不自动消失)
|
||||||
|
- action: { label, onClick } 可选操作按钮
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
window.chatToast.show({ title: '奖励金币', message: '你收到 100 枚金币!', icon: '🪙', color: '#f59e0b' });
|
||||||
|
|
||||||
|
多条 Toast 从右下角向上堆叠,各自独立计时。
|
||||||
|
|
||||||
|
@author ChatRoom Laravel
|
||||||
|
@version 1.0.0
|
||||||
|
--}}
|
||||||
|
|
||||||
|
{{-- Toast 容器(固定右下角,由 JS 动态填充) --}}
|
||||||
|
<div id="chat-toast-container"
|
||||||
|
style="position:fixed; bottom:24px; right:24px; z-index:999998;
|
||||||
|
display:flex; flex-direction:column-reverse; gap:10px; pointer-events:none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 全局右下角 Toast 小卡片通知系统。
|
||||||
|
*
|
||||||
|
* 可在聊天室任何 JS 代码中调用:
|
||||||
|
* window.chatToast.show({ title, message, icon, color, duration, action });
|
||||||
|
*/
|
||||||
|
window.chatToast = (function() {
|
||||||
|
const container = document.getElementById('chat-toast-container');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示一条 Toast 通知卡片。
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.title 标题文字
|
||||||
|
* @param {string} opts.message 内容(支持 HTML)
|
||||||
|
* @param {string} [opts.icon] 左侧 Emoji(默认 💬)
|
||||||
|
* @param {string} [opts.color] 强调色(默认 #336699)
|
||||||
|
* @param {number} [opts.duration] 自动消失毫秒,0 表示不自动消失(默认 6000)
|
||||||
|
* @param {object} [opts.action] 操作按钮 { label: string, onClick: function }
|
||||||
|
*/
|
||||||
|
function show({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon = '💬',
|
||||||
|
color = '#336699',
|
||||||
|
duration = 6000,
|
||||||
|
action = null
|
||||||
|
}) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.style.cssText = `
|
||||||
|
background:#fff; border-radius:10px; overflow:hidden;
|
||||||
|
box-shadow:0 8px 32px rgba(0,0,0,.18);
|
||||||
|
min-width:260px; max-width:320px;
|
||||||
|
font-size:13px; color:#374151; line-height:1.6;
|
||||||
|
pointer-events:all;
|
||||||
|
animation:toastSlideIn .3s ease;
|
||||||
|
opacity:1; transition:opacity .4s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 操作按钮 HTML
|
||||||
|
const actionHtml = action ? `
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<button class="chat-toast-action-btn"
|
||||||
|
style="background:${color}; color:#fff; border:none; border-radius:6px;
|
||||||
|
padding:5px 14px; font-size:12px; font-weight:bold; cursor:pointer;">
|
||||||
|
${action.label}
|
||||||
|
</button>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div style="background:${color}; padding:8px 14px;
|
||||||
|
display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<span style="color:#fff; font-weight:bold; font-size:13px;">
|
||||||
|
${icon} ${title}
|
||||||
|
</span>
|
||||||
|
<button class="chat-toast-close"
|
||||||
|
style="background:rgba(255,255,255,.25); border:none; color:#fff;
|
||||||
|
width:22px; height:22px; border-radius:50%; cursor:pointer;
|
||||||
|
font-size:14px; line-height:22px; text-align:center; padding:0;">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 14px 10px;">
|
||||||
|
<div>${message}</div>
|
||||||
|
${actionHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 关闭按钮
|
||||||
|
card.querySelector('.chat-toast-close').addEventListener('click', () => dismiss(card));
|
||||||
|
|
||||||
|
// 操作按钮
|
||||||
|
if (action) {
|
||||||
|
card.querySelector('.chat-toast-action-btn').addEventListener('click', () => {
|
||||||
|
action.onClick?.();
|
||||||
|
dismiss(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
|
||||||
|
// 自动消失
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => dismiss(card), duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 淡出并移除 Toast 卡片 */
|
||||||
|
function dismiss(card) {
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(() => card.remove(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes toastSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(32px) scale(.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user