From 5c53b8cf2fcbbb750aaae4611c436e60d95414f5 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 1 Mar 2026 01:28:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9Awindow.chatBanner=20?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=A4=A7=E5=8D=A1=E7=89=87=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: - window.chatBanner.show(options) 全局 API,完全自定义: icon/title/name/body/sub/gradient/titleColor/autoClose/buttons - window.chatBanner.close(id) 关闭指定 banner - showFriendBanner / showAppointmentBanner 均改用 chatBanner 实现 - setupBannerNotification() 监听私有+房间频道的 BannerNotification 事件 后端: - BannerNotification 事件(ShouldBroadcastNow),支持 user/room 双目标 - BannerBroadcastController(仅超级管理员路由,三层中间件保护) - 内容字段 strip_tags 净化防 XSS,按钮 action 白名单校验 安全: - window.chatBanner.show() 被人控制台调用只影响自己,无法推给他人 - HTTP 入口 POST /admin/banner/broadcast 仅超管可访问 --- app/Events/BannerNotification.php | 97 +++++ .../Admin/BannerBroadcastController.php | 82 ++++ .../views/chat/partials/scripts.blade.php | 410 +++++++++++------- routes/web.php | 3 + 4 files changed, 429 insertions(+), 163 deletions(-) create mode 100644 app/Events/BannerNotification.php create mode 100644 app/Http/Controllers/Admin/BannerBroadcastController.php diff --git a/app/Events/BannerNotification.php b/app/Events/BannerNotification.php new file mode 100644 index 0000000..86df3b9 --- /dev/null +++ b/app/Events/BannerNotification.php @@ -0,0 +1,97 @@ + '💚📩', + * 'title' => '好友申请', + * 'name' => 'lkddi1', + * 'body' => '将你加为好友了!', + * 'gradient' => ['#1e3a5f', '#1d4ed8', '#0891b2'], + * 'autoClose' => 0, + * 'buttons' => [ + * ['label' => '➕ 回加好友', 'color' => '#10b981', 'action' => 'add_friend', 'actionData' => 'lkddi1'], + * ['label' => '稍后再说', 'color' => 'rgba(255,255,255,0.15)', 'action' => 'close'], + * ], + * ] + * )); + * + * // 推给整个房间 + * broadcast(new BannerNotification(target: 'room', targetId: 1, options: [...] )); + * + * @author ChatRoom Laravel + * + * @version 1.0.0 + */ + +namespace App\Events; + +use Illuminate\Broadcasting\Channel; +use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PresenceChannel; +use Illuminate\Broadcasting\PrivateChannel; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; +use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; + +class BannerNotification implements ShouldBroadcastNow +{ + use Dispatchable, InteractsWithSockets, SerializesModels; + + /** + * 构造通用大卡片通知事件。 + * + * @param string $target 推送目标类型:'user'(私有频道)| 'room'(房间全员) + * @param string|int $targetId 目标 ID:用户名(user)或 房间 ID(room) + * @param array $options 前端 chatBanner.show() 选项(详见文件顶部注释) + */ + public function __construct( + public readonly string $target, + public readonly string|int $targetId, + public readonly array $options = [], + ) {} + + /** + * 根据 $target 决定广播到私有频道还是 Presence 频道。 + */ + public function broadcastOn(): Channel + { + return match ($this->target) { + 'user' => new PrivateChannel('user.'.$this->targetId), + 'room' => new PresenceChannel('room.'.$this->targetId), + default => new PrivateChannel('user.'.$this->targetId), + }; + } + + /** + * 指定广播事件名称,供前端 .listen('.BannerNotification') 匹配。 + */ + public function broadcastAs(): string + { + return 'BannerNotification'; + } + + /** + * 广播负载:传递完整的 options 给前端渲染。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'target' => $this->target, + 'target_id' => $this->targetId, + 'options' => $this->options, + ]; + } +} diff --git a/app/Http/Controllers/Admin/BannerBroadcastController.php b/app/Http/Controllers/Admin/BannerBroadcastController.php new file mode 100644 index 0000000..40a4f22 --- /dev/null +++ b/app/Http/Controllers/Admin/BannerBroadcastController.php @@ -0,0 +1,82 @@ +validate([ + 'target' => ['required', 'in:user,room'], + 'target_id' => ['required'], + 'options' => ['required', 'array'], + 'options.icon' => ['nullable', 'string', 'max:20'], + 'options.title' => ['nullable', 'string', 'max:50'], + 'options.name' => ['nullable', 'string', 'max:100'], + 'options.body' => ['nullable', 'string', 'max:500'], + 'options.sub' => ['nullable', 'string', 'max:200'], + 'options.gradient' => ['nullable', 'array', 'max:5'], + 'options.titleColor' => ['nullable', 'string', 'max:30'], + 'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'], + 'options.buttons' => ['nullable', 'array', 'max:4'], + ]); + + // 对可能包含用户输入的字段进行 HTML 净化(防 XSS) + $opts = $validated['options']; + foreach (['title', 'name', 'body', 'sub'] as $field) { + if (isset($opts[$field])) { + $opts[$field] = strip_tags($opts[$field], '
'); + } + } + // 按钮 label 不允许 HTML + if (! empty($opts['buttons'])) { + $opts['buttons'] = array_map(function ($btn) { + $btn['label'] = strip_tags($btn['label'] ?? ''); + $btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981'); + // action 只允许预定义值,防止注入任意 JS + $btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link']) + ? $btn['action'] : 'close'; + + return $btn; + }, $opts['buttons']); + } + + broadcast(new BannerNotification( + target: $validated['target'], + targetId: $validated['target_id'], + options: $opts, + )); + + return response()->json(['status' => 'success', 'message' => '广播已发送']); + } +} diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index dd41d19..deeefcb 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -690,6 +690,44 @@ } document.addEventListener('DOMContentLoaded', setupFriendNotification); + // ── BannerNotification:通用大卡片通知监听 ────────────────── + /** + * 监听 BannerNotification 事件,渲染 chatBanner 大卡片。 + * + * 安全说明: + * - BannerNotification 仅由后端可信代码 broadcast,前端无法伪造推给他人。 + * - 私有频道需鉴权,presence 频道需加入房间,均须服务端验证身份。 + * - window.chatBanner.show() 即便被人在控制台手动调用,也只影响其自身浏览器,无法影响他人。 + * - options.body / options.sub 的 HTML 内容由服务端控制,用户输入始终经过 escapeHtml 处理。 + */ + function setupBannerNotification() { + if (!window.Echo || !window.chatContext) { + setTimeout(setupBannerNotification, 500); + return; + } + const myName = window.chatContext.username; + const roomId = window.chatContext.roomId; + + // 监听私有用户频道(单独推给某人) + window.Echo.private(`user.${myName}`) + .listen('.BannerNotification', (e) => { + if (e.options && typeof e.options === 'object') { + window.chatBanner.show(e.options); + } + }); + + // 监听房间频道(推给房间所有人) + if (roomId) { + window.Echo.join(`room.${roomId}`) + .listen('.BannerNotification', (e) => { + if (e.options && typeof e.options === 'object') { + window.chatBanner.show(e.options); + } + }); + } + } + document.addEventListener('DOMContentLoaded', setupBannerNotification); + /** * 显示好友添加居中大卡弹窗(同任命公告风格)。 * 互相好友 → 绿色渐变 + 互为好友文案 @@ -698,93 +736,35 @@ * @param {string} fromUsername 添加者用户名 * @param {boolean} hasAddedBack 接收方是否已将添加者加为好友 */ - function showFriendBanner(fromUsername, hasAddedBack) { - // 移除已有的好友弹窗(防止重叠) - const old = document.getElementById('friend-banner'); - if (old) old.remove(); - - const banner = document.createElement('div'); - banner.id = 'friend-banner'; - banner.style.cssText = ` - position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); - z-index: 99999; text-align: center; - animation: appoint-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); - `; - - if (hasAddedBack) { - // 已互相好友 → 绿色渐变卡片 - banner.innerHTML = ` -
-
🎉💚🎉
-
- ══ 好友通知 ══ -
-
- ${escapeHtml(fromUsername)} -
-
- 将你加为好友!你们现在互为好友 🎊 -
-
- ${new Date().toLocaleTimeString('zh-CN')} -
-
- `; - } else { - // 单向添加 → 蓝绿渐变 + 回加按钮(可点击) - const btnId = 'friend-banner-btn-' + Date.now(); - banner.style.pointerEvents = 'auto'; // 允许点击按钮 - banner.innerHTML = ` -
-
💚📩
-
- ══ 好友申请 ══ -
-
- ${escapeHtml(fromUsername)} -
-
- 将你加为好友! -
-
- 但你还没有回加对方为好友 -
-
- - -
-
- ${new Date().toLocaleTimeString('zh-CN')} -
-
- `; - - // 等 DOM 插入后再绑定按钮事件 - setTimeout(() => { - const btn = document.getElementById(btnId); - if (btn) { - btn.addEventListener('click', () => quickFriendAction('add', fromUsername, btn)); - } - }, 50); - } - - document.body.appendChild(banner); - - // 确保动画关键帧已注入 - if (!document.getElementById('appoint-keyframes')) { + // ═══════════════════════════════════════════════════ + // window.chatBanner —— 全局大卡片通知公共组件 + // ═══════════════════════════════════════════════════ + /** + * 全局大卡片通知组件。 + * + * 用法: + * window.chatBanner.show({ + * id: 'my-banner', // 可选,防止同 ID 重叠 + * icon: '🎉💚🎉', // Emoji 图标 + * title: '好友通知', // 小标题 + * name: 'lkddi1', // 大名字行(可留空) + * body: '将你加为好友了!', // 主内容(支持 HTML) + * sub: '你们现在互为好友 🎊', // 副内容(小字) + * gradient: ['#065f46','#059669','#10b981'], // 渐变颜色 + * titleColor: '#a7f3d0', // 小标题颜色 + * autoClose: 5000, // 自动关闭 ms,0=不关闭 + * buttons: [ + * { label:'确定', color:'#10b981', onClick(btn, close) { close(); } }, + * { label:'取消', color:'rgba(255,255,255,0.15)', onClick(btn, close) { close(); } }, + * ], + * }); + * + * window.chatBanner.close('my-banner'); // 关闭指定 banner + */ + window.chatBanner = (function() { + // 注入动画样式(全局只注入一次) + function ensureKeyframes() { + if (document.getElementById('appoint-keyframes')) return; const style = document.createElement('style'); style.id = 'appoint-keyframes'; style.textContent = ` @@ -801,15 +781,162 @@ document.head.appendChild(style); } - // 非单向(互相好友):5 秒后自动淡出;单向:需手动关闭(有按钮) + /** + * 显示大卡片通知。 + * + * @param {Object} opts 选项(见上方注释) + */ + function show(opts = {}) { + ensureKeyframes(); + + const id = opts.id || 'chat-banner-default'; + const gradient = (opts.gradient || ['#4f46e5', '#7c3aed', '#db2777']).join(', '); + const titleColor = opts.titleColor || '#fde68a'; + const autoClose = opts.autoClose ?? 5000; + + // 移除同 ID 的旧弹窗 + const old = document.getElementById(id); + if (old) old.remove(); + + // 构建按钮 HTML + const hasButtons = opts.buttons && opts.buttons.length > 0; + let buttonsHtml = ''; + if (hasButtons) { + buttonsHtml = '
'; + opts.buttons.forEach((btn, idx) => { + buttonsHtml += ``; + }); + buttonsHtml += '
'; + } + + const banner = document.createElement('div'); + banner.id = id; + banner.style.cssText = ` + position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); + z-index: 99999; text-align: center; + animation: appoint-pop 0.5s cubic-bezier(0.175,0.885,0.32,1.275); + ${hasButtons ? 'pointer-events: auto;' : 'pointer-events: none;'} + `; + + banner.innerHTML = ` +
+ ${opts.icon ? `
${opts.icon}
` : ''} + ${opts.title ? `
+ ══ ${opts.title} ══ +
` : ''} + ${opts.name ? `
+ ${escapeHtml(opts.name)} +
` : ''} + ${opts.body ? `
${opts.body}
` : ''} + ${opts.sub ? `
${opts.sub}
` : ''} + ${buttonsHtml} +
+ ${new Date().toLocaleTimeString('zh-CN')} +
+
+ `; + + document.body.appendChild(banner); + + // 绑定按钮点击事件 + if (hasButtons) { + const closeFn = () => { + banner.style.animation = 'appoint-fade-out 0.5s ease forwards'; + setTimeout(() => banner.remove(), 500); + }; + opts.buttons.forEach((btn, idx) => { + const el = banner.querySelector(`[data-banner-btn="${idx}"]`); + if (el && btn.onClick) { + el.addEventListener('click', () => btn.onClick(el, closeFn)); + } else if (el) { + el.addEventListener('click', closeFn); + } + }); + } + + // 自动关闭 + if (autoClose > 0) { + setTimeout(() => { + if (!document.getElementById(id)) return; + banner.style.animation = 'appoint-fade-out 0.5s ease forwards'; + setTimeout(() => banner.remove(), 500); + }, autoClose); + } + } + + /** + * 关闭指定 ID 的 banner。 + * + * @param {string} id banner 的 DOM ID + */ + function close(id) { + const el = document.getElementById(id || 'chat-banner-default'); + if (!el) return; + el.style.animation = 'appoint-fade-out 0.5s ease forwards'; + setTimeout(() => el.remove(), 500); + } + + return { + show, + close + }; + })(); + + /** + * 好友添加大卡弹窗(使用 chatBanner 公共组件)。 + * + * @param {string} fromUsername 添加者用户名 + * @param {boolean} hasAddedBack 是否已互相添加 + */ + function showFriendBanner(fromUsername, hasAddedBack) { if (hasAddedBack) { - setTimeout(() => { - banner.style.animation = 'appoint-fade-out 0.5s ease forwards'; - setTimeout(() => banner.remove(), 500); - }, 5000); + window.chatBanner.show({ + id: 'friend-banner', + icon: '🎉💚🎉', + title: '好友通知', + name: fromUsername, + body: '将你加为好友了!', + sub: '你们现在互为好友 🎊', + gradient: ['#065f46', '#059669', '#10b981'], + titleColor: '#a7f3d0', + autoClose: 5000, + }); + } else { + window.chatBanner.show({ + id: 'friend-banner', + icon: '💚📩', + title: '好友申请', + name: fromUsername, + body: '将你加为好友了!', + sub: '但你还没有回加对方为好友', + gradient: ['#1e3a5f', '#1d4ed8', '#0891b2'], + titleColor: '#bae6fd', + autoClose: 0, + buttons: [{ + label: '➕ 回加好友', + color: '#10b981', + onClick: (btn) => quickFriendAction('add', fromUsername, btn), + }, + { + label: '稍后再说', + color: 'rgba(255,255,255,0.15)', + onClick: (btn, close) => close(), + }, + ], + }); } } + /** * 显示好友事件通知浮窗(右下角淡入淡出)。 * @@ -1752,86 +1879,43 @@ /** * 显示任命公告弹窗(居中,5 秒后淡出) */ + /** + * 显示任命公告弹窗(改用 chatBanner 公共组件)。 + * + * @param {Object} data 任命数据:type, target_username, position_icon, position_name, department_name, operator_name + */ function showAppointmentBanner(data) { const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : ''; const isRevoke = data.type === 'revoke'; - const banner = document.createElement('div'); - banner.id = 'appointment-banner'; - banner.style.cssText = ` - position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); - z-index: 99999; text-align: center; pointer-events: none; - animation: appoint-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); - `; if (isRevoke) { - banner.innerHTML = ` -
-
📋
-
- ── 职务撤销 ── -
-
- ${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)} -
-
- ${dept}${escapeHtml(data.position_name)} 职务已被撤销 -
-
- 由 ${escapeHtml(data.operator_name)} 执行 · ${new Date().toLocaleTimeString('zh-CN')} -
-
- `; + window.chatBanner.show({ + id: 'appointment-banner', + icon: '📋', + title: '职务撤销', + name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`, + body: `${dept}${escapeHtml(data.position_name)} 职务已被撤销`, + sub: `由 ${escapeHtml(data.operator_name)} 执行`, + gradient: ['#374151', '#4b5563', '#6b7280'], + titleColor: '#d1d5db', + autoClose: 4500, + }); } else { - banner.innerHTML = ` -
-
🎊🎖️🎊
-
- ══ 任命公告 ══ -
-
- ${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)} -
-
- 荣任 ${dept}${escapeHtml(data.position_name)} -
-
- 由 ${escapeHtml(data.operator_name)} 任命 · ${new Date().toLocaleTimeString('zh-CN')} -
-
- `; + window.chatBanner.show({ + id: 'appointment-banner', + icon: '🎊🎖️🎊', + title: '任命公告', + name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`, + body: `荣任 ${dept}${escapeHtml(data.position_name)}`, + sub: `由 ${escapeHtml(data.operator_name)} 任命`, + gradient: ['#4f46e5', '#7c3aed', '#db2777'], + titleColor: '#fde68a', + autoClose: 4500, + }); } - - // 弹窗动画关键帧(动态注入一次) - if (!document.getElementById('appoint-keyframes')) { - const style = document.createElement('style'); - style.id = 'appoint-keyframes'; - style.textContent = ` - @keyframes appoint-pop { - 0% { opacity: 0; transform: translate(-50%,-50%) scale(0.5); } - 70% { transform: translate(-50%,-50%) scale(1.05); } - 100% { opacity: 1; transform: translate(-50%,-50%) scale(1); } - } - @keyframes appoint-fade-out { - from { opacity: 1; } - to { opacity: 0; transform: translate(-50%,-50%) scale(0.9); } - } - `; - document.head.appendChild(style); - } - - document.body.appendChild(banner); - - // 4.5 秒后淡出移除 - setTimeout(() => { - banner.style.animation = 'appoint-fade-out 0.5s ease forwards'; - setTimeout(() => banner.remove(), 500); - }, 4500); } + /** * 监听任命公告事件:根据 type 区分任命(礼花+紫色弹窗)和撤销(灰色弹窗) */ diff --git a/routes/web.php b/routes/web.php index db412e9..96e6d52 100644 --- a/routes/web.php +++ b/routes/web.php @@ -191,6 +191,9 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad Route::put('/departments/{department}', [\App\Http\Controllers\Admin\DepartmentController::class, 'update'])->name('departments.update'); Route::put('/positions/{position}', [\App\Http\Controllers\Admin\PositionController::class, 'update'])->name('positions.update'); + // 大卡片通知广播(仅超级管理员,安全隔离:普通用户无此接口) + Route::post('/banner/broadcast', [\App\Http\Controllers\Admin\BannerBroadcastController::class, 'send'])->name('admin.banner.broadcast'); + // 聊天室参数(含保存) Route::get('/system', [\App\Http\Controllers\Admin\SystemController::class, 'edit'])->name('system.edit'); Route::put('/system', [\App\Http\Controllers\Admin\SystemController::class, 'update'])->name('system.update');