功能:window.chatBanner 全局大卡片公共组件
前端: - 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 仅超管可访问
This commit is contained in:
97
app/Events/BannerNotification.php
Normal file
97
app/Events/BannerNotification.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:通用大卡片通知广播事件
|
||||
*
|
||||
* 可向指定用户(私有频道)或房间所有人(Presence 频道)推送全屏大卡片通知。
|
||||
* 前端通过 window.chatBanner.show(options) 渲染,支持完全自定义。
|
||||
*
|
||||
* 使用示例(后端):
|
||||
*
|
||||
* // 推给单个用户
|
||||
* broadcast(new BannerNotification(
|
||||
* target: 'user',
|
||||
* targetId: 'lkddi',
|
||||
* options: [
|
||||
* 'icon' => '💚📩',
|
||||
* '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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'target' => $this->target,
|
||||
'target_id' => $this->targetId,
|
||||
'options' => $this->options,
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Http/Controllers/Admin/BannerBroadcastController.php
Normal file
82
app/Http/Controllers/Admin/BannerBroadcastController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:管理员大卡片通知广播控制器
|
||||
*
|
||||
* 仅超级管理员(chat.level:super 中间件保护)可调用此接口,
|
||||
* 通过 BannerNotification 事件向指定用户或房间推送自定义大卡通知。
|
||||
*
|
||||
* 安全保证:
|
||||
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
|
||||
* - 普通用户无权访问此接口,无法伪造对他人的广播
|
||||
* - options 中的用户输入字段在后端经过 strip_tags 清洗
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Events\BannerNotification;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BannerBroadcastController extends Controller
|
||||
{
|
||||
/**
|
||||
* 向指定目标广播大卡片通知。
|
||||
*
|
||||
* 请求参数:
|
||||
* - target: 'user' | 'room'
|
||||
* - target_id: 用户名 或 房间 ID
|
||||
* - options: 与 window.chatBanner.show() 参数相同的对象
|
||||
* - icon, title, name, body, sub, gradient(array), titleColor, autoClose, buttons(array)
|
||||
*/
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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], '<b><strong><em><span><br>');
|
||||
}
|
||||
}
|
||||
// 按钮 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' => '广播已发送']);
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div style="background: linear-gradient(135deg, #065f46, #059669, #10b981);
|
||||
border-radius: 20px; padding: 28px 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);">
|
||||
<div style="font-size: 40px; margin-bottom: 8px;">🎉💚🎉</div>
|
||||
<div style="color: #a7f3d0; font-size: 13px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
|
||||
══ 好友通知 ══
|
||||
</div>
|
||||
<div style="color: white; font-size: 22px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
||||
${escapeHtml(fromUsername)}
|
||||
</div>
|
||||
<div style="color: #d1fae5; font-size: 14px; margin-top: 10px;">
|
||||
将你加为好友!<strong style="color: #a7f3d0;">你们现在互为好友 🎊</strong>
|
||||
</div>
|
||||
<div style="color: rgba(255,255,255,0.45); font-size: 11px; margin-top: 10px;">
|
||||
${new Date().toLocaleTimeString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// 单向添加 → 蓝绿渐变 + 回加按钮(可点击)
|
||||
const btnId = 'friend-banner-btn-' + Date.now();
|
||||
banner.style.pointerEvents = 'auto'; // 允许点击按钮
|
||||
banner.innerHTML = `
|
||||
<div style="background: linear-gradient(135deg, #1e3a5f, #1d4ed8, #0891b2);
|
||||
border-radius: 20px; padding: 28px 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);">
|
||||
<div style="font-size: 40px; margin-bottom: 8px;">💚📩</div>
|
||||
<div style="color: #bae6fd; font-size: 13px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
|
||||
══ 好友申请 ══
|
||||
</div>
|
||||
<div style="color: white; font-size: 22px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
||||
${escapeHtml(fromUsername)}
|
||||
</div>
|
||||
<div style="color: #bae6fd; font-size: 14px; margin-top: 10px;">
|
||||
将你加为好友!
|
||||
</div>
|
||||
<div style="color: rgba(255,255,255,0.6); font-size: 12px; margin-top: 6px;">
|
||||
但你还没有回加对方为好友
|
||||
</div>
|
||||
<div style="margin-top: 16px;">
|
||||
<button id="${btnId}"
|
||||
style="background: #10b981; color: #fff; border: none; border-radius: 8px;
|
||||
padding: 8px 20px; font-size: 13px; font-weight: bold; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);">
|
||||
➕ 回加好友
|
||||
</button>
|
||||
<button onclick="document.getElementById('friend-banner').remove()"
|
||||
style="background: rgba(255,255,255,0.15); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 8px 16px; font-size: 13px; cursor: pointer; margin-left: 10px;">
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
<div style="color: rgba(255,255,255,0.35); font-size: 11px; margin-top: 12px;">
|
||||
${new Date().toLocaleTimeString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 等 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 = '<div style="margin-top:18px; display:flex; gap:10px; justify-content:center;">';
|
||||
opts.buttons.forEach((btn, idx) => {
|
||||
buttonsHtml += `<button data-banner-btn="${idx}"
|
||||
style="background:${btn.color || '#10b981'}; color:#fff; border:none; border-radius:8px;
|
||||
padding:8px 20px; font-size:13px; font-weight:bold; cursor:pointer;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,0.25);">
|
||||
${btn.label || '确定'}
|
||||
</button>`;
|
||||
});
|
||||
buttonsHtml += '</div>';
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div style="background: linear-gradient(135deg, ${gradient});
|
||||
border-radius: 20px; padding: 28px 44px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);
|
||||
min-width: 260px;">
|
||||
${opts.icon ? `<div style="font-size:40px; margin-bottom:8px;">${opts.icon}</div>` : ''}
|
||||
${opts.title ? `<div style="color:${titleColor}; font-size:13px; font-weight:bold; letter-spacing:3px; margin-bottom:12px;">
|
||||
══ ${opts.title} ══
|
||||
</div>` : ''}
|
||||
${opts.name ? `<div style="color:white; font-size:22px; font-weight:900; text-shadow:0 2px 8px rgba(0,0,0,0.3);">
|
||||
${escapeHtml(opts.name)}
|
||||
</div>` : ''}
|
||||
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${opts.body}</div>` : ''}
|
||||
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${opts.sub}</div>` : ''}
|
||||
${buttonsHtml}
|
||||
<div style="color:rgba(255,255,255,0.35); font-size:11px; margin-top:14px;">
|
||||
${new Date().toLocaleTimeString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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: '<strong style="color:#a7f3d0;">你们现在互为好友 🎊</strong>',
|
||||
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 = `
|
||||
<div style="background: linear-gradient(135deg, #374151, #4b5563, #6b7280);
|
||||
border-radius: 20px; padding: 24px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.15); backdrop-filter: blur(8px);">
|
||||
<div style="font-size: 36px; margin-bottom: 8px;">📋</div>
|
||||
<div style="color: #d1d5db; font-size: 12px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
|
||||
── 职务撤销 ──
|
||||
</div>
|
||||
<div style="color: white; font-size: 20px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
||||
${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}
|
||||
</div>
|
||||
<div style="color: #d1d5db; font-size: 13px; margin-top: 8px;">
|
||||
<strong style="color: #f3f4f6;">${dept}${escapeHtml(data.position_name)}</strong> 职务已被撤销
|
||||
</div>
|
||||
<div style="color: rgba(255,255,255,0.4); font-size: 11px; margin-top: 10px;">
|
||||
由 ${escapeHtml(data.operator_name)} 执行 · ${new Date().toLocaleTimeString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
window.chatBanner.show({
|
||||
id: 'appointment-banner',
|
||||
icon: '📋',
|
||||
title: '职务撤销',
|
||||
name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`,
|
||||
body: `<strong style="color:#f3f4f6;">${dept}${escapeHtml(data.position_name)}</strong> 职务已被撤销`,
|
||||
sub: `由 ${escapeHtml(data.operator_name)} 执行`,
|
||||
gradient: ['#374151', '#4b5563', '#6b7280'],
|
||||
titleColor: '#d1d5db',
|
||||
autoClose: 4500,
|
||||
});
|
||||
} else {
|
||||
banner.innerHTML = `
|
||||
<div style="background: linear-gradient(135deg, #4f46e5, #7c3aed, #db2777);
|
||||
border-radius: 20px; padding: 28px 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);">
|
||||
<div style="font-size: 40px; margin-bottom: 8px;">🎊🎖️🎊</div>
|
||||
<div style="color: #fde68a; font-size: 13px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
|
||||
══ 任命公告 ══
|
||||
</div>
|
||||
<div style="color: white; font-size: 22px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
||||
${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}
|
||||
</div>
|
||||
<div style="color: #e0e7ff; font-size: 14px; margin-top: 8px;">
|
||||
荣任 <strong style="color: #fde68a;">${dept}${escapeHtml(data.position_name)}</strong>
|
||||
</div>
|
||||
<div style="color: rgba(255,255,255,0.5); font-size: 11px; margin-top: 10px;">
|
||||
由 ${escapeHtml(data.operator_name)} 任命 · ${new Date().toLocaleTimeString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
window.chatBanner.show({
|
||||
id: 'appointment-banner',
|
||||
icon: '🎊🎖️🎊',
|
||||
title: '任命公告',
|
||||
name: `${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}`,
|
||||
body: `荣任 <strong style="color:#fde68a;">${dept}${escapeHtml(data.position_name)}</strong>`,
|
||||
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 区分任命(礼花+紫色弹窗)和撤销(灰色弹窗)
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user