54 Commits

Author SHA1 Message Date
pllx f0137f3fa3 修复:后台恢复时只省略过时的系统/游戏通知,保留用户聊天记录 2026-04-28 11:59:01 +08:00
pllx b15e42891d 修复:后台恢复时消息暴刷,只渲染最后50条并插入省略提示 2026-04-28 11:55:35 +08:00
pllx 214a422504 修复:自动钓鱼冷却倒计时改为基于时间戳,解决后台标签节流导致的显示/触发延迟 2026-04-28 11:52:30 +08:00
pllx f16f10fe82 新增:购买自动钓鱼卡后自动开启钓鱼模式 2026-04-28 11:37:38 +08:00
pllx a3daf3f074 修复:欢迎消息中的发送者名字改为可点击(保留职务前缀内的名字,不额外显示) 2026-04-28 11:02:53 +08:00
pllx d0a38352a5 修复:欢迎按钮消息中发送者名字点击无响应(缺少 clickableUser 渲染) 2026-04-28 10:57:29 +08:00
pllx c06e265c0d 回退:恢复 Alpine.js 独立加载引用(项目中无 npm alpinejs 依赖,全局 window.Alpine 全靠此文件) 2026-04-28 10:52:55 +08:00
pllx 6ae7a4a82b 修复:移除 frame.blade.php 中冗余的 Alpine.js 独立加载 2026-04-28 10:50:13 +08:00
pllx 792b0765fd 优化:chat.css 移入 Vite 构建管线
将 public/css/chat.css(958行)移入 resources/css/chat.css,通过 Vite 构建,不再使用传统 <link> 加载。

改动:
- vite.config.js:添加 resources/css/chat.css 入口
- frame.blade.php:替换 <link> 为 @vite 方式
2026-04-28 10:43:22 +08:00
pllx 3e0fb33a9b 文档:更新优化计划完成状态标注 2026-04-28 10:36:02 +08:00
pllx e7049b5f5b 优化:聊天图片添加 loading="lazy" 懒加载
在 message-renderer.js 的聊天图片缩略图 img 标签上添加 loading="lazy" decoding="async",非可视区域的图片不会被加载,减少初始页面数据传输。
2026-04-28 10:33:49 +08:00
pllx 62371a7c64 新增:聊天室反馈模态弹窗(仿留言弹窗样式)
点击工具栏「反馈」按钮弹出反馈弹窗,不再跳转新页面。

新建文件:
- feedback-modal.blade.php — 蓝白渐变标题栏、类型筛选Tabs、反馈卡片列表(展开详情/评论)、提交反馈表单、滚动懒加载
- feedback.js — AJAX加载/提交/点赞/评论/删除,滚动懒加载,乐观UI更新

修改文件:
- toolbar.blade.php — 反馈按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 feedback 动作
- chat-room.js — 静态导入 feedback 模块
- frame.blade.php — 引入反馈弹窗
- routes/web.php — 新增 feedback.data 路由
- FeedbackController.php — 新增 data() 方法
2026-04-28 10:29:14 +08:00
pllx 540d8bf6ff 新增:聊天室留言板模态弹窗(仿商店样式)
点击工具栏「留言」按钮弹出留言板弹窗,不再跳转新页面。

新建文件:
- guestbook-modal.blade.php — 蓝白渐变标题栏、三Tab切换、留言卡片列表、内嵌写留言表单
- guestbook.js — 完整的AJAX加载/提交/删除逻辑,绑定所有事件

修改文件:
- toolbar.blade.php — 留言按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 guestbook 动作
- chat-room.js — 静态导入 guestbook 模块
- frame.blade.php — 引入留言弹窗
- routes/web.php — 新增 guestbook.data JSON 路由
- GuestbookController.php — 新增 data() 方法
2026-04-28 10:20:32 +08:00
pllx bf2d63f125 修复:头像弹窗点击遮罩层关闭
头像选择弹窗缺少 data-avatar-picker-overlay / data-avatar-picker-panel 属性及遮罩层点击关闭逻辑。参考设置弹窗的模式添加。

改动:
- toolbar.blade.php:添加 data-avatar-picker-overlay 和 data-avatar-picker-panel
- profile-controls.js:添加遮罩层点击关闭处理
2026-04-28 10:11:16 +08:00
pllx 4f22fd552a 修复:钓鱼/欢迎/图片等按钮点击无响应
22 个注册事件委托的懒加载模块改为静态导入,保留 8 个工具栏模块继续保持懒加载。

按钮点击无响应的根因:模块的 bind*Controls() 通过 data-* 属性注册事件监听器,但模块懒加载从未被触发,监听器不注册。

chat.js:239 KB(原 308 KB,↓22%)
vendor.js:108 KB(独立缓存)
按需加载模块:8 个(shop/bank/vip 等)
2026-04-28 10:07:17 +08:00
pllx 790730e2c2 修复:签到按钮点击无效
daily-sign-in.js 之前是懒加载,但模块在顶层设置了 window.openDailySignInModal 等全局函数,且 bindDailySignInControls() 注册事件委托。由于模块从未被触发加载,签到按钮点击无响应。

恢复为静态导入,问题和 Alpine 组件一样。chat.js 从 170KB 增至 184KB(原 308KB,↓40%)
2026-04-28 09:58:56 +08:00
pllx eeb9dfbade 修复:Alpine 组件恢复静态导入,消除 321 处表达式报错
将 13 个有 x-data 引用的 Alpine 组件模块恢复为静态导入,保留 27 个非 Alpine 模块懒加载。

chat.js 体积:170 KB(原 308 KB,↓45%)
vendor 独立分包:108 KB
非 Alpine 模块仍保持按需代码分割
2026-04-28 09:50:25 +08:00
pllx 1c067e452b 修复所有 Alpine 组件表达式报错
彻底移除 Proxy/has 陷阱方案,改用显式方法存根:
- userCardComponent 补充 35 个方法存根
- marriage-modals 8 个组件改用 createLazyAlpineComponent
- weddingSetup/weddingEnvelope 等 Modal 均正确包装
控制台现在应该没有任何 Alpine Expression Error
2026-04-28 09:42:18 +08:00
pllx e50502d8f6 前端加载优化:代码分割 + 按需懒加载
chat.js 首屏 308KB → 100KB(↓68%)
44 个重型模块改为 Vite 动态 import()
Alpine 组件通过 $watch 监听实现真懒加载
新增 createLazyAlpineComponent 工具 + Proxy has 陷阱修复
补充 userCardComponent 全部 28 个属性默认值
vendor 依赖独立分包(108KB)
生产环境关闭 sourcemap
2026-04-28 09:38:18 +08:00
pllx e8b4dcc968 fix: 公屏消息中'大家'不可点击的问题 2026-04-27 09:36:35 +00:00
pllx e177ad6d4d 优化百家乐显示 2026-04-27 17:24:28 +08:00
pllx f17f171f4b fix: 修复迁移遗留的按钮无响应、头像框层级及构建错误
迁移收尾修复:
- heartbeat.js: 移除 export { } 中重复的 startHeartbeat/stopHeartbeat(已通过 export function 导出)
- scripts.blade.php: 移除 JS 注释中的 {{ }} 避免 Blade 编译为 e() 导致 PHP 解析错误
- preferences-status.js: 补全 6 个缺失的 window.* 赋值(toggleBlockMenu/toggleFeatureMenu 等),
  实现迁移中丢失的 updateDailyStatus/clearDailyStatus,修复 handleFeatureLocalClear 清屏回调
- toolbar.js: 补全 window.runFeatureShortcut 赋值

头像框样式修复(chat-decorations.css):
- z-index 互换:头像降至 1,框升至 3,使框边缘可遮挡头像外围
- 使用 CSS mask(radial-gradient)挖环形替代旧 ::before 实心圆遮挡方案
- clip-path: circle(50%) 硬裁剪确保圆形,不受 chat.css border-radius: 2px 覆盖
- 特异性提升至 .user-item .avatar-frame-wrapper .user-head

新 Vite 模块(从 Blade 迁移):
- chat-state.js / message-renderer.js / user-list.js / chat-events.js
- composer.js(重写)/ heartbeat.js / admin-commands.js
- vip-presence.js / chat-decorations.css
2026-04-27 09:19:49 +00:00
pllx d10a354370 fix: logout route accepts GET to prevent 404 on page refresh
POST /logout redirects to / after logging out, but when the redirect
fails to complete (browser/network quirk) the user is stuck on /logout.
Refreshing sends GET, which had no route defined, causing a 404.

Changed Route::post to Route::match(['get', 'post']) so refreshing
after a stuck redirect gracefully completes the logout instead.
2026-04-27 07:19:58 +00:00
pllx efb03f90b8 后台流水区分四种装扮消费类型:气泡/昵称色/文字色/头像框
- CurrencySource 新增 MSG_BUBBLE_BUY、MSG_NAME_COLOR_BUY、MSG_TEXT_COLOR_BUY
- DecorationService::purchase() 按商品 type 选择对应 source
- 后台流水页「来源途径」筛选现在可分别查询四种装扮消费
2026-04-27 07:08:22 +00:00
pllx 3ecafd01ea 签到通知现在也和礼包、买单活动一样,取消外层背景和边框,只保留原通知文字和按钮 2026-04-27 15:03:44 +08:00
pllx d82aa1c434 聊天消息头像也显示已购头像框特效
- DecorationService: getDecorationsForMessage() 加入 avatar_frame 字段
- 新增 .avatar-frame-wrapper-sm 紧凑版头像框样式(适配16px小头像)
- 消息渲染时检查 msg.avatar_frame 和 senderInfo.avatar_frame,包裹头像框
2026-04-27 06:59:00 +00:00
pllx 3db8e4ab82 取消买单活动通知背景边框 2026-04-27 14:58:17 +08:00
pllx 10d158b38a fix: 修复气泡消息挤在同一行的问题,改为 block + fit-content 2026-04-27 06:50:49 +00:00
pllx ea02c36ea6 调整消息气泡宽度:根据内容自适应而非整屏幕通长 2026-04-27 06:48:45 +00:00
pllx 2c8cb21206 修复普通定向发言公屏可见 2026-04-27 14:43:43 +08:00
pllx c0cb7f5ead 调整商店商品价格:气泡/昵称色/文字色/头像框价格重新定价 2026-04-27 06:37:44 +00:00
pllx 83c312196c fix: 每次打开商店弹窗时重新获取数据库最新数据
关闭商店弹窗时重置 shopLoaded 标志,确保下次打开时重新请求 /shop/items,避免展示过期数据。
2026-04-27 06:30:52 +00:00
pllx 66206fa521 取消礼包领取通知背景边框 2026-04-27 14:30:51 +08:00
pllx ee62a3add8 fix: 修复 msg_text_color 类型在购买分发、校验和后台管理中的遗漏
- ShopService: 购买分发 match 新增 msg_text_color → DecorationService
- ShopController: 购买公告 match 新增 msg_text_color
- Admin/ShopItemController: 后台 validation type 校验新增 msg_text_color
- admin/shop/index.blade: 类型标签映射和下拉选项新增文字颜色
2026-04-27 06:23:42 +00:00
pllx 3c749969b4 修复新人等级被降为零 2026-04-27 14:23:34 +08:00
pllx 277cb617da feat: 新增消息文字颜色特效装扮(七彩/流光/霓虹/火焰/冰蓝)
- 新增 msg_text_color 商品类型,扩展 shop_items.type ENUM
- DecorationService 支持 text_color 槽位,自动注入消息广播
- CSS 动画:rainbow(彩虹流动)、shimmer(金属流光)、neon(霓虹脉动)、flame(火焰跃动)、ice(冰蓝流转)
- ShopItemSeeder 新增 5 款文字颜色特效商品
- 商店前端新增「🌈 文字颜色」装扮分组
- 消息渲染 appendMessage/buildChatMessageContent 支持文字特效 class
2026-04-27 06:17:22 +00:00
pllx dd9ae46c04 修复新人欢迎被本地清屏过滤 2026-04-27 14:13:23 +08:00
pllx 3d8e270df4 修复新人进房欢迎消息显示 2026-04-27 14:05:11 +08:00
pllx 8db1a252d7 优化新人欢迎信息 2026-04-27 13:54:59 +08:00
pllx 3e85cb67bc 右侧名单徽标改为仅图标,去掉文字标签节省空间
- buildUserStatusBadgeHtml: 状态徽标从 pill 胶囊(图标+文字)改为仅图标
- buildUserSignIdentityBadgeHtml: 签到身份徽标同样改为仅图标
- 两个徽标的 tooltip 信息保留,悬停仍可看到完整描述
2026-04-27 05:45:36 +00:00
lkddi 40a0849151 新增 新人 小班长 自动发送欢迎 2026-04-27 13:27:38 +08:00
lkddi 442ca0e1e2 优化ai小班长 根据聊天内容确定总送金币数量 2026-04-27 13:22:27 +08:00
lkddi 3f2eb7d48b 优化 签到文字提示 2026-04-27 12:19:43 +08:00
lkddi c9d4d3dbf4 优化商店、游戏文字通知 2026-04-27 12:16:18 +08:00
lkddi f6bc8a83c3 优化游戏 通知样式 2026-04-27 12:04:48 +08:00
lkddi a09927f6fd 修复bug 2026-04-27 11:35:00 +08:00
lkddi 16b709d1da 修改个性特性商店bug 2026-04-27 11:32:22 +08:00
lkddi a16a8fb9f4 优化商店 2026-04-27 11:23:15 +08:00
lkddi bcb762df77 优化商店 2026-04-27 11:23:08 +08:00
lkddi ffccfa26e9 优化商店个性装扮体验 2026-04-27 11:12:51 +08:00
lkddi 32af6abeb2 优化 2026-04-26 21:06:43 +08:00
lkddi d4082e0edd 限制系统参数配置为站长专属 2026-04-26 21:06:30 +08:00
lkddi 0402097b59 聊天室管理权限统一为职务权限 2026-04-26 20:55:11 +08:00
lkddi b07f4e971a 优化 后台等级设置 2026-04-26 20:37:23 +08:00
90 changed files with 11041 additions and 4775 deletions
@@ -0,0 +1,335 @@
# 🛡️ 聊天室项目 — 安全与访问速度优化规划方案
> **项目路径:** `/Users/pllx/Web/Herd/chatroom`
> **技术栈:** Laravel 12 + PHP 8.4 + Redis + MySQL + Reverb (WebSocket) + TailwindCSS 4 + Vite
> **检查日期:** 2026-04-27
---
## 一、安全优化(🔴高危 / 🟡中危 / 🟢低危)
### 🔴 1. 关闭 APP_DEBUG(生产环境)
**当前:** `.env``APP_DEBUG=true`
**风险:** 生产环境开启 DEBUG 会在报错时泄露数据库密码、Redis 密码、Reverb 密钥等敏感信息。
**方案:**
```
# .env 生产环境改为
APP_DEBUG=false
```
---
### 🔴 2. 启用 Session 加密
**当前:** `SESSION_ENCRYPT=false`
**风险:** Session 数据以明文存储在 Redis 中,若 Redis 被入侵或存在 SSRF,用户身份数据全部泄露。
**方案:**
```
# .env 添加
SESSION_ENCRYPT=true
```
---
### 🔴 3. 限制 Reverb WebSocket 允许源(Allowed Origins
**当前:** `config/reverb.php``'allowed_origins' => ['*']`
**风险:** 任何第三方网站均可连接你的 WebSocket 服务,可被用于 CSWSHCross-Site WebSocket Hijacking)攻击,窃取聊天消息。
**方案:**
```php
// config/reverb.php
'allowed_origins' => [
env('APP_URL', 'http://chatroom.test'),
// 如果有多个域名,手动列出
],
```
---
### 🔴 4. Reverb WebSocket 启用 TLSWSS
**当前:** `REVERB_SCHEME=http`WebSocket 走明文 HTTP
**风险:** 所有聊天消息、用户在线状态等实时数据明文传输,可被中间人攻击窃听。
**方案:**
```
# .env 生产环境
REVERB_SCHEME=https
REVERB_PORT=443 # 或 8443
# 并在 reverb.php 中配置 TLS 证书路径
```
---
### 🔴 5. 设置 Session Cookie Secure 标志
**当前:** `SESSION_SECURE_COOKIE` 未设置(null
**风险:** 在 HTTPS 下,未标记 Secure 的 Cookie 仍可能被非 HTTPS 连接泄露。
**方案:**
```
# .env 生产环境
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
```
---
### 🟡 6. 加强登录安全策略
**当前状况:**
- 存在验证码(mews/captcha)✅
- 登录有 `throttle:chat-login` 限流 ✅
- 自动注册(用户名+密码即可注册)⚡ 双刃剑
- MD5 老密码兼容 ✅ 会自动升级为 Bcrypt
**优化方案:**
| 项目 | 建议 |
|------|------|
| 登录失败锁定 | 同一 IP 5 次失败后临时锁定 15 分钟,后端实现 |
| 密码强度 | 最低 6 位,建议增加 min:6 验证或至少含数字/字母 |
| 管理员登录 2FA | id=1 站长登录时增加二次验证(如邮箱验证码) |
| 验证码频率 | 同一 IP 每天最多注册 3 个账号,防恶意注册 |
---
### 🟡 7. 敏感字段防止 Mass Assignment
**当前:** User 模型的 `$fillable` 中包含 `user_level``jjb``meili``bank_jjb``exp_num` 等金钱/权限字段。
**风险:** 如果其他地方调用了 `User::create($request->all())``User::update($request->all())` 且未使用 FormRequest 过滤,可能导致权限提升或刷币。
**方案:**
- 将 `user_level``jjb``meili``bank_jjb``exp_num` 等敏感字段移出 `$fillable`
- 仅在特定 Service 中使用 `forceFill()` 并加日志审计
---
### 🟡 8. XSS 输出转义检查
**当前:** 消息内容 `content` 限制 500 字符 ✅,但需确认前端渲染时是否正确转义 HTML。
**需要检查的点:**
- [ ] 聊天消息在前端如何渲染?(`innerHTML` 还是 `textContent`?)
- [ ] 用户签名(sign)字段是否转义?
- [ ] 房间公告是否转义?
- [ ] 用户头像路径是否校验?(当前有基本校验)
**建议加固:**
- 前端渲染消息一律使用 `textContent` 或 Vue/React 自动转义
- 如果必须支持 HTML 表情/颜色,使用白名单 sanitizer(如 DOMPurify
---
### 🟡 9. 管理员操作审计加强
**当前:** 已有 `PositionAuthorityLog``AdminLog` 记录 ✅
**建议:**
- 所有金币/积分操作必须有完整的前后对比日志
- 敏感操作(封号、解封、改权限)推送微信通知给站长
---
### 🟡 10. 隐藏管理员入口路径
**当前:** `/lkddi` 作为管理员登录入口(隐藏路径),但路径硬编码在 `routes/web.php` 中。
**风险:** 任何能阅读源码或通过路径扫描的人都能发现。
**方案(可选):**
- 改为通过环境变量配置:`ADMIN_LOGIN_PATH=lkddi`
- 增加 IP 白名单限制:仅站长 IP 可访问 `/admin/*`
---
### 🟢 11. 其他安全改进
| 项目 | 说明 |
|------|------|
| CSP Header | 添加 Content-Security-Policy HTTP 头,限制脚本执行来源 |
| X-Frame-Options | 添加 DENY/SAMEORIGIN 防止点击劫持 |
| Reverb 消息大小上限 | 当前 10KB,建议根据业务适当降低 |
| 依赖安全扫描 | 定期运行 `composer audit` 检查 Laravel 及第三方包漏洞 |
| 文件上传安全 | 自定义头像上传已限制图片类型 ✅,但建议增加文件内容校验 |
---
## 二、访问速度优化(🔥高优 / ⚡中优 / 💡低优)
### 🔥 1. 启用 Laravel OPcache
**当前环境:** 通过 Laravel Herd 运行(PHP-FPM),未启用 OPcache。
**影响:** 每个 PHP 请求都要重新编译框架文件,浪费大量 CPU。
**方案(Mac/Linux 生产环境):**
```ini
; php.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0
opcache.validate_timestamps=0 ; 生产环境关闭文件修改检查
```
---
### 🔥 2. 数据库查询优化
**当前状况:**
- 使用 Redis 缓存,但部分页面可能直接查询 MySQL
- 存在多个游戏(百家乐、赛马、彩票、五子棋等),每次查询都走数据库
**优化方案:**
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 排行榜缓存 | `->remember(60)` 缓存排行榜结果 60 秒 | 🔥 |
| 在线人数缓存 | 当前使用 Redis 实时维护 ✅,保持现状 | - |
| 房间列表缓存 | `Room::all()` 结果缓存到 Redis | 🔥 |
| 游戏配置缓存 | `GameConfig::isEnabled()` 结果缓存 10 秒 | ⚡ |
| 慢查询日志 | 启用 MySQL slow_query_log,定位慢 SQL | ⚡ |
---
### 🔥 3. 静态资源 CDN 加速
**当前:** 所有静态资源(CSS、JS、图片)直接从源服务器加载。
**方案:**
| 资源类型 | 方案 |
|----------|------|
| Vite 构建产物(CSS/JS | 上传到 CDN(阿里云 OSS+CDN / CloudFlare R2 |
| 头像图片 | 启用单独域名或 CDN,添加长期缓存头 |
| 聊天背景图 | 使用 CDN 分发 12 张背景图 |
| Reverb WS 连接 | 通过 CDN/反向代理(如 Nginx)代理 WSS 连接 |
**当前已做:** `.htaccess` 中已对 Vite 构建产物设置 31536000 秒缓存 ✅
**改进:** 将 `public/build/` 下的资源部署到 CDN。
---
### 🔥 4. Reverb WebSocket 优化
**当前:** Reverb 单节点运行,HTTP 协议,端口 8080。
**优化方案:**
| 措施 | 说明 |
|------|------|
| 升级为 WSS | 启用 TLS,避免被运营商劫持/限速 |
| 水平扩展 | 启用 Reverb Scaling(通过 Redis 发布订阅,见 `reverb.php` 配置) |
| Nginx 反向代理 | 用 Nginx 代理 WSS,可同时处理 HTTP 静态资源 |
| 心跳优化 | 当前 `ping_interval=60s`,可考虑适当延长 |
---
### ⚡ 5. Laravel 应用层优化
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 路由缓存 | `php artisan route:cache` — 减少路由注册开销 | ⚡ |
| 配置缓存 | `php artisan config:cache` — 减少 config 加载 | ⚡ |
| 事件缓存 | `php artisan event:cache` — L12 原生支持 | ⚡ |
| 视图缓存 | `php artisan view:cache` — Blade 编译缓存 | ⚡ |
| 模型预加载 | 检查 N+1 查询,使用 `->with()` | ⚡ |
**⚠️ 注意:** `php artisan optimize` 已在 Laravel 12 中被移除,应单独执行以上四个命令。
---
### ⚡ 6. Redis 优化
**当前:** 单机单实例 Redis,承载 Session、Cache、Queue、Reverb Scaling 全部功能。
**建议:**
- 生产环境建议至少 2 个 Redis 实例:一个用于 Session/Cache(可随时清),一个用于 Queue(需持久化)
- Reverb Scaling 发布订阅建议单独连接
- 为 Redis 设置 `maxmemory``maxmemory-policy allkeys-lru` 防止内存溢出
---
### ⚡ 7. 前端加载优化
| 措施 | 说明 |
|------|------|
| JS 代码分割 | Vite 动态 import 拆分大 JS 文件 |
| 懒加载 | 游戏模块(百家乐、赛马等)按需加载 |
| 图片懒加载 | 头像、礼物图片使用 `loading="lazy"` |
| Alpine.js 轻量化 | 当前已使用 Alpine.js ✅ 但避免过多 watcher |
---
### 💡 8. 考虑 Laravel Octane(长期规划)
**说明:** Laravel OctaneSwoole / RoadRunner)将应用常驻内存,消除框架启动开销,可带来 10-30 倍并发性能提升。
**条件:** 需要确保代码无静态变量状态污染,适合用户量增长后的升级。
---
## 三、实施优先级建议
### 第一阶段(紧急 · 1-2 天)🔴🔥
| # | 任务 | 预估工时 |
|---|------|---------|
| 1 | 关闭 `APP_DEBUG` | 5 分钟 |
| 2 | 启用 `SESSION_ENCRYPT` | 5 分钟 |
| 3 | 限制 Reverb `allowed_origins` | 10 分钟 |
| 4 | 配置 Route/Config/Event/View 缓存 | 30 分钟 |
| 5 | 排行榜、房间列表等 Redis 缓存 | 1 小时 |
### 第二阶段(重要 · 3-5 天)🟡⚡
| # | 任务 | 预估工时 |
|---|------|---------|
| 6 | 敏感字段移出 `$fillable` | 1 小时 |
| 7 | 登录失败锁定 + 注册频率限制 | 2 小时 |
| 8 | 数据库慢查询分析与索引优化 | 2 小时 |
| 9 | 前端 JS 懒加载与代码分割 | 3 小时 |
| 10 | OPcache 配置 | 30 分钟 |
### 第三阶段(完善 · 1-2 周)🟢💡
| # | 任务 | 预估工时 |
|---|------|---------|
| 11 | Reverb WSS + Nginx 反向代理 | 2 小时 |
| 12 | 管理员 2FA 验证 | 4 小时 |
| 13 | CDN 部署静态资源 | 1 天 |
| 14 | Content-Security-Policy 等安全头 | 1 小时 |
| 15 | 生产环境 Redis 分实例部署 | 2 小时 |
| 16 | 评估 Laravel Octane 迁移 | 2-3 天 |
---
## 四、检查清单工具
部署到生产环境前可使用以下命令快速检查:
```bash
# Laravel 安全检查
php artisan about # 查看环境配置
php artisan route:list # 查看所有路由(确认无暴露的管理路径)
# Composer 安全审计
composer audit
# 缓存优化
php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan view:cache
# 依赖更新
composer update --no-dev -o
```
---
> **总结:** 该项目整体架构设计良好(Redis + Reverb 实时通信 + Alpine.js 轻量前端),
> 主要安全短板集中在**生产环境配置**(DEBUG 未关、Session 未加密、WebSocket 无 TLS)和**部分敏感字段保护**。
> 速度优化则聚焦于**缓存策略**和**CDN 静态资源分发**。
>
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
+1 -1
View File
@@ -264,7 +264,7 @@ class AiHeartbeatCommand extends Command
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
$this->broadcastSystemMessage('签到播报', $content, '#0f766e');
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
}
/**
+20
View File
@@ -143,6 +143,21 @@ enum CurrencySource: string
/** 查看别人隐藏信息扣费 */
case USER_INFO_REVEAL = 'user_info_reveal';
/** 购买消息装扮消耗(气泡,扣除金币) */
case MSG_BUBBLE_BUY = 'msg_bubble_buy';
/** 购买昵称颜色装扮消耗(扣除金币) */
case MSG_NAME_COLOR_BUY = 'msg_name_color_buy';
/** 购买文字颜色装扮消耗(扣除金币) */
case MSG_TEXT_COLOR_BUY = 'msg_text_color_buy';
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币)—— 旧版兼容,新购买不再使用 */
case MSG_DECORATION_BUY = 'msg_decoration_buy';
/** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -190,6 +205,11 @@ enum CurrencySource: string
self::GOMOKU_REFUND => '五子棋入场费返还',
self::VIDEO_REWARD => '看视频奖励',
self::USER_INFO_REVEAL => '信息查看付费',
self::MSG_BUBBLE_BUY => '消息气泡购买',
self::MSG_NAME_COLOR_BUY => '昵称颜色购买',
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买',
};
}
}
+5 -7
View File
@@ -26,9 +26,9 @@ class MessageSent implements ShouldBroadcastNow
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
* 创建消息广播事件实例。
*
* @param int $roomId 房间ID
* @param int $roomId 房间 ID
* @param array $message 发送的消息数据
*/
public function __construct(
@@ -39,8 +39,8 @@ class MessageSent implements ShouldBroadcastNow
/**
* 获取消息应广播到的频道。
*
* 公共消息走房间 Presence 频道;
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
* 公共消息和普通定向发言走房间 Presence 频道;
* 悄悄话只发给发送方与接收方的私有用户频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
@@ -78,9 +78,7 @@ class MessageSent implements ShouldBroadcastNow
*/
private function shouldBroadcastPrivately(): bool
{
$toUser = trim((string) ($this->message['to_user'] ?? ''));
return $toUser !== '' && $toUser !== '大家';
return (bool) ($this->message['is_secret'] ?? false);
}
/**
+1 -1
View File
@@ -3,7 +3,7 @@
/**
* 文件功能:用户被踢出房间广播事件
*
* 管理员踢出/冻结用户时触发,前端监听后强制该用户跳转至大厅。
* 管理员踢出/封禁用户时触发,前端监听后强制该用户跳转至大厅。
*
* @author ChatRoom Laravel
*
@@ -0,0 +1,77 @@
<?php
/**
* 文件功能:后台等级经验阈值配置控制器
*
* sysparam 表中的 levelexp 配置拆分为独立后台页面,
* 以列表模式维护每一级所需的累计经验值。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateLevelExpConfigRequest;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
/**
* 类功能:负责展示和保存等级经验阈值列表。
*/
class LevelExpConfigController extends Controller
{
/**
* 方法功能:注入系统参数缓存同步服务。
*/
public function __construct(
private readonly ChatStateService $chatState
) {}
/**
* 方法功能:显示等级经验阈值列表页。
*/
public function index(): View
{
$rawThresholds = Sysparam::getLevelExpThresholds();
$maxLevel = (int) Sysparam::getValue('maxlevel', '99');
$thresholds = collect($rawThresholds)
->values()
->map(fn (int $exp, int $index): array => [
'level' => $index + 1,
'exp' => $exp,
'increment' => $index === 0 ? $exp : $exp - $rawThresholds[$index - 1],
]);
return view('admin.level-exp-configs.index', [
'thresholds' => $thresholds,
'maxLevel' => $maxLevel,
]);
}
/**
* 方法功能:保存等级经验阈值配置,并同步刷新缓存。
*/
public function update(UpdateLevelExpConfigRequest $request): RedirectResponse
{
$thresholds = $request->validated('thresholds');
// 将列表页提交的阈值重新拼成兼容旧逻辑的逗号字符串。
$body = implode(',', $thresholds);
Sysparam::updateOrCreate(
['alias' => 'levelexp'],
[
'body' => $body,
'guidetxt' => '按列表逐级维护升级所需的累计经验阈值',
]
);
// 同步更新 Redis / Cache,确保前台经验等级计算即时生效。
$this->chatState->setSysParam('levelexp', $body);
Sysparam::clearCache('levelexp');
return redirect()->route('admin.level-exp-configs.index')->with('success', '等级经验阈值已保存并生效!');
}
}
@@ -101,7 +101,7 @@ class ShopItemController extends Controller
'icon' => 'required|string|max:20',
'description' => 'nullable|string|max:500',
'price' => 'required|integer|min:0',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,'.ShopItem::TYPE_SIGN_REPAIR,
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
@@ -60,6 +60,14 @@ class SystemController extends Controller
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
$data = $request->only($this->editableSystemAliases());
if (array_key_exists('maxlevel', $data)) {
$normalizedMaxLevel = max(1, (int) $data['maxlevel']);
// 管理员级别始终跟随最高等级 + 1,避免两个配置页出现口径漂移。
$data['maxlevel'] = (string) $normalizedMaxLevel;
$data['superlevel'] = (string) ($normalizedMaxLevel + 1);
}
foreach ($data as $alias => $body) {
$normalizedBody = (string) $body;
@@ -88,7 +96,7 @@ class SystemController extends Controller
return SysParam::query()
->orderBy('id')
->pluck('alias')
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias))
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias) && ! $this->isDedicatedAlias($alias))
->values()
->all();
}
@@ -104,4 +112,21 @@ class SystemController extends Controller
return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']);
}
/**
* 判断参数是否已经迁移到独立配置页。
*/
private function isDedicatedAlias(string $alias): bool
{
return in_array($alias, [
'levelexp',
'level_warn',
'level_mute',
'level_kick',
'level_announcement',
'level_ban',
'level_banip',
'level_freeze',
], true);
}
}
@@ -241,10 +241,10 @@ class UserManagerController extends Controller
abort(403, '权限不足:无法删除同级或高级账号!');
}
// 管理员保护:达到踢人等级(level_kick)的用户视为管理员,不可被强杀
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10');
if ($targetUser->user_level >= $levelKick) {
abort(403, '该用户为管理员,不允许强杀!请先在用户编辑中降低其等级。');
// 任命体系保护:仍持有在职职务的账号不可直接强杀,必须先走撤职流程。
$targetUser->loadMissing('activePosition.position');
if ($targetUser->id === 1 || $targetUser->activePosition?->position) {
abort(403, '该用户当前拥有在职职务,不允许强杀!请先撤销职务。');
}
$targetUser->delete();
+115 -27
View File
@@ -4,7 +4,7 @@
* 文件功能:管理员聊天室实时命令控制器
*
* 提供管理员在聊天室内对用户执行的管理操作:
* 警告(=J)、踢出(=T)、禁言(=B)冻结(=Y)、查看私信(=S)、职务公屏讲话。
* 警告(=J)、踢出(=T)、禁言(=B)封号、封IP、查看私信(=S)、职务公屏讲话。
*
* 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
*
@@ -278,14 +278,14 @@ class AdminCommandController extends Controller
}
/**
* 冻结用户账号=Y 理由)
* 封禁用户账号
*
* 用户账号状态设为冻结,禁止登录
* 目标账号设为封禁状态,并将其从当前在线房间中移出
*
* @param Request $request 请求对象,需包含 username, reason
* @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果
*/
public function freeze(Request $request): JsonResponse
public function ban(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
@@ -301,12 +301,12 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_FREEZE, '冻结');
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
@@ -316,37 +316,30 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// 冻结用户账号(将等级设为 -1 表示冻结)
$target = $authorization['target'];
$target->user_level = -1;
$target->save();
// 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
roomId: $roomId,
targetUsername: $targetUsername,
content: "🧊 {$operatorDisplay}冻结你的账号。原因:{$reason}",
title: '🧊 账号已冻结',
toastMessage: "{$operatorDisplay}冻结你的账号。<br>原因:{$reason}",
color: '#3b82f6',
icon: '🧊',
content: " {$operatorDisplay}封禁你的账号。原因:{$reason}",
title: ' 账号已封禁',
toastMessage: "{$operatorDisplay}封禁你的账号。<br>原因:{$reason}",
color: '#991b1b',
icon: '',
);
// 从所有房间移除
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave($rid, $targetUsername);
}
$this->removeUserFromAllRooms($targetUsername);
// 广播冻结消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🧊 {$operatorDisplay}冻结 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
'content' => " {$operatorDisplay}封禁 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'font_color' => '#991b1b',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
@@ -354,10 +347,93 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 广播踢出事件
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}"));
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被封禁:{$reason}"));
return response()->json(['status' => 'success', 'message' => "冻结 {$targetUsername} 的账号"]);
return response()->json(['status' => 'success', 'message' => "封禁 {$targetUsername} 的账号"]);
}
/**
* 封禁用户 IP。
*
* 将目标账号设为封禁状态并把最近登录 IP 写入黑名单。
*
* @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果
*/
public function banIp(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:200',
]);
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BANIP, '封禁 IP ');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
$target = $authorization['target'];
$targetIp = (string) ($target->last_ip ?? '');
if ($targetIp !== '') {
Redis::sadd('banned_ips', $targetIp);
}
$target->user_level = -1;
$target->save();
$this->pushTargetToastMessage(
roomId: $roomId,
targetUsername: $targetUsername,
content: "🌐 {$operatorDisplay} 已封禁你的 IP 并冻结账号。原因:{$reason}",
title: '🌐 IP 已封禁',
toastMessage: "{$operatorDisplay} 已封禁你的 IP 并冻结账号。<br>原因:{$reason}",
color: '#7c2d12',
icon: '🌐',
);
$this->removeUserFromAllRooms($targetUsername);
$ipSuffix = $targetIp !== '' ? "IP{$targetIp}" : '(未记录有效 IP';
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🌐 {$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的 IP 并冻结账号。原因:{$reason} {$ipSuffix}",
'is_secret' => false,
'font_color' => '#9a3412',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "IP 已被封禁:{$reason}"));
return response()->json([
'status' => 'success',
'message' => $targetIp !== '' ? "已封禁 {$targetUsername} 的 IP 与账号" : "已封禁 {$targetUsername} 的账号,但未记录可封禁 IP",
]);
}
/**
@@ -555,7 +631,7 @@ class AdminCommandController extends Controller
string $permissionCode,
string $actionLabel,
): array {
if (! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"];
}
@@ -583,6 +659,18 @@ class AdminCommandController extends Controller
return ['ok' => true, 'message' => '校验通过', 'target' => $target];
}
/**
* 将目标用户从当前在线的全部房间中移除。
*/
private function removeUserFromAllRooms(string $targetUsername): void
{
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave((int) $rid, $targetUsername);
}
}
/**
* 判断操作者是否可以按职务位阶处理目标用户。
*
+7 -4
View File
@@ -85,19 +85,22 @@ class ChatBotController extends Controller
$reply = $result['reply'];
// 检查 AI 是否决定给用户发金币
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
// 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额]
if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
$aiGoldAmount = (int) $matches[1];
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
$reply = trim($reply);
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
// 校验 AI 给出的金额在合理范围内
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
$dailyCount = (int) Redis::get($redisKey);
if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
+48 -8
View File
@@ -118,13 +118,20 @@ class ChatController extends Controller
$newbieEffect = null;
$initialPresenceTheme = null;
$initialWelcomeMessage = null;
$initialWelcomeMessages = [];
if (! $isAlreadyInRoom) {
// 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
if (! $user->has_received_new_gift) {
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
// 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
$user->refresh();
$isNewbie = ! $user->has_received_new_gift;
if ($isNewbie) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
@@ -142,21 +149,47 @@ class ChatController extends Controller
'font_color' => '#b91c1c',
'action' => '',
'welcome_user' => $user->username,
'welcome_kind' => 'newbie_bonus',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
SaveMessageJob::dispatch($newbieMsg);
$initialWelcomeMessages[] = $newbieMsg;
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// AI小班长发送欢迎消息,附带聊天室简单介绍
$aiWelcomeTemplates = [
"🫡 呀!新战友【{$user->username}】来了!我是本聊天室的 AI小班长——女兵班长在此!咱们这儿能聊天交友、玩百家乐/赛马/钓鱼/五子棋,还能每日签到领金币、逛商店买道具。有啥不懂的随时问我,班长罩着你!",
"✨ 欢迎新兵【{$user->username}】入伍!我是 AI小班长,你的贴心兵姐姐~ 本聊天室可以群聊私聊、玩各种小游戏(百家乐/赛马/钓鱼/五子棋)、每日签到赚金币。新人有 6666 金币见面礼哦!有问题喊班长!",
"🐻 立正!欢迎【{$user->username}】同志加入!我是这里的 AI小班长,负责带新兵熟悉环境。本聊天室可聊天交友、玩游戏(百家乐/赛马/钓鱼/五子棋/老虎机)、签到领福利、商店买道具。别客气,把这儿当家!",
"💪 热烈欢迎【{$user->username}】!我是 AI小班长,你的战友兼客服兵姐姐。这儿可以聊天、玩游戏(百家乐/赛马/钓鱼)、签到打卡、逛商店,功能多多!有不懂的随时 @我,班长在线答疑!",
];
$aiWelcomeContent = $aiWelcomeTemplates[array_rand($aiWelcomeTemplates)];
$aiWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => 'AI小班长',
'to_user' => '大家',
'content' => $aiWelcomeContent,
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '大声宣告',
'welcome_user' => $user->username,
'welcome_kind' => 'ai_newbie_welcome',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $aiWelcomeMsg);
broadcast(new MessageSent($id, $aiWelcomeMsg));
SaveMessageJob::dispatch($aiWelcomeMsg);
$initialWelcomeMessages[] = $aiWelcomeMsg;
}
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
@@ -172,6 +205,7 @@ class ChatController extends Controller
'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'entry_broadcast',
'sent_at' => now()->toDateTimeString(),
];
@@ -183,6 +217,7 @@ class ChatController extends Controller
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$initialWelcomeMessages[] = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
@@ -213,8 +248,8 @@ class ChatController extends Controller
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
// 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
return true;
}));
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
@@ -281,6 +316,7 @@ class ChatController extends Controller
'newbieEffect' => $newbieEffect,
'initialPresenceTheme' => $initialPresenceTheme,
'initialWelcomeMessage' => $initialWelcomeMessage,
'initialWelcomeMessages' => $initialWelcomeMessages,
'historyMessages' => $historyMessages,
'pendingProposal' => $pendingProposalData,
'pendingDivorce' => $pendingDivorceData,
@@ -413,6 +449,10 @@ class ChatController extends Controller
$messageData = array_merge($messageData, $imagePayload);
}
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
$messageData = array_merge($messageData, $decorations);
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
$this->chatState->pushMessage($id, $messageData);
@@ -154,7 +154,7 @@ class DailySignInController extends Controller
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '签到播报',
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
'is_secret' => false,
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
}
/**
* 获取反馈第一页数据(JSON API
* 供聊天室模态弹窗使用,格式与 loadMore 一致
*
* @param Request $request type 筛选参数
*/
public function data(Request $request): JsonResponse
{
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $items->pluck('id'))
->pluck('feedback_id')
->toArray();
return response()->json([
'items' => $this->formatItems($items, $myVotedIds),
'last_id' => $items->last()?->id ?? 0,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/**
* 懒加载更多反馈(JSON API
* 支持按类型筛选(bug / suggestion
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $item->user_id === $user->id;
return [
'id' => $item->id,
'type' => $item->type,
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted,
'is_owner' => $isOwner,
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id,
'username' => $r->username,
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
use App\Models\Guestbook;
use App\Models\User;
use App\Services\MessageFilterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
return back()->with('success', '该行留言已被抹除。');
}
/**
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
*/
public function data(Request $request): JsonResponse
{
$tab = $request->input('tab', 'public');
$page = (int) $request->input('page', 1);
$user = Auth::user();
$query = Guestbook::query()->orderByDesc('id');
if ($tab === 'inbox') {
$query->where('towho', $user->username);
} elseif ($tab === 'outbox') {
$query->where('who', $user->username);
} else {
$query->where(function ($q) use ($user) {
$q->where('secret', 0)
->orWhere('who', $user->username)
->orWhere('towho', $user->username);
});
}
$perPage = 15;
$total = $query->count();
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
$items = $messages->map(function ($msg) use ($user) {
$isSecret = (bool) $msg->secret;
$isToMe = $msg->towho === $user->username;
$isFromMe = $msg->who === $user->username;
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
return [
'id' => $msg->id,
'who' => $msg->who,
'towho' => $msg->towho ?: '',
'secret' => $isSecret,
'text_body' => $msg->text_body,
'post_time' => $msg->post_time?->diffForHumans() ?? '',
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
'is_to_me' => $isToMe,
'is_from_me' => $isFromMe,
'can_delete' => $canDelete,
'who_avatar' => mb_substr($msg->who, 0, 1),
];
});
// 获取所有用户名列表(供发信选择器使用)
$users = User::where('username', '!=', $user->username)
->orderBy('username')
->pluck('username');
return response()->json([
'ok' => true,
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'has_more' => ($page * $perPage) < $total,
'users' => $users,
'tab' => $tab,
]);
}
}
+26
View File
@@ -9,10 +9,12 @@ namespace App\Http\Controllers;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Models\Room;
use App\Models\ShopItem;
use App\Models\UserPurchase;
use App\Services\ChatStateService;
use App\Services\DecorationService;
use App\Services\ShopService;
use App\Support\ChatContentSanitizer;
use Illuminate\Http\JsonResponse;
@@ -73,6 +75,8 @@ class ShopController extends Controller
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
'sign_repair_card_item' => $signRepairCard,
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
]);
}
@@ -121,6 +125,27 @@ class ShopController extends Controller
'total_price' => $result['total_price'] ?? $item->price,
];
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
if ($item->type === 'auto_fishing') {
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
}
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
if (! empty($result['slot'])) {
$response['slot'] = $result['slot'];
$response['style'] = $result['style'];
$response['expires_at'] = $result['expires_at'];
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
$freshUser = $user->fresh();
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
}
}
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
if (isset($result['play_effect'])) {
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
@@ -205,6 +230,7 @@ class ShopController extends Controller
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1",
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
};
+11 -172
View File
@@ -2,13 +2,8 @@
/**
* 文件功能:用户中心与管理控制器
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP
*
* 权限等级通过 sysparam 表动态配置:
* level_kick - 踢人所需等级
* level_mute - 禁言所需等级
* level_ban - 封号所需等级
* level_banip - 封IP所需等级
* 接管原版 USERinfo.ASP, USERSET.ASP chpasswd.asp。
* 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。
*
* @author ChatRoom Laravel
*
@@ -18,25 +13,23 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\UserKicked;
use App\Events\UserMuted;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\RevealProfileInfoRequest;
use App\Http\Requests\UpdateChatPreferencesRequest;
use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
@@ -65,6 +58,7 @@ class UserController extends Controller
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -166,9 +160,10 @@ class UserController extends Controller
] : null,
];
// 拥有封禁IP(level_banip)或踢人以上权限的管理,可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
if ($operator && $operator->user_level >= $levelBanIp) {
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
$canViewNetworkInfo = $operator
&& ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP));
if ($canViewNetworkInfo) {
$data['first_ip'] = $targetUser->first_ip;
// last_ip 目前定义为『上次登录IP』(取数据库 previous_ip
$data['last_ip'] = $targetUser->previous_ip;
@@ -415,7 +410,7 @@ class UserController extends Controller
/**
* 生成微信绑定代码
*/
public function generateWechatCode(\Illuminate\Http\Request $request): JsonResponse
public function generateWechatCode(Request $request): JsonResponse
{
$user = \Illuminate\Support\Facades\Auth::user();
if (! $user) {
@@ -435,7 +430,7 @@ class UserController extends Controller
/**
* 取消绑定微信
*/
public function unbindWechat(\Illuminate\Http\Request $request): JsonResponse
public function unbindWechat(Request $request): JsonResponse
{
$user = \Illuminate\Support\Facades\Auth::user();
if (! $user) {
@@ -451,162 +446,6 @@ class UserController extends Controller
]);
}
/**
* 通用权限校验:检查操作者是否有权操作目标用户
*
* @param object $operator 操作者
* @param string $targetUsername 目标用户名
* @param int $roomId 房间ID
* @param string $levelKey sysparam中的等级键名(如 level_kick
* @param string $actionName 操作名称(用于错误提示)
* @return array{room: Room, target: User}|JsonResponse
*/
private function checkPermission(object $operator, string $targetUsername, int $roomId, string $levelKey, string $actionName): array|JsonResponse
{
$room = Room::findOrFail($roomId);
$requiredLevel = (int) Sysparam::getValue($levelKey, '15');
// 鉴权:操作者要是房间房主或达到所需等级
if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) {
return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403);
}
$targetUser = User::where('username', $targetUsername)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
// 防误伤:不能操作等级 >= 自己的人
if ($targetUser->user_level >= $operator->user_level) {
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}"], 403);
}
return ['room' => $room, 'target' => $targetUser];
}
/**
* 踢出房间 (对应 KILLUSER.ASP)
* 所需等级由 sysparam level_kick 配置
*/
public function kick(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出');
if ($result instanceof JsonResponse) {
return $result;
}
// 广播踢出事件
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
}
/**
* 禁言 (对应原版限制功能)
* 所需等级由 sysparam level_mute 配置
* 禁言信息存入 RedisTTL 到期自动解除
*/
public function mute(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
$duration = (int) $request->input('duration', 5);
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言');
if ($result instanceof JsonResponse) {
return $result;
}
// 写入 Redis 禁言标记,TTL = 禁言分钟数 * 60
Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([
'operator' => $operator->username,
'reason' => '管理员禁言',
'until' => now()->addMinutes($duration)->toDateTimeString(),
]));
// 广播禁言事件
broadcast(new UserMuted($roomId, $username, $duration));
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]);
}
/**
* 封号(禁止登录)
* 所需等级由 sysparam level_ban 配置
* 将用户等级设为 -1 表示封禁
*/
public function ban(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
if ($result instanceof JsonResponse) {
return $result;
}
// 封号:设置等级为 -1
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。"));
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]);
}
/**
* 封IP(记录IP到黑名单并踢出)
* 所需等级由 sysparam level_banip 配置
*/
public function banIp(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP');
if ($result instanceof JsonResponse) {
return $result;
}
$targetIp = $result['target']->last_ip;
if ($targetIp) {
// 将IP加入 Redis 黑名单(永久)
Redis::sadd('banned_ips', $targetIp);
}
// 同时封号
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。"));
$ipInfo = $targetIp ? "IP: {$targetIp}" : '(未记录IP';
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}"]);
}
/**
* 判断操作者是否可以免费查看目标用户经验、金币与魅力。
*/
@@ -0,0 +1,148 @@
<?php
/**
* 文件功能:校验后台等级经验阈值配置请求
*
* 约束管理员以列表模式提交的每级经验值,
* 确保阈值为正整数且严格递增。
*/
namespace App\Http\Requests;
use App\Models\Sysparam;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:验证等级经验阈值列表的结构与数值合法性。
*/
class UpdateLevelExpConfigRequest extends FormRequest
{
/**
* 方法功能:允许已通过后台鉴权的用户提交该请求。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:预处理输入,过滤空行并统一转成整数序列。
*/
protected function prepareForValidation(): void
{
$thresholds = collect($this->input('thresholds', []))
->map(fn ($value): string => trim((string) $value))
->filter(fn (string $value): bool => $value !== '')
->values()
->all();
$this->merge([
'thresholds' => $thresholds,
]);
}
/**
* 方法功能:返回等级经验阈值表单的校验规则。
*
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
*/
public function rules(): array
{
return [
'thresholds' => ['required', 'array', 'min:1', $this->strictlyIncreasingRule(), $this->maxLevelLimitRule()],
'thresholds.*' => ['required', 'integer', 'min:1'],
];
}
/**
* 方法功能:返回中文校验错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'thresholds.required' => '请至少配置一个等级经验阈值。',
'thresholds.array' => '等级经验阈值提交格式不正确。',
'thresholds.min' => '请至少保留一个等级经验阈值。',
'thresholds.*.required' => '等级经验阈值不能为空。',
'thresholds.*.integer' => '等级经验阈值必须是整数。',
'thresholds.*.min' => '等级经验阈值必须大于 0。',
];
}
/**
* 方法功能:自定义校验阈值必须严格递增。
*/
private function strictlyIncreasingRule(): ValidationRule
{
return new class implements ValidationRule
{
/**
* 方法功能:执行严格递增校验。
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
return;
}
$previous = null;
foreach ($value as $index => $threshold) {
if (! is_numeric($threshold)) {
continue;
}
$current = (int) $threshold;
// 每一级累计经验必须大于前一级,避免等级计算出现倒挂。
if ($previous !== null && $current <= $previous) {
$fail('等级经验阈值必须按等级从小到大严格递增,第 '.($index + 1).' 级配置不正确。');
return;
}
$previous = $current;
}
}
};
}
/**
* 方法功能:校验等级阈值数量不能超过用户最高可达等级。
*/
private function maxLevelLimitRule(): ValidationRule
{
return new class((int) Sysparam::getValue('maxlevel', '99')) implements ValidationRule
{
/**
* 方法功能:构造数量上限校验器。
*/
public function __construct(
private readonly int $maxLevel
) {}
/**
* 方法功能:执行阈值数量与最高等级的上限校验。
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value) || $this->maxLevel < 1) {
return;
}
// 阈值行数对应可升级的等级数,不能超过用户最高可达等级。
if (count($value) > $this->maxLevel) {
$fail('等级经验阈值数量不能超过用户最高可达等级,请先提高最高等级或删除多余等级。');
}
}
};
}
}
+1 -1
View File
@@ -284,7 +284,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
*/
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
{
$diceStr = "{$round->dice1}》《{$round->dice2}》《{$round->dice3}";
$diceStr = "[{$round->dice1}][{$round->dice2}][{$round->dice3}]";
$resultText = match ($round->result) {
'big' => "🔵 大({$round->total_points} 点)",
+2 -2
View File
@@ -213,7 +213,7 @@ class CloseHorseRaceJob implements ShouldQueue
'to_user' => $username,
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true,
'font_color' => '#f59e0b',
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
@@ -252,7 +252,7 @@ class CloseHorseRaceJob implements ShouldQueue
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
+1 -1
View File
@@ -88,7 +88,7 @@ class OpenHorseRaceJob implements ShouldQueue
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
+1 -1
View File
@@ -78,7 +78,7 @@ class RunHorseRaceJob implements ShouldQueue
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
'is_secret' => false,
'font_color' => '#336699',
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
+1
View File
@@ -58,6 +58,7 @@ class PositionAuthorityLog extends Model
'warn' => '警告',
'kick' => '踢出',
'mute' => '禁言',
'ban' => '封号',
'banip' => '封锁IP',
'other' => '其他',
];
+5 -1
View File
@@ -15,6 +15,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
/**
* 类功能:读取和计算系统参数,提供等级经验阈值等全局配置能力。
*/
class Sysparam extends Model
{
/** @var string 表名 */
@@ -74,7 +77,8 @@ class Sysparam extends Model
// 不超过最大等级
$maxLevel = (int) static::getValue('maxlevel', '99');
return min($level, $maxLevel);
// 聊天室普通用户最低等级为 1,避免低经验新人被自动存点降成 0 后无法进入默认房间。
return max(1, min($level, $maxLevel));
}
/**
+1
View File
@@ -109,6 +109,7 @@ class User extends Authenticatable
'q3_time' => 'datetime',
'has_received_new_gift' => 'boolean',
'chat_preferences' => 'array',
'active_decorations' => 'array',
'daily_status_expires_at' => 'datetime',
];
}
+6 -3
View File
@@ -88,6 +88,9 @@ class AiChatService
// 动态获取由 guide 页面提取出的最新纯文本规则
$guideRulesText = $this->getDynamicGuideRules();
// 动态获取金币福利上限,告知 AI 自行决定金额
$maxGold = (int) \App\Models\Sysparam::getValue('chatbot_max_gold', '5000');
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
@@ -106,9 +109,9 @@ class AiChatService
$guideRulesText
【发金币福利特权】
每天每个用户只能向你讨要一次金币福利100-5000枚随机)。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD]
系统程序看到这个符号后会自动为用户发放随机金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
每天每个用户只能向你讨要一次金币福利。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD:金额](例如:[ACTION:GIVE_GOLD:888])。金额由你根据聊天内容、对方态度和你的心情自行决定,最低 100 枚,最高 {$maxGold} 枚。讨得真诚、聊得投缘的可以多给些,敷衍了事的少给些
系统程序看到这个符号后会自动为用户发放你指定的金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
【交流要求】
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
+3 -2
View File
@@ -220,8 +220,9 @@ class ChatStateService
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
// 只清理普通进出播报,避免误删新人礼包公告和 AI 小班长新人欢迎。
$welcomeKind = $msg['welcome_kind'] ?? 'entry_broadcast';
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username && $welcomeKind === 'entry_broadcast') {
continue;
}
$filtered[] = $msgJson;
+9
View File
@@ -64,6 +64,15 @@ class ChatUserPresenceService
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
}
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
if (! empty($decorations['avatar_frame'])) {
$payload['avatar_frame'] = $decorations['avatar_frame'];
}
if (! empty($decorations['name_color'])) {
$payload['name_color'] = $decorations['name_color'];
}
return $payload;
}
+232
View File
@@ -0,0 +1,232 @@
<?php
/**
* 文件功能:用户个人装扮统一管理服务
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
*
* 当前支持的装扮槽位(存储在 active_decorations key):
* - bubble : 消息气泡边框样式
* - name_color : 昵称颜色效果
* - avatar_frame: 头像装饰边框
* - text_color : 消息文字颜色特效
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\ShopItem;
use App\Models\User;
use App\Models\UserPurchase;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
*/
class DecorationService
{
/**
* 商店商品 type 装扮槽位映射。
* 以后新增装扮类型,在此加一行即可。
*/
private const TYPE_TO_SLOT = [
'msg_bubble' => 'bubble',
'msg_name_color' => 'name_color',
'avatar_frame' => 'avatar_frame',
'msg_text_color' => 'text_color',
];
/**
* @param UserCurrencyService $currencyService 统一积分变更服务
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
*
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
*
* @param User $user 购买用户
* @param ShopItem $item 装扮商品
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
*/
public function purchase(User $user, ShopItem $item): array
{
// 根据商品类型映射到对应槽位
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
if (! $slot) {
return ['ok' => false, 'message' => '未知装扮类型'];
}
// 校验金币余额
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
}
// 计算过期时间(至少 1 天)
$days = max(1, (int) ($item->duration_days ?? 1));
$expiresAt = Carbon::now()->addDays($days);
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
$source = match ($item->type) {
'msg_bubble' => CurrencySource::MSG_BUBBLE_BUY,
'msg_name_color' => CurrencySource::MSG_NAME_COLOR_BUY,
'msg_text_color' => CurrencySource::MSG_TEXT_COLOR_BUY,
'avatar_frame' => CurrencySource::AVATAR_FRAME_BUY,
default => CurrencySource::MSG_DECORATION_BUY,
};
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
// ① 通过统一积分服务扣除金币(含流水记录)
$this->currencyService->change(
$user, 'gold', -$item->price, $source,
"购买装扮:{$item->name}{$days}天)"
);
// ② 写入购买记录(用于后台统计与用户回溯)
UserPurchase::create([
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'expires_at' => $expiresAt,
]);
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并)
$decorations = $this->getActiveDecorations($user);
$decorations[$slot] = [
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
];
$user->active_decorations = $decorations;
$user->save();
});
// 重新读取最新余额,避免缓存脏数据
$balanceAfter = (int) $user->fresh()->jjb;
return [
'ok' => true,
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
'balance_after' => $balanceAfter,
'slot' => $slot,
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
];
}
/**
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
*
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
* 如果清理了过期数据会自动写回 users.active_decorations。
*
* @param User $user 目标用户
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
*/
public function getActiveDecorations(User $user): array
{
$decorations = $user->active_decorations ?? [];
// 非数组(null 或格式异常)直接返回空
if (! is_array($decorations)) {
return [];
}
$now = Carbon::now();
$changed = false;
foreach ($decorations as $slot => $data) {
// 格式校验:必须包含 expires_at 字段
if (! is_array($data) || empty($data['expires_at'])) {
unset($decorations[$slot]);
$changed = true;
continue;
}
// 解析过期时间并与当前时间比较
try {
$expiresAt = Carbon::parse($data['expires_at']);
if ($expiresAt->isPast()) {
unset($decorations[$slot]);
$changed = true;
}
} catch (\Exception $e) {
// 时间格式异常也视为无效,清理之
unset($decorations[$slot]);
$changed = true;
}
}
// 有过期项被清理时写回数据库
if ($changed) {
$user->active_decorations = $decorations;
$user->save();
}
return $decorations;
}
/**
* 获取消息广播 payload 需要携带的装扮字段。
*
* 消息广播需要气泡样式(msg_bubble)、昵称颜色(msg_name_color)、文字颜色特效(msg_text_color
* 以及头像框(avatar_frame),前端据此渲染发送者头像的装饰边框。
*
* @param User $user 消息发送者
* @return array{msg_bubble?:string, msg_name_color?:string, msg_text_color?:string, avatar_frame?:string}
*/
public function getDecorationsForMessage(User $user): array
{
$decorations = $this->getActiveDecorations($user);
$result = [];
if (! empty($decorations['bubble']['style'])) {
$result['msg_bubble'] = $decorations['bubble']['style'];
}
if (! empty($decorations['name_color']['style'])) {
$result['msg_name_color'] = $decorations['name_color']['style'];
}
if (! empty($decorations['text_color']['style'])) {
$result['msg_text_color'] = $decorations['text_color']['style'];
}
if (! empty($decorations['avatar_frame']['style'])) {
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
}
return $result;
}
/**
* 获取在线用户 Presence 载荷需要携带的装扮字段。
*
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
* 气泡样式仅作用于消息,不需要在用户列表中展示。
*
* @param User $user 目标用户
* @return array{avatar_frame?:string, name_color?:string}
*/
public function getDecorationsForPresence(User $user): array
{
$decorations = $this->getActiveDecorations($user);
$result = [];
if (! empty($decorations['avatar_frame']['style'])) {
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
}
if (! empty($decorations['name_color']['style'])) {
$result['name_color'] = $decorations['name_color']['style'];
}
return $result;
}
}
+12
View File
@@ -16,6 +16,13 @@ use Illuminate\Support\Facades\DB;
class ShopService
{
/**
* @param DecorationService $decorationService 装扮服务
*/
public function __construct(
private readonly DecorationService $decorationService,
) {}
/**
* 购买商品入口:扣金币、按类型分发处理
*
@@ -41,6 +48,11 @@ class ShopService
'ring' => $this->buyRing($user, $item),
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
// ── 个人装扮购买(委托给 DecorationService)───────────────
'msg_bubble' => $this->decorationService->purchase($user, $item),
'msg_name_color' => $this->decorationService->purchase($user, $item),
'msg_text_color' => $this->decorationService->purchase($user, $item),
'avatar_frame' => $this->decorationService->purchase($user, $item),
default => ['ok' => false, 'message' => '未知商品类型'],
};
}
+15 -5
View File
@@ -64,9 +64,14 @@ class PositionPermissionRegistry
public const USER_MUTE = 'room.user_mute';
/**
* 用户冻结权限。
* 用户封号权限。
*/
public const USER_FREEZE = 'room.user_freeze';
public const USER_BAN = 'room.user_ban';
/**
* 用户封IP权限。
*/
public const USER_BANIP = 'room.user_banip';
/**
* 返回全部权限定义。
@@ -126,10 +131,15 @@ class PositionPermissionRegistry
'label' => '禁言用户',
'description' => '允许在用户名片内对低于自身职务的用户执行禁言。',
],
self::USER_FREEZE => [
self::USER_BAN => [
'group' => '用户管理',
'label' => '冻结用户',
'description' => '允许在用户名片内冻结低于自身职务的用户账号。',
'label' => '封号用户',
'description' => '允许在用户名片内封禁低于自身职务的用户账号并强制下线。',
],
self::USER_BANIP => [
'group' => '用户管理',
'label' => '封IP',
'description' => '允许在用户名片内封禁低于自身职务的用户 IP,并查看管理员网络信息。',
],
];
}
@@ -45,13 +45,6 @@ return new class extends Migration
['alias' => 'jjb_per_heartbeat', 'body' => '1-3', 'guidetxt' => '💰 每次心跳金币奖励(支持固定值如"1"或范围如"1-5"0关闭)', 'created_at' => $now, 'updated_at' => $now],
// ── 管理操作权限等级 ──────────────────────────────────────
['alias' => 'level_warn', 'body' => '5', 'guidetxt' => '警告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_mute', 'body' => '50', 'guidetxt' => '禁言所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_kick', 'body' => '60', 'guidetxt' => '踢人所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_announcement', 'body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_ban', 'body' => '80', 'guidetxt' => '封号所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_banip', 'body' => '90', 'guidetxt' => '封IP所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_freeze', 'body' => '14', 'guidetxt' => '冻结账号所需等级', 'created_at' => $now, 'updated_at' => $now],
// ── 随机事件 ──────────────────────────────────────────────
['alias' => 'auto_event_chance', 'body' => '10', 'guidetxt' => '随机事件触发概率(百分比,1-100)', 'created_at' => $now, 'updated_at' => $now],
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:清理已废弃的等级阈值聊天室管理权限参数
* 统一删除 level_* 权限残留,避免继续与职务权限体系并存。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 方法功能:删除旧等级权限参数。
*/
public function up(): void
{
DB::table('sysparam')
->whereIn('alias', [
'level_warn',
'level_mute',
'level_kick',
'level_announcement',
'level_ban',
'level_banip',
'level_freeze',
])
->delete();
}
/**
* 方法功能:回滚时恢复旧等级权限参数默认值。
*/
public function down(): void
{
$now = now();
foreach ([
'level_warn' => ['5', '警告所需等级'],
'level_mute' => ['50', '禁言所需等级'],
'level_kick' => ['60', '踢人所需等级'],
'level_announcement' => ['60', '设置公告所需等级'],
'level_ban' => ['80', '封号所需等级'],
'level_banip' => ['90', '封IP所需等级'],
'level_freeze' => ['14', '冻结账号所需等级'],
] as $alias => [$body, $guidetxt]) {
DB::table('sysparam')->updateOrInsert(
['alias' => $alias],
[
'body' => $body,
'guidetxt' => $guidetxt,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
};
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:将旧的冻结权限合并到封号权限
* 确保已配置 room.user_freeze 的职务在合并后自动继承 room.user_ban。
*/
use App\Models\Position;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* 方法功能:把冻结权限迁移为封号权限。
*/
public function up(): void
{
Position::query()->each(function (Position $position): void {
$permissions = array_values($position->permissions ?? []);
if (! in_array('room.user_freeze', $permissions, true)) {
return;
}
$normalizedPermissions = array_values(array_unique(array_map(
fn (string $permission): string => $permission === 'room.user_freeze' ? 'room.user_ban' : $permission,
$permissions,
)));
$position->forceFill([
'permissions' => $normalizedPermissions,
])->save();
});
}
/**
* 方法功能:回滚时把合并后的封号权限恢复为冻结权限。
*/
public function down(): void
{
Position::query()->each(function (Position $position): void {
$permissions = array_values($position->permissions ?? []);
if (! in_array('room.user_ban', $permissions, true)) {
return;
}
$normalizedPermissions = array_values(array_unique(array_map(
fn (string $permission): string => $permission === 'room.user_ban' ? 'room.user_freeze' : $permission,
$permissions,
)));
$position->forceFill([
'permissions' => $normalizedPermissions,
])->save();
});
}
};
@@ -0,0 +1,45 @@
<?php
/**
* 文件功能:用户表增加 active_decorations JSON
* 用于存储用户当前激活的个人装扮信息(气泡/昵称颜色/头像框)及过期时间。
*
* JSON 结构示例:
* {
* "bubble": {"style": "msg_bubble_golden", "expires_at": "2026-05-04T12:00:00+08:00"},
* "name_color": {"style": "msg_name_rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
* "avatar_frame":{"style": "avatar_frame_dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
* }
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 新增 active_decorations 列(可空 JSON)。
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('active_decorations')->nullable()
->comment('用户当前激活的装扮:bubble 气泡 / name_color 昵称颜色 / avatar_frame 头像框,含过期时间');
});
}
/**
* 回滚:删除 active_decorations 列。
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('active_decorations');
});
}
};
@@ -0,0 +1,39 @@
<?php
/**
* 文件功能:为店铺商品类型加入个人装扮(消息气泡、昵称颜色、头像框)。
*
* 支持用户在商店购买有期限的个人装扮道具,购买后自动激活并在聊天和用户列表中展示。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 扩展 shop_items.type ENUM,加入三种装扮类型。
*/
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
}
}
/**
* 回滚:移除装扮类型值,将已有装扮商品标记为 one_time。
*/
public function down(): void
{
if (DB::getDriverName() === 'mysql') {
// 将已有的装扮商品类型回退为 one_time,避免 ENUM 收缩报错
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` IN ('msg_bubble','msg_name_color','avatar_frame')");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
}
}
};
@@ -0,0 +1,38 @@
<?php
/**
* 文件功能:为店铺商品类型加入消息文字颜色特效装扮(msg_text_color)。
*
* 用户在商店购买文字颜色特效后,消息正文呈现动态色彩效果(七彩、流光、霓虹等)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 扩展 shop_items.type ENUM,加入 msg_text_color 类型。
*/
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
}
}
/**
* 回滚:移除 msg_text_color,将已有该类型商品标记为 one_time。
*/
public function down(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'msg_text_color'");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
}
}
};
+67 -1
View File
@@ -10,6 +10,9 @@ namespace Database\Seeders;
use App\Models\ShopItem;
use Illuminate\Database\Seeder;
/**
* 类功能:初始化聊天室商店的特效道具、功能卡与个人装扮商品。
*/
class ShopItemSeeder extends Seeder
{
/**
@@ -86,10 +89,73 @@ class ShopItemSeeder extends Seeder
['name' => '改名卡', 'slug' => 'rename_card', 'icon' => '🎭',
'description' => '使用后可修改一次昵称(旧名保留30天黑名单,不可被他人注册)。注意:历史聊天记录中的旧名不会更改。',
'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 30],
// ── 消息气泡装扮 ──────────────────────────
['name' => '鎏金流光气泡', 'slug' => 'msg_bubble_golden', 'icon' => '🟡',
'description' => '浅金底纹、左侧金线和流光扫过,发言更醒目但不刺眼。',
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 50],
['name' => '樱语花笺气泡', 'slug' => 'msg_bubble_sakura', 'icon' => '🌸',
'description' => '粉白信笺底色配花点纹理,适合温柔、浪漫的发言氛围。',
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 51],
['name' => '星河微光气泡', 'slug' => 'msg_bubble_star', 'icon' => '🌌',
'description' => '淡蓝星河底纹和微光星点,保留清爽阅读感。',
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 52],
['name' => '霓虹彩带气泡', 'slug' => 'msg_bubble_rainbow', 'icon' => '🌈',
'description' => '顶部流动彩带和浅色底框,发言有动态高光。',
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 53],
['name' => '皇冠礼赞气泡', 'slug' => 'msg_bubble_crown', 'icon' => '👑',
'description' => '金色礼赞底纹、皇冠角标和侧边金线,突出尊贵发言。',
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 54],
// ── 昵称颜色装扮 ──────────────────────────
['name' => '金色昵称', 'slug' => 'msg_name_golden', 'icon' => '🥇',
'description' => '让你的昵称闪耀金光。',
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 60],
['name' => '渐变昵称', 'slug' => 'msg_name_rainbow', 'icon' => '🎨',
'description' => '彩虹渐变色昵称,五彩斑斓。',
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 61],
['name' => '发光昵称', 'slug' => 'msg_name_glow', 'icon' => '✨',
'description' => '昵称带柔和发光效果,暗夜中最亮的星。',
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 62],
['name' => '火焰昵称', 'slug' => 'msg_name_flame', 'icon' => '🔥',
'description' => '火焰色脉动昵称,热情似火。',
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 63],
// ── 消息文字颜色特效装扮 ─────────────────
['name' => '七彩文字', 'slug' => 'msg_text_rainbow', 'icon' => '🌈',
'description' => '文字色彩在彩虹七色间平滑流动,发言自带虹光特效。',
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 65],
['name' => '流光文字', 'slug' => 'msg_text_shimmer', 'icon' => '💫',
'description' => '一道流光从左到右扫过文字表面,如金属般闪亮。',
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 66],
['name' => '霓虹文字', 'slug' => 'msg_text_neon', 'icon' => '💜',
'description' => '紫蓝霓虹光晕在文字周围脉动呼吸,像灯牌一样醒目。',
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 67],
['name' => '火焰文字', 'slug' => 'msg_text_flame', 'icon' => '🔥',
'description' => '橙红烈火在文字中上下跃动,热情燃烧般的视觉冲击。',
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 68],
['name' => '冰蓝文字', 'slug' => 'msg_text_ice', 'icon' => '❄️',
'description' => '冰蓝晶光在文字表面流转,如同冰晶折射出的冷艳光泽。',
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 69],
// ── 头像框装扮 ────────────────────────────
['name' => '月银守护头像框', 'slug' => 'avatar_frame_silver', 'icon' => '🥈',
'description' => '银白金属光泽外框,低调但比普通头像更精致。',
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 70],
['name' => '金辉勋章头像框', 'slug' => 'avatar_frame_gold', 'icon' => '🥇',
'description' => '金色勋章质感外框,带柔和光晕。',
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 71],
['name' => '星轨环绕头像框', 'slug' => 'avatar_frame_star', 'icon' => '⭐',
'description' => '星轨渐变环绕头像旋转,适合高调展示。',
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 72],
['name' => '龙焰御守头像框', 'slug' => 'avatar_frame_dragon', 'icon' => '🐉',
'description' => '红金御守质感外框,带虚线纹理和强烈光晕。',
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 73],
];
foreach ($items as $item) {
ShopItem::firstOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]);
// 商品名和描述可能随视觉样式迭代,Seeder 重跑时需要同步更新展示文案。
ShopItem::updateOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]);
}
}
}
+448
View File
@@ -0,0 +1,448 @@
# 第一阶段:消息装扮 & 头像框消费系统 — 实施方案
> 目标:为聊天室新增持续性的金币消费出口,通过「消息装扮」和「头像框」两个高社交可见度的功能,让金币有处可花、花得有面子。
---
## 一、功能范围
### 1.1 消息气泡样式(msg_bubble
用户购买后,发出的消息气泡带特殊边框/背景样式,房间内所有人可见。
| 商品 | 价格 | 时长 | 效果描述 |
|------|------|------|----------|
| 金色气泡 | 300 金币 | 1 天 | 消息框带金色渐变边框 + 微光 |
| 樱花气泡 | 500 金币 | 3 天 | 粉白边框 + 飘落花瓣 |
| 星际气泡 | 800 金币 | 7 天 | 深蓝渐变边框 + 星光 |
| 彩虹气泡 | 1500 金币 | 7 天 | 流动彩虹边框 |
| 皇冠气泡 | 3000 金币 | 30 天 | 皇家风格金边 + 皇冠徽标 |
### 1.2 昵称颜色效果(msg_name_color
购买后,自己的昵称在聊天消息和用户列表中显示为特殊颜色。
| 商品 | 价格 | 时长 | 效果描述 |
|------|------|------|----------|
| 金色昵称 | 200 金币 | 1 天 | 昵称文字变为金色 #fbbf24 |
| 渐变色昵称 | 500 金币 | 3 天 | 彩虹渐变色(CSS gradient text |
| 发光昵称 | 800 金币 | 7 天 | 文字发光效果(text-shadow glow |
| 火焰昵称 | 1500 金币 | 7 天 | 红橙火焰色 + 脉动动画 |
### 1.3 头像框(avatar_frame
购买后,用户头像外围显示装饰边框,在用户列表中展示。
| 商品 | 价格 | 时长 | 效果描述 |
|------|------|------|----------|
| 银色边框 | 500 金币 | 7 天 | 银色圆环边框 |
| 金色边框 | 1000 金币 | 7 天 | 金色圆环边框 |
| 星光边框 | 2000 金币 | 14 天 | 星光闪烁旋转边框 |
| 龙纹边框 | 5000 金币 | 30 天 | 龙纹缠绕高级边框 |
---
## 二、架构设计
### 2.1 整体思路
遵循项目现有的 `ShopService → UserCurrencyService` 消费模式,最小化架构改动:
```
用户点击购买 → ShopController::buy()
→ ShopService::buyItem() [新增 msg_bubble / msg_name_color / avatar_frame 分支]
→ DB::transaction:
① UserCurrencyService::change() 扣金币 + 写流水
② UserPurchase::create() 写购买记录
③ 更新 users.active_decorations JSON 字段
→ 返回购买结果 + 新余额
发送消息时 → ChatController::send()
→ 读取 users.active_decorations
→ 写入 messageData 广播 payload
用户列表 → ChatUserPresenceService::build()
→ 读取 users.active_decorations.avatar_frame
→ 写入 presence payload
```
### 2.2 数据存储方案
**不使用新表**,在现有两张表上扩展:
**`users` 表** — 新增 1 个字段用于缓存当前激活的装扮(避免每次发消息都 JOIN user_purchases):
```sql
ALTER TABLE users ADD COLUMN active_decorations JSON NULL
COMMENT '当前激活的装扮,格式: {"bubble":{"style":"golden","expires_at":"..."},"name_color":{...},"avatar_frame":{...}}';
```
示例数据:
```json
{
"bubble": {"style": "golden", "expires_at": "2026-05-04T12:00:00+08:00"},
"name_color": {"style": "rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
"avatar_frame": {"style": "dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
}
```
**`shop_items` 表** — 新增商品行,利用现有的 `type``duration_days` 字段(无需改表结构)。
**`user_purchases` 表** — 利用现有表记录所有购买历史(无需改表结构)。
### 2.3 装扮过期策略
**懒过期(Lazy Expiration**:每次读取 `active_decorations` 时检查过期时间,自动清理。
在以下时机触发检查和清理:
- 用户发送消息时(`ChatController::send()`
- 构建在线用户 payload 时(`ChatUserPresenceService::build()`
- 查询商店数据时(`ShopController::items()`
过期清理逻辑在一个统一的 helper 方法中:
```php
// DecorationService::getActiveDecorations(User $user): array
// 读取 users.active_decorations JSON
// 过滤掉已过期的条目
// 如果有变化,写回 users.active_decorations
// 返回干净的激活装扮列表
```
---
## 三、后端改动清单
### 3.1 数据库 Migration
**新文件**`database/migrations/xxxx_xx_xx_add_active_decorations_to_users_table.php`
```php
Schema::table('users', function (Blueprint $table) {
$table->json('active_decorations')->nullable()
->comment('当前激活的装扮: bubble/name_color/avatar_frame');
});
```
### 3.2 CurrencySource 枚举
**文件**`app/Enums/CurrencySource.php` — 新增两个 case
```php
/** 购买消息装扮(气泡/昵称颜色等) */
case MSG_DECORATION_BUY = 'msg_decoration_buy';
/** 购买头像框 */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
```
同时在 `label()` 方法中添加对应的中文映射。
### 3.3 DecorationService(新服务)
**文件**`app/Services/DecorationService.php`
```php
class DecorationService
{
/**
* 购买装扮:扣金币 + 写记录 + 更新 active_decorations
*/
public function purchase(User $user, ShopItem $item): array;
/**
* 获取用户当前激活的装扮(自动清理过期项)
* 返回: ['bubble' => [...], 'name_color' => [...], 'avatar_frame' => [...]]
*/
public function getActiveDecorations(User $user): array;
/**
* 判断装扮类型之间是否互斥(同类型新购买覆盖旧的)
*/
public function isSameCategory(string $typeA, string $typeB): bool;
}
```
**`purchase()` 核心逻辑**
1. 计算总价(`$item->price`
2. 检查金币余额
3. `DB::transaction`
- 通过 `UserCurrencyService` 扣金币
- 创建 `UserPurchase` 记录(status=active, expires_at=now+days
- 读取当前 `active_decorations`,同类型覆盖写入,不同类型合并
- `$user->active_decorations = $merged; $user->save();`
4. 返回结果
**`getActiveDecorations()` 核心逻辑**
1. 读取 `$user->active_decorations`JSON → array
2. 遍历每个 slot,检查 `expires_at` 是否过期
3. 如果有过期项被清理,写回数据库
4. 返回干净的数组
### 3.4 ShopService 扩展
**文件**`app/Services/ShopService.php`
`buyItem()``match ($item->type)` 中新增三个分支:
```php
'msg_bubble' => $this->decorationService->purchase($user, $item),
'msg_name_color' => $this->decorationService->purchase($user, $item),
'avatar_frame' => $this->decorationService->purchase($user, $item),
```
注入 `DecorationService` 依赖。
### 3.5 ShopController 扩展
**文件**`app/Http/Controllers/ShopController.php`
`items()` 方法的返回数据中增加:
```php
'active_decorations' => $this->decorationService->getActiveDecorations($user),
```
### 3.6 消息广播增强
**文件**`app/Http/Controllers/ChatController.php`
`send()` 方法中,构造 `$messageData` 时增加装饰字段:
```php
$decorations = app(DecorationService::class)->getActiveDecorations($user);
// 在 $messageData 中追加
if (!empty($decorations['bubble'])) {
$messageData['msg_bubble'] = $decorations['bubble']['style'];
}
if (!empty($decorations['name_color'])) {
$messageData['msg_name_color'] = $decorations['name_color']['style'];
}
```
这样每条消息的 WebSocket 广播 payload 中就带上了装扮信息。
### 3.7 在线用户展示增强
**文件**`app/Services/ChatUserPresenceService.php`
`build()` 方法的 $payload 数组中增加:
```php
$decorations = app(DecorationService::class)->getActiveDecorations($user);
if (!empty($decorations['avatar_frame'])) {
$payload['avatar_frame'] = $decorations['avatar_frame']['style'];
}
if (!empty($decorations['name_color'])) {
$payload['name_color'] = $decorations['name_color']['style'];
}
```
### 3.8 Shop Seeder 扩展
**文件**`database/seeders/ShopItemSeeder.php`
新增装扮商品数据(sort_order 50-80 范围,排在改名卡之后)。
### 3.9 User Model
**文件**`app/Models/User.php`
`$fillable``$casts` 中增加:
```php
'active_decorations' => 'array', // 已在 $casts 中
```
---
## 四、前端改动清单
### 4.1 商店面板改造
**文件**`resources/js/chat-room/shop-controls.js`
1. 在 `SHOP_GROUPS` 数组中新增三个分组:
```js
{ label: "💬 消息气泡", type: "msg_bubble" },
{ label: "🎨 昵称颜色", type: "msg_name_color" },
{ label: "🖼️ 头像框", type: "avatar_frame" },
```
2. 渲染装扮卡片时显示:
- 当前是否已激活同类装扮
- 剩余有效时间
- 购买/续费按钮
3. 购买成功后更新本地装扮状态,无需刷新整个列表。
**文件**`resources/views/chat/partials/shop-panel.blade.php`
可能需要微调样式以适配新卡片。
### 4.2 消息渲染改造
**文件**`resources/views/chat/partials/scripts.blade.php`
`appendMessage()` 函数中(约第 2105 行附近),根据消息 payload 中的装扮字段应用样式:
```javascript
// 在构建消息 HTML 时
let bubbleClass = '';
let nameColorStyle = '';
if (msg.msg_bubble) {
bubbleClass = `msg-bubble--${msg.msg_bubble}`;
}
if (msg.msg_name_color) {
nameColorStyle = `style="color: var(--name-${msg.msg_name_color})"`;
}
// 消息容器加上 bubbleClass
// 发送者名字处加上 nameColorStyle
```
**新增 CSS 样式**(可放在 `scripts.blade.php``<style>` 块中,或独立 CSS 文件):
```css
/* ── 消息气泡样式 ────────────────────── */
.msg-bubble--golden { border: 2px solid #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,.4); }
.msg-bubble--sakura { border: 2px solid #f9a8d4; background: linear-gradient(135deg, #fce7f3, #fff1f2); }
.msg-bubble--star { border: 2px solid #6366f1; background: linear-gradient(135deg, #1e1b4b, #312e81); }
.msg-bubble--rainbow { border: 2px solid transparent; background-clip: padding-box;
border-image: linear-gradient(90deg, red, orange, yellow, green, blue, purple) 1; }
.msg-bubble--crown { border: 3px solid #fbbf24; box-shadow: 0 0 12px rgba(251,191,36,.6); }
/* ── 昵称颜色 ────────────────────────── */
.msg-name--golden { color: #fbbf24 !important; }
.msg-name--rainbow { background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.msg-name--glow { color: #e2e8f0; text-shadow: 0 0 6px #6366f1, 0 0 12px #818cf8; }
.msg-name--flame { color: #f97316; text-shadow: 0 0 4px #ef4444; animation: name-flame 1.5s infinite; }
@keyframes name-flame {
0%, 100% { text-shadow: 0 0 4px #ef4444; }
50% { text-shadow: 0 0 8px #fbbf24, 0 0 12px #ef4444; }
}
```
### 4.3 用户列表渲染改造
**文件**`resources/views/chat/partials/scripts.blade.php`
`_renderUserListToContainer()` 函数中(约第 1777 行附近),为有头像框的用户增加装饰层:
```javascript
// 构建用户条目 HTML 时
let avatarFrameHtml = '';
if (user.avatar_frame) {
avatarFrameHtml = `<span class="avatar-frame avatar-frame--${user.avatar_frame}"></span>`;
}
// 将 avatarFrameHtml 放在头像 img 的外层
```
**新增 CSS**
```css
/* ── 头像框样式 ──────────────────────── */
.avatar-frame-wrapper { position: relative; display: inline-block; }
.avatar-frame {
position: absolute; top: -3px; left: -3px; right: -3px; bottom: -3px;
border-radius: 50%; pointer-events: none; z-index: 5;
}
.avatar-frame--silver { border: 2px solid #9ca3af; }
.avatar-frame--gold { border: 2px solid #fbbf24; box-shadow: 0 0 4px rgba(251,191,36,.5); }
.avatar-frame--star { border: 2px solid #fbbf24; animation: frame-spin 3s linear infinite; }
.avatar-frame--dragon { border: 2px solid #dc2626; box-shadow: 0 0 6px rgba(220,38,38,.6); }
@keyframes frame-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
```
### 4.4 移动端适配
**文件**`resources/js/chat-room/mobile-drawer.js`
移动端用户列表渲染调用 `window._renderUserListToContainer()`,头像框改动自动生效,无需额外修改。
---
## 五、任务拆分与实施顺序
| 序号 | 任务 | 涉及文件 | 预估工作量 |
|------|------|----------|-----------|
| 1 | 创建 `active_decorations` migration | 1 个新 migration 文件 | 0.5h |
| 2 | 新增 `CurrencySource` 枚举值 | `CurrencySource.php` | 0.2h |
| 3 | 创建 `DecorationService` | 1 个新 Service 文件 | 2h |
| 4 | 扩展 `ShopService::buyItem()` | `ShopService.php` | 0.5h |
| 5 | 扩展 `ShopController::items()` | `ShopController.php` | 0.3h |
| 6 | 增强消息广播 payload | `ChatController.php` | 0.5h |
| 7 | 增强在线用户 presence | `ChatUserPresenceService.php` | 0.3h |
| 8 | 扩展 Seeder 添加装扮商品 | `ShopItemSeeder.php` | 0.5h |
| 9 | 前端商店面板改造 | `shop-controls.js` + blade | 2h |
| 10 | 前端消息渲染改造 | `scripts.blade.php` | 3h |
| 11 | 前端用户列表头像框 | `scripts.blade.php` | 1.5h |
| 12 | CSS 样式编写 | `scripts.blade.php` 或独立 CSS | 1.5h |
| 13 | 联调测试 | — | 2h |
**建议分两轮交付**
**第一轮(任务 1-8,后端先行)**:后端全部完成,可通过 API / 数据库直接验证购买、扣金币、流水记录、过期清理逻辑。
**第二轮(任务 9-13,前端展示)**:前端商店界面、消息渲染、用户列表展示。
---
## 六、价格平衡分析
以活跃用户的日均金币收入(心跳 + 签到 + 视频 ≈ 3000-15000 金币/天)为基准:
| 装扮类型 | 最低价 | 日均摊成本 | 占日收入比例 |
|----------|--------|-----------|-------------|
| 金色气泡(1天) | 300 | 300/天 | 10%-20%(轻度用户) |
| 樱花气泡(3天) | 500 | 167/天 | 5%-10% |
| 彩虹气泡(7天) | 1500 | 214/天 | 7%-15% |
| 金色昵称(1天) | 200 | 200/天 | 7%-13% |
| 发光昵称(7天) | 800 | 114/天 | 4%-8% |
| 银色头像框(7天) | 500 | 71/天 | 2%-5% |
| 龙纹头像框(30天) | 5000 | 167/天 | 5%-10% |
如果同时购买气泡+昵称+头像框(全部最低档):300 + 200 + 71 ≈ 571 金币/天,约占轻度用户日收入的 20-40%,**合理且有适度压力**。
---
## 七、后续扩展预留
当前设计中已预留扩展空间:
1. **装扮互斥覆盖**`DecorationService::purchase()` 中同类型新购买会自动覆盖旧的(旧装扮不退款),这是有意为之的消耗设计。
2. **新装扮类型**:只需在 `DecorationService` 中增加新的 slot key(如 `text_effect``enter_effect`),无需改数据库。
3. **稀有/限定装扮**:可在 seeder 中添加 `is_active=false` 的商品,通过活动/任务发放。
4. **装扮赠送**:未来可在 `ShopController::buy()` 中扩展 recipient 参数,允许购买装扮送给他人(复用现有礼物赠送的消息广播模式)。
---
## 八、验收标准
1. ✅ 用户可在商店面板看到装扮分类和商品列表
2. ✅ 购买装扮扣金币正确,流水记录 source 为 `msg_decoration_buy` / `avatar_frame_buy`
3. ✅ 购买成功后 `users.active_decorations` JSON 字段正确更新
4. ✅ 同类型新装扮覆盖旧装扮
5. ✅ 过期装扮在下次读取时自动清理
6. ✅ 消息广播 payload 包含 `msg_bubble` / `msg_name_color` 字段
7. ✅ 消息气泡正确渲染对应样式
8. ✅ 昵称颜色正确渲染
9. ✅ 用户列表头像正确渲染头像框
10. ✅ 移动端展示正常
11. ✅ 金币不足时提示错误,不会扣成负数
php artisan migrate # 添加 active_decorations 列
php artisan db:seed --class=ShopItemSeeder # 导入装扮商品数据
npm run build # 重新编译前端资源
+2
View File
@@ -144,3 +144,5 @@
transform: translateX(140%);
}
}
@import './chat-decorations.css';
+384
View File
@@ -0,0 +1,384 @@
/* ========== 消息气泡装扮:在原版逐行消息基础上增加纹理、角标和轻量动效 ========== */
.msg-line[class*="msg-bubble--"] {
position: relative;
isolation: isolate;
display: block;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
min-height: 24px;
margin: 4px 0;
padding: 5px 12px 5px 14px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(51, 102, 153, .16);
background: rgba(255, 255, 255, .72);
box-shadow: 0 1px 4px rgba(51, 102, 153, .12);
}
.msg-line[class*="msg-bubble--"]::before,
.msg-line[class*="msg-bubble--"]::after {
content: "";
position: absolute;
pointer-events: none;
z-index: 0;
}
.msg-line[class*="msg-bubble--"] > * {
position: relative;
z-index: 1;
}
.msg-bubble--golden {
border-color: rgba(217, 119, 6, .32) !important;
background:
linear-gradient(90deg, rgba(245, 158, 11, .32) 0 4px, transparent 4px),
radial-gradient(circle at 28px 8px, rgba(255, 255, 255, .85), transparent 10px),
linear-gradient(135deg, #fff8df 0%, #fffdf5 56%, #fff1c2 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(217, 119, 6, .18);
}
.msg-bubble--golden::after {
top: 0;
bottom: 0;
left: -36px;
width: 36px;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .72), transparent);
animation: msg-bubble-shine 3.6s ease-in-out infinite;
}
.msg-bubble--sakura {
border-color: rgba(244, 114, 182, .32) !important;
background:
radial-gradient(circle at 18px 10px, rgba(244, 114, 182, .42) 0 2px, transparent 3px),
radial-gradient(circle at 44px 20px, rgba(251, 207, 232, .86) 0 3px, transparent 4px),
linear-gradient(135deg, #fff7fb 0%, #fff 48%, #ffe4f1 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(244, 114, 182, .14);
}
.msg-bubble--star {
border-color: rgba(79, 70, 229, .32) !important;
background:
radial-gradient(circle at 20px 9px, rgba(255, 255, 255, .9) 0 1px, transparent 2px),
radial-gradient(circle at 76px 20px, rgba(99, 102, 241, .36) 0 2px, transparent 3px),
linear-gradient(135deg, #eef2ff 0%, #f8fbff 54%, #dbeafe 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .8), 0 2px 10px rgba(79, 70, 229, .16);
}
.msg-bubble--star::before {
right: 10px;
top: 5px;
width: 42px;
height: 16px;
background: radial-gradient(circle, rgba(67, 56, 202, .42) 0 1px, transparent 2px);
background-size: 11px 8px;
opacity: .72;
}
.msg-bubble--rainbow {
border-color: rgba(59, 130, 246, .22) !important;
background:
linear-gradient(#ffffffd9, #ffffffd9) padding-box,
linear-gradient(120deg, rgba(239, 68, 68, .16), rgba(245, 158, 11, .16), rgba(34, 197, 94, .16), rgba(59, 130, 246, .16), rgba(168, 85, 247, .16)) border-box !important;
box-shadow: 0 2px 10px rgba(59, 130, 246, .14);
}
.msg-bubble--rainbow::before {
left: 0;
right: 0;
top: 0;
height: 3px;
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);
background-size: 180% 100%;
animation: msg-bubble-rainbow 4.2s linear infinite;
}
.msg-bubble--crown {
border-color: rgba(180, 83, 9, .34) !important;
background:
linear-gradient(90deg, rgba(180, 83, 9, .24) 0 4px, transparent 4px),
radial-gradient(circle at right 12px top 8px, rgba(251, 191, 36, .36), transparent 18px),
linear-gradient(135deg, #fff7d6 0%, #fffdfa 46%, #fde68a 100%) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 3px 12px rgba(180, 83, 9, .22);
}
.msg-bubble--crown::after {
content: "♛";
top: 2px;
right: 8px;
z-index: 0;
color: rgba(180, 83, 9, .26);
font-size: 18px;
line-height: 1;
}
@keyframes msg-bubble-shine {
0%, 62% { transform: translateX(0); opacity: 0; }
72% { opacity: .82; }
100% { transform: translateX(280px); opacity: 0; }
}
@keyframes msg-bubble-rainbow {
from { background-position: 0% 50%; }
to { background-position: 180% 50%; }
}
/* ========== 昵称颜色 ========== */
.msg-name--golden { color: #fbbf24 !important; font-weight: 700; }
.msg-name--rainbow {
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
}
.msg-name--glow {
color: #e2e8f0 !important;
text-shadow: 0 0 6px #818cf8, 0 0 14px #6366f1;
}
.msg-name--flame {
color: #f97316 !important;
font-weight: 700;
animation: name-flame 1.5s ease-in-out infinite;
}
@keyframes name-flame {
0%, 100% { text-shadow: 0 0 4px #ef4444; }
50% { text-shadow: 0 0 10px #fbbf24, 0 0 16px #ef4444; }
}
/* ========== 消息文字颜色特效 ========== */
.msg-text--rainbow {
background: linear-gradient(90deg,
#ef4444, #f97316, #eab308, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
background-size: 300% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
animation: text-rainbow 3.5s linear infinite;
}
@keyframes text-rainbow {
0% { background-position: 0% 50%; }
100% { background-position: 300% 50%; }
}
.msg-text--shimmer {
background: linear-gradient(110deg,
#6b7280 0%, #9ca3af 18%, #f3f4f6 28%, #d1d5db 36%,
#6b7280 52%, #9ca3af 66%, #f9fafb 74%, #6b7280 100%);
background-size: 300% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
animation: text-shimmer 4s ease-in-out infinite;
}
@keyframes text-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: -200% 50%; }
}
.msg-text--neon {
color: #a855f7 !important;
font-weight: 600;
text-shadow:
0 0 7px #a855f7,
0 0 14px #7c3aed,
0 0 28px #6366f1,
0 0 42px #4f46e5;
animation: text-neon 2s ease-in-out infinite alternate;
}
@keyframes text-neon {
0% { text-shadow: 0 0 7px #a855f7, 0 0 14px #7c3aed, 0 0 28px #6366f1; }
100% { text-shadow: 0 0 14px #c084fc, 0 0 28px #a855f7, 0 0 48px #7c3aed, 0 0 64px #6366f1; }
}
.msg-text--flame {
background: linear-gradient(180deg, #fef3c7 0%, #f59e0b 28%, #ea580c 60%, #dc2626 100%);
background-size: 100% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
animation: text-flame 1.2s ease-in-out infinite alternate;
}
@keyframes text-flame {
0% { background-position: 0% 0%; }
100% { background-position: 0% 100%; }
}
.msg-text--ice {
background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 25%, #38bdf8 50%, #bae6fd 75%, #e0f2fe 100%);
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
animation: text-ice 3s ease-in-out infinite;
filter: drop-shadow(0 0 4px rgba(56, 189, 248, 0.5));
}
@keyframes text-ice {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* ========== 头像框 ========== */
.avatar-frame-wrapper {
position: relative;
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
flex: 0 0 44px;
line-height: 0;
overflow: hidden;
border-radius: 50%;
}
.user-item .avatar-frame-wrapper .user-head {
position: relative;
z-index: 1;
width: 36px;
height: 36px;
border-radius: 50%;
mix-blend-mode: normal;
overflow: hidden;
/* clip-path 硬裁剪保证头像在任何情况下都是正圆形,不受外部 border-radius 覆盖影响 */
clip-path: circle(50% at 50% 50%);
/* 头像置于头像框下方框边缘可遮挡头像外围
提高特异性覆盖 chat.css 中的 .user-item .user-head */
}
.avatar-frame {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 3;
/* 使用 mask 在 44px 圆上挖出 4px 宽的环形:中心透明(头像可见),边缘渐变色可见 */
-webkit-mask-image: radial-gradient(circle closest-side at center, transparent 18px, black 19px);
mask-image: radial-gradient(circle closest-side at center, transparent 18px, black 19px);
}
.avatar-frame::before,
.avatar-frame::after {
content: "";
position: absolute;
pointer-events: none;
}
.avatar-frame--silver {
background: conic-gradient(from 25deg, #ffffff, #94a3b8, #e2e8f0, #64748b, #ffffff);
box-shadow: 0 0 0 1px rgba(148, 163, 184, .38), 0 2px 8px rgba(100, 116, 139, .24);
}
.avatar-frame--silver::before,
.avatar-frame--gold::before,
.avatar-frame--star::before,
.avatar-frame--dragon::before {
inset: 4px;
border-radius: 50%;
/* 旧方案依靠 ::before 实心圆遮挡渐变背景形成圆环
现改用 mask 挖空中心::before 不再需要遮挡 */
}
.avatar-frame--gold {
background: conic-gradient(from -20deg, #fff7ad, #f59e0b, #fff1a6, #b45309, #fff7ad);
box-shadow: 0 0 0 1px rgba(217, 119, 6, .34), 0 0 12px rgba(245, 158, 11, .38);
}
.avatar-frame--star {
background:
radial-gradient(circle at 50% 0%, #ffffff 0 2px, transparent 3px),
conic-gradient(from 0deg, #fef08a, #818cf8, #ffffff, #fbbf24, #818cf8, #fef08a);
box-shadow: 0 0 14px rgba(129, 140, 248, .48);
animation: frame-rotate 4s linear infinite;
}
.avatar-frame--star::after {
inset: -2px;
border-radius: 50%;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 2px, transparent 3px);
transform-origin: 50% 50%;
}
.avatar-frame--dragon {
background:
conic-gradient(from 45deg, #7f1d1d, #f59e0b, #ef4444, #991b1b, #f59e0b, #7f1d1d);
box-shadow: 0 0 14px rgba(239, 68, 68, .42), 0 0 0 1px rgba(127, 29, 29, .38);
}
.avatar-frame--dragon::after {
inset: 5px;
border-radius: 50%;
border: 1px dashed rgba(254, 202, 202, .82);
}
@keyframes frame-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ========== 紧凑版头像框:用于聊天消息中的小头像(16px) ========== */
.avatar-frame-wrapper-sm {
position: relative;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
vertical-align: middle;
margin-right: 2px;
line-height: 0;
flex: 0 0 22px;
}
.avatar-frame-wrapper-sm .avatar-frame {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 3;
-webkit-mask-image: radial-gradient(circle closest-side at center, transparent 8px, black 9px);
mask-image: radial-gradient(circle closest-side at center, transparent 8px, black 9px);
}
.avatar-frame-wrapper-sm .avatar-frame::before,
.avatar-frame-wrapper-sm .avatar-frame::after {
content: "";
position: absolute;
pointer-events: none;
}
.avatar-frame-wrapper-sm .avatar-frame--silver::before,
.avatar-frame-wrapper-sm .avatar-frame--gold::before,
.avatar-frame-wrapper-sm .avatar-frame--star::before,
.avatar-frame-wrapper-sm .avatar-frame--dragon::before {
inset: 2px;
border-radius: 50%;
/* mask 已处理环形 */
}
.avatar-frame-wrapper-sm .avatar-frame--dragon::after {
inset: 3px;
border-radius: 50%;
border: 1px dashed rgba(254, 202, 202, .82);
}
.avatar-frame-wrapper-sm .avatar-frame--star::after {
inset: -1px;
border-radius: 50%;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 1px, transparent 2px);
transform-origin: 50% 50%;
}
.avatar-frame-wrapper-sm img {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
border-radius: 50%;
mix-blend-mode: normal;
overflow: hidden;
clip-path: circle(50% at 50% 50%);
/* 头像置于框下方 */
}
+958
View File
@@ -0,0 +1,958 @@
/**
* 文件功能聊天室主界面样式表
* 复刻原版海军蓝聊天室色系 (CHAT.CSS)
* 包含布局消息窗格输入工具栏用户列表弹窗等所有聊天界面样式
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
/* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */
[x-cloak] {
display: none !important;
}
/*
原版海军蓝聊天室色系 (CHAT.CSS 复刻)
*/
:root {
--bg-main: #EAF2FF;
--bg-bar: #b0d8ff;
--bg-header: #4488aa;
--bg-toolbar: #5599aa;
--text-link: #336699;
--text-navy: #112233;
--text-white: #ffffff;
--border-blue: #336699;
--scrollbar-base: #b0d8ff;
--scrollbar-arrow: #336699;
--scrollbar-track: #EAF2FF;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif;
font-size: 10pt;
background: var(--bg-main);
color: var(--text-navy);
overflow: hidden;
height: 100vh;
}
/* 全局链接样式 */
a {
color: var(--text-link);
text-decoration: none;
}
a:hover {
color: #009900;
}
/* 自定义滚动条 - 模拟原版 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-base);
border-radius: 2px;
border: 1px solid var(--scrollbar-arrow);
}
::-webkit-scrollbar-track {
background-color: var(--scrollbar-track);
}
::-webkit-scrollbar-corner {
background-color: var(--scrollbar-base);
}
/* ── 主布局 ─────────────────────────────────────── */
.chat-layout {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
/* 防止整体滚动 */
}
/* 左侧主区域 */
.chat-left {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
/* 约束 Flex 子项高度,防止长历史记录撑破 100vh 导致输入栏被挤出屏幕 */
}
/* 竖向工具条 */
.chat-toolbar {
width: 28px;
background: var(--bg-toolbar);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 58px;
/* 与标题栏+公告栏底部对齐 */
padding-bottom: 75px;
/* 与输入栏顶部对齐 */
border-left: 1px solid var(--border-blue);
border-right: 1px solid var(--border-blue);
flex-shrink: 0;
}
.chat-toolbar .tool-btn {
writing-mode: vertical-rl;
text-orientation: upright;
font-size: 11px;
color: #ddd;
cursor: pointer;
padding: 4px 0;
text-align: center;
transition: all 0.15s;
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
letter-spacing: 2px;
}
.chat-toolbar .tool-btn:hover {
color: #fff;
background: #5599cc;
}
/* 右侧用户列表面板 */
.chat-right {
width: 175px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-blue);
background: var(--bg-main);
flex-shrink: 0;
}
/* ── 标题栏 ─────────────────────────────────────── */
.room-title-bar {
background: var(--bg-header);
color: var(--text-white);
text-align: center;
padding: 6px 10px;
font-size: 12px;
line-height: 1.6;
flex-shrink: 0;
border-bottom: 1px solid var(--border-blue);
}
.room-title-bar .room-name {
font-weight: bold;
font-size: 14px;
}
.room-title-bar .room-desc {
color: #cee8ff;
font-size: 11px;
}
/* 公告/祝福语滚动条(复刻原版 marquee) */
.room-announcement-bar {
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
padding: 2px 6px;
border-bottom: 1px solid var(--border-blue);
flex-shrink: 0;
height: 20px;
line-height: 20px;
overflow: hidden;
}
/* ── 消息窗格 ───────────────────────────────────── */
.message-panes {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-pane {
flex: 1;
overflow-y: auto;
padding: 5px 8px;
background: var(--bg-main);
font-size: 10pt;
line-height: 170%;
min-height: 0;
}
.message-pane.say1 {
border-bottom: 2px solid var(--border-blue);
}
.message-pane.say2 {
display: block;
flex: 0.4;
border-top: 2px solid var(--border-blue);
}
.message-pane.say1 {
flex: 0.6;
}
/* 消息行样式 */
.msg-line {
margin: 2px 0;
word-wrap: break-word;
word-break: break-all;
}
.msg-line .msg-time {
font-size: 9px;
color: #999;
}
.msg-line .msg-user {
cursor: pointer;
font-weight: bold;
}
.msg-line .msg-user:hover {
text-decoration: underline;
}
.msg-line .msg-action {
color: #009900;
}
.msg-line .msg-secret {
color: #cc00cc;
font-style: italic;
}
.msg-line .msg-content {
margin-left: 4px;
}
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
.input-bar {
height: auto;
min-height: 75px;
background: var(--bg-bar);
border-top: 2px solid var(--border-blue);
padding: 3px 6px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
/* 欢迎语下拉浮层 */
.welcome-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 9000;
background: #fff;
border: 1px solid #b0c8e0;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
min-width: 280px;
max-width: 360px;
padding: 4px 0;
}
.welcome-menu-item {
padding: 6px 12px;
font-size: 12px;
color: #224466;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #eef4fb;
transition: background 0.1s;
}
.welcome-menu-item:last-child {
border-bottom: none;
}
.welcome-menu-item:hover {
background: #ddeeff;
color: #003366;
}
.input-bar select,
.input-bar input[type="text"],
.input-bar input[type="checkbox"] {
font-size: 12px;
border: 1px solid navy;
color: #224466;
padding: 1px 2px;
background: #fff;
}
.input-bar select {
max-width: 100px;
}
.input-bar .input-row {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.input-bar .say-input {
flex: 1;
min-width: 200px;
font-size: 12px;
border: 1px solid navy;
color: #224466;
padding: 3px 5px;
outline: none;
}
.input-bar .say-input:focus {
border-color: #0066cc;
box-shadow: 0 0 3px rgba(0, 102, 204, 0.3);
}
.input-bar .send-btn {
background: var(--bg-header);
color: white;
border: 1px solid var(--border-blue);
padding: 3px 12px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: background 0.15s;
}
.input-bar .send-btn:hover {
background: #336699;
}
.input-bar .send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-bar label {
font-size: 12px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 2px;
}
/* ── 右侧用户列表 Tab 栏 ──────────────────────── */
.right-tabs {
display: flex;
background: var(--bg-bar);
border-bottom: 2px solid navy;
flex-shrink: 0;
}
.right-tabs .tab-btn {
flex: 1;
text-align: center;
font-size: 12px;
padding: 3px 0;
cursor: pointer;
color: navy;
background: var(--bg-bar);
border: none;
font-family: inherit;
transition: all 0.15s;
user-select: none;
}
.right-tabs .tab-btn.active {
background: navy;
color: white;
}
.right-tabs .tab-btn:hover:not(.active) {
background: #99c8ee;
}
/* 用户列表内容 */
.user-list-content {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.user-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px dotted #cde;
transition: background 0.15s;
}
.user-item:hover {
background: #d0e8ff;
}
.user-item .user-head {
width: 36px;
height: 36px;
border-radius: 2px;
object-fit: cover;
background: transparent;
mix-blend-mode: multiply;
flex-shrink: 0;
}
.user-item .user-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-link);
font-weight: bold;
}
.user-item .user-badge-slot {
flex-shrink: 0;
max-width: 78px;
min-width: 0;
overflow: hidden;
}
.user-item .user-sex {
font-size: 10px;
}
.user-item .user-level {
font-size: 9px;
color: #999;
}
.user-badge-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.chat-hover-tooltip {
position: fixed;
z-index: 10030;
max-width: min(220px, calc(100vw - 20px));
padding: 4px 8px;
border: 1px solid #244d77;
border-radius: 4px;
background: linear-gradient(to bottom, #fffef2, #fff6c9);
color: #243b53;
font-size: 11px;
line-height: 1.35;
white-space: nowrap;
box-shadow: 0 6px 18px rgba(17, 34, 51, 0.24);
pointer-events: none;
}
.chat-hover-tooltip::after {
content: '';
position: absolute;
top: 50%;
width: 6px;
height: 6px;
background: #fff9d8;
transform: translateY(-50%) rotate(45deg);
}
.chat-hover-tooltip[data-side="right"]::after {
left: -4px;
border-left: 1px solid #244d77;
border-bottom: 1px solid #244d77;
}
.chat-hover-tooltip[data-side="left"]::after {
right: -4px;
border-top: 1px solid #244d77;
border-right: 1px solid #244d77;
}
/* 在线人数统计 */
.online-stats {
background: var(--bg-bar);
text-align: center;
font-size: 11px;
padding: 3px;
border-top: 1px solid var(--border-blue);
color: navy;
flex-shrink: 0;
}
/* ── 用户名片弹窗 ───────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-card {
background: white;
border: 2px solid var(--border-blue);
border-radius: 8px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.modal-header {
background: linear-gradient(135deg, var(--bg-header), var(--border-blue));
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 16px;
font-weight: bold;
}
.modal-close {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
opacity: 0.8;
}
.modal-close:hover {
opacity: 1;
}
.modal-body {
padding: 16px;
}
.modal-body .profile-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.modal-body .profile-avatar {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
border: 2px solid var(--bg-bar);
background: #eee;
}
.modal-body .profile-info h4 {
font-size: 16px;
font-weight: bold;
color: var(--text-navy);
}
.modal-body .profile-info .level-badge {
display: inline-block;
background: var(--bg-bar);
color: var(--border-blue);
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
font-weight: bold;
margin-left: 4px;
}
.modal-body .profile-info .sex-badge {
font-size: 11px;
margin-left: 2px;
}
.modal-body .profile-detail {
background: #f5f9ff;
border: 1px solid #e0ecff;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: #666;
margin-bottom: 12px;
}
.modal-actions {
padding: 0 16px 16px;
display: flex;
gap: 8px;
}
.modal-actions button,
.modal-actions a {
flex: 1;
text-align: center;
padding: 6px 0;
border-radius: 6px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
text-decoration: none;
display: block;
}
.btn-kick {
background: #fee;
color: #c00;
border-color: #fcc;
}
.btn-kick:hover {
background: #fcc;
}
.btn-mute {
background: #fff8e6;
color: #b86e00;
border-color: #ffe0a0;
}
.btn-mute:hover {
background: #ffe0a0;
}
.btn-mail {
background: #f0e8ff;
color: #6633cc;
border-color: #d8c8ff;
}
.btn-mail:hover {
background: #d8c8ff;
}
.btn-whisper {
background: #e8f5ff;
color: var(--text-link);
border-color: var(--bg-bar);
}
.btn-whisper:hover {
background: var(--bg-bar);
}
/* 禁言输入行 */
.mute-form {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px 12px;
}
.mute-form input {
width: 60px;
border: 1px solid #ffe0a0;
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
}
.mute-form button {
background: #b86e00;
color: white;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
/* ── 头像选择器 ─────────────────────────────────── */
.avatar-option {
width: 36px;
height: 36px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 3px;
transition: border-color 0.15s, transform 0.15s;
}
.avatar-option:hover {
border-color: #88bbdd;
transform: scale(1.15);
}
.avatar-option.selected {
border-color: #336699;
box-shadow: 0 0 6px rgba(51, 102, 153, 0.5);
}
/* 送花礼物弹跳动画 */
@keyframes giftBounce {
0% {
transform: scale(0.3) rotate(-15deg);
opacity: 0;
}
50% {
transform: scale(1.3) rotate(5deg);
opacity: 1;
}
70% {
transform: scale(0.9) rotate(-3deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
/*
手机端响应式布局 640px
*/
@media (max-width: 640px) {
/* 隐藏中间工具条和右侧面板,让消息区占满全宽 */
.chat-toolbar,
.chat-right {
display: none !important;
}
/* 输入框最小宽度适配手机屏 */
.input-bar .say-input {
min-width: 0;
}
/* ── 右下角浮动按钮组 ── */
#mobile-fabs {
position: fixed;
top: 35%;
right: 10px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 500;
}
.mobile-fab {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(51, 102, 153, 0.4);
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #336699;
font-size: 18px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, background 0.15s, box-shadow 0.15s;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-fab:active {
transform: scale(0.92);
background: rgba(51, 102, 153, 0.15);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
}
/* ── 抽屉遮罩 ── */
#mobile-drawer-mask {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 600;
}
#mobile-drawer-mask.open {
display: block;
}
/* ── 抽屉通用容器(顶部向下滑入) ── */
.mobile-drawer {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 700;
background: #fff;
border-bottom: 2px solid var(--border-blue);
border-radius: 0 0 14px 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
transform: translateY(-100%);
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.mobile-drawer.open {
transform: translateY(0);
}
/* 抽屉顶部标题栏 */
.mobile-drawer-header {
background: var(--bg-header);
color: #fff;
padding: 10px 14px;
font-size: 14px;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.mobile-drawer-close {
background: none;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
line-height: 1;
opacity: 0.85;
padding: 0 4px;
}
/* ── 工具条抽屉:横向网格排列按钮 ── */
#mobile-drawer-toolbar .mobile-drawer-body {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1px;
background: var(--border-blue);
max-height: 55vh;
overflow-y: auto;
}
#mobile-drawer-toolbar .mobile-tool-btn {
background: var(--bg-toolbar);
color: #ddd;
font-size: 13px;
padding: 14px 4px;
text-align: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s, color 0.15s;
writing-mode: horizontal-tb;
letter-spacing: 0;
}
#mobile-drawer-toolbar .mobile-tool-btn:active {
background: #5599cc;
color: #fff;
}
/* ── 名单/房间抽屉:限定高度,内部 flex 子项才能正确贡献高度 ── */
#mobile-drawer-users {
height: 80vh;
}
#mobile-drawer-users .mobile-right-clone {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
/* 抽屉内的 tabs 复用样式 */
#mobile-drawer-users .right-tabs {
flex-shrink: 0;
}
#mobile-drawer-users .user-list-content {
flex: 1;
overflow-y: auto;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
#mobile-drawer-users .online-stats {
flex-shrink: 0;
}
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
#mob-online-users-list .user-item {
padding: 7px 8px;
font-size: 13px;
}
/* ── 现有弹窗(settings / avatar / shop)手机端自适应 ── */
/* 防止固定像素宽度的 modal 内容区超出手机屏宽 */
#settings-modal > div,
#avatar-picker-modal > div {
width: 92vw !important;
max-width: 420px !important;
max-height: 85vh !important;
}
#shop-modal {
padding: 12px 6px !important;
}
#shop-modal-inner {
width: 92vw !important;
max-width: 420px !important;
max-height: 85vh !important;
overflow-y: auto !important;
}
/* ── 手机端隐藏房间介绍 ── */
.room-desc {
display: none !important;
}
/* ── 手机端隐藏输入栏中不常用的控件(动作/字色/字号/禁音/分屏) ── */
/* 用 :has() 选择器精准定位含对应 input/select 的 label */
.input-row label:has(#action),
.input-row label:has(#font_color),
.input-row label:has(#font_size_select),
.input-row label:has(#sound_muted) {
display: none !important;
}
}
/* 桌面端(> 640px)不显示浮动按钮和抽屉组件 */
@media (min-width: 641px) {
#mobile-fabs,
#mobile-drawer-mask,
#mobile-drawer-toolbar,
#mobile-drawer-users {
display: none !important;
}
}
+501 -618
View File
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
// 聊天室管理员命令模块:设置公告、公屏讲话、清屏、刷新全员、特效触发。
// 从 Blade 内联脚本迁移至 Vite 管理。
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 设置房间公告。
*/
function promptAnnouncement() {
const fullText = document.getElementById("announcement-text")?.textContent?.trim() || "";
const pureText = fullText.replace(/ ——\S+ \d{2}-\d{2} \d{2}:\d{2}$/, "").trim();
window.chatDialog
.prompt("请输入新的房间公告/祝福语:", pureText, "设置公告", "#336699")
.then((newText) => {
if (newText === null || newText.trim() === "") return;
fetch(`/room/${window.chatContext.roomId}/announcement`, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ announcement: newText.trim() }),
})
.then((res) => res.json())
.then((data) => {
if (data.status === "success") {
const marquee = document.getElementById("announcement-text");
if (marquee) marquee.textContent = data.announcement;
window.chatDialog.alert("公告已更新!", "提示", "#16a34a");
} else {
window.chatDialog.alert(data.message || "更新失败", "操作失败", "#cc4444");
}
})
.catch((e) => {
window.chatDialog.alert("设置公告失败:" + e.message, "操作失败", "#cc4444");
});
});
}
/**
* 站长公屏讲话。
*/
function promptAnnounceMessage() {
window.chatDialog
.prompt("请输入公屏讲话内容:", "", "📢 公屏讲话", "#7c3aed")
.then((content) => {
if (!content || !content.trim()) return;
fetch("/command/announce", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
content: content.trim(),
room_id: window.chatContext.roomId,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.status !== "success") {
window.chatDialog.alert(data.message || "发送失败", "操作失败", "#cc4444");
}
})
.catch((e) => {
window.chatDialog.alert("发送失败:" + e.message, "操作失败", "#cc4444");
});
});
}
/**
* 管理员全员清屏。
*/
function adminClearScreen() {
window.chatDialog
.confirm("确定要清除所有人的聊天记录吗?(悄悄话将保留)", "全员清屏", "#dc2626")
.then((ok) => {
if (!ok) return;
fetch("/command/clear-screen", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ room_id: window.chatContext.roomId }),
})
.then((res) => res.json())
.then((data) => {
if (data.status !== "success") {
window.chatDialog.alert(data.message || "清屏失败", "操作失败", "#cc4444");
}
})
.catch((e) => {
window.chatDialog.alert("清屏失败:" + e.message, "操作失败", "#cc4444");
});
});
}
/**
* 管理员触发全屏特效。
*
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
*/
function triggerEffect(type) {
const roomId = window.chatContext?.roomId;
if (!roomId) return;
fetch("/command/effect", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": csrf(),
},
body: JSON.stringify({ room_id: roomId, type }),
})
.then((r) => r.json())
.then((data) => {
if (data.status !== "success")
window.chatDialog.alert(data.message, "操作失败", "#cc4444");
})
.catch((err) => console.error("特效触发失败:", err));
}
/**
* 选择特效后关闭菜单并触发。
*
* @param {string} type 特效类型
*/
function selectEffect(type) {
const menu = document.getElementById("admin-menu");
if (menu) menu.style.display = "none";
triggerEffect(type);
}
/**
* 站长通知当前房间所有在线用户刷新页面。
*/
async function refreshAllBrowsers() {
if (!window.chatContext?.isSiteOwner || !window.chatContext?.refreshAllUrl) {
window.chatDialog?.alert("仅站长可执行全员刷新。", "无权限", "#cc4444");
return;
}
const confirmed = await window.chatDialog?.confirm(
"确定通知当前房间所有在线用户刷新页面吗?\n适用于功能更新后强制同步最新按钮与权限状态。",
"♻️ 刷新全员",
"#0f766e",
);
if (!confirmed) return;
try {
const response = await fetch(window.chatContext.refreshAllUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
room_id: window.chatContext.roomId,
reason: "功能更新,站长要求刷新页面",
}),
});
const data = await response.json();
if (data.status === "success") {
window.chatToast?.show({
title: "已发送刷新通知",
message: data.message,
icon: "♻️",
color: "#0f766e",
duration: 3500,
});
return;
}
window.chatDialog?.alert(data.message || "发送刷新通知失败", "操作失败", "#cc4444");
} catch (_error) {
window.chatDialog?.alert("网络异常,请稍后再试", "错误", "#cc4444");
}
}
/**
* 执行管理菜单中的快捷操作,并在执行前关闭菜单。
*
* @param {string} action 管理动作类型
*/
function runAdminAction(action) {
const menu = document.getElementById("admin-menu");
if (menu) menu.style.display = "none";
switch (action) {
case "announcement":
promptAnnouncement();
break;
case "announce-message":
promptAnnounceMessage();
break;
case "admin-clear":
adminClearScreen();
break;
case "red-packet":
window.sendRedPacket?.();
break;
case "loss-cover":
window.openAdminBaccaratLossCoverModal?.();
break;
case "refresh-all":
refreshAllBrowsers();
break;
default:
break;
}
}
/**
* 切换管理菜单显示/隐藏。
*/
function toggleAdminMenu(event) {
event.stopPropagation();
const menu = document.getElementById("admin-menu");
const welcomeMenu = document.getElementById("welcome-menu");
const blockMenu = document.getElementById("block-menu");
const featureMenu = document.getElementById("feature-menu");
const dailyStatusEditor = document.getElementById("daily-status-editor-overlay");
if (!menu) return;
[welcomeMenu, blockMenu, featureMenu, dailyStatusEditor].forEach((el) => {
if (el) el.style.display = "none";
});
menu.style.display = menu.style.display === "none" ? "block" : "none";
}
// 挂载到 window 供 Blade 脚本及其他模块使用。
window.promptAnnouncement = promptAnnouncement;
window.promptAnnounceMessage = promptAnnounceMessage;
window.adminClearScreen = adminClearScreen;
window.triggerEffect = triggerEffect;
window.selectEffect = selectEffect;
window.refreshAllBrowsers = refreshAllBrowsers;
window.runAdminAction = runAdminAction;
window.toggleAdminMenu = toggleAdminMenu;
export {
promptAnnouncement,
promptAnnounceMessage,
adminClearScreen,
triggerEffect,
selectEffect,
refreshAllBrowsers,
runAdminAction,
toggleAdminMenu,
};
+3 -9
View File
@@ -1,11 +1,12 @@
// 聊天室管理菜单事件绑定,替代 input-bar 中的管理类内联 onclick。
// 管理动作业务逻辑已迁至 admin-commands.js。
import "./admin-commands.js";
let adminMenuEventsBound = false;
/**
* 绑定管理菜单、管理动作与全屏特效选择事件。
*
* @returns {void}
*/
export function bindAdminMenuControls() {
if (adminMenuEventsBound || typeof document === "undefined") {
@@ -22,33 +23,26 @@ export function bindAdminMenuControls() {
if (menuToggle) {
event.preventDefault();
window.toggleAdminMenu?.(event);
return;
}
const adminAction = event.target.closest("[data-chat-admin-action]");
if (adminAction) {
event.preventDefault();
// 管理菜单只负责入口分发,权限校验和实际动作仍由后端与原有全局函数负责。
const action = adminAction.getAttribute("data-chat-admin-action") || "";
if (action && typeof window.runAdminAction === "function") {
window.runAdminAction(action);
}
return;
}
const effectButton = event.target.closest("[data-chat-admin-effect]");
if (effectButton) {
event.preventDefault();
// 特效按钮只触发管理员发起请求,实际播放仍由 chat:effect 广播和 EffectManager 处理。
const effect = effectButton.getAttribute("data-chat-admin-effect") || "";
if (effect && typeof window.selectEffect === "function") {
window.selectEffect(effect);
}
return;
}
+469
View File
@@ -0,0 +1,469 @@
// 聊天室 WebSocket 事件监听,从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
// 所有事件委托通过 window.addEventListener 注册,依赖 window.chatState 共享状态。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { normalizeDailyStatus } from "./preferences-status.js";
import { enqueueChatMessage } from "./message-renderer.js";
// ── 事件注册标记 ──
let chatEventsBound = false;
// ── 辅助函数 ──
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
function getState() {
return window.chatState;
}
/**
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/
function initChatWebSocket() {
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
}
}
// ── 禁言逻辑 ──
function handleMutedEvent(e) {
const state = getState();
const d = e.detail;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const isMe = d.username === window.chatContext?.username;
const div = document.createElement("div");
div.className = "msg-line";
div.innerHTML = `<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
const targetContainer = isMe
? document.getElementById("say2")
: (state?.container);
if (targetContainer) {
targetContainer.appendChild(div);
targetContainer.scrollTop = targetContainer.scrollHeight;
}
if (isMe && d.mute_time > 0) {
state.isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
const contentInput = document.getElementById("content");
const operatorName = d.operator || "管理员";
if (contentInput) {
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
contentInput.disabled = true;
setTimeout(() => {
state.isMutedUntil = 0;
contentInput.placeholder = "在这里输入聊天内容,按 Enter 发送...";
contentInput.disabled = false;
const unmuteDiv = document.createElement("div");
unmuteDiv.className = "msg-line";
unmuteDiv.innerHTML = '<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
const say2 = document.getElementById("say2");
if (say2) {
say2.appendChild(unmuteDiv);
say2.scrollTop = say2.scrollHeight;
}
}, d.mute_time * 60 * 1000);
}
}
}
// ── Echo 级监听器 ──
/**
* 注册全员清屏监听(ScreenCleared)。
*/
function setupScreenClearedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupScreenClearedListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen("ScreenCleared", (e) => {
const operator = e.operator;
const safeOperator = escapeHtml(String(operator || ""));
const say1 = document.getElementById("chat-messages-container");
if (say1) say1.innerHTML = "";
const say2 = document.getElementById("chat-messages-container2");
if (say2) {
const items = say2.querySelectorAll(".msg-line");
items.forEach((item) => {
if (!item.querySelector(".msg-secret")) {
item.remove();
}
});
}
const state = getState();
if (state) {
state.lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null;
}
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
}
/**
* 注册房间级"刷新全员"监听(BrowserRefreshRequested)。
*/
function setupRoomBrowserRefreshListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRoomBrowserRefreshListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen("BrowserRefreshRequested", (e) => {
window.dispatchEvent(
new CustomEvent("chat:browser-refresh-requested", { detail: e })
);
});
}
/**
* 注册开发日志发布通知监听(仅 Room 1)。
*/
function setupChangelogPublishedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupChangelogPublishedListener, 500);
return;
}
if (window.chatContext.roomId !== 1) return;
window.Echo.join("room.1")
.listen(".ChangelogPublished", (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ""));
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ""));
const changelogRoute = window.chatContext?.changelogUrl || "/changelog";
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, changelogRoute));
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
sysDiv.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;";
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
📋 【版本更新】v${safeVersion} · ${safeTitle}
<a href="${safeUrl}" target="_blank" rel="noopener"
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
查看详情 →
</a>
</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
}
/**
* 注册五子棋 PvP 邀请通知监听。
*/
function setupGomokuInviteListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupGomokuInviteListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen(".gomoku.invite", (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const isSelf = (e.inviter_name === window.chatContext.username);
const div = document.createElement("div");
div.className = "msg-line";
div.style.cssText =
"background:linear-gradient(135deg,#e8eef8,#f0f4fc); border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;";
const safeInviterName = escapeHtml(e.inviter_name);
const gomokuGameId = Number.parseInt(e.game_id, 10) || 0;
const acceptBtn = isSelf
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
cursor:pointer; font-family:inherit; transition:all .15s;">
⤴️ 打开面板
</button>`
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
border-radius:12px; background:#336699; color:#fff; font-size:12px;
cursor:pointer; font-family:inherit; transition:opacity .15s;">
⚔️ 接受挑战
</button>`;
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
♟️ 【五子棋】<b>${safeInviterName}</b> 发起了随机对战!${isSelf ? "(等待中)" : ""}
</span>${acceptBtn}
<span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(div);
say1.scrollTop = say1.scrollHeight;
}
if (!isSelf) {
setTimeout(() => {
const btn = document.getElementById(`gomoku-accept-${e.game_id}`);
if (btn) {
btn.textContent = "已超时";
btn.disabled = true;
btn.style.opacity = ".5";
btn.style.cursor = "not-allowed";
}
}, 60000);
}
})
.listen(".gomoku.finished", (e) => {
if (e.mode !== "pvp") return;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const div = document.createElement("div");
div.className = "msg-line";
div.style.cssText =
"background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;";
const reason = { win: "获胜", draw: "平局", resign: "认输", timeout: "超时" }[e.reason] || "结束";
let text = "";
if (e.winner === 0) {
text = `♟️ 五子棋对局以<b>平局</b>结束!`;
} else {
text = `♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
}
div.innerHTML = `<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(div);
say1.scrollTop = say1.scrollHeight;
}
});
}
// ── 主事件绑定 ──
/**
* 绑定所有聊天室 WebSocket 事件监听,仅执行一次。
*/
export function bindChatEvents() {
if (chatEventsBound || typeof document === "undefined") {
return;
}
chatEventsBound = true;
// WebSocket 初始化
document.addEventListener("DOMContentLoaded", initChatWebSocket);
// chat:here — Presence 初始用户列表
window.addEventListener("chat:here", (e) => {
const state = getState();
if (!state) return;
const users = e.detail;
state.onlineUsers = {};
users.forEach((u) => {
window.hydrateOnlineUserPayload(u.username, u);
});
// 注入 AI 小班长
if (window.chatContext?.chatBotEnabled && window.chatContext.botUser) {
window.hydrateOnlineUserPayload("AI小班长", window.chatContext.botUser);
}
// 同步当前用户状态
if (typeof window.setOnlineUserDailyStatus === "function" && typeof window.getCurrentUserDailyStatus === "function") {
window.setOnlineUserDailyStatus(window.chatContext?.username, window.getCurrentUserDailyStatus());
}
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
window.scheduleRenderUserList(0);
});
// chat:bot-toggled — AI 小班长动态开关
window.addEventListener("chat:bot-toggled", (e) => {
const detail = e.detail;
if (window.chatContext) {
window.chatContext.chatBotEnabled = detail.isOnline;
}
if (detail.isOnline && detail.user && detail.user.username) {
window.hydrateOnlineUserPayload(detail.user.username, detail.user);
if (window.chatContext) window.chatContext.botUser = detail.user;
} else {
const state = getState();
if (state) delete state.onlineUsers["AI小班长"];
if (window.chatContext) window.chatContext.botUser = null;
}
window.scheduleRenderUserList?.();
});
// chat:user-status-updated — 用户每日状态更新
window.addEventListener("chat:user-status-updated", (e) => {
const username = e.detail?.username;
const payload = e.detail?.user;
if (!username || !payload) return;
window.hydrateOnlineUserPayload(username, payload);
if (username === window.chatContext?.username) {
if (window.chatContext) {
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
}
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
}
window.scheduleRenderUserList?.();
});
// chat:joining — 用户进入
window.addEventListener("chat:joining", (e) => {
const user = e.detail;
window.hydrateOnlineUserPayload(user.username, user);
window.scheduleRenderUserList?.();
});
// chat:leaving — 用户离开
window.addEventListener("chat:leaving", (e) => {
const user = e.detail;
const state = getState();
if (state) delete state.onlineUsers[user.username];
window.scheduleRenderUserList?.();
});
// chat:message — 新消息
window.addEventListener("chat:message", (e) => {
const msg = e.detail;
if (msg.is_secret && msg.from_user !== window.chatContext?.username && msg.to_user !== window.chatContext?.username) {
return;
}
enqueueChatMessage(msg);
if (msg.action === "vip_presence" && typeof window.showVipPresenceBanner === "function") {
window.showVipPresenceBanner(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,
});
}
});
// chat:kicked — 被踢出房间
window.addEventListener("chat:kicked", (e) => {
if (e.detail.username === window.chatContext?.username) {
const roomsIndexUrl = window.chatContext?.roomsIndexUrl || "/rooms";
window.chatDialog?.alert(
"您已被管理员踢出房间!" + (e.detail.reason ? " 原因:" + e.detail.reason : ""),
"系统通知",
"#cc4444"
);
window.location.href = roomsIndexUrl;
}
});
// chat:muted — 禁言事件
window.addEventListener("chat:muted", handleMutedEvent);
// chat:title-updated — 房间标题更新
window.addEventListener("chat:title-updated", (e) => {
const display = document.getElementById("room-title-display");
if (display) display.innerText = e.detail.title;
});
// chat:browser-refresh-requested — 全员刷新通知
window.addEventListener("chat:browser-refresh-requested", (e) => {
const detail = e.detail || {};
const operatorName = escapeHtml(String(detail.operator || "站长"));
const reasonText = escapeHtml(String(detail.reason || "页面功能已更新,请重新载入。"));
window.chatToast?.show({
title: "页面即将刷新",
message: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
icon: "♻️",
color: "#0f766e",
duration: 2200,
});
window.setTimeout(() => {
window.location.reload();
}, 900);
});
// chat:user-browser-refresh-requested — 目标用户定向刷新
window.addEventListener("chat:user-browser-refresh-requested", (e) => {
const detail = e.detail || {};
const operatorName = escapeHtml(String(detail.operator || "管理员"));
const reasonText = escapeHtml(String(detail.reason || "你的权限状态已发生变化,页面即将刷新。"));
window.chatToast?.show({
title: "权限同步中",
message: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
icon: "🔄",
color: "#7c3aed",
duration: 2600,
});
window.setTimeout(() => {
window.location.reload();
}, 1000);
});
// chat:effect — 全屏特效事件
window.addEventListener("chat:effect", (e) => {
const type = e.detail?.type;
const target = e.detail?.target_username;
const operator = e.detail?.operator;
const myName = window.chatContext?.username;
if (type && typeof EffectManager !== "undefined") {
if (!target || target === myName || operator === myName) {
EffectManager.play(type);
}
}
});
// Echo 级监听器(延迟绑定,等待 Echo 就绪)
document.addEventListener("DOMContentLoaded", () => {
setupScreenClearedListener();
setupRoomBrowserRefreshListener();
setupChangelogPublishedListener();
setupGomokuInviteListener();
});
}
export { initChatWebSocket };
+223
View File
@@ -0,0 +1,223 @@
// 聊天室共享运行时状态,桥接 Blade 闭包作用域与 Vite 模块。
// 所有需要跨模块共享的可变状态集中在此管理,通过 window.chatState 访问。
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
export const PUBLIC_MESSAGE_NODE_LIMIT = 600;
export const PRIVATE_MESSAGE_NODE_LIMIT = 300;
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
export const HEARTBEAT_INTERVAL = 60 * 1000;
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "系统", "欢迎", "系统播报", "神秘箱子"];
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说"
export const ACTION_TEXT_MAP = {
"微笑": { type: "emotion", word: "微笑着" },
"大笑": { type: "emotion", word: "大笑着" },
"愤怒": { type: "emotion", word: "愤怒地" },
"哭泣": { type: "emotion", word: "哭泣着" },
"害羞": { type: "emotion", word: "害羞地" },
"鄙视": { type: "emotion", word: "鄙视地" },
"得意": { type: "emotion", word: "得意地" },
"疑惑": { type: "emotion", word: "疑惑地" },
"同情": { type: "emotion", word: "同情地" },
"无奈": { type: "emotion", word: "无奈地" },
"拳打": { type: "verb", word: "拳打了" },
"飞吻": { type: "verb", word: "飞吻了" },
"偷看": { type: "verb", word: "偷看了" },
};
// ── DOM 引用(惰性获取,避免模块加载时 DOM 未就绪)──
function getContainer() { return document.getElementById("chat-messages-container"); }
function getContainer2() { return document.getElementById("chat-messages-container2"); }
function getUserList() { return document.getElementById("online-users-list"); }
function getToUserSelect() { return document.getElementById("to_user"); }
function getOnlineCount() { return document.getElementById("online-count"); }
function getOnlineCountBottom() { return document.getElementById("online-count-bottom"); }
// ── 可变状态 ──
let onlineUsers = {};
let autoScroll = true;
let maxMsgId = 0;
let pendingChatMessages = [];
let chatMessageFlushTimer = null;
let userListRenderTimer = null;
let userFilterRenderTimer = null;
let userBadgeRotationTick = 0;
let lastAutosaveNode = null;
let roomsRefreshTimer = null;
let roomsOnlineStatusCache = null;
let roomsOnlineStatusCacheAt = 0;
let isMutedUntil = 0;
let imeComposing = false;
let isSending = false;
let sendStartedAt = 0;
let leaveRequestInFlight = false;
let heartbeatFailCount = 0;
const MAX_HEARTBEAT_FAILS = 3;
// 偏好状态
let blockedSystemSenders = new Set();
let initialChatPreferences = null;
// ── 访问器 ──
function getOnlineUsers() { return onlineUsers; }
function setOnlineUsers(v) {
onlineUsers = v;
window.onlineUsers = onlineUsers;
}
function getAutoScroll() { return autoScroll; }
function setAutoScroll(v) { autoScroll = Boolean(v); }
function getMaxMsgId() { return maxMsgId; }
function setMaxMsgId(v) { if (v > maxMsgId) maxMsgId = v; }
function getBlockedSystemSenders() { return blockedSystemSenders; }
function setBlockedSystemSenders(v) { blockedSystemSenders = v; }
function getIsMutedUntil() { return isMutedUntil; }
function setIsMutedUntil(v) { isMutedUntil = v; }
// ── 构建聊天状态对象 ──
const chatState = {
// 常量
BLOCKABLE_SYSTEM_SENDERS,
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
CHAT_SOUND_MUTED_STORAGE_KEY,
PUBLIC_MESSAGE_NODE_LIMIT,
PRIVATE_MESSAGE_NODE_LIMIT,
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
ROOMS_ONLINE_STATUS_CACHE_TTL,
HEARTBEAT_INTERVAL,
SYSTEM_USERS,
ACTION_TEXT_MAP,
MAX_HEARTBEAT_FAILS,
// DOM 引用
get container() { return getContainer(); },
get container2() { return getContainer2(); },
get userList() { return getUserList(); },
get toUserSelect() { return getToUserSelect(); },
get onlineCount() { return getOnlineCount(); },
get onlineCountBottom() { return getOnlineCountBottom(); },
// 在线用户
get onlineUsers() { return onlineUsers; },
set onlineUsers(v) {
onlineUsers = v;
window.onlineUsers = onlineUsers;
},
// 自动滚屏
get autoScroll() { return autoScroll; },
set autoScroll(v) { autoScroll = Boolean(v); },
// 最大消息 ID
get maxMsgId() { return maxMsgId; },
set maxMsgId(v) { maxMsgId = v; },
trackMaxMsgId(v) { if (v > maxMsgId) maxMsgId = v; },
// 消息队列
get pendingChatMessages() { return pendingChatMessages; },
set pendingChatMessages(v) { pendingChatMessages = v; },
get chatMessageFlushTimer() { return chatMessageFlushTimer; },
set chatMessageFlushTimer(v) { chatMessageFlushTimer = v; },
// 用户列表渲染
get userListRenderTimer() { return userListRenderTimer; },
set userListRenderTimer(v) { userListRenderTimer = v; },
get userFilterRenderTimer() { return userFilterRenderTimer; },
set userFilterRenderTimer(v) { userFilterRenderTimer = v; },
get userBadgeRotationTick() { return userBadgeRotationTick; },
set userBadgeRotationTick(v) { userBadgeRotationTick = v; },
// 存点节点
get lastAutosaveNode() { return lastAutosaveNode; },
set lastAutosaveNode(v) { lastAutosaveNode = v; },
// 房间在线状态缓存
get roomsRefreshTimer() { return roomsRefreshTimer; },
set roomsRefreshTimer(v) { roomsRefreshTimer = v; },
get roomsOnlineStatusCache() { return roomsOnlineStatusCache; },
set roomsOnlineStatusCache(v) { roomsOnlineStatusCache = v; },
get roomsOnlineStatusCacheAt() { return roomsOnlineStatusCacheAt; },
set roomsOnlineStatusCacheAt(v) { roomsOnlineStatusCacheAt = v; },
// 禁言
get isMutedUntil() { return isMutedUntil; },
set isMutedUntil(v) { isMutedUntil = v; },
// 发送锁
get imeComposing() { return imeComposing; },
set imeComposing(v) { imeComposing = v; },
get isSending() { return isSending; },
set isSending(v) { isSending = v; },
get sendStartedAt() { return sendStartedAt; },
set sendStartedAt(v) { sendStartedAt = v; },
// 退出房间
get leaveRequestInFlight() { return leaveRequestInFlight; },
set leaveRequestInFlight(v) { leaveRequestInFlight = v; },
// 心跳计数
get heartbeatFailCount() { return heartbeatFailCount; },
set heartbeatFailCount(v) { heartbeatFailCount = v; },
// 偏好
get blockedSystemSenders() { return blockedSystemSenders; },
set blockedSystemSenders(v) { blockedSystemSenders = v; },
get initialChatPreferences() { return initialChatPreferences; },
set initialChatPreferences(v) { initialChatPreferences = v; },
// 重置所有状态(用于测试或强制同步)
reset() {
onlineUsers = {};
window.onlineUsers = onlineUsers;
autoScroll = true;
maxMsgId = 0;
pendingChatMessages = [];
chatMessageFlushTimer = null;
userListRenderTimer = null;
userFilterRenderTimer = null;
userBadgeRotationTick = 0;
lastAutosaveNode = null;
roomsRefreshTimer = null;
roomsOnlineStatusCache = null;
roomsOnlineStatusCacheAt = 0;
isMutedUntil = 0;
imeComposing = false;
isSending = false;
sendStartedAt = 0;
leaveRequestInFlight = false;
heartbeatFailCount = 0;
blockedSystemSenders = new Set();
},
};
// 挂载到 window 供 Blade 脚本及其他模块使用
window.chatState = chatState;
// 向后兼容 Blade 中已暴露的 window 接口
export function hydrateOnlineUserPayload(username, payload) {
const nextPayload = { ...(onlineUsers[username] || {}) };
// 清除旧状态字段
delete nextPayload.daily_status_key;
delete nextPayload.daily_status_label;
delete nextPayload.daily_status_icon;
delete nextPayload.daily_status_group;
delete nextPayload.daily_status_expires_at;
onlineUsers[username] = { ...nextPayload, ...payload };
window.onlineUsers = onlineUsers;
}
window.hydrateOnlineUserPayload = hydrateOnlineUserPayload;
export function renderUserList() {
// 占位:实际渲染逻辑由 user-list.js 挂载到 window.chatState.renderUserList
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
}
export { onlineUsers, autoScroll, maxMsgId };
+332 -14
View File
@@ -1,40 +1,347 @@
// 聊天输入区事件绑定,逐步替代底部输入栏内联提交事件
// 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
let chatComposerEventsBound = false;
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
function getState() {
return window.chatState;
}
function getDraftStorageKey() {
return `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`;
}
// ── 草稿管理 ──
function persistChatDraft(value = null) {
try {
const contentInput = document.getElementById("content");
const draft = value ?? contentInput?.value ?? "";
if (draft === "") {
sessionStorage.removeItem(getDraftStorageKey());
return;
}
sessionStorage.setItem(getDraftStorageKey(), draft);
} catch (_) {
// 会话存储不可用时静默降级
}
}
function loadChatDraft() {
try {
return sessionStorage.getItem(getDraftStorageKey()) || "";
} catch (_) {
return "";
}
}
// ── 消息发送 ──
/**
* 绑定聊天表单提交事件
* 发送主流程仍由 Blade 主脚本的 sendMessage 维护,这里只统一 submit 入口。
*
* @returns {void}
* 将当前输入区状态整理为一份稳定快照
*/
function collectChatComposerState() {
const contentInput = document.getElementById("content");
const submitBtn = document.getElementById("send-btn");
const imageInput = document.getElementById("chat_image");
const toUserSelect = document.getElementById("to_user");
const actionSelect = document.getElementById("action");
const fontColorInput = document.getElementById("font_color");
const secretCheckbox = document.getElementById("is_secret");
const contentRaw = contentInput?.value ?? "";
const selectedImage = imageInput?.files?.[0] ?? null;
return {
contentInput,
submitBtn,
imageInput,
contentRaw,
content: contentRaw.trim(),
selectedImage,
toUser: toUserSelect?.value || "大家",
action: actionSelect?.value || "",
fontColor: fontColorInput?.value || "",
isSecret: Boolean(secretCheckbox?.checked),
};
}
/**
* 基于当前聊天快照构造稳定的 multipart 请求体。
*/
function buildChatMessageFormData(composerState) {
const formData = new FormData();
formData.append("content", composerState.contentRaw);
formData.append("to_user", composerState.toUser);
formData.append("action", composerState.action);
formData.append("font_color", composerState.fontColor);
if (composerState.isSecret) {
formData.append("is_secret", "1");
}
if (composerState.selectedImage) {
formData.append("image", composerState.selectedImage);
}
return formData;
}
/**
* 处理聊天图片选择后的前端状态展示。
*/
function handleChatImageSelected(input) {
const file = input?.files?.[0] ?? null;
if (!file) return;
// 用户选择图片后,立即触发自动发送
sendMessage(null);
}
/**
* 清理当前选中的聊天图片。
*/
function clearSelectedChatImage(resetInput = false) {
const imageInput = document.getElementById("chat_image");
if (resetInput && imageInput) {
imageInput.value = "";
}
}
/**
* 页面从后台恢复后,同步草稿、图片提示和发送锁状态。
*/
function syncChatComposerAfterResume() {
const state = getState();
const contentInput = document.getElementById("content");
if (!contentInput) return;
const savedDraft = loadChatDraft();
if (contentInput.value === "" && savedDraft !== "") {
contentInput.value = savedDraft;
} else if (contentInput.value !== "") {
persistChatDraft(contentInput.value);
}
const imageInput = document.getElementById("chat_image");
if (!imageInput?.files?.length) {
clearSelectedChatImage();
}
if (state) {
state.imeComposing = false;
}
if (state && state.isSending && Date.now() - state.sendStartedAt > 15000) {
const submitBtn = document.getElementById("send-btn");
if (submitBtn) submitBtn.disabled = false;
state.isSending = false;
state.sendStartedAt = 0;
}
}
/**
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)。
*/
async function sendMessage(e) {
if (e) e.preventDefault();
const state = getState();
if (state?.isSending) return;
if (state) {
state.isSending = true;
state.sendStartedAt = Date.now();
}
// 前端禁言检查
if (state && state.isMutedUntil > Date.now()) {
const remaining = Math.ceil((state.isMutedUntil - Date.now()) / 1000);
const remainMin = Math.ceil(remaining / 60);
const muteDiv = document.createElement("div");
muteDiv.className = "msg-line";
muteDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">【提示】您正在禁言中,还需等待约 ${remainMin} 分钟(${remaining} 秒)后方可发言。</span>`;
const say2 = document.getElementById("say2");
if (say2) {
say2.appendChild(muteDiv);
say2.scrollTop = say2.scrollHeight;
}
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
return;
}
const composerState = collectChatComposerState();
const { contentInput, submitBtn, content, contentRaw, selectedImage, toUser } = composerState;
if (!content && !selectedImage) {
contentInput?.focus();
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
return;
}
// AI 小助手私聊转发
if (toUser === "AI小班长" && content && typeof window.sendToChatBot === "function") {
window.sendToChatBot(content, composerState.isSecret);
}
// ── 神秘箱子暗号拦截 ──
const passcodePattern = /^[A-Z0-9]{4,8}$/;
if (!selectedImage && passcodePattern.test(content.trim())) {
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
try {
const claimRes = await fetch("/mystery-box/claim", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ passcode: content.trim() }),
});
const claimData = await claimRes.json();
if (claimData.ok) {
contentInput.value = "";
persistChatDraft("");
contentInput.focus();
window._mysteryBoxActive = false;
window._mysteryBoxPasscode = null;
const isPositive = (claimData.reward ?? 1) >= 0;
window.chatDialog?.alert(
claimData.message || "开箱成功!",
isPositive ? "🎉 恭喜!" : "☠️ 中了陷阱!",
isPositive ? "#10b981" : "#ef4444"
);
if (window.__chatUser && claimData.balance !== undefined) {
window.__chatUser.jjb = claimData.balance;
}
return;
}
} catch (_) {
// 网络错误时静默回退正常发送
}
}
submitBtn.disabled = true;
const formData = buildChatMessageFormData({ ...composerState, contentRaw });
try {
const response = await fetch(window.chatContext.sendUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
body: formData,
});
const data = await response.json();
if (response.ok && data.status === "success") {
contentInput.value = "";
persistChatDraft("");
clearSelectedChatImage(true);
contentInput.focus();
} else {
window.chatDialog?.alert(
"发送失败: " + (data.message || JSON.stringify(data.errors)),
"操作失败",
"#cc4444"
);
}
} catch (error) {
window.chatDialog?.alert("网络连接错误,消息发送失败!", "网络错误", "#cc4444");
console.error(error);
} finally {
submitBtn.disabled = false;
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
}
}
// ── 事件绑定 ──
/**
* 绑定聊天输入区的所有事件:submit、IME、keydown、草稿、焦点恢复。
*/
export function bindChatComposerControls() {
if (chatComposerEventsBound || typeof document === "undefined") {
return;
}
chatComposerEventsBound = true;
// 表单提交
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.matches("[data-chat-form]")) {
return;
}
event.preventDefault();
if (typeof window.sendMessage === "function") {
void window.sendMessage(event);
}
});
// 输入框事件绑定
const contentInput = document.getElementById("content");
if (contentInput) {
contentInput.addEventListener("input", function () {
persistChatDraft(this.value);
});
// IME 组词开始
contentInput.addEventListener("compositionstart", () => {
const state = getState();
if (state) state.imeComposing = true;
});
// IME 组词结束
contentInput.addEventListener("compositionend", () => {
setTimeout(() => {
const state = getState();
if (state) state.imeComposing = false;
}, 10);
});
// Enter 发送
contentInput.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const state = getState();
if (state?.imeComposing) return;
sendMessage(e);
}
});
}
// 页面恢复事件
syncChatComposerAfterResume();
window.addEventListener("pageshow", syncChatComposerAfterResume);
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible") {
syncChatComposerAfterResume();
}
});
window.addEventListener("focus", function () {
setTimeout(syncChatComposerAfterResume, 0);
});
}
/**
* 设置聊天动作并把焦点带回输入框。
* 该入口兼容旧模板可能存在的 `setAction(...)` 调用,切换右侧标签仍交给 Blade 里的 switchTab 处理。
*
* @param {string} action 动作名称
* @param {(tab:string) => void} switchTabHandler 右侧标签切换函数
* @returns {void}
*/
export function setChatComposerAction(
action,
@@ -47,7 +354,6 @@ export function setChatComposerAction(
actionSelect.value = action;
}
// 右侧在线名单切换仍在 Blade 主脚本内,模块只通过兼容入口调用。
if (typeof switchTabHandler === "function") {
switchTabHandler("users");
}
@@ -56,3 +362,15 @@ export function setChatComposerAction(
contentInput.focus();
}
}
// ── 挂载到 window ──
window.sendMessage = sendMessage;
window.handleChatImageSelected = handleChatImageSelected;
window.collectChatComposerState = collectChatComposerState;
window.buildChatMessageFormData = buildChatMessageFormData;
window.clearSelectedChatImage = clearSelectedChatImage;
window.persistChatDraft = persistChatDraft;
window.loadChatDraft = loadChatDraft;
window.syncChatComposerAfterResume = syncChatComposerAfterResume;
export { sendMessage, handleChatImageSelected, clearSelectedChatImage, collectChatComposerState, buildChatMessageFormData };
+479 -41
View File
@@ -1,93 +1,531 @@
// 每日签到弹窗事件代理,先迁移按钮与遮罩事件,签到业务仍由 Blade 主脚本维护
// 每日签到完整模块:事件代理、API 请求与日历渲染全部由 Vite 管理
import { escapeHtml } from "./html.js";
let dailySignInEventsBound = false;
/**
* 调用每日签到存量全局函数。
*
* @param {string} functionName 全局函数名
* @param {...unknown} args 参数
* @returns {void}
*/
function callDailySignInGlobal(functionName, ...args) {
// 当前模块只负责 data-* 事件到旧函数的桥接,接口请求和日历渲染暂不迁移。
if (typeof window[functionName] === "function") {
window[functionName](...args);
}
// ── 状态(全局共享,兼容 Blade 中 window.dailySignInState 引用)──
window.dailySignInState = window.dailySignInState || {
month: null,
prevMonth: null,
nextMonth: null,
repairCardItem: null,
repairCardCount: 0,
rewardRules: [],
status: null,
};
// ── 辅助函数 ──────────────────────────────────────────
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 读取每日签到月份翻页目标
*
* @param {"prev"|"next"|string} direction 翻页方向
* @returns {string|null}
* 从服务端响应中提取最新金币余额
*/
function resolveDailySignInMonth(direction) {
if (direction === "prev") {
return window.dailySignInState?.prevMonth || null;
}
function resolveDailySignInGoldBalance(data) {
const candidates = [
data?.data?.user?.jjb,
data?.data?.user?.gold,
data?.data?.presence?.jjb,
data?.data?.presence?.gold,
data?.data?.my_jjb,
data?.data?.new_jjb,
data?.data?.balance,
data?.my_jjb,
data?.new_jjb,
data?.balance,
];
if (direction === "next") {
return window.dailySignInState?.nextMonth || null;
for (const candidate of candidates) {
const amount = Number(candidate);
if (Number.isFinite(amount)) return amount;
}
return null;
}
/**
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件
*
* @returns {void}
* 从签到响应中提取当前用户最新在线载荷
*/
export function bindDailySignInControls() {
if (dailySignInEventsBound || typeof document === "undefined") {
function resolveDailySignInPresencePayload(data) {
const candidates = [
data?.data?.presence,
data?.data?.online_user,
data?.data?.onlineUser,
data?.data?.user_payload,
data?.data?.userPayload,
data?.data?.user,
data?.presence,
data?.online_user,
data?.onlineUser,
];
return candidates.find(p => p && typeof p === 'object') || null;
}
/**
* 从签到响应中提取签到身份字段。
*/
function resolveDailySignInIdentityPayload(data) {
const identity = data?.data?.identity || data?.data?.sign_identity || data?.identity || data?.sign_identity;
if (!identity || typeof identity !== 'object') return {};
return {
sign_identity_key: identity.key ?? identity.sign_identity_key ?? identity.code ?? '',
sign_identity_label: identity.label ?? identity.name ?? identity.sign_identity_label ?? '',
sign_identity_icon: identity.icon ?? identity.sign_identity_icon ?? '',
sign_identity_color: identity.color ?? identity.sign_identity_color ?? undefined,
sign_identity_bg_color: identity.bg_color ?? identity.background_color ?? identity.sign_identity_bg_color ?? undefined,
sign_identity_border_color: identity.border_color ?? identity.sign_identity_border_color ?? undefined,
};
}
/**
* 将签到成功结果同步到金币余额与在线名单。
*/
function applyDailySignInResult(data) {
const balance = resolveDailySignInGoldBalance(data);
const payload = resolveDailySignInPresencePayload(data);
const identityPayload = resolveDailySignInIdentityPayload(data);
const username = window.chatContext?.username;
if (balance !== null && window.chatContext) {
window.chatContext.userJjb = balance;
window.chatContext.myGold = balance;
}
if (username) {
// hydrateOnlineUserPayload 由 Blade 主脚本暴露在 window 上供 Vite 模块桥接调用。
if (typeof window.hydrateOnlineUserPayload === "function") {
window.hydrateOnlineUserPayload(username, {
...(payload || {}),
...identityPayload,
username,
});
}
}
// 通知 Blade 主脚本刷新在线用户列表。
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
}
// ── 渲染函数 ──────────────────────────────────────────
function getState() {
return window.dailySignInState;
}
function renderDailySignInStatus() {
const status = getState().status || {};
const streakEl = document.getElementById('daily-sign-streak');
const previewEl = document.getElementById('daily-sign-preview');
const cardCountEl = document.getElementById('daily-sign-card-count');
const cardPriceEl = document.getElementById('daily-sign-card-price');
const claimBtn = document.getElementById('daily-sign-claim-btn');
const buyBtn = document.getElementById('daily-sign-buy-card-btn');
const cardItem = getState().repairCardItem;
if (streakEl) streakEl.textContent = `连续 ${Number(status.current_streak_days || 0)}`;
if (previewEl) {
const rule = status.preview_rule || {};
const parts = [];
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward} 金币`);
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward} 经验`);
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward} 魅力`);
previewEl.textContent = status.signed_today ? '今日已签到' : `今日可领:${parts.join(' + ') || '签到奖励'}`;
}
if (cardCountEl) cardCountEl.textContent = `补签卡 ${getState().repairCardCount || 0}`;
if (cardPriceEl) {
cardPriceEl.textContent = cardItem
? `${cardItem.icon || '🗓️'} ${cardItem.name}${Number(cardItem.price || 0).toLocaleString()} 金币`
: '补签卡暂未上架';
}
if (claimBtn) {
claimBtn.disabled = !!status.signed_today;
claimBtn.textContent = status.signed_today ? '今日已签到' : '今日签到';
claimBtn.style.opacity = status.signed_today ? '0.55' : '1';
claimBtn.style.cursor = status.signed_today ? 'not-allowed' : 'pointer';
}
if (buyBtn) {
buyBtn.disabled = !cardItem?.id;
buyBtn.style.opacity = cardItem?.id ? '1' : '0.55';
buyBtn.style.cursor = cardItem?.id ? 'pointer' : 'not-allowed';
}
}
function renderDailySignInCalendar(payload) {
const grid = document.getElementById('daily-sign-calendar-grid');
const label = document.getElementById('daily-sign-month-label');
if (!grid) return;
if (label) label.textContent = payload.month_label || payload.month || '本月';
const days = Array.isArray(payload.days) ? payload.days : [];
grid.innerHTML = '';
const firstWeekday = Number(days[0]?.weekday || 0);
for (let i = 0; i < firstWeekday; i += 1) {
const blank = document.createElement('div');
blank.className = 'daily-sign-day blank';
grid.appendChild(blank);
}
days.forEach(day => {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'daily-sign-day';
if (day.signed) cell.classList.add('signed');
if (day.can_makeup) cell.classList.add('missed');
if (day.is_today) cell.classList.add('today');
if (day.is_future) cell.classList.add('future');
const stateText = day.signed
? `${day.is_makeup ? '补签' : '已签'} ${day.streak_days || ''}`
: (day.is_future ? '未到' : (day.is_today ? '今天' : '漏签'));
cell.innerHTML = `<span class="day-num">${day.day}</span><span class="day-state">${escapeHtml(stateText)}</span>`;
cell.title = day.reward_text || stateText;
if (day.can_makeup) cell.dataset.dailySignMakeup = day.date;
grid.appendChild(cell);
});
}
function renderDailySignInRewardRules() {
const list = document.getElementById('daily-sign-rewards-list');
const progress = document.getElementById('daily-sign-reward-progress');
if (!list) return;
const currentDays = Number(getState().status?.current_streak_days || 0);
const rules = getState().rewardRules || [];
if (progress) progress.textContent = `当前 ${currentDays}`;
if (!rules.length) {
list.innerHTML = '<div style="font-size:12px;color:#94a3b8;padding:4px;">暂无奖励规则</div>';
return;
}
dailySignInEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
list.innerHTML = rules.map(rule => {
const streakDays = Number(rule.streak_days || 0);
const parts = [];
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward}`);
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward}经验`);
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward}魅力`);
const icon = escapeHtml(rule.identity_badge_icon || '✅');
const name = escapeHtml(rule.identity_badge_name || '签到奖励');
const color = escapeHtml(rule.identity_badge_color || '#0f766e');
const activeClass = currentDays >= streakDays ? ' active' : '';
const distanceText = currentDays >= streakDays ? '已达成' : `还差 ${Math.max(streakDays - currentDays, 0)}`;
const rewardText = escapeHtml(parts.join(' + ') || '签到记录');
return `
<div class="daily-sign-reward-card${activeClass}" title="${name} · ${rewardText} · ${escapeHtml(distanceText)}">
<div class="daily-sign-reward-title">
<span>第 ${streakDays} 天</span>
<span style="color:${color};">${icon}</span>
</div>
<div class="daily-sign-reward-name">${name}</div>
<div class="daily-sign-reward-desc">${rewardText}</div>
</div>
`;
}).join('');
}
// ── API 请求函数 ──────────────────────────────────────
async function loadDailySignInStatus() {
const statusUrl = window.chatContext?.dailySignInStatusUrl;
if (!statusUrl) return;
const response = await fetch(statusUrl, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf() },
});
const data = await response.json();
if (!response.ok || data?.status === 'error') {
throw new Error(data?.message || '签到状态加载失败');
}
getState().status = data.data || {};
renderDailySignInStatus();
}
async function loadDailySignInCalendar(month) {
const calendarUrl = window.chatContext?.dailySignInCalendarUrl;
if (!calendarUrl) return;
const url = new URL(calendarUrl, window.location.origin);
if (month) url.searchParams.set('month', month);
const response = await fetch(url.toString(), {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf() },
});
const data = await response.json();
if (!response.ok || data?.status === 'error') {
throw new Error(data?.message || '签到日历加载失败');
}
const payload = data.data || {};
const state = getState();
state.month = payload.month || month || null;
state.prevMonth = payload.prev_month || null;
state.nextMonth = payload.next_month || null;
state.repairCardItem = payload.sign_repair_card_item || null;
state.repairCardCount = Number(payload.makeup_card_count || 0);
state.rewardRules = Array.isArray(payload.reward_rules) ? payload.reward_rules : [];
renderDailySignInCalendar(payload);
renderDailySignInStatus();
renderDailySignInRewardRules();
}
// ── 公开操作 ──────────────────────────────────────────
async function openDailySignInModal() {
const modal = document.getElementById('daily-sign-modal');
if (!window.chatContext?.dailySignInCalendarUrl || !modal) {
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
return;
}
modal.style.display = 'flex';
await Promise.all([
loadDailySignInStatus(),
loadDailySignInCalendar(getState().month),
]);
}
function closeDailySignInModal() {
const modal = document.getElementById('daily-sign-modal');
if (modal) modal.style.display = 'none';
}
async function quickDailySignIn() {
await openDailySignInModal();
}
async function claimDailySignInFromModal() {
const claimUrl = window.chatContext?.dailySignInClaimUrl;
if (!claimUrl) {
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
return;
}
try {
const response = await fetch(claimUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrf(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ room_id: window.chatContext?.roomId ?? null }),
});
const data = await response.json();
if (!response.ok || data?.status === 'error' || data?.ok === false) {
throw new Error(data?.message || '签到失败');
}
applyDailySignInResult(data);
await Promise.all([
loadDailySignInStatus(),
loadDailySignInCalendar(getState().month),
]);
renderDailySignInRewardRules();
window.chatToast?.show({
title: '签到成功',
message: data?.message || '今日签到奖励已到账。',
icon: data?.data?.sign_identity_icon || data?.data?.identity?.icon || '✅',
color: '#16a34a',
duration: 3200,
});
} catch (error) {
window.chatDialog?.alert(error.message || '签到失败,请稍后重试。', '签到失败', '#cc4444');
}
}
async function makeupDailySignIn(targetDate) {
const makeupUrl = window.chatContext?.dailySignInMakeupUrl;
if (!makeupUrl) return;
const ok = await window.chatDialog?.confirm(`确认使用 1 张补签卡补签 ${targetDate} 吗?`, '确认补签');
if (!ok) return;
try {
const response = await fetch(makeupUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrf(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ target_date: targetDate, room_id: window.chatContext?.roomId ?? null }),
});
const data = await response.json();
if (!response.ok || data?.status === 'error' || data?.ok === false) {
const firstError = data?.errors ? Object.values(data.errors).flat()[0] : null;
throw new Error(firstError || data?.message || '补签失败');
}
applyDailySignInResult(data);
await Promise.all([
loadDailySignInStatus(),
loadDailySignInCalendar(getState().month),
]);
renderDailySignInRewardRules();
window.chatToast?.show({
title: '补签成功',
message: data?.message || '补签已完成。',
icon: '🗓️',
color: '#0f766e',
duration: 3200,
});
} catch (error) {
window.chatDialog?.alert(error.message || '补签失败,请稍后重试。', '补签失败', '#cc4444');
}
}
async function promptSignRepairQuantity(item) {
const unitPrice = Number(item?.price || 0);
const ruleText = item?.description || '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。';
const promptPromise = window.chatDialog?.prompt(
`请输入要购买的补签卡数量(1-99):\n单价 ${unitPrice.toLocaleString()} 金币\n说明:${ruleText}`,
'1',
'购买补签卡',
'#0f766e',
);
const inputEl = document.getElementById('global-dialog-input');
const previousInputStyle = inputEl?.getAttribute('style') || '';
if (inputEl) {
inputEl.style.minHeight = '40px';
inputEl.style.height = '40px';
inputEl.style.resize = 'none';
inputEl.style.overflow = 'hidden';
}
const rawQuantity = await promptPromise;
if (inputEl) inputEl.setAttribute('style', previousInputStyle);
if (rawQuantity === null || rawQuantity === undefined) return null;
const quantity = Number.parseInt(String(rawQuantity).trim(), 10);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
window.chatDialog?.alert('购买数量必须是 1 到 99 之间的整数。', '数量不正确', '#cc4444');
return null;
}
return quantity;
}
async function buyDailySignRepairCard() {
const item = getState().repairCardItem;
if (!item?.id) {
window.chatDialog?.alert('补签卡暂未上架。', '提示', '#f59e0b');
return;
}
const quantity = await promptSignRepairQuantity(item);
if (quantity === null) return;
const totalPrice = Number(item.price || 0) * quantity;
const ok = await window.chatDialog?.confirm(
`确认花费 ${totalPrice.toLocaleString()} 金币购买【${item.name}】× ${quantity} 吗?\n说明:补签卡只能补签本月未签到日期。`,
'购买补签卡',
);
if (!ok) return;
if (typeof window.buyItem === 'function') {
window.buyItem(item.id, item.name, item.price, 'all', '', quantity);
setTimeout(() => {
loadDailySignInCalendar(getState().month);
loadDailySignInStatus();
}, 900);
return;
}
window.openShopModal?.();
}
// ── 暴露到 window(兼容 Blade 存量引用)───────────────
window.openDailySignInModal = openDailySignInModal;
window.closeDailySignInModal = closeDailySignInModal;
window.quickDailySignIn = quickDailySignIn;
window.loadDailySignInCalendar = loadDailySignInCalendar;
window.claimDailySignInFromModal = claimDailySignInFromModal;
window.makeupDailySignIn = makeupDailySignIn;
window.promptSignRepairQuantity = promptSignRepairQuantity;
window.buyDailySignRepairCard = buyDailySignRepairCard;
// ── 事件绑定 ──────────────────────────────────────────
/**
* 读取每日签到月份翻页目标。
*/
function resolveDailySignInMonth(direction) {
if (direction === "prev") return getState().prevMonth || null;
if (direction === "next") return getState().nextMonth || null;
return null;
}
/**
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件。
*/
export function bindDailySignInControls() {
if (dailySignInEventsBound || typeof document === "undefined") return;
dailySignInEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const overlay = event.target.closest("[data-daily-sign-modal-overlay]");
// 只在点击遮罩本身时关闭,避免点击弹窗内容区误触关闭。
if (overlay && event.target === overlay) {
callDailySignInGlobal("closeDailySignInModal");
closeDailySignInModal();
return;
}
if (event.target.closest("[data-daily-sign-close]")) {
event.preventDefault();
callDailySignInGlobal("closeDailySignInModal");
closeDailySignInModal();
return;
}
if (event.target.closest("[data-daily-sign-claim]")) {
event.preventDefault();
callDailySignInGlobal("claimDailySignInFromModal");
claimDailySignInFromModal();
return;
}
if (event.target.closest("[data-daily-sign-buy-repair-card]")) {
event.preventDefault();
callDailySignInGlobal("buyDailySignRepairCard");
buyDailySignRepairCard();
return;
}
const makeupButton = event.target.closest("[data-daily-sign-makeup]");
if (makeupButton) {
event.preventDefault();
// 日历格子由 Blade 主脚本动态生成,这里只读取日期并转发补签旧函数。
callDailySignInGlobal("makeupDailySignIn", makeupButton.getAttribute("data-daily-sign-makeup") || "");
makeupDailySignIn(makeupButton.getAttribute("data-daily-sign-makeup") || "");
return;
}
const monthButton = event.target.closest("[data-daily-sign-month]");
if (monthButton) {
event.preventDefault();
// 月份状态仍由 window.dailySignInState 维护,模块只读取方向并转发旧加载函数。
callDailySignInGlobal("loadDailySignInCalendar", resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
loadDailySignInCalendar(resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
}
});
}
+625
View File
@@ -0,0 +1,625 @@
/**
* 用户反馈模态弹窗模块
* 供聊天室工具栏"反馈"按钮使用,AJAX 加载反馈列表,内嵌提交表单。
*/
let feedbackBound = false;
let fbCurrentTab = 'all';
let fbLastId = null;
let fbHasMore = true;
let fbLoading = false;
// ── DOM 缓存 ──
let $modal, $inner, $list, $tabs, $toast, $writeBtn, $writeOverlay, $form, $type, $title, $content;
let $closeBtn, $writeClose, $formCancel, $loader;
function cacheDom() {
$modal = document.getElementById('feedback-modal');
if (!$modal) return false;
$inner = document.getElementById('feedback-modal-inner');
$list = document.getElementById('feedback-list');
$tabs = document.querySelectorAll('.feedback-tab');
$toast = document.getElementById('feedback-toast');
$writeBtn = document.getElementById('feedback-submit-btn');
$writeOverlay = document.getElementById('feedback-write-overlay');
$form = document.getElementById('feedback-form');
$type = document.getElementById('fb-type');
$title = document.getElementById('fb-title');
$content = document.getElementById('fb-content');
$closeBtn = document.querySelector('[data-feedback-modal-close]');
$writeClose = document.querySelector('[data-feedback-write-close]');
$formCancel = document.querySelector('[data-fb-form-cancel]');
$loader = document.getElementById('feedback-loader');
return true;
}
function getDataUrl() {
return $modal?.getAttribute('data-feedback-data-url') || '/feedback/data';
}
function getMoreUrl() {
return $modal?.getAttribute('data-feedback-more-url') || '/feedback/more';
}
function getStoreUrl() {
return $modal?.getAttribute('data-feedback-store-url') || '/feedback';
}
function getVoteUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-vote-url-template') || '/feedback/__ID__/vote';
return tmpl.replace('__ID__', id);
}
function getReplyUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-reply-url-template') || '/feedback/__ID__/reply';
return tmpl.replace('__ID__', id);
}
function getDestroyUrl(id) {
const tmpl = $modal?.getAttribute('data-feedback-destroy-url-template') || '/feedback/__ID__';
return tmpl.replace('__ID__', id);
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta?.getAttribute('content') || '';
}
// ── Toast 提示 ──
function showToast(msg, type) {
if (!$toast) return;
$toast.textContent = msg;
$toast.style.display = 'block';
$toast.style.background = type === 'error' ? '#fee2e2' : '#d1fae5';
$toast.style.color = type === 'error' ? '#dc2626' : '#065f46';
$toast.style.border = '1px solid ' + (type === 'error' ? '#fecaca' : '#a7f3d0');
setTimeout(() => { $toast.style.display = 'none'; }, 3000);
}
// ── 打开/关闭 ──
export function openFeedbackModal() {
if (!$modal) { cacheDom(); if (!$modal) return; }
$modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!feedbackBound) {
bindFeedbackControls();
}
loadFeedbackData('all');
}
export function closeFeedbackModal() {
if (!$modal) return;
$modal.style.display = 'none';
document.body.style.overflow = '';
$writeOverlay?.classList.remove('active');
}
// ── 加载反馈数据 ──
export function loadFeedbackData(tab) {
tab = tab || fbCurrentTab;
fbCurrentTab = tab;
fbLastId = null;
fbHasMore = true;
fbLoading = false;
if (!$list) return;
// 更新 Tab 高亮
$tabs.forEach(btn => {
const t = btn.getAttribute('data-feedback-tab');
btn.classList.toggle('active', t === tab);
});
$list.innerHTML = '<div class="fb-loading">加载中…</div>';
$loader?.classList.add('hidden');
let url = getDataUrl();
if (tab !== 'all') {
url += '?type=' + encodeURIComponent(tab);
}
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
const items = res.items || [];
fbLastId = res.last_id || (items.length > 0 ? items[items.length - 1].id : null);
fbHasMore = res.has_more === true;
if (items.length === 0) {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">💬</span>暂无反馈</div>';
$loader?.classList.add('hidden');
return;
}
renderFeedbackList(items);
updateLoader();
})
.catch(() => {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">😵</span>数据加载失败</div>';
$loader?.classList.add('hidden');
});
}
// ── 加载更多 ──
export function loadMoreFeedback() {
if (fbLoading || !fbHasMore || !fbLastId) return;
fbLoading = true;
$loader?.classList.remove('hidden');
$loader.textContent = '加载中…';
let url = getMoreUrl() + '?after_id=' + fbLastId;
if (fbCurrentTab !== 'all') {
url += '&type=' + encodeURIComponent(fbCurrentTab);
}
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
const items = res.items || [];
fbLoading = false;
if (items.length > 0) {
fbLastId = items[items.length - 1].id;
fbHasMore = res.has_more === true;
appendFeedbackList(items);
} else {
fbHasMore = false;
}
updateLoader();
})
.catch(() => {
fbLoading = false;
$loader?.classList.add('hidden');
});
}
// ── 渲染列表 ──
function renderFeedbackList(items) {
let html = '';
items.forEach(item => {
html += buildFeedbackCard(item);
});
$list.innerHTML = html;
}
function appendFeedbackList(items) {
items.forEach(item => {
$list.insertAdjacentHTML('beforeend', buildFeedbackCard(item));
});
}
function buildFeedbackCard(item) {
const typeClass = item.type === 'bug' ? 'bug' : 'suggestion';
const statusClass = 'fb-status-badge';
const statusStyle = getStatusStyle(item.status_color);
let actionsHtml = '';
// 赞同按钮(不能赞同自己的)
const voteIcon = item.voted ? '👍' : '👆';
const voteBtnClass = item.voted ? 'fb-vote-btn voted' : 'fb-vote-btn';
const voteDisabled = item.is_owner ? 'disabled' : '';
actionsHtml += `<button class="${voteBtnClass}" data-fb-vote="${item.id}" ${voteDisabled} title="${item.is_owner ? '不能赞同自己的反馈' : '赞同'}">${voteIcon} <span data-fb-vote-count="${item.id}">${item.votes_count}</span></button>`;
// 删除按钮
if (item.can_delete) {
actionsHtml += `<button class="fb-delete-btn" data-fb-delete="${item.id}">删除</button>`;
}
// 展开评论数
const repliesLabel = item.replies_count > 0 ? `💬 ${item.replies_count}` : '💬 0';
return `<div class="fb-card" data-fb-id="${item.id}">
<div class="fb-card-header">
<span class="fb-type-badge ${typeClass}">${escapeHtml(item.type_label)}</span>
<span class="${statusClass}" style="${statusStyle}">${escapeHtml(item.status_label)}</span>
<span class="fb-reply-count">${repliesLabel}</span>
</div>
<div class="fb-card-title">${escapeHtml(item.title)}</div>
<div class="fb-card-meta">${escapeHtml(item.username)} · ${escapeHtml(item.created_at)}</div>
<div class="fb-card-footer-actions">${actionsHtml}</div>
<div class="fb-detail" style="display:none;">
<div class="fb-detail-content">${nl2br(escapeHtml(item.content))}</div>
${item.admin_remark ? `<div class="fb-admin-remark"><div class="fb-admin-remark-label">🛡️ 开发者官方回复</div>${nl2br(escapeHtml(item.admin_remark))}</div>` : ''}
<div class="fb-replies" data-fb-replies="${item.id}">
${(item.replies || []).map(r => buildReplyHtml(r)).join('')}
</div>
<div class="fb-reply-form">
<textarea data-fb-reply-input="${item.id}" placeholder="补充说明…" maxlength="1000" rows="1"></textarea>
<button data-fb-reply-btn="${item.id}">发送</button>
</div>
</div>
</div>`;
}
function buildReplyHtml(reply) {
const adminClass = reply.is_admin ? 'admin' : '';
const badgeHtml = reply.is_admin ? '<span class="fb-reply-admin-badge">开发者</span>' : '';
return `<div class="fb-reply-item ${adminClass}">
<div class="fb-reply-header">
<span class="fb-reply-username">${escapeHtml(reply.username)}</span>
${badgeHtml}
<span class="fb-reply-time">${escapeHtml(reply.created_at)}</span>
</div>
<div class="fb-reply-body">${nl2br(escapeHtml(reply.content))}</div>
</div>`;
}
function getStatusStyle(color) {
const map = {
gray: 'background:#f3f4f6;color:#4b5563;',
green: 'background:#dcfce7;color:#15803d;',
blue: 'background:#dbeafe;color:#1d4ed8;',
emerald: 'background:#d1fae5;color:#047857;',
red: 'background:#fee2e2;color:#dc2626;',
orange: 'background:#ffedd5;color:#c2410c;',
};
return map[color] || 'background:#f3f4f6;color:#4b5563;';
}
function updateLoader() {
if (!$loader) return;
if (fbHasMore) {
$loader.classList.remove('hidden');
$loader.textContent = fbLoading ? '加载中…' : '↓ 下拉加载更多';
} else {
$loader.classList.add('hidden');
}
}
// ── 提交反馈表单 ──
function openWriteForm() {
if (!$writeOverlay) return;
$writeOverlay.classList.add('active');
if ($title) {
$title.value = '';
setTimeout(() => $title.focus(), 100);
}
if ($content) $content.value = '';
if ($type) $type.value = 'bug';
}
function closeWriteForm() {
if (!$writeOverlay) return;
$writeOverlay.classList.remove('active');
}
function submitFeedbackForm(event) {
event.preventDefault();
if (!$form) return;
const title = ($title?.value || '').trim();
const content = ($content?.value || '').trim();
const type = $type?.value || 'bug';
if (!title) {
showToast('请填写标题', 'error');
return;
}
if (!content) {
showToast('请填写详细描述', 'error');
return;
}
const submitBtn = $form.querySelector('.fb-form-submit');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = '提交中…';
}
fetch(getStoreUrl(), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ type, title, content }),
})
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
showToast('✅ ' + (res.message || '反馈已提交,感谢您的贡献!'), 'success');
closeWriteForm();
loadFeedbackData(fbCurrentTab);
} else {
showToast('❌ ' + (res.message || '提交失败,请重试'), 'error');
}
})
.catch(() => {
showToast('❌ 网络异常,请重试', 'error');
})
.finally(() => {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = '✈️ 提交反馈';
}
});
}
// ── 赞同/取消赞同 ──
function toggleVote(id) {
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
const voteBtn = card?.querySelector('[data-fb-vote]');
if (!voteBtn) return;
// 乐观更新
const wasVoted = voteBtn.classList.contains('voted');
const countSpan = voteBtn.querySelector('[data-fb-vote-count]');
let prevCount = parseInt(countSpan?.textContent || '0');
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👆' : '👍') + ' <span data-fb-vote-count="' + id + '">' + (wasVoted ? prevCount - 1 : prevCount + 1) + '</span>';
fetch(getVoteUrl(id), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
voteBtn.innerHTML = (res.voted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + res.votes_count + '</span>';
if (res.voted) {
voteBtn.classList.add('voted');
} else {
voteBtn.classList.remove('voted');
}
} else {
// 回滚
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + prevCount + '</span>';
showToast('❌ ' + (res.message || '操作失败'), 'error');
}
})
.catch(() => {
// 回滚
voteBtn.classList.toggle('voted');
voteBtn.innerHTML = (wasVoted ? '👍' : '👆') + ' <span data-fb-vote-count="' + id + '">' + prevCount + '</span>';
});
}
// ── 提交评论 ──
function submitReply(id) {
const input = $list?.querySelector(`[data-fb-reply-input="${id}"]`);
const btn = $list?.querySelector(`[data-fb-reply-btn="${id}"]`);
if (!input || !btn) return;
const content = input.value.trim();
if (!content) return;
btn.disabled = true;
btn.textContent = '发送中…';
fetch(getReplyUrl(id), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ content }),
})
.then(r => r.json())
.then(res => {
if (res.status === 'success' && res.reply) {
// 追加评论到列表
const repliesContainer = $list?.querySelector(`[data-fb-replies="${id}"]`);
if (repliesContainer) {
repliesContainer.insertAdjacentHTML('beforeend', buildReplyHtml(res.reply));
}
input.value = '';
// 更新评论计数
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
if (card) {
const countEl = card.querySelector('.fb-reply-count');
if (countEl) {
const current = parseInt(countEl.textContent?.replace(/[^0-9]/g, '') || '0');
countEl.textContent = '💬 ' + (current + 1);
}
}
showToast('✅ 评论已提交', 'success');
} else {
showToast('❌ ' + (res.message || '评论失败'), 'error');
}
})
.catch(() => {
showToast('❌ 网络异常', 'error');
})
.finally(() => {
btn.disabled = false;
btn.textContent = '发送';
});
}
// ── 删除反馈(通过 AJAX) ──
function deleteFeedback(id) {
if (!confirm('确定要删除这条反馈吗?')) return;
fetch(getDestroyUrl(id), {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => {
if (r.ok) {
return r.json();
}
throw new Error('删除失败');
})
.then(res => {
if (res.status === 'success') {
showToast('✅ ' + (res.message || '反馈已删除'), 'success');
const card = $list?.querySelector(`[data-fb-id="${id}"]`);
if (card) card.remove();
// 检查列表是否为空
if ($list && $list.querySelectorAll('.fb-card').length === 0) {
$list.innerHTML = '<div class="fb-empty"><span class="fb-empty-icon">💬</span>暂无反馈</div>';
}
} else {
showToast('❌ ' + (res.message || '删除失败'), 'error');
}
})
.catch(() => {
showToast('❌ 删除失败', 'error');
});
}
// ── 展开/收起详情 ──
function toggleDetail(card) {
if (!card) return;
const detail = card.querySelector('.fb-detail');
if (!detail) return;
const isVisible = detail.style.display !== 'none';
// 收起其他已展开的
$list?.querySelectorAll('.fb-card .fb-detail').forEach(d => {
if (d !== detail) d.style.display = 'none';
});
detail.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// 自动调整 textarea 高度
const ta = card.querySelector('.fb-reply-form textarea');
if (ta) {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
}
}
// ── 事件绑定 ──
export function bindFeedbackControls() {
if (feedbackBound) return;
if (!cacheDom()) {
setTimeout(() => {
if (cacheDom()) bindFeedbackControls();
}, 500);
return;
}
feedbackBound = true;
// 关闭按钮
$closeBtn?.addEventListener('click', closeFeedbackModal);
// 遮罩层点击关闭
$modal?.addEventListener('click', (e) => {
if (e.target === $modal) closeFeedbackModal();
});
// Tab 切换
$tabs.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-feedback-tab') || 'all';
closeWriteForm();
loadFeedbackData(tab);
});
});
// 提交反馈按钮(底部)
$writeBtn?.addEventListener('click', openWriteForm);
// 写表单关闭/取消
$writeClose?.addEventListener('click', closeWriteForm);
$formCancel?.addEventListener('click', closeWriteForm);
// 点击写表单遮罩关闭
$writeOverlay?.addEventListener('click', (e) => {
if (e.target === $writeOverlay) closeWriteForm();
});
// 表单提交
$form?.addEventListener('submit', submitFeedbackForm);
// ── 事件委托:列表内部交互 ──
$list?.addEventListener('click', (e) => {
// 卡片点击展开/收起(排除按钮)
const card = e.target.closest('.fb-card');
if (card && !e.target.closest('button') && !e.target.closest('textarea')) {
toggleDetail(card);
return;
}
// 赞同按钮
const voteBtn = e.target.closest('[data-fb-vote]');
if (voteBtn) {
const id = voteBtn.getAttribute('data-fb-vote');
if (!voteBtn.disabled) toggleVote(id);
return;
}
// 删除按钮
const deleteBtn = e.target.closest('[data-fb-delete]');
if (deleteBtn) {
const id = deleteBtn.getAttribute('data-fb-delete');
deleteFeedback(id);
return;
}
// 评论发送按钮
const replyBtn = e.target.closest('[data-fb-reply-btn]');
if (replyBtn) {
const id = replyBtn.getAttribute('data-fb-reply-btn');
if (!replyBtn.disabled) submitReply(id);
return;
}
});
// ── 事件委托:列表滚动加载更多 ──
$list?.addEventListener('scroll', () => {
if (!$list) return;
const { scrollTop, scrollHeight, clientHeight } = $list;
if (scrollTop + clientHeight >= scrollHeight - 60) {
loadMoreFeedback();
}
});
// ── 事件委托:评论输入框自动调整高度 ──
$list?.addEventListener('input', (e) => {
const ta = e.target.closest('[data-fb-reply-input]');
if (ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 80) + 'px';
}
});
// ── 事件委托:评论输入框 Enter 发送 ──
$list?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
const ta = e.target.closest('[data-fb-reply-input]');
if (ta) {
e.preventDefault();
const id = ta.getAttribute('data-fb-reply-input');
submitReply(id);
}
}
});
// ESC 关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if ($writeOverlay?.classList.contains('active')) {
closeWriteForm();
} else if ($modal?.style.display === 'flex') {
closeFeedbackModal();
}
}
});
}
// ── 工具函数 ──
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function nl2br(str) {
if (!str) return '';
return str.replace(/\n/g, '<br>');
}
+20 -13
View File
@@ -200,34 +200,41 @@ function setFishingButton(text, disabled) {
}
/**
* 启动自动钓鱼冷却倒计时。
* 启动自动钓鱼冷却倒计时(基于时间戳,不受浏览器后台节流影响)
*
* @param {number} cooldown
* @param {number} cooldown 冷却秒数
* @returns {void}
*/
function startAutoFishingCooldown(cooldown) {
let remaining = cooldown;
setFishingButton(`⏳ 冷却 ${remaining}s`, true);
const endTime = Date.now() + cooldown * 1000;
setFishingButton(`⏳ 冷却 ${cooldown}s`, true);
showAutoFishStopButton(cooldown);
// 基于时间戳更新倒计时 UI — 后台节流后回来也能准确显示
autoFishCooldownCountdown = window.setInterval(() => {
remaining -= 1;
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
setFishingButton(`⏳ 冷却 ${remaining}s`, true);
if (remaining <= 0) {
window.clearInterval(autoFishCooldownCountdown);
autoFishCooldownCountdown = null;
}
}, 1000);
}, 200);
autoFishCooldownTimer = window.setTimeout(() => {
autoFishCooldownTimer = null;
hideAutoFishStopButton();
if (autoFishing) {
void startFishing();
// 基于时间戳检测冷却结束 — 后台节流后立即触发
autoFishCooldownTimer = null;
const checkEnd = () => {
if (Date.now() >= endTime) {
autoFishCooldownTimer = null;
hideAutoFishStopButton();
if (autoFishing) {
void startFishing();
}
return;
}
}, cooldown * 1000);
autoFishCooldownTimer = window.setTimeout(checkEnd, 200);
};
autoFishCooldownTimer = window.setTimeout(checkEnd, Math.min(cooldown * 1000, 200));
}
/**
+392
View File
@@ -0,0 +1,392 @@
/**
* 星光留言板模态弹窗模块
* 供聊天室工具栏"留言"按钮使用,AJAX 加载留言数据,内嵌写留言表单。
*/
let guestbookBound = false;
let gbCurrentTab = 'public';
let gbCurrentPage = 1;
let gbTotalPages = 1;
let gbUsers = [];
// ── DOM 缓存 ──
let $modal, $inner, $list, $tabs, $toast, $writeBtn, $writeForm, $form, $towho, $textBody, $pager, $pageInfo, $prevBtn, $nextBtn;
let $closeBtn;
function cacheDom() {
$modal = document.getElementById('guestbook-modal');
if (!$modal) return false;
$inner = document.getElementById('guestbook-modal-inner');
$list = document.getElementById('guestbook-list');
$tabs = document.querySelectorAll('.guestbook-tab');
$toast = document.getElementById('guestbook-toast');
$writeBtn = document.getElementById('guestbook-write-btn');
$writeForm = document.getElementById('guestbook-write-form');
$form = document.getElementById('guestbook-form');
$towho = document.getElementById('gb-towho');
$textBody = document.getElementById('gb-text-body');
$pager = document.getElementById('guestbook-pager');
$pageInfo = document.getElementById('gb-page-info');
$prevBtn = document.getElementById('gb-page-prev');
$nextBtn = document.getElementById('gb-page-next');
$closeBtn = document.querySelector('[data-guestbook-modal-close]');
return true;
}
function getDataUrl() {
return $modal?.getAttribute('data-guestbook-data-url') || '/guestbook/data';
}
function getStoreUrl() {
return $modal?.getAttribute('data-guestbook-store-url') || '/guestbook';
}
function getDestroyUrl(id) {
const tmpl = $modal?.getAttribute('data-guestbook-destroy-url-template') || '/guestbook/__ID__';
return tmpl.replace('__ID__', id);
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta?.getAttribute('content') || '';
}
// ── Toast 提示 ──
function showToast(msg, type) {
if (!$toast) return;
$toast.textContent = msg;
$toast.style.display = 'block';
$toast.style.background = type === 'error' ? '#fee2e2' : '#d1fae5';
$toast.style.color = type === 'error' ? '#dc2626' : '#065f46';
$toast.style.border = '1px solid ' + (type === 'error' ? '#fecaca' : '#a7f3d0');
setTimeout(() => { $toast.style.display = 'none'; }, 3000);
}
// ── 打开/关闭 ──
export function openGuestbookModal() {
if (!$modal) { cacheDom(); if (!$modal) return; }
$modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!guestbookBound) {
bindGuestbookControls();
}
loadGuestbookMessages('public');
}
export function closeGuestbookModal() {
if (!$modal) return;
$modal.style.display = 'none';
document.body.style.overflow = '';
$writeForm.classList.remove('active');
}
// ── 加载留言列表 ──
export function loadGuestbookMessages(tab, page) {
tab = tab || gbCurrentTab;
page = page || 1;
gbCurrentTab = tab;
gbCurrentPage = page;
if (!$list) return;
// 更新 Tab 高亮
$tabs.forEach(btn => {
const t = btn.getAttribute('data-guestbook-tab');
btn.classList.toggle('active', t === tab);
});
$list.innerHTML = '<div class="gb-loading">加载中…</div>';
$pager.style.display = 'none';
const url = getDataUrl() + '?tab=' + encodeURIComponent(tab) + '&page=' + page;
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(res => {
if (!res.ok) {
$list.innerHTML = '<div class="gb-empty"><span class="gb-empty-icon">😵</span>数据加载失败</div>';
return;
}
gbUsers = res.users || [];
const total = res.total || 0;
const perPage = res.per_page || 15;
const totalPages = Math.ceil(total / perPage) || 1;
gbTotalPages = totalPages;
if (res.items.length === 0) {
const emptyMsg = tab === 'inbox' ? '暂无收件' : tab === 'outbox' ? '暂无发件' : '暂无公共留言';
$list.innerHTML = '<div class="gb-empty"><span class="gb-empty-icon">📭</span>' + emptyMsg + '</div>';
$pager.style.display = 'none';
return;
}
let html = '';
res.items.forEach(item => {
const isSecret = item.secret;
const cardClass = isSecret ? 'gb-card gb-secret' : 'gb-card';
const avatarClass = isSecret ? 'gb-avatar secret' : 'gb-avatar';
const towhoClass = item.towho ? 'gb-towho' : 'gb-towho public';
const towhoLabel = item.towho || '大家';
let actionsHtml = '';
if (!item.is_from_me) {
actionsHtml += '<button class="gb-btn gb-btn-reply" data-gb-reply="' + escapeAttr(item.who) + '">回复TA</button>';
}
if (item.can_delete) {
actionsHtml += '<button class="gb-btn gb-btn-delete" data-gb-delete="' + item.id + '">删除</button>';
}
html += '<div class="' + cardClass + '" data-gb-id="' + item.id + '">'
+ '<div class="gb-card-header">'
+ '<div class="gb-card-author">'
+ '<span class="' + avatarClass + '">' + escapeHtml(item.who_avatar) + '</span>'
+ '<span class="gb-who">' + escapeHtml(item.who) + '</span>'
+ '<span class="gb-arrow">→</span>'
+ '<span class="' + towhoClass + '">' + escapeHtml(towhoLabel) + '</span>'
+ (isSecret ? '<span class="gb-secret-badge">🔒 悄悄话</span>' : '')
+ '</div>'
+ '<span class="gb-time">' + escapeHtml(item.post_time) + '</span>'
+ '</div>'
+ '<div class="gb-body">' + nl2br(escapeHtml(item.text_body)) + '</div>'
+ '<div class="gb-card-footer">' + actionsHtml + '</div>'
+ '</div>';
});
$list.innerHTML = html;
// 分页
if (totalPages > 1) {
$pager.style.display = 'flex';
$pageInfo.textContent = '第 ' + page + ' / ' + totalPages + ' 页';
$prevBtn.disabled = page <= 1;
$nextBtn.disabled = page >= totalPages;
} else {
$pager.style.display = 'none';
}
// 更新用户列表(写表单的收件人下拉)
updateUserSelect();
})
.catch(() => {
$list.innerHTML = '<div class="gb-empty"><span class="gb-empty-icon">😵</span>网络请求失败</div>';
});
}
function updateUserSelect() {
if (!$towho) return;
const currentVal = $towho.value;
$towho.innerHTML = '<option value="">🌍 公共留言(所有人可见)</option>';
gbUsers.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = '👤 ' + name;
if (name === currentVal) opt.selected = true;
$towho.appendChild(opt);
});
}
// ── 写留言表单 ──
function openWriteForm(replyTo) {
if (!$writeForm || !$form) return;
$writeForm.classList.add('active');
$writeBtn.textContent = '✕ 收起表单';
if (replyTo && $towho) {
// 设置收件人为回复对象
for (let i = 0; i < $towho.options.length; i++) {
if ($towho.options[i].value === replyTo) {
$towho.selectedIndex = i;
break;
}
}
}
if ($textBody) {
$textBody.value = '';
setTimeout(() => $textBody.focus(), 100);
}
}
function closeWriteForm() {
if (!$writeForm) return;
$writeForm.classList.remove('active');
$writeBtn.textContent = '✏️ 写新留言';
}
function submitGuestbookForm(event) {
event.preventDefault();
if (!$form) return;
const formData = new FormData($form);
const body = (formData.get('text_body') || '').trim();
if (!body) {
showToast('请填写留言内容', 'error');
return;
}
fetch(getStoreUrl(), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
body: formData,
})
.then(r => {
// 即使返回 302 或非 JSON,也尝试解析
const ct = r.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
return r.json();
}
return r.text().then(t => {
// 如果有 redirect 且没有错误,视为成功
if (r.redirected) return { ok: true, redirect: r.url };
try { return JSON.parse(t); } catch { return { ok: false }; }
});
})
.then(res => {
if (res && (res.ok === true || res.success)) {
showToast('✅ 飞鸽传书已成功发送!', 'success');
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, 1);
} else {
const err = res?.error || res?.message || '发送失败,请重试';
showToast('❌ ' + err, 'error');
}
})
.catch(() => {
// 后盾:如果表单提交是标准 POST(重定向),检查是否有成功消息
// 假设成功
showToast('✅ 飞鸽传书已成功发送!', 'success');
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, 1);
});
}
// ── 删除留言 ──
function deleteMessage(id) {
if (!confirm('确定要删除这条留言吗?')) return;
fetch(getDestroyUrl(id), {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Accept': 'application/json',
},
})
.then(r => {
if (r.ok || r.status === 302) {
showToast('✅ 留言已删除', 'success');
loadGuestbookMessages(gbCurrentTab, gbCurrentPage);
} else {
showToast('❌ 删除失败', 'error');
}
})
.catch(() => {
// 假设成功(标准 Laravel redirect
showToast('✅ 留言已删除', 'success');
loadGuestbookMessages(gbCurrentTab, gbCurrentPage);
});
}
// ── 事件绑定 ──
export function bindGuestbookControls() {
if (guestbookBound) return;
if (!cacheDom()) {
// 可能 modal 还没渲染,延迟重试
setTimeout(() => {
if (cacheDom()) bindGuestbookControls();
}, 500);
return;
}
guestbookBound = true;
// 关闭按钮
$closeBtn?.addEventListener('click', closeGuestbookModal);
// 遮罩层点击关闭
$modal?.addEventListener('click', (e) => {
if (e.target === $modal) closeGuestbookModal();
});
// Tab 切换
$tabs.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-guestbook-tab') || 'public';
closeWriteForm();
loadGuestbookMessages(tab, 1);
});
});
// 写留言按钮
$writeBtn?.addEventListener('click', () => {
if ($writeForm.classList.contains('active')) {
closeWriteForm();
} else {
openWriteForm('');
}
});
// 表单取消
document.querySelector('[data-gb-form-cancel]')?.addEventListener('click', closeWriteForm);
// 表单提交
$form?.addEventListener('submit', submitGuestbookForm);
// 回复TA / 删除 — 事件委托
$list?.addEventListener('click', (e) => {
const replyBtn = e.target.closest('[data-gb-reply]');
if (replyBtn) {
const who = replyBtn.getAttribute('data-gb-reply');
openWriteForm(who);
return;
}
const deleteBtn = e.target.closest('[data-gb-delete]');
if (deleteBtn) {
const id = deleteBtn.getAttribute('data-gb-delete');
deleteMessage(id);
return;
}
});
// 分页
$prevBtn?.addEventListener('click', () => {
if (gbCurrentPage > 1) {
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, gbCurrentPage - 1);
}
});
$nextBtn?.addEventListener('click', () => {
if (gbCurrentPage < gbTotalPages) {
closeWriteForm();
loadGuestbookMessages(gbCurrentTab, gbCurrentPage + 1);
}
});
// ESC 关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && $modal?.style.display === 'flex') {
closeGuestbookModal();
}
});
}
// ── 工具函数 ──
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeAttr(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function nl2br(str) {
if (!str) return '';
return str.replace(/\n/g, '<br>');
}
+238
View File
@@ -0,0 +1,238 @@
/**
* 聊天室心跳与存点模块:定时存点、手动存点、退出房间、掉线检测。
* 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
*/
import { escapeHtml } from "./html.js";
import { pruneMessageContainer } from "./message-renderer.js";
const MAX_HEARTBEAT_FAILS = 3;
const HEARTBEAT_INTERVAL = 60 * 1000;
let heartbeatInterval = null;
let heartbeatFailCount = 0;
let leaveRequestInFlight = false;
/**
* 获取 CSRF Token。
*
* @returns {string}
*/
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 获取共享状态对象。
*
* @returns {Object|null}
*/
function getState() {
return window.chatState;
}
// ── 存点功能(手动 + 自动)─────────────────────
/**
* 执行一次存点请求,向服务端同步在线状态并获取经验/金币。
*
* @param {boolean} silent 静默模式(true=仅心跳,不显示存点提示)
* @returns {Promise<void>}
*/
async function saveExp(silent = false) {
if (!window.chatContext?.heartbeatUrl) return;
const state = getState();
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
});
// 检测登录态失效
if (response.status === 401 || response.status === 419) {
await notifyExpiredLeave();
window.chatDialog?.alert(
"⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。",
"连接警告",
"#b45309"
);
window.location.href = "/";
return;
}
const data = await response.json();
if (response.ok && data.status === "success") {
heartbeatFailCount = 0;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const d = data.data;
const identitySummary = d.identity_summary ? `${d.identity_summary} · ` : "";
let levelInfo = "";
if (d.is_max_level) {
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb} · 已满级 ✓`;
} else {
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb}`;
}
// 本次获得的奖励提示
let gainInfo = "";
if (d.exp_gain > 0 || d.jjb_gain > 0) {
const parts = [];
if (d.exp_gain > 0) parts.push(`经验+${d.exp_gain}`);
if (d.jjb_gain > 0) parts.push(`金币+${d.jjb_gain}`);
gainInfo = ` 本次获得:${parts.join("")}`;
}
// 升级通知
if (data.data.leveled_up) {
const upDiv = document.createElement("div");
upDiv.className = "msg-line";
upDiv.innerHTML = `<span style="color: #d97706; font-weight: bold;">【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟</span><span class="msg-time">(${timeStr})</span>`;
const container2 = state?.container2;
if (container2) {
container2.appendChild(upDiv);
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
}
}
// 存点消息输出到包厢窗口
if (!silent) {
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
if (container2) {
const detailDiv = document.createElement("div");
detailDiv.className = "msg-line";
detailDiv.dataset.autosave = "1";
detailDiv.innerHTML = `<span style="color: green;">${escapeHtml(levelInfo + gainInfo)}</span><span class="msg-time">(${timeStr})</span>`;
// 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知
if (state) {
state.lastAutosaveNode?.remove();
state.lastAutosaveNode = detailDiv;
}
container2.appendChild(detailDiv);
pruneMessageContainer(container2, window.chatState?.PRIVATE_MESSAGE_NODE_LIMIT || 300);
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
}
}
}
} catch (e) {
console.error("存点失败", e);
heartbeatFailCount++;
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
window.chatDialog?.alert(
"⚠️ 与服务器的连接已断开,请检查网络后重新登录。",
"连接警告",
"#b45309"
);
window.location.href = "/";
return;
}
if (!silent) {
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
if (container2) {
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
sysDiv.innerHTML = '<span style="color: red;">【系统】存点失败,请稍后重试</span>';
container2.appendChild(sysDiv);
}
}
}
}
// ── 退出房间 ──
/**
* 主动退出房间并关闭页面。
*
* @returns {Promise<void>}
*/
async function leaveRoom() {
if (leaveRequestInFlight) return;
leaveRequestInFlight = true;
try {
await fetch(window.chatContext.leaveUrl + "?explicit=1", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
});
} catch (e) {
console.error(e);
}
// 弹出窗口直接关闭,如果不是弹出窗口则跳回首页
window.close();
setTimeout(() => {
window.location.href = "/";
}, 500);
}
/**
* 通知服务端登录已过期(用于 401/419 响应时)。
*
* @returns {Promise<void>}
*/
async function notifyExpiredLeave() {
if (leaveRequestInFlight) return;
leaveRequestInFlight = true;
try {
if (!window.chatContext?.expiredLeaveUrl) return;
await fetch(window.chatContext.expiredLeaveUrl, {
method: "GET",
headers: { "Accept": "application/json" },
credentials: "same-origin",
});
} catch (e) {
console.error(e);
}
}
// ── 定时器管理 ──
/**
* 启动心跳定时器(每 60 秒自动存点)。
*/
export function startHeartbeat() {
stopHeartbeat(); // 防止重复启动
// 首次心跳延迟 10 秒,让 WebSocket 先连接
const initialTimer = window.setTimeout(() => saveExp(true), 10000);
const intervalTimer = window.setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
heartbeatInterval = { initial: initialTimer, interval: intervalTimer };
}
/**
* 停止心跳定时器。
*/
export function stopHeartbeat() {
if (heartbeatInterval) {
window.clearTimeout(heartbeatInterval.initial);
window.clearInterval(heartbeatInterval.interval);
heartbeatInterval = null;
}
}
// ── 挂载到 window ──
window.saveExp = saveExp;
window.leaveRoom = leaveRoom;
window.notifyExpiredLeave = notifyExpiredLeave;
window.startHeartbeat = startHeartbeat;
window.stopHeartbeat = stopHeartbeat;
export { saveExp, leaveRoom, notifyExpiredLeave, HEARTBEAT_INTERVAL, MAX_HEARTBEAT_FAILS };
+8 -2
View File
@@ -56,11 +56,17 @@ function restoreHistoryMessages(initialState) {
* @returns {void}
*/
function appendWelcomeMessage(initialState) {
if (!initialState.welcomeMessage) {
const messages = Array.isArray(initialState.welcomeMessages) && initialState.welcomeMessages.length > 0
? initialState.welcomeMessages
: (initialState.welcomeMessage ? [initialState.welcomeMessage] : []);
if (messages.length === 0) {
return;
}
window.setTimeout(() => appendInitialMessage(initialState.welcomeMessage), 220);
window.setTimeout(() => {
// 本次进房欢迎绕过历史清屏过滤,确保新人礼包和 AI 欢迎能在当前屏看到。
messages.forEach((message) => appendInitialMessage(message));
}, 220);
}
/**
+153
View File
@@ -0,0 +1,153 @@
/**
* 懒加载工具模块
*
* 提供按需动态导入辅助函数,支持:
* 1. 首次加载时自动调用初始化函数(如 bind*Controls
* 2. 包裹目标函数,实现 window.xxx = (...args) => 自动加载并调用
* 3. 模块缓存机制,避免重复加载
* 4. Alpine 组件懒加载:返回同步 stub,在 $watch 触发时异步加载真实组件
*/
/**
* 创建一个可延迟加载的模块引用。
*
* @param {() => Promise<*>} importFn 动态 import 工厂函数
* @param {(module: *) => void} [initFn] 首次加载成功后调用的初始化回调
* @returns {{ load: () => Promise<*>, wrap: (name: string) => Function, get: (name: string) => Function }}
*/
export function createLazyModule(importFn, initFn) {
/** @type {Promise<*>|null} */
let promise = null;
let initialized = false;
return {
/**
* 确保模块已加载,返回模块对象。
* @returns {Promise<*>}
*/
async load() {
if (!promise) {
promise = importFn().then((mod) => {
if (!initialized && initFn) {
initialized = true;
initFn(mod);
}
return mod;
});
}
return promise;
},
/**
* 创建一个延迟执行的目标函数包装器。
* 首次调用时自动加载模块,之后直接调用缓存。
*
* @param {string} fnName 模块中导出的函数名
* @returns {Function}
*/
wrap(fnName) {
return async (...args) => {
const mod = await this.load();
const fn = mod[fnName];
if (typeof fn !== "function") {
throw new Error(
`懒加载模块中找不到函数 "${fnName}"`,
);
}
return fn(...args);
};
},
/**
* 返回一个返回模块导出的 getter 函数。
* 适用于返回非函数值(如 Alpine 组件对象)。
*
* @param {string} name 模块中导出的名称
* @returns {Function}
*/
get(name) {
return async () => {
const mod = await this.load();
return mod[name];
};
},
};
}
/**
* 创建 Alpine 组件延迟加载包装器。
*
* Alpine 的 x-data="ComponentName()" 要求工厂函数返回一个同步对象。
* 本函数返回一个函数,该函数:
* 1. 立即返回一个包含安全默认值的 stub 对象
* 2. 通过 Alpine 的 $watch 监听显示状态变化
* 3. 仅当组件变为可见(show/showUserModal 变为 true)时,才异步加载真实模块
* 4. 通过 Alpine 的响应式代理(this)写入真实数据,触发模板重新渲染
*
* 这实现了"真懒加载"——用户若不打开面板,对应代码块永远不会下载。
*
* @param {() => Promise<*>} importFn 动态 import 工厂函数
* @param {string} exportName 模块导出的组件工厂函数名
* @param {Record<string, any>} [defaults={}] 安全默认值
* @param {string} [watchKey='show'] 用于触发懒加载的 $watch 属性名
* @returns {Function} Alpine 组件工厂函数
*/
export function createLazyAlpineComponent(importFn, exportName, defaults = {}, watchKey = "show") {
return function (...args) {
const stub = {
[watchKey]: false,
...defaults,
init() {
const proxy = this;
let loaded = false;
if (
watchKey in proxy &&
typeof proxy.$watch === "function"
) {
proxy.$watch(watchKey, (value) => {
if (value && !loaded) {
loaded = true;
importFn()
.then((mod) => {
const componentFn = mod[exportName];
const realData =
typeof componentFn === "function"
? componentFn(...args)
: componentFn;
Object.keys(realData).forEach((key) => {
if (key !== "init") {
proxy[key] = realData[key];
}
});
for (const key in realData) {
if (!(key in proxy)) {
proxy[key] = realData[key];
}
}
if (
typeof proxy.init === "function" &&
proxy.init !== stub.init
) {
proxy.init.call(proxy);
}
})
.catch((err) => {
console.error(
`[懒加载] Alpine 组件 "${exportName}" 加载失败:`,
err,
);
loaded = false;
});
}
});
}
},
};
return stub;
};
}
+557
View File
@@ -0,0 +1,557 @@
// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { isExpiredChatImageMessage } from "./message-utils.js";
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
import { escapePresenceText } from "./vip-presence.js";
import {
BLOCKABLE_SYSTEM_SENDERS,
PUBLIC_MESSAGE_NODE_LIMIT,
PRIVATE_MESSAGE_NODE_LIMIT,
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
SYSTEM_USERS,
ACTION_TEXT_MAP,
} from "./chat-state.js";
// ── 游戏标签判断 ──
const GAME_LABEL_PREFIXES = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"];
function isGameLabel(name) {
if (GAME_LABEL_PREFIXES.some((p) => name.startsWith(p))) return true;
if (name.includes(" ")) return true;
return false;
}
// ── 构建自然语序的动作串 ──
function buildActionStr(action, fromHtml, toHtml, verb = "说") {
const info = ACTION_TEXT_MAP[action];
if (!info) return `${fromHtml}${toHtml}${escapeHtml(String(action || ""))}${verb}`;
if (info.type === "emotion") return `${fromHtml}${info.word}${toHtml}${verb}`;
return `${fromHtml}${info.word}${toHtml}${verb}`;
}
// ── 可点击用户名 ──
function clickableUser(uName, color, extraClass = "") {
const safeName = escapeHtml(uName);
if (uName === "AI小班长") {
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
}
if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) {
return `<span class="msg-user${extraClass}" style="color: ${color};">${safeName}</span>`;
}
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
}
// ── 解析内容中【用户名】为可点击标记 ──
function parseBracketUsers(content, color = "#000099") {
return content.replace(/【([^】]+)】/g, (_match, uName) => {
return "【" + clickableUser(uName, color) + "】";
});
}
/**
* 构建聊天消息的内容 HTML。
*/
export function buildChatMessageContent(msg, fontColor, textColorClass) {
const rawContent = msg.content || "";
if (msg.message_type === "image" && !isExpiredChatImageMessage(msg)) {
const fullUrl = escapeHtml(msg.image_url || "");
const thumbUrl = escapeHtml(msg.image_thumb_url || "");
const imageName = escapeHtml(msg.image_original_name || "聊天图片");
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; max-width:220px; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
: "";
return `
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}" data-chat-image-lightbox-open
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
<img src="${thumbUrl}" alt="${imageName}" loading="lazy" decoding="async"
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
</a>
${captionHtml}
</span>
`;
}
if (msg.message_type === "expired_image" || isExpiredChatImageMessage(msg)) {
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
const captionHtml = rawContent
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
: "";
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
}
return rawContent;
}
/**
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)。
*
* @param {Object} msg 消息对象
* @param {Object|null} renderBatch 批量渲染上下文
*/
export function appendMessage(msg, renderBatch = null) {
const state = window.chatState;
if (!state) return;
state.trackMaxMsgId(msg.id || 0);
const isMe = msg.from_user === window.chatContext?.username;
const fontColor = msg.font_color || "#000000";
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
const shouldHideByBlock = blockRuleKey ? state.blockedSystemSenders.has(blockRuleKey) : false;
const div = document.createElement("div");
div.className = "msg-line";
if (msg?.from_user) {
div.dataset.fromUser = msg.from_user;
}
if (blockRuleKey) {
div.dataset.blockKey = blockRuleKey;
}
// ── 消息气泡装扮 ──
if (msg.msg_bubble) {
const bubbleStyle = msg.msg_bubble.replace(/^msg_bubble_/, "");
div.classList.add("msg-bubble--" + bubbleStyle);
}
const timeStr = msg.sent_at || "";
let timeStrOverride = false;
let nameClass = "";
if (msg.msg_name_color) {
nameClass = " msg-name--" + msg.msg_name_color.replace(/^msg_name_/, "");
}
let textColorClass = "";
if (msg.msg_text_color) {
textColorClass = " msg-text--" + msg.msg_text_color.replace(/^msg_text_/, "");
}
// 用户头像
const senderInfo = state.onlineUsers[msg.from_user];
const senderHead = (senderInfo && senderInfo.headface) || "1.gif";
let headImgSrc = senderHead.startsWith("storage/") ? "/" + senderHead : `/images/headface/${senderHead}`;
if (msg.from_user.endsWith("播报") || msg.from_user === "星海小博士" || msg.from_user === "系统传音" || msg.from_user === "系统公告") {
headImgSrc = "/images/bugle.png";
}
// ── 头像框装扮 ──
let avatarFrameClass = null;
const avatarFrameRaw = msg.avatar_frame || (senderInfo && senderInfo.avatar_frame);
if (avatarFrameRaw) {
avatarFrameClass = "avatar-frame--" + avatarFrameRaw.replace(/^avatar_frame_/, "");
}
let headImg = "";
if (avatarFrameClass) {
headImg = '<span class="avatar-frame-wrapper-sm">' +
'<span class="avatar-frame ' + avatarFrameClass + '"></span>' +
'<img src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
'</span>';
} else {
headImg = '<img src="' + headImgSrc + '" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src=\'/images/headface/1.gif\'">';
}
const messageBodyHtml = buildChatMessageContent(msg, fontColor, textColorClass);
let html = "";
// ── 消息路由 ──
if (msg.action === "system_welcome") {
div.style.cssText = "margin: 3px 0;";
const iconImg = `<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
const parsedContent = parseBracketUsers(msg.content);
html = `${iconImg} ${parsedContent}`;
} else if (msg.action === "vip_presence") {
const accent = msg.presence_color || "#f59e0b";
div.style.cssText =
`background: linear-gradient(135deg, #ffffff, ${accent}08); border: 2px solid ${accent}44; border-radius: 16px; padding: 12px 16px; margin: 8px 0; box-shadow: 0 4px 15px ${accent}15; position: relative; overflow: hidden;`;
const icon = escapeHtml(msg.presence_icon || "👑");
const levelName = escapeHtml(msg.presence_level_name || "尊贵会员");
const typeLabel = msg.presence_type === "leave"
? "华丽离场"
: (msg.presence_type === "purchase" ? "荣耀开通" : "荣耀入场");
const safeText = escapePresenceText(msg.presence_text || "");
html = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
</div>
`;
timeStrOverride = true;
} else if (msg.action === "欢迎") {
div.style.cssText =
"background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);";
const userName = msg.from_user;
const rawContent = msg.content || "";
const colonIndex = rawContent.indexOf("");
let clickablePrefix = "";
let bodyPart = rawContent;
if (colonIndex !== -1) {
const prefixStr = rawContent.substring(0, colonIndex);
bodyPart = rawContent.substring(colonIndex);
const lastIdx = prefixStr.lastIndexOf(userName);
if (lastIdx !== -1) {
clickablePrefix =
prefixStr.substring(0, lastIdx) +
clickableUser(userName, "#1d4ed8", nameClass);
} else {
clickablePrefix = prefixStr;
}
}
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (SYSTEM_USERS.includes(msg.from_user)) {
if (msg.from_user === "系统公告") {
div.style.cssText =
"background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);";
const parsedContent = parseBracketUsers(msg.content, "#dc2626");
html = `<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (msg.from_user === "系统传音") {
const content = msg.content || "";
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
const isPlainNotification =
content.includes("【百家乐】") ||
content.includes("【赛马】") ||
content.includes("神秘箱子") ||
content.includes("【双色球") ||
content.includes("【五子棋】") ||
content.includes("【老虎机】") ||
content.includes("购买了");
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (isPlainNotification) {
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`;
} else {
div.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;";
let sysTranContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
}
} else if (msg.from_user === "系统" && msg.to_user && msg.to_user !== "大家") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;";
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
let giftHtml = "";
if (msg.gift_image) {
giftHtml = `<img src="${msg.gift_image}" alt="${msg.gift_name || ""}" style="display:inline-block;width:40px;height:40px;vertical-align:middle;margin-left:6px;animation:giftBounce 0.6s ease-in-out;">`;
}
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
}
} else if (msg.is_secret) {
if (msg.from_user === "系统") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;";
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
const fromHtml = clickableUser(msg.from_user, "#cc00cc", nameClass);
const toHtml = clickableUser(msg.to_user, "#cc00cc");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml, "悄悄说") :
`${fromHtml}${toHtml}悄悄说:`;
html = `${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
}
} else if (msg.to_user && msg.to_user !== "大家") {
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
const toHtml = clickableUser(msg.to_user, "#000099");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, toHtml) :
`${fromHtml}${toHtml}说:`;
html = `${headImg}${verbStr}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
} else {
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
const everyoneHtml = clickableUser("大家", "#000099");
const verbStr = msg.action ?
buildActionStr(msg.action, fromHtml, everyoneHtml) :
`${fromHtml}${everyoneHtml}说:`;
html = `${headImg}${verbStr}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
}
if (!timeStrOverride) {
html += ` <span class="msg-time">(${timeStr})</span>`;
}
div.innerHTML = html;
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
if (shouldHideByBlock) {
div.dataset.blockHidden = "1";
div.style.display = "none";
}
// 后端下发的带有 welcome_user 的系统欢迎/离开消息,替换同类旧消息
if (msg.welcome_user) {
const welcomeKind = msg.welcome_kind || "entry_broadcast";
div.setAttribute("data-system-user", msg.welcome_user);
div.setAttribute("data-system-welcome-kind", welcomeKind);
const removeSameWelcome = (root) => {
root?.querySelectorAll("[data-system-user]").forEach((el) => {
if (el.dataset.systemUser === msg.welcome_user && (el.dataset.systemWelcomeKind || "entry_broadcast") === welcomeKind) {
el.remove();
}
});
};
removeSameWelcome(state.container);
removeSameWelcome(renderBatch?.publicFragment);
removeSameWelcome(renderBatch?.privateFragment);
}
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username;
// 存点通知标记
const isAutoSave = (msg.from_user === "系统" || msg.from_user === "") &&
msg.content && (msg.content.includes("自动存点") || msg.content.includes("手动存点"));
if (isAutoSave) {
div.dataset.autosave = "1";
}
if (isRelatedToMe) {
if (isAutoSave) {
state.lastAutosaveNode?.remove();
state.lastAutosaveNode = div;
}
if (renderBatch) {
renderBatch.privateFragment.appendChild(div);
renderBatch.shouldPrunePrivate = true;
renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || state.autoScroll;
return;
}
const container2 = state.container2;
if (container2) {
container2.appendChild(div);
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
if (state.autoScroll) {
container2.scrollTop = container2.scrollHeight;
}
}
} else {
if (renderBatch) {
renderBatch.publicFragment.appendChild(div);
renderBatch.shouldPrunePublic = true;
renderBatch.shouldScrollPublic = renderBatch.shouldScrollPublic || state.autoScroll;
return;
}
const container = state.container;
if (container) {
container.appendChild(div);
pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
if (state.autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
}
}
/**
* 裁剪聊天窗口内过旧的消息节点,防止长时间在线后 DOM 无限膨胀。
*/
export function pruneMessageContainer(targetContainer, maxNodes) {
if (!targetContainer || targetContainer.childElementCount <= maxNodes) {
return;
}
const state = window.chatState;
while (targetContainer.childElementCount > maxNodes) {
const firstNode = targetContainer.firstElementChild;
if (state && firstNode === state.lastAutosaveNode) {
state.lastAutosaveNode = null;
}
firstNode?.remove();
}
}
/**
* 创建聊天消息批量渲染上下文。
*/
export function createChatMessageRenderBatch() {
return {
publicFragment: document.createDocumentFragment(),
privateFragment: document.createDocumentFragment(),
shouldPrunePublic: false,
shouldPrunePrivate: false,
shouldScrollPublic: false,
shouldScrollPrivate: false,
};
}
/**
* 提交批量渲染的聊天消息,并把裁剪和滚动合并到每批一次。
*/
export function commitChatMessageRenderBatch(renderBatch) {
const state = window.chatState;
if (!state) return;
const hasPublicMessages = renderBatch.publicFragment.childNodes.length > 0;
const hasPrivateMessages = renderBatch.privateFragment.childNodes.length > 0;
if (hasPublicMessages) {
const container = state.container;
if (container) container.appendChild(renderBatch.publicFragment);
}
if (hasPrivateMessages) {
const container2 = state.container2;
if (container2) container2.appendChild(renderBatch.privateFragment);
}
if (renderBatch.shouldPrunePublic) {
const container = state.container;
if (container) pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldPrunePrivate) {
const container2 = state.container2;
if (container2) pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldScrollPublic) {
const container = state.container;
if (container) container.scrollTop = container.scrollHeight;
}
if (renderBatch.shouldScrollPrivate) {
const container2 = state.container2;
if (container2) container2.scrollTop = container2.scrollHeight;
}
}
/**
* 将广播消息放入轻量队列,避免连续广播时同步挤占同一帧的 DOM 渲染。
*/
export function enqueueChatMessage(msg) {
const state = window.chatState;
if (!state) return;
state.trackMaxMsgId(msg.id || 0);
state.pendingChatMessages.push(msg);
if (state.chatMessageFlushTimer !== null) {
return;
}
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
}
/**
* 判断是否为普通用户聊天消息(非系统/游戏通知)。
*/
function isUserChatMessage(msg) {
if (!msg || !msg.from_user) return false;
const u = msg.from_user;
if (SYSTEM_USERS.includes(u)) return false;
if (u.endsWith("播报")) return false;
if (u === "百家乐" || u === "跑马") return false;
return true;
}
/** 后台恢复时系统通知最多保留条数 */
const MAX_SYSTEM_BURST = 20;
/** 后台恢复时超过该时间的系统通知直接丢弃(分钟) */
const MAX_SYSTEM_AGE_MINUTES = 10;
/**
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
*/
export function flushQueuedChatMessages() {
const state = window.chatState;
if (!state) return;
state.chatMessageFlushTimer = null;
// 大批量消息堆积(后台标签页恢复)时,保留所有用户聊天记录,
// 但过时的系统/游戏通知只保留最近 MAX_SYSTEM_BURST 条。
if (state.pendingChatMessages.length > MAX_SYSTEM_BURST + 30) {
const now = Date.now();
const maxAge = MAX_SYSTEM_AGE_MINUTES * 60 * 1000;
const totalSystem = state.pendingChatMessages.filter((m) => !isUserChatMessage(m)).length;
let systemSeen = 0;
let dropped = 0;
const filtered = state.pendingChatMessages.filter((msg) => {
if (isUserChatMessage(msg)) return true;
systemSeen++;
// 超过10分钟的系统通知直接丢弃
let msgTime = 0;
if (msg.sent_at) {
msgTime = new Date(msg.sent_at.replace(" ", "T")).getTime();
}
if (msgTime > 0 && now - msgTime > maxAge) {
dropped++;
return false;
}
// 从旧到新遍历,只保留最后 MAX_SYSTEM_BURST 条系统通知
const remainingAfter = totalSystem - systemSeen;
if (remainingAfter >= MAX_SYSTEM_BURST) {
dropped++;
return false;
}
return true;
});
if (dropped > 0) {
const container = state.container;
if (container) {
const notice = document.createElement("div");
notice.className = "msg-line msg-burst-notice";
notice.style.cssText =
"text-align:center;padding:6px 0;margin:4px 0;font-size:12px;color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;";
notice.textContent = `⏫ 省略了 ${dropped} 条系统通知`;
container.appendChild(notice);
}
}
state.pendingChatMessages = filtered;
}
const batch = state.pendingChatMessages.splice(0, CHAT_MESSAGE_FLUSH_BATCH_SIZE);
const renderBatch = createChatMessageRenderBatch();
batch.forEach((msg) => appendMessage(msg, renderBatch));
commitChatMessageRenderBatch(renderBatch);
if (state.pendingChatMessages.length === 0) {
return;
}
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
}
// ── 挂载到 window 供 Blade 脚本及其他模块使用 ──
window.appendMessage = appendMessage;
window.buildChatMessageContent = buildChatMessageContent;
window.pruneMessageContainer = pruneMessageContainer;
window.createChatMessageRenderBatch = createChatMessageRenderBatch;
window.commitChatMessageRenderBatch = commitChatMessageRenderBatch;
window.enqueueChatMessage = enqueueChatMessage;
window.flushQueuedChatMessages = flushQueuedChatMessages;
export { clickableUser, buildActionStr, parseBracketUsers };
@@ -307,6 +307,9 @@ export function handleFeatureLocalClear(onLocalClear) {
if (typeof onLocalClear === "function") {
onLocalClear();
} else if (typeof window.localClearScreen === "function") {
// 默认调用聊天室清屏函数,将当前可见消息全部移除。
window.localClearScreen();
}
}
@@ -463,3 +466,390 @@ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlocke
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted);
}
/**
* 根据消息内容识别其对应的屏蔽规则键。
*
* @param {Record<string, unknown>} msg 消息对象
* @returns {string|null}
*/
export function resolveBlockedSystemSenderKey(msg) {
const fromUser = String(msg?.from_user || "");
const content = String(msg?.content || "");
if (fromUser === "钓鱼播报") {
return "钓鱼播报";
}
if (fromUser === "神秘箱子") {
return "神秘箱子";
}
if (fromUser === "星海小博士") {
return "星海小博士";
}
// 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以"系统传音"发送,但正文里带有"钓鱼播报"字样。
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("钓鱼播报") || content.includes("自动钓鱼模式"))) {
return "钓鱼播报";
}
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("神秘箱子")) {
return "神秘箱子";
}
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("百家乐")) {
return "百家乐";
}
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("赛马") || content.includes("跑马"))) {
return "跑马";
}
return null;
}
// ── 偏好持久化 ──
/**
* 构建当前聊天室偏好快照。
*
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
*/
export function buildChatPreferencesPayload() {
const state = window.chatState;
return {
blocked_system_senders: state ? Array.from(state.blockedSystemSenders) : [],
sound_muted: isSoundMuted(),
};
}
/**
* 将聊天室偏好写入本地缓存,供刷新前快速恢复与迁移兜底。
*/
export function persistChatPreferencesToLocal() {
const state = window.chatState;
if (state) {
persistBlockedSystemSenders(state.blockedSystemSenders);
}
setSoundMuted(isSoundMuted());
}
/**
* 将当前聊天室偏好保存到当前登录账号。
*/
export async function saveChatPreferences() {
const payload = buildChatPreferencesPayload();
persistChatPreferencesToLocal();
if (!window.chatContext?.chatPreferencesUrl) {
return;
}
try {
const response = await fetch(window.chatContext.chatPreferencesUrl, {
method: "PUT",
headers: {
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]')?.content ?? "",
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("save chat preferences failed");
}
const data = await response.json();
if (data?.status === "success") {
window.chatContext.chatPreferences = normalizeChatPreferences(data.data || payload);
}
} catch (error) {
console.error("聊天室偏好保存失败:", error);
}
}
// ── 屏蔽 UI 同步 ──
/**
* 同步屏蔽菜单中的复选框状态。
*/
export function syncBlockedSystemSenderCheckboxes() {
const state = window.chatState;
const blockedSet = state ? state.blockedSystemSenders : new Set();
const checkboxMap = {
"block-sender-fishing": "钓鱼播报",
"block-sender-doctor": "星海小博士",
"block-sender-baccarat": "百家乐",
"block-sender-horse-race": "跑马",
"block-sender-mystery-box": "神秘箱子",
};
Object.entries(checkboxMap).forEach(([id, sender]) => {
const checkbox = document.getElementById(id);
if (checkbox) {
checkbox.checked = blockedSet.has(sender);
}
});
}
/**
* 批量切换当前已渲染消息的显示状态。
*
* @param {string} blockKey 屏蔽规则键
* @param {boolean} hidden true = 隐藏,false = 恢复显示
*/
export function setRenderedMessagesVisibilityBySender(blockKey, hidden) {
const state = window.chatState;
[state?.container, state?.container2].forEach(targetContainer => {
if (!targetContainer) return;
targetContainer.querySelectorAll("[data-block-key]").forEach(node => {
if (node.dataset.blockKey === blockKey) {
if (hidden) {
node.dataset.blockHidden = "1";
node.style.display = "none";
} else if (node.dataset.blockHidden === "1") {
node.removeAttribute("data-block-hidden");
node.style.display = "";
}
}
});
});
if (!hidden && state?.autoScroll) {
const container = state.container;
const container2 = state.container2;
if (container) container.scrollTop = container.scrollHeight;
if (container2) container2.scrollTop = container2.scrollHeight;
}
}
/**
* 更新指定系统播报项的屏蔽状态,并在勾选后立即清理当前窗口。
*
* @param {string} sender 系统播报发送者/规则键
* @param {boolean} blocked 是否屏蔽
*/
export function toggleBlockedSystemSender(sender, blocked) {
const state = window.chatState;
if (!state) return;
if (!BLOCKABLE_SYSTEM_SENDERS.includes(sender)) return;
if (blocked) {
state.blockedSystemSenders.add(sender);
setRenderedMessagesVisibilityBySender(sender, true);
} else {
state.blockedSystemSenders.delete(sender);
setRenderedMessagesVisibilityBySender(sender, false);
}
persistBlockedSystemSenders(state.blockedSystemSenders);
syncBlockedSystemSenderCheckboxes();
void saveChatPreferences();
}
// ── 挂载到 window:偏好持久化 ──
window.saveChatPreferences = saveChatPreferences;
window.syncBlockedSystemSenderCheckboxes = syncBlockedSystemSenderCheckboxes;
window.setRenderedMessagesVisibilityBySender = setRenderedMessagesVisibilityBySender;
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
window.persistChatPreferencesToLocal = persistChatPreferencesToLocal;
window.buildChatPreferencesPayload = buildChatPreferencesPayload;
// ── 挂载到 window:菜单/浮层控制(供 bindBlockMenuControls 事件代理调用)──
window.toggleBlockMenu = toggleBlockMenu;
window.toggleFeatureMenu = toggleFeatureMenu;
window.closeFeatureMenu = closeFeatureMenu;
window.openDailyStatusEditor = openDailyStatusEditor;
window.closeDailyStatusEditor = closeDailyStatusEditor;
window.handleFeatureLocalClear = handleFeatureLocalClear;
// ── 每日状态 UI 同步 ──
/**
* 获取当前登录用户仍然有效的每日状态。
*
* @returns {Object|null}
*/
export function getCurrentUserDailyStatus() {
return normalizeDailyStatus(window.chatContext?.currentDailyStatus);
}
/**
* 清除用户在线载荷中的状态字段,避免合并时残留旧状态。
*
* @param {Record<string, unknown>} payload 用户在线载荷
*/
export function removeDailyStatusFields(payload) {
if (!payload || typeof payload !== "object") return;
delete payload.daily_status_key;
delete payload.daily_status_label;
delete payload.daily_status_icon;
delete payload.daily_status_group;
delete payload.daily_status_expires_at;
}
/**
* 将状态写回指定用户的在线载荷。
*
* @param {string} username 用户名
* @param {Object|null} status 标准化后的状态对象
*/
export function setOnlineUserDailyStatus(username, status) {
const onlineUsers = window.chatState?.onlineUsers || window.onlineUsers || {};
if (!username || !onlineUsers[username]) return;
removeDailyStatusFields(onlineUsers[username]);
if (!status) return;
onlineUsers[username].daily_status_key = status.key;
onlineUsers[username].daily_status_label = status.label;
onlineUsers[username].daily_status_icon = status.icon;
onlineUsers[username].daily_status_group = status.group;
onlineUsers[username].daily_status_expires_at = status.expires_at;
}
/**
* 同步状态按钮文字与图标。
*/
function syncDailyStatusTrigger() {
const shortcutIcon = document.getElementById("daily-status-shortcut-icon");
const shortcutLabel = document.getElementById("daily-status-shortcut-label");
const activeStatus = getCurrentUserDailyStatus();
if (shortcutIcon) shortcutIcon.textContent = activeStatus?.icon || "🙂";
if (shortcutLabel) shortcutLabel.textContent = activeStatus?.label || "状态";
}
/**
* 同步状态面板中当前选中项的高亮样式。
*/
function syncDailyStatusMenuSelection() {
const activeKey = getCurrentUserDailyStatus()?.key || "";
document.querySelectorAll("#daily-status-editor-overlay .daily-status-item").forEach((button) => {
const selected = button.dataset.statusKey === activeKey;
button.style.borderColor = selected ? "#6366f1" : "#e5e7eb";
button.style.background = selected ? "linear-gradient(180deg,#eef2ff 0%,#e0e7ff 100%)" : "#ffffffcc";
button.style.color = selected ? "#312e81" : "#334155";
button.style.boxShadow = selected ? "0 8px 18px rgba(99,102,241,.18)" : "none";
button.style.transform = selected ? "translateY(-1px)" : "translateY(0)";
});
}
/**
* 同步聊天室状态相关 UI(按钮、面板高亮、聊天上下文)。
*/
export function syncDailyStatusUi() {
const activeStatus = getCurrentUserDailyStatus();
if (window.chatContext) window.chatContext.currentDailyStatus = activeStatus;
syncDailyStatusTrigger();
syncDailyStatusMenuSelection();
}
// ── 每日状态更新与清除 ──
/**
* 向服务端发送每日状态更新请求。
*
* @param {string} statusKey 状态键值
* @returns {Promise<void>}
*/
export async function updateDailyStatus(statusKey) {
const url = window.chatContext?.dailyStatusUpdateUrl;
if (!url || !statusKey) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
try {
const response = await fetch(url, {
method: "PUT",
headers: {
"X-CSRF-TOKEN": csrf,
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ daily_status_key: statusKey }),
});
if (!response.ok) throw new Error("update daily status failed");
const data = await response.json();
if (data?.status === "success" && window.chatContext) {
window.chatContext.currentDailyStatus = data.data ?? null;
}
closeDailyStatusEditor();
syncDailyStatusUi();
// 让在线用户列表同步当前用户的最新状态
const username = window.chatContext?.username;
if (username) {
setOnlineUserDailyStatus(username, getCurrentUserDailyStatus());
}
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
} catch (error) {
console.error("每日状态更新失败:", error);
}
}
/**
* 清除当前登录用户的每日状态。
*
* @returns {Promise<void>}
*/
export async function clearDailyStatus() {
const url = window.chatContext?.dailyStatusUpdateUrl;
if (!url) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
try {
const response = await fetch(url, {
method: "PUT",
headers: {
"X-CSRF-TOKEN": csrf,
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ daily_status_key: null }),
});
if (!response.ok) throw new Error("clear daily status failed");
const data = await response.json();
if (data?.status === "success" && window.chatContext) {
window.chatContext.currentDailyStatus = null;
}
closeDailyStatusEditor();
syncDailyStatusUi();
// 移除当前用户在线载荷中的状态字段
const username = window.chatContext?.username;
if (username) {
setOnlineUserDailyStatus(username, null);
}
if (typeof window.renderUserList === "function") {
window.renderUserList();
}
} catch (error) {
console.error("每日状态清除失败:", error);
}
}
// ── 挂载到 window:每日状态 ──
window.getCurrentUserDailyStatus = getCurrentUserDailyStatus;
window.setOnlineUserDailyStatus = setOnlineUserDailyStatus;
window.syncDailyStatusUi = syncDailyStatusUi;
window.updateDailyStatus = updateDailyStatus;
window.clearDailyStatus = clearDailyStatus;
@@ -703,6 +703,11 @@ export function bindProfileControls() {
if (event.target.closest("[data-settings-modal-overlay]")) {
closeSettingsModal();
}
// ── 头像选择弹窗:点击遮罩层关闭 ──
if (event.target.closest("[data-avatar-picker-overlay]") && !event.target.closest("[data-avatar-picker-panel]")) {
closeAvatarPicker();
}
});
document.addEventListener("change", (event) => {
+231 -13
View File
@@ -47,9 +47,40 @@ const SHOP_GROUPS = [
},
];
const DECORATION_GROUPS = [
{
label: "💬 消息气泡",
desc: "同类型只保留最新购买",
type: "msg_bubble",
},
{
label: "🎨 昵称颜色",
desc: "同类型只保留最新购买",
type: "msg_name_color",
},
{
label: "🌈 文字颜色",
desc: "同类型只保留最新购买",
type: "msg_text_color",
},
{
label: "🖼️ 头像框",
desc: "同类型只保留最新购买",
type: "avatar_frame",
},
];
const DECORATION_TYPE_TO_SLOT = {
msg_bubble: "bubble",
msg_name_color: "name_color",
msg_text_color: "text_color",
avatar_frame: "avatar_frame",
};
let shopControlEventsBound = false;
let shopLoaded = false;
let giftItem = null;
let activeDecorations = {};
/**
* 读取商店根节点上由 Blade 注入的接口地址。
@@ -106,6 +137,7 @@ export function openShopModal() {
}
modal.style.display = "flex";
bindShopTabs();
if (!shopLoaded) {
shopLoaded = true;
fetchShopData();
@@ -122,6 +154,46 @@ export function closeShopModal() {
if (modal) {
modal.style.display = "none";
}
shopLoaded = false;
}
/**
* 绑定商店 Tab 切换逻辑。
*
* @returns {void}
*/
function bindShopTabs() {
const tabsContainer = document.getElementById("shop-tabs");
if (!tabsContainer || tabsContainer.dataset.shopTabsBound) {
return;
}
tabsContainer.dataset.shopTabsBound = "1";
tabsContainer.addEventListener("click", (event) => {
const tab = event.target.closest("[data-shop-tab]");
if (!tab) {
return;
}
const tabName = tab.dataset.shopTab;
const itemsList = document.getElementById("shop-items-list");
const decorationsList = document.getElementById("shop-decorations-list");
// 切换当前 Tab 的选中状态。
tabsContainer.querySelectorAll(".shop-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shopTab === tabName);
});
// 切换对应商品列表,装扮列表需要显式恢复 grid 布局。
if (tabName === "items") {
if (itemsList) itemsList.style.display = "";
if (decorationsList) decorationsList.style.display = "none";
} else {
if (itemsList) itemsList.style.display = "none";
// 装扮列表在 CSS 中为 display:none,需显式设置为 grid 才能覆盖
if (decorationsList) decorationsList.style.display = "grid";
}
});
}
/**
@@ -136,6 +208,7 @@ export async function fetchShopData() {
});
const data = await response.json();
renderShop(data);
renderDecorations(data);
} catch (error) {
showShopToast("⚠ 加载失败,请重试", false);
}
@@ -245,6 +318,90 @@ export function renderShop(data) {
});
}
/**
* 渲染个人装扮商品列表。
*
* @param {Record<string, any>} data 商店接口数据
* @returns {void}
*/
export function renderDecorations(data) {
const list = document.getElementById("shop-decorations-list");
if (!list) {
return;
}
// 记录当前激活装扮,渲染时只给当前款式显示"已激活"。
activeDecorations = data.active_decorations || {};
const items = Array.isArray(data.items) ? data.items : [];
list.innerHTML = "";
// 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。
const note = document.createElement("div");
note.className = "decoration-note";
note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。";
list.appendChild(note);
DECORATION_GROUPS.forEach((group) => {
const groupItems = items.filter((item) => item.type === group.type);
if (!groupItems.length) {
return;
}
// 分组标题(独占一整行),描述文字内嵌到标题中避免单独占一格
const header = document.createElement("div");
header.className = "shop-group-header";
header.innerHTML = `${escapeHtml(group.label)}${group.desc ? ` <span>${escapeHtml(group.desc)}</span>` : ''}`;
list.appendChild(header);
groupItems.forEach((item) => {
const card = document.createElement("div");
card.className = "shop-card";
// active_decorations 由后端按槽位名索引,先把商品 type 映射到对应槽位。
const slot = DECORATION_TYPE_TO_SLOT[item.type] || null;
const activeDeco = slot ? (activeDecorations[slot] || null) : null;
const isActiveStyle = activeDeco && activeDeco.style === item.slug;
const expiresAt = activeDeco ? activeDeco.expires_at : null;
let daysLeft = "";
if (isActiveStyle && expiresAt) {
const remaining = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
daysLeft = remaining > 0 ? `剩余 ${remaining}` : "即将过期";
}
const button = document.createElement("button");
if (isActiveStyle) {
button.className = "shop-btn";
button.textContent = "续费 💰 " + Number(item.price || 0).toLocaleString();
} else {
button.className = "shop-btn";
button.textContent = "购买 💰 " + Number(item.price || 0).toLocaleString();
}
button.addEventListener("click", () => confirmAndBuyItem(item));
// 仅当前已激活的款式显示状态标签,同槽位其他款式保持普通购买状态。
const statusHtml = isActiveStyle
? `<span class="decoration-status active">已激活${daysLeft ? ' · ' + daysLeft : ''}</span>`
: "";
const validityHtml = buildValidityHtml(item);
card.innerHTML = `
<div class="shop-card-top">
<span class="shop-card-icon">${escapeHtml(item.icon)}</span>
<span class="shop-card-name">${escapeHtml(item.name)}</span>
</div>
${statusHtml ? `<div class="decoration-status-line">${statusHtml}</div>` : ""}
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
${validityHtml}
${isActiveStyle && expiresAt ? `<div class="decoration-expiry">⏳ 到期:${escapeHtml(expiresAt.replace('T', ' ').slice(0, 16))}</div>` : ""}
`;
card.appendChild(button);
list.appendChild(card);
});
});
}
/**
* 创建单个商品卡片,并挂载购买或使用按钮事件。
*
@@ -302,9 +459,7 @@ function buildShopCardHtml(item, options) {
<span style="position:absolute;top:-4px;right:-6px;background:#f43f5e;color:#fff;font-size:9px;font-weight:800;min-width:15px;height:15px;border-radius:8px;text-align:center;line-height:15px;padding:0 2px;">${options.ownedQty}</span>
</span>`
: `<span class="shop-card-icon">${escapeHtml(item.icon)}</span>`;
const durationLabel = options.isAutoFishing && Number(item.duration_minutes || 0) > 0
? `<div style="font-size:9px;margin-top:3px;color:#7c3aed;">⏱ 有效期 ${escapeHtml(formatMinutes(item.duration_minutes))}</div>`
: "";
const validityHtml = buildValidityHtml(item);
const ringBonus = options.isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0)
? `<div style="font-size:9px;margin-top:3px;display:flex;gap:8px;">
${Number(item.intimacy_bonus || 0) > 0 ? `<span style="color:#f43f5e;">💞 亲密 +${Number(item.intimacy_bonus || 0)}</span>` : ""}
@@ -319,10 +474,52 @@ function buildShopCardHtml(item, options) {
</div>
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
${ringBonus}
${durationLabel}
${validityHtml}
`;
}
/**
* 生成购买前展示的有效期或生效方式文案。
*
* @param {Record<string, any>} item 商品数据
* @returns {string}
*/
function buildValidityText(item) {
if (Number(item.duration_days || 0) > 0) {
return `有效期:${Number(item.duration_days)}`;
}
if (Number(item.duration_minutes || 0) > 0) {
return `有效期:${formatMinutes(item.duration_minutes)}`;
}
if (item.type === "instant") {
return "购买后立即播放 1 次";
}
if (item.type === "ring") {
return "购买后存入背包,求婚时消耗";
}
if (["one_time", "sign_repair"].includes(item.type)) {
return "购买后存入背包,使用时消耗";
}
return "";
}
/**
* 生成商品卡片里的有效期标签 HTML。
*
* @param {Record<string, any>} item 商品数据
* @returns {string}
*/
function buildValidityHtml(item) {
const text = buildValidityText(item);
return text ? `<div class="shop-validity">⏱ ${escapeHtml(text)}</div>` : "";
}
/**
* 格式化分钟数,供自动钓鱼卡有效期展示。
*
@@ -351,11 +548,15 @@ async function confirmAndBuyItem(item) {
}
}
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""} 吗?`;
const validityText = buildValidityText(item);
const replacementText = DECORATION_TYPE_TO_SLOT[item.type]
? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。"
: "";
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`;
const confirmed = await confirmShopPurchase(confirmMessage);
if (confirmed) {
buyItem(item.id, item.name, item.price, "all", "", quantity);
buyItem(item.id, item.name, item.price, "all", "", quantity, item.type);
}
}
@@ -401,7 +602,8 @@ export function openGiftDialog(item) {
}
if (itemName) {
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}`;
const validityText = buildValidityText(item);
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}${validityText ? ` · ${validityText}` : ""}`;
}
if (dialog) {
@@ -443,7 +645,7 @@ export function confirmGift() {
}
closeGiftDialog();
buyItem(item.id, item.name, item.price, recipient, message);
buyItem(item.id, item.name, item.price, recipient, message, 1, item.type);
}
/**
@@ -457,7 +659,7 @@ export function confirmGift() {
* @param {number} quantity 购买数量
* @returns {Promise<void>}
*/
export async function buyItem(itemId, name, price, recipient, message, quantity = 1) {
export async function buyItem(itemId, name, price, recipient, message, quantity = 1, itemType = '') {
try {
const response = await fetch(getShopUrls().buy, {
method: "POST",
@@ -473,7 +675,7 @@ export async function buyItem(itemId, name, price, recipient, message, quantity
const data = await response.json();
if (data.status === "success") {
handleBuySuccess(data, name);
handleBuySuccess(data, name, itemType);
return;
}
@@ -490,7 +692,7 @@ export async function buyItem(itemId, name, price, recipient, message, quantity
* @param {string} itemName 商品名称
* @returns {void}
*/
function handleBuySuccess(data, itemName) {
function handleBuySuccess(data, itemName, itemType = '') {
const balance = document.getElementById("shop-jjb");
if (data.jjb !== undefined && balance) {
@@ -499,6 +701,14 @@ function handleBuySuccess(data, itemName) {
showShopToast(`${itemName} 购买成功!`, true);
// 装扮购买成功后先更新本地缓存,随后再拉接口刷新完整状态。
if (data.slot && data.style) {
activeDecorations[data.slot] = {
style: data.style,
expires_at: data.expires_at,
};
}
// 购买者本地也要立即看到特效,广播只负责其他在线用户。
if (data.play_effect && window.EffectManager) {
window.EffectManager.play(data.play_effect);
@@ -507,8 +717,15 @@ function handleBuySuccess(data, itemName) {
shopLoaded = false;
setTimeout(() => {
fetchShopData();
shopLoaded = true;
}, 1000);
}, 300);
// 自动钓鱼卡购买后立即开启自动钓鱼
if (itemType === "auto_fishing" && typeof window.checkAndAutoStartFishing === "function") {
if (!window.chatContext) window.chatContext = {};
window.chatContext.autoFishingMinutesLeft = Number(data.auto_fishing_minutes_left || 1);
window.chatContext.fishingCooldownSeconds = 0;
setTimeout(() => window.checkAndAutoStartFishing(), 500);
}
}
/**
@@ -625,6 +842,7 @@ function exposeShopGlobals() {
window.loadShop = loadShop;
window.fetchShopData = fetchShopData;
window.renderShop = renderShop;
window.renderDecorations = renderDecorations;
window.openGiftDialog = openGiftDialog;
window.closeGiftDialog = closeGiftDialog;
window.confirmGift = confirmGift;
+5
View File
@@ -21,6 +21,8 @@ export function runToolbarAction(action) {
friend: () => window.openFriendPanel?.(),
avatar: () => window.openAvatarPicker?.(),
settings: () => window.openSettingsModal?.(),
guestbook: () => window.openGuestbookModal?.(),
feedback: () => window.openFeedbackModal?.(),
};
actions[action]?.();
@@ -59,6 +61,9 @@ function confirmToolbarLeaveRoom() {
*
* @returns {void}
*/
// ── 挂载到 window ──
window.runFeatureShortcut = runFeatureShortcut;
export function bindToolbarControls() {
if (toolbarEventsBound || typeof document === "undefined") {
return;
+38 -7
View File
@@ -603,18 +603,49 @@ export function userCardComponent() {
}
},
/** 冻结用户 */
async freezeUser() {
/** 封禁账号 */
async banUser() {
const confirmed = await this.$confirm(
'确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录',
'冻结账号',
'#cc4444'
'确定要封禁 ' + this.userInfo.username + ' 的账号吗?封禁后将无法登录',
'封禁账号',
'#991b1b'
);
if (!confirmed) return;
const reason = await this.$prompt('冻结原因:', '严重违规', '填写原因', '#cc4444');
const reason = await this.$prompt('封禁原因:', '严重违规', '填写原因', '#991b1b');
if (reason === null) return;
try {
const res = await fetch('/command/freeze', {
const res = await fetch('/command/ban', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '严重违规'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
this.$alert('操作失败:' + data.message, '操作失败', '#cc4444');
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
},
/** 封禁 IP */
async banIpUser() {
const confirmed = await this.$confirm(
'确定要封禁 ' + this.userInfo.username + ' 的 IP 吗?该操作会同时冻结账号。',
'封禁 IP',
'#9a3412'
);
if (!confirmed) return;
const reason = await this.$prompt('封禁原因:', '严重违规', '填写原因', '#9a3412');
if (reason === null) return;
try {
const res = await fetch('/command/banip', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
+343
View File
@@ -0,0 +1,343 @@
// 聊天室在线用户列表渲染:名单、搜索、徽标轮换。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml } from "./html.js";
import { normalizeDailyStatus } from "./preferences-status.js";
// ── 每日状态解析 ──
function resolveUserDailyStatus(user) {
return normalizeDailyStatus(user);
}
// ── 构建职务 / 管理员徽标 ──
function buildUserPrimaryBadgeHtml(user, username) {
if (user.position_icon) {
const posTitle = (user.position_name || "在职") + " · " + username;
const safePosTitle = escapeHtml(String(posTitle));
const safePositionIcon = escapeHtml(String(user.position_icon || "🎖️"));
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
}
if (user.is_admin) {
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
}
return "";
}
// ── 构建用户 VIP 徽标 ──
function buildUserVipBadgeHtml(user) {
if (!user.vip_icon) {
return "";
}
const vipColor = user.vip_color || "#f59e0b";
const safeVipTitle = escapeHtml(String(user.vip_name || "VIP"));
const safeVipIcon = escapeHtml(String(user.vip_icon || "👑"));
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
}
// ── 构建状态徽标 ──
function buildUserStatusBadgeHtml(user) {
const status = resolveUserDailyStatus(user);
if (!status) {
return "";
}
const safeIcon = escapeHtml(status.icon);
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
}
// ── 构建签到身份徽标 ──
function buildUserSignIdentityBadgeHtml(user) {
const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? "");
const identityIcon = String(user.sign_identity_icon ?? "");
if (!identityKey || !identityIcon) {
return "";
}
const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? "");
const safeIcon = escapeHtml(identityIcon);
const safeTooltip = escapeHtml(identityLabel ? `签到 · ${identityLabel}` : "签到身份");
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
}
/**
* 按轮换节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。
*/
export function buildUserBadgeHtml(user, username) {
const state = window.chatState;
const tick = state ? state.userBadgeRotationTick : 0;
const badges = [
buildUserSignIdentityBadgeHtml(user),
buildUserStatusBadgeHtml(user),
buildUserPrimaryBadgeHtml(user, username),
buildUserVipBadgeHtml(user),
].filter(Boolean);
if (badges.length === 0) {
return "";
}
return badges[tick % badges.length];
}
/**
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
*/
export function refreshRenderedUserBadges(scope = document) {
const state = window.chatState;
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
scope.querySelectorAll(".user-item[data-username]").forEach((item) => {
const username = item.dataset.username;
const badgeSlot = item.querySelector(".user-badge-slot");
if (!username || !badgeSlot) {
return;
}
badgeSlot.innerHTML = buildUserBadgeHtml(onlineUsers[username] || {}, username);
});
}
/**
* 核心渲染函数:将在线用户渲染到指定容器(桌面端名单区和手机端抽屉共用)。
*/
export function renderUserListToContainer(targetContainer, sortBy, keyword) {
if (!targetContainer) return;
const state = window.chatState;
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
const fragment = document.createDocumentFragment();
// 在列表顶部添加"大家"条目
const allDiv = document.createElement("div");
allDiv.className = "user-item";
allDiv.dataset.userListEveryone = "1";
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
fragment.appendChild(allDiv);
// 构建用户数组并排序
let userArr = [];
for (let username in onlineUsers) {
userArr.push({ username, ...onlineUsers[username] });
}
if (sortBy === "name") {
userArr.sort((a, b) => a.username.localeCompare(b.username, "zh"));
} else if (sortBy === "level") {
userArr.sort((a, b) => (b.user_level || 0) - (a.user_level || 0));
}
userArr.forEach((user) => {
const username = user.username;
// 搜索过滤
if (keyword && !username.toLowerCase().includes(keyword)) return;
const item = document.createElement("div");
item.className = "user-item";
item.dataset.username = username;
item.dataset.userListEntry = "1";
const headface = (user.headface || "1.gif");
const headImgSrc = headface.startsWith("storage/") ? "/" + headface : "/images/headface/" + headface;
const badges = buildUserBadgeHtml(user, username);
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? "color:#e91e8c;" : "";
// 昵称颜色装扮
let userNameExtraClass = "";
if (user.name_color) {
userNameExtraClass = " msg-name--" + user.name_color.replace(/^msg_name_/, "");
}
// 头像框装扮
let avatarHtml = "";
if (user.avatar_frame) {
const frameClass = "avatar-frame--" + user.avatar_frame.replace(/^avatar_frame_/, "");
avatarHtml = '<span class="avatar-frame-wrapper">' +
'<span class="avatar-frame ' + frameClass + '"></span>' +
'<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
'</span>';
} else {
avatarHtml = '<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">';
}
item.innerHTML = `
${avatarHtml}
<span class="user-name${userNameExtraClass}" style="${nameColor}">${username}</span>
<span class="user-badge-slot">${badges}</span>
`;
// 具体点击、双击与手机双触发由 Vite 的 right-panel.js 统一事件委托处理
fragment.appendChild(item);
});
targetContainer.replaceChildren(fragment);
refreshRenderedUserBadges(targetContainer);
}
/**
* 渲染完整用户列表(含下拉选单与在线计数)。
*/
export function renderUserList() {
const state = window.chatState;
if (!state) return;
if (state.userListRenderTimer) {
window.clearTimeout(state.userListRenderTimer);
state.userListRenderTimer = null;
}
const userList = state.userList;
const toUserSelect = state.toUserSelect;
// 获取排序方式和搜索词
const sortSelect = document.getElementById("user-sort-select");
const sortBy = sortSelect ? sortSelect.value : "default";
const searchInput = document.getElementById("user-search-input");
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
// 调用核心渲染
if (userList) {
renderUserListToContainer(userList, sortBy, keyword);
}
// 下拉框重建
if (toUserSelect) {
const selectFragment = document.createDocumentFragment();
const everyoneOption = document.createElement("option");
everyoneOption.value = "大家";
everyoneOption.textContent = "大家";
selectFragment.appendChild(everyoneOption);
for (let username in state.onlineUsers) {
if (username !== window.chatContext?.username) {
const option = document.createElement("option");
option.value = username;
option.textContent = username === "AI小班长" ? "🤖 AI小班长" : username;
selectFragment.appendChild(option);
}
}
toUserSelect.replaceChildren(selectFragment);
}
// 在线计数
const count = Object.keys(state.onlineUsers).length;
const onlineCount = state.onlineCount;
const onlineCountBottom = state.onlineCountBottom;
if (onlineCount) onlineCount.innerText = count;
if (onlineCountBottom) onlineCountBottom.innerText = count;
const footer = document.getElementById("online-count-footer");
if (footer) footer.innerText = count;
// 派发用户列表更新事件,供手机端抽屉同步
window.dispatchEvent(new Event("chatroom:users-updated"));
}
/**
* 合并高频在线名单变动,避免 Presence 连续进出时重复重建名单 DOM。
*/
export function scheduleRenderUserList(delay = 120) {
const state = window.chatState;
if (!state) return;
if (state.userListRenderTimer) {
window.clearTimeout(state.userListRenderTimer);
}
state.userListRenderTimer = window.setTimeout(() => {
state.userListRenderTimer = null;
renderUserList();
}, delay);
}
/**
* 搜索/过滤用户列表(仅操作 DOM 可见性,不重建)。
*/
export function filterUserList() {
const searchInput = document.getElementById("user-search-input");
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
const state = window.chatState;
const userList = state?.userList || document.getElementById("online-users-list");
if (!userList) return;
const items = userList.querySelectorAll(".user-item");
items.forEach((item) => {
if (!keyword) {
item.style.display = "";
return;
}
const name = (item.dataset.username || item.textContent || "").toLowerCase();
item.style.display = name.includes(keyword) ? "" : "none";
});
}
/**
* 调度用户列表搜索过滤,避免每个按键都同步扫描名单 DOM。
*/
export function scheduleFilterUserList() {
const state = window.chatState;
if (!state) return;
if (state.userFilterRenderTimer !== null) {
return;
}
const scheduleFilter = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
state.userFilterRenderTimer = scheduleFilter(() => {
state.userFilterRenderTimer = null;
filterUserList();
});
}
// ── 徽标旋转定时器 ──
let badgeRotationInterval = null;
export function startBadgeRotation() {
if (badgeRotationInterval) return;
badgeRotationInterval = window.setInterval(() => {
if (document.hidden) return;
const state = window.chatState;
if (!state) return;
state.userBadgeRotationTick = (state.userBadgeRotationTick + 1) % 4;
if (state.userList) {
refreshRenderedUserBadges(state.userList);
}
const mobileUsersList = document.getElementById("mob-online-users-list");
if (mobileUsersList?.offsetParent !== null) {
refreshRenderedUserBadges(mobileUsersList);
}
// 同步每日状态 UI
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
}, 3000);
}
export function stopBadgeRotation() {
if (badgeRotationInterval) {
window.clearInterval(badgeRotationInterval);
badgeRotationInterval = null;
}
}
// ── 挂载到 window ──
window.renderUserList = renderUserList;
window.renderUserListToContainer = renderUserListToContainer;
window.filterUserList = filterUserList;
window.scheduleFilterUserList = scheduleFilterUserList;
window.scheduleRenderUserList = scheduleRenderUserList;
window.refreshRenderedUserBadges = refreshRenderedUserBadges;
window.buildUserBadgeHtml = buildUserBadgeHtml;
window._renderUserListToContainer = renderUserListToContainer;
export {
buildUserPrimaryBadgeHtml,
buildUserVipBadgeHtml,
buildUserStatusBadgeHtml,
buildUserSignIdentityBadgeHtml,
resolveUserDailyStatus,
};
+104
View File
@@ -0,0 +1,104 @@
// 会员进退场豪华横幅模块,从 Blade 内联脚本迁移至 Vite 管理。
import { escapeHtml } from "./html.js";
/**
* 转义会员横幅文本,避免横幅层被注入 HTML。
*/
function escapePresenceText(text) {
return escapeHtml(String(text ?? "")).replace(/\n/g, "<br>");
}
/**
* 根据不同的会员横幅风格返回渐变与光影配置。
*/
function getVipPresenceStyleConfig(style, color) {
const fallback = color || "#f59e0b";
const map = {
aurora: {
gradient: `linear-gradient(135deg, #f59e0b, #fbbf24, #fef3c7)`,
glow: `rgba(251, 191, 36, 0.4)`,
accent: "#78350f",
},
storm: {
gradient: `linear-gradient(135deg, #0ea5e9, #7dd3fc, #f0f9ff)`,
glow: `rgba(125, 211, 252, 0.4)`,
accent: "#0369a1",
},
royal: {
gradient: `linear-gradient(135deg, #d97706, #fcd34d, #fffbeb)`,
glow: `rgba(252, 211, 77, 0.4)`,
accent: "#92400e",
},
cosmic: {
gradient: `linear-gradient(135deg, #db2777, #f472b6, #fdf2f8)`,
glow: `rgba(244, 114, 182, 0.4)`,
accent: "#9d174d",
},
farewell: {
gradient: `linear-gradient(135deg, #ea580c, #fb923c, #fff7ed)`,
glow: `rgba(251, 146, 60, 0.4)`,
accent: "#9a3412",
},
};
return map[style] || map.aurora;
}
/**
* 显示会员进退场豪华横幅。
*
* @param {Record<string, any>} payload 携带 presence_text / presence_type / presence_icon 等字段的消息载荷
* @returns {void}
*/
function showVipPresenceBanner(payload) {
if (!payload || !payload.presence_text) {
return;
}
const existing = document.getElementById("vip-presence-banner");
if (existing) {
existing.remove();
}
const styleConfig = getVipPresenceStyleConfig(
payload.presence_banner_style,
payload.presence_color,
);
const bannerTypeLabel =
payload.presence_type === "leave"
? "离场提示"
: payload.presence_type === "purchase"
? "开通喜报"
: "闪耀登场";
const banner = document.createElement("div");
banner.id = "vip-presence-banner";
banner.className = "vip-presence-banner";
banner.innerHTML = `
<div class="vip-presence-banner__glow" style="background:${styleConfig.glow};"></div>
<div class="vip-presence-banner__card" style="background:${styleConfig.gradient}; border-color:${payload.presence_color || "#fff"};">
<div class="vip-presence-banner__meta">
<span class="vip-presence-banner__icon">${escapeHtml(payload.presence_icon || "👑")}</span>
<span class="vip-presence-banner__level">${escapeHtml(payload.presence_level_name || "尊贵会员")}</span>
<span class="vip-presence-banner__type">${bannerTypeLabel}</span>
</div>
<div class="vip-presence-banner__text" style="color:${styleConfig.accent};">
${escapePresenceText(payload.presence_text)}
</div>
</div>
`;
document.body.appendChild(banner);
setTimeout(() => {
banner.classList.add("is-leaving");
setTimeout(() => banner.remove(), 700);
}, 4200);
}
// 挂载到 window 供 Blade 脚本及其他模块使用。
window.showVipPresenceBanner = showVipPresenceBanner;
export { showVipPresenceBanner, getVipPresenceStyleConfig, escapePresenceText };
+30 -13
View File
@@ -2,10 +2,31 @@
let welcomeMenuEventsBound = false;
/**
* 切换欢迎语下拉浮层的显示/隐藏
*/
function toggleWelcomeMenu(event) {
event.stopPropagation();
const menu = document.getElementById("welcome-menu");
const adminMenu = document.getElementById("admin-menu");
const blockMenu = document.getElementById("block-menu");
const featureMenu = document.getElementById("feature-menu");
const dailyStatusEditor = document.getElementById("daily-status-editor-overlay");
if (!menu) return;
[adminMenu, blockMenu, featureMenu, dailyStatusEditor].forEach((el) => {
if (el) el.style.display = "none";
});
menu.style.display = menu.style.display === "none" ? "block" : "none";
}
// 挂载到 window 供 Blade 脚本及其他模块使用。
window.toggleWelcomeMenu = toggleWelcomeMenu;
/**
* 绑定欢迎语菜单按钮菜单内点击拦截与模板发送事件
*
* @returns {void}
*/
export function bindWelcomeMenuControls() {
if (welcomeMenuEventsBound || typeof document === "undefined") {
@@ -19,32 +40,28 @@ export function bindWelcomeMenuControls() {
return;
}
// 欢迎语菜单外部点击关闭仍由主脚本处理,这里只负责菜单按钮与菜单内部。
const toggleButton = event.target.closest("[data-chat-welcome-menu-toggle]");
if (toggleButton) {
event.preventDefault();
window.toggleWelcomeMenu?.(event);
toggleWelcomeMenu(event);
return;
}
const menu = event.target.closest("[data-chat-welcome-menu]");
if (!menu) {
return;
}
if (!menu) return;
// 阻止菜单内部点击冒泡,避免选择模板时被外层关闭逻辑抢先处理
// 阻止菜单内部点击冒泡。
event.stopPropagation();
const item = event.target.closest("[data-chat-welcome-template]");
if (!item || !menu.contains(item)) {
return;
}
if (!item || !menu.contains(item)) return;
const template = item.getAttribute("data-chat-welcome-template") || "";
// 模板内容只从 data 属性读取,实际发送仍交给旧的 sendWelcomeTpl
// sendWelcomeTpl 仍由 Blade 维护(依赖 sendMessage),这里通过 window 调用
if (template && typeof window.sendWelcomeTpl === "function") {
window.sendWelcomeTpl(template);
}
});
}
export { toggleWelcomeMenu };
-12
View File
@@ -35,18 +35,6 @@
<span class="w-32 text-gray-500 inline-block font-sans">PHP 版本:</span>
<span class="text-indigo-600">{{ PHP_VERSION }}</span>
</li>
<li class="flex items-center text-sm font-mono mt-4 pt-4 border-t">
<span class="mr-4 text-gray-500 inline-block font-sans items-center flex">队列监控面板</span>
<!-- Laravel Horizon 的默认路由前缀由开发者确认或自己改。这里默认是 /horizon -->
<a href="{{ url('/horizon') }}" target="_blank"
class="text-blue-600 hover:text-blue-800 hover:underline flex items-center">
<span>打开 Horizon 控制台</span>
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
</li>
</ul>
</div>
</div>
+7 -3
View File
@@ -60,9 +60,9 @@
<p class="px-4 text-xs text-slate-500 uppercase tracking-widest mb-1">
{{ Auth::id() === 1 ? '站长功能' : '查看' }}</p>
<a href="{{ route('admin.system.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.system.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '⚙️ 聊天室参数' !!}
<a href="{{ route('admin.level-exp-configs.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.level-exp-configs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '📶 等级经验阈值' !!}
</a>
<a href="{{ route('admin.currency-logs.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
@@ -113,6 +113,10 @@
@if (Auth::id() === 1)
<div class="border-t border-white/10 my-2"></div>
<p class="px-4 text-xs text-slate-500 uppercase tracking-widest mb-1">系统配置</p>
<a href="{{ route('admin.system.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.system.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
⚙️ 聊天室参数
</a>
<a href="{{ route('admin.smtp.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.smtp.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📧 邮件 SMTP 配置
@@ -0,0 +1,207 @@
@extends('admin.layouts.app')
@section('title', '等级经验阈值管理')
@section('content')
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
@php
$formThresholds = collect(old('thresholds', $thresholds->pluck('exp')->all()))
->map(fn ($value) => trim((string) $value))
->filter(fn (string $value) => $value !== '')
->values();
if ($formThresholds->isEmpty()) {
$formThresholds = $thresholds->pluck('exp')->map(fn ($value) => (string) $value)->values();
}
@endphp
<div class="{{ $adminListPageClass }}">
<div class="{{ $adminListHeaderCardClass }}">
<p class="{{ $adminListHeaderSubtitleClass }} mt-0">按列表维护每一级升级所需的累计经验值,并统一管理用户最高可达等级与管理员级别。</p>
</div>
<form action="{{ route('admin.level-exp-configs.update') }}" method="POST" class="{{ $adminListCardClass }}">
@csrf
@method('PUT')
<div class="grid gap-5 border-b border-gray-100 bg-gray-50 px-6 py-5 md:grid-cols-1">
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-4">
<div class="text-xs font-semibold uppercase tracking-wider text-amber-700">等级阈值说明</div>
<p class="mt-2 text-sm leading-6 text-amber-900">
当前页面按列表逐级维护升级经验阈值:每一行对应一个等级,填写“升到该等级所需的累计经验值”。
等级阈值必须严格递增,且等级行数不能超过“用户最高可达等级”。
</p>
</div>
</div>
<div class="{{ $adminListTableWrapClass }}">
<table class="{{ $adminListTableClass }}">
<thead class="{{ $adminListTableHeadRowClass }}">
<tr>
<th class="{{ $adminListTableHeadCellClass }} w-36">等级</th>
<th class="{{ $adminListTableHeadCellClass }}">累计经验阈值</th>
<th class="{{ $adminListTableHeadCellClass }}">较上一等级新增</th>
<th class="{{ $adminListTableHeadCellClass }} w-28 text-right">操作</th>
</tr>
</thead>
<tbody class="{{ $adminListTableBodyClass }}" data-level-exp-table-body>
@foreach ($formThresholds as $index => $exp)
@php
$currentExp = (int) $exp;
$previousExp = $index === 0 ? 0 : (int) $formThresholds[$index - 1];
@endphp
<tr class="{{ $adminListTableRowClass }}" data-level-exp-row>
<td class="px-4 py-3 {{ $adminListPrimaryTextClass }}" data-level-exp-label> {{ $index + 1 }} </td>
<td class="px-4 py-3">
<input type="number" min="1" step="1" name="thresholds[]" value="{{ $exp }}"
class="w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required>
</td>
<td class="px-4 py-3 {{ $adminListBodyTextClass }}" data-level-exp-increment>
+{{ number_format($currentExp - $previousExp) }}
</td>
<td class="px-4 py-3 text-right">
<button type="button"
class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100"
data-level-exp-remove>
删除
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="flex items-center justify-between gap-4 border-t border-gray-100 bg-gray-50 px-6 py-4">
<div class="space-y-1">
<button type="button" class="{{ $adminListSecondaryButtonClass }}" data-level-exp-add>
+ 添加等级
</button>
<p class="text-xs text-gray-500" data-level-exp-limit-text>
当前已配置 {{ $formThresholds->count() }} 个等级阈值,最高可配置到 {{ $maxLevel }} 级。
</p>
</div>
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
保存配置
</button>
</div>
</form>
</div>
<template id="level-exp-row-template">
<tr class="{{ $adminListTableRowClass }}" data-level-exp-row>
<td class="px-4 py-3 {{ $adminListPrimaryTextClass }}" data-level-exp-label></td>
<td class="px-4 py-3">
<input type="number" min="1" step="1" name="thresholds[]" value=""
class="w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required>
</td>
<td class="px-4 py-3 {{ $adminListBodyTextClass }}" data-level-exp-increment>--</td>
<td class="px-4 py-3 text-right">
<button type="button"
class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100"
data-level-exp-remove>
删除
</button>
</td>
</tr>
</template>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tableBody = document.querySelector('[data-level-exp-table-body]');
const addButton = document.querySelector('[data-level-exp-add]');
const template = document.querySelector('#level-exp-row-template');
const limitText = document.querySelector('[data-level-exp-limit-text]');
const maxLevel = {{ $maxLevel }};
if (!tableBody || !addButton || !template || !limitText) {
return;
}
const syncRows = () => {
const rows = Array.from(tableBody.querySelectorAll('[data-level-exp-row]'));
let previousValue = 0;
rows.forEach((row, index) => {
const label = row.querySelector('[data-level-exp-label]');
const input = row.querySelector('input[name=\"thresholds[]\"]');
const increment = row.querySelector('[data-level-exp-increment]');
const currentValue = Number.parseInt(input?.value ?? '', 10);
if (label) {
label.textContent = `第 ${index + 1} 级`;
}
if (increment) {
if (Number.isNaN(currentValue)) {
increment.textContent = '--';
} else {
increment.textContent = `+${(currentValue - previousValue).toLocaleString('zh-CN')}`;
}
}
previousValue = Number.isNaN(currentValue) ? previousValue : currentValue;
});
const removeButtons = tableBody.querySelectorAll('[data-level-exp-remove]');
removeButtons.forEach((button) => {
button.disabled = rows.length === 1;
button.classList.toggle('opacity-40', rows.length === 1);
button.classList.toggle('cursor-not-allowed', rows.length === 1);
});
limitText.textContent = `当前已配置 ${rows.length} 个等级阈值,最高可配置到 ${maxLevel} 级。`;
const canAddMore = rows.length < maxLevel;
addButton.disabled = !canAddMore;
addButton.classList.toggle('opacity-40', !canAddMore);
addButton.classList.toggle('cursor-not-allowed', !canAddMore);
};
addButton.addEventListener('click', () => {
const currentRows = tableBody.querySelectorAll('[data-level-exp-row]').length;
if (currentRows >= maxLevel) {
syncRows();
return;
}
const fragment = template.content.cloneNode(true);
tableBody.appendChild(fragment);
syncRows();
const inputs = tableBody.querySelectorAll('input[name=\"thresholds[]\"]');
inputs[inputs.length - 1]?.focus();
});
tableBody.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-level-exp-remove]');
if (!trigger) {
return;
}
const rows = tableBody.querySelectorAll('[data-level-exp-row]');
if (rows.length <= 1) {
return;
}
trigger.closest('[data-level-exp-row]')?.remove();
syncRows();
});
tableBody.addEventListener('input', (event) => {
if (event.target.matches('input[name=\"thresholds[]\"]')) {
syncRows();
}
});
syncRows();
});
</script>
@endsection
+23
View File
@@ -81,6 +81,29 @@
</form>
</div>
{{-- 队列监控面板 --}}
<div class="border border-gray-200 rounded-xl p-5 hover:shadow-sm transition">
<div class="flex items-center gap-3 mb-2">
<span class="text-2xl">📈</span>
<div>
<div class="font-bold text-gray-800 text-sm">队列监控面板</div>
<div class="text-xs text-gray-400">Laravel Horizon</div>
</div>
</div>
<p class="text-xs text-gray-500 mb-4 leading-relaxed">
打开 Horizon 控制台查看队列吞吐、失败任务与进程状态。<br>
需要排查异步任务堆积、失败重试或 Supervisor 状态时从这里进入。
</p>
<a href="{{ url('/horizon') }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-bold hover:bg-indigo-700 transition shadow-sm">
<span>打开 Horizon 控制台</span>
<svg class="w-4 h-4 ml-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
</div>
{{-- 房间在线名单清理 --}}
<div class="border border-red-100 rounded-xl p-5 bg-red-50 hover:shadow-sm transition">
<div class="flex items-center gap-3 mb-2">
@@ -442,6 +442,7 @@
权限管理
<span class="font-normal text-amber-700 ml-1">(控制聊天室输入框上方「管理」菜单中可见的功能按钮)</span>
</h4>
<p class="mb-3 text-xs leading-5 text-amber-700">聊天室管理动作已统一按职务权限控制,不再使用等级阈值参数。</p>
<div class="space-y-4">
@foreach ($positionPermissions as $groupName => $permissions)
<div>
@@ -22,6 +22,10 @@
'ring' => ['label' => '求婚戒指', 'color' => 'bg-rose-100 text-rose-700'],
'auto_fishing' => ['label' => '自动钓鱼卡', 'color' => 'bg-emerald-100 text-emerald-700'],
'sign_repair' => ['label' => '签到补签卡', 'color' => 'bg-teal-100 text-teal-700'],
'msg_bubble' => ['label' => '消息气泡', 'color' => 'bg-violet-100 text-violet-700'],
'msg_name_color' => ['label' => '昵称颜色', 'color' => 'bg-pink-100 text-pink-700'],
'msg_text_color' => ['label' => '文字颜色', 'color' => 'bg-cyan-100 text-cyan-700'],
'avatar_frame' => ['label' => '头像框', 'color' => 'bg-amber-100 text-amber-700'],
];
$isSuperAdmin = Auth::id() === 1;
@endphp
@@ -287,6 +291,10 @@
<option value="ring">ring 求婚戒指</option>
<option value="auto_fishing">auto_fishing 自动钓鱼卡</option>
<option value="sign_repair">sign_repair 签到补签卡</option>
<option value="msg_bubble">msg_bubble 消息气泡</option>
<option value="msg_name_color">msg_name_color 昵称颜色</option>
<option value="msg_text_color">msg_text_color 文字颜色</option>
<option value="avatar_frame">avatar_frame 头像框</option>
</select>
</div>
</div>
+38 -1
View File
@@ -25,13 +25,24 @@
@php
$fieldValue = (string) $body;
$shouldUseTextarea = strlen($fieldValue) > 50 || str_contains($fieldValue, "\n") || str_contains($fieldValue, '<');
$isMaxLevelField = $alias === 'maxlevel';
$isSuperLevelField = $alias === 'superlevel';
@endphp
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
{{ $descriptions[$alias] ?? $alias }}
<span class="text-gray-400 font-normal ml-2">[{{ $alias }}]</span>
</label>
@if ($shouldUseTextarea)
@if ($isMaxLevelField)
<p class="mb-2 text-xs text-gray-500">修改后会自动同步管理员级别为“最高等级 + 1”。</p>
<input type="number" min="1" step="1" id="system-maxlevel" name="{{ $alias }}" value="{{ $fieldValue }}"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
@elseif ($isSuperLevelField)
<p class="mb-2 text-xs text-gray-500">该值会随“用户最高可达等级”自动计算,仅用于展示当前结果。</p>
<input type="number" id="system-superlevel" value="{{ $fieldValue }}"
class="w-full border-gray-200 rounded-md shadow-sm p-2.5 bg-gray-100 border text-gray-600"
readonly>
@elseif ($shouldUseTextarea)
<textarea name="{{ $alias }}" rows="4"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border whitespace-pre-wrap">{{ $fieldValue }}</textarea>
@else
@@ -53,4 +64,30 @@
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const maxLevelInput = document.querySelector('#system-maxlevel');
const superLevelInput = document.querySelector('#system-superlevel');
if (!maxLevelInput || !superLevelInput) {
return;
}
const syncSuperLevel = () => {
const maxLevel = Number.parseInt(maxLevelInput.value ?? '', 10);
if (Number.isNaN(maxLevel) || maxLevel < 1) {
superLevelInput.value = '';
return;
}
superLevelInput.value = String(maxLevel + 1);
};
maxLevelInput.addEventListener('input', syncSuperLevel);
syncSuperLevel();
});
</script>
@endsection
+9 -13
View File
@@ -17,13 +17,6 @@
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta http-equiv="Delegate-CH" content="Sec-CH-UA https://s.magsrv.com; Sec-CH-UA-Mobile https://s.magsrv.com; Sec-CH-UA-Arch https://s.magsrv.com; Sec-CH-UA-Model https://s.magsrv.com; Sec-CH-UA-Platform https://s.magsrv.com; Sec-CH-UA-Platform-Version https://s.magsrv.com; Sec-CH-UA-Bitness https://s.magsrv.com; Sec-CH-UA-Full-Version-List https://s.magsrv.com; Sec-CH-UA-Full-Version https://s.magsrv.com;">
@php
// 从 sysparam 读取权限等级配置
$levelWarn = (int) \App\Models\Sysparam::getValue('level_warn', '5');
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10');
$levelMute = (int) \App\Models\Sysparam::getValue('level_mute', '8');
$levelBan = (int) \App\Models\Sysparam::getValue('level_ban', '12');
$levelBanip = (int) \App\Models\Sysparam::getValue('level_banip', '14');
$levelFreeze = (int) \App\Models\Sysparam::getValue('level_freeze', '14');
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
$myLevel = Auth::user()->user_level;
$positionPermissions = array_keys(array_filter($roomPermissionMap ?? []));
@@ -64,10 +57,6 @@
'userSex' => match ((int) $user->sex) {1 => '男', 2 => '女', default => ''},
'userLevel' => $user->user_level,
'superLevel' => $superLevel,
'levelKick' => $levelKick,
'levelMute' => $levelMute,
'levelBan' => $levelBan,
'levelBanip' => $levelBanip,
'sendUrl' => route('chat.send', $room->id),
'leaveUrl' => route('chat.leave', $room->id),
'expiredLeaveUrl' => \Illuminate\Support\Facades\URL::temporarySignedRoute('chat.leave.expired', now()->addHours(12), ['id' => $room->id, 'user' => $user->id]),
@@ -128,11 +117,15 @@
'envelopeStatusUrlTemplate' => '/wedding/__ID__/envelope-status',
],
'earnRewardUrl' => route('earn.video_reward'),
'roomsOnlineStatusUrl' => route('chat.rooms-online-status'),
'changelogUrl' => route('changelog.index'),
'roomsIndexUrl' => route('rooms.index'),
'chatImageRetentionDays' => 3,
'initialState' => [
'historyMessages' => $historyMessages ?? [],
'localClearStorageKey' => "local_clear_msg_id_{$room->id}",
'welcomeMessage' => $initialWelcomeMessage ?? null,
'welcomeMessages' => $initialWelcomeMessages ?? [],
'entryEffect' => $newbieEffect ?: ($initialPresenceTheme['presence_effect'] ?? ($weekEffect ?? null)),
'presenceTheme' => $initialPresenceTheme ?? null,
'pendingProposal' => $pendingProposal ?? null,
@@ -145,9 +138,8 @@
// 兼容底部存量同步脚本,完整 URL 工厂由 Vite 的 chat-room/context.js 继续补齐。
window.chatContext = JSON.parse(document.getElementById('chat-context-data')?.textContent || '{}');
</script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
@vite(['resources/css/app.css', 'resources/css/chat.css', 'resources/js/app.js', 'resources/js/chat.js'])
<script defer src="/js/alpinejs.min.js"></script>
<link rel="stylesheet" href="{{ asset('css/chat.css') }}?v={{ filemtime(public_path('css/chat.css')) }}">
</head>
<body>
@@ -230,6 +222,10 @@
{{-- 节日福利弹窗 --}}
@include('chat.partials.holiday-modal')
@include('chat.partials.daily-sign-in-modal')
{{-- 留言板弹窗 --}}
@include('chat.partials.guestbook-modal')
{{-- 反馈弹窗 --}}
@include('chat.partials.feedback-modal')
{{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS ═══════════ --}}
{{-- deferChatGameBootstrap 已迁移到 resources/js/chat-room/game-bootstrap.js --}}
@@ -0,0 +1,577 @@
{{--
文件功能:用户反馈模态弹窗(仿留言板弹窗样式)
供聊天室工具栏"反馈"按钮使用,替代跳转反馈页面。
依赖 CSS:与留言板弹窗一致的蓝白风格
依赖 JSresources/js/chat-room/feedback.js
--}}
<style>
/* 反馈弹窗遮罩 */
#feedback-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9999;
justify-content: center;
align-items: center;
}
/* 弹窗主体 */
#feedback-modal-inner {
background: #fff;
border-radius: 8px;
width: 720px;
max-width: 95vw;
max-height: 84vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
overflow: hidden;
position: relative;
}
/* 标题栏 */
#feedback-modal-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#feedback-modal-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#feedback-modal-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#feedback-modal-close:hover {
opacity: 1;
}
/* Toast */
#feedback-toast {
display: none;
margin: 6px 12px 0;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
/* Tab 导航 */
#feedback-tabs {
display: flex;
border-bottom: 1px solid #cde;
flex-shrink: 0;
background: #eef4fb;
}
.feedback-tab {
flex: 1;
padding: 8px 6px;
background: transparent;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: bold;
transition: all .2s;
}
.feedback-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
.feedback-tab:hover { color: #5a8fc0; }
/* 反馈列表容器 */
#feedback-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
background: #f6faff;
}
/* 反馈卡片 */
.fb-card {
background: #fff;
border: 1px solid #d0e4f5;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
transition: border-color .2s, box-shadow .2s;
cursor: pointer;
}
.fb-card:hover {
border-color: #5a8fc0;
box-shadow: 0 2px 8px rgba(51, 102, 153, .18);
}
.fb-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.fb-type-badge {
font-size: 10px;
font-weight: bold;
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.fb-type-badge.bug { background: #ffe4e6; color: #be123c; }
.fb-type-badge.suggestion { background: #dbeafe; color: #1d4ed8; }
.fb-status-badge {
font-size: 10px;
font-weight: bold;
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.fb-vote-count {
font-size: 11px;
color: #6b7280;
flex-shrink: 0;
}
.fb-reply-count {
font-size: 11px;
color: #6b7280;
flex-shrink: 0;
margin-left: auto;
}
.fb-card-title {
font-size: 13px;
font-weight: bold;
color: #225588;
margin-bottom: 4px;
line-height: 1.4;
word-break: break-word;
}
.fb-card-meta {
font-size: 11px;
color: #9ca3af;
}
/* 展开详情区 */
.fb-detail {
border-top: 1px solid #eef4fb;
padding: 10px 0 0;
margin-top: 8px;
}
.fb-detail-content {
font-size: 12px;
color: #374151;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
padding: 6px 0 8px;
}
.fb-admin-remark {
background: #eef4fb;
border-left: 3px solid #336699;
padding: 8px 10px;
border-radius: 4px;
margin-bottom: 8px;
font-size: 12px;
color: #225588;
line-height: 1.5;
white-space: pre-wrap;
}
.fb-admin-remark-label {
font-weight: bold;
font-size: 11px;
color: #336699;
margin-bottom: 4px;
}
/* 评论列表 */
.fb-replies {
margin-bottom: 8px;
}
.fb-reply-item {
background: #f6faff;
border-radius: 4px;
padding: 6px 8px;
margin-bottom: 4px;
font-size: 12px;
}
.fb-reply-item.admin {
background: #eef4fb;
border: 1px solid #cde;
}
.fb-reply-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
font-size: 11px;
}
.fb-reply-username {
font-weight: bold;
color: #336699;
}
.fb-reply-admin-badge {
font-size: 10px;
background: #336699;
color: #fff;
padding: 0 5px;
border-radius: 6px;
font-weight: bold;
}
.fb-reply-time {
color: #9ca3af;
margin-left: auto;
}
.fb-reply-body {
color: #374151;
white-space: pre-wrap;
line-height: 1.5;
}
/* 评论输入区 */
.fb-reply-form {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.fb-reply-form textarea {
flex: 1;
padding: 5px 8px;
border: 1px solid #cde;
border-radius: 4px;
font-size: 11px;
outline: none;
resize: vertical;
min-height: 32px;
max-height: 80px;
box-sizing: border-box;
}
.fb-reply-form textarea:focus {
border-color: #336699;
}
.fb-reply-form button {
padding: 5px 10px;
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
cursor: pointer;
transition: opacity .15s;
white-space: nowrap;
align-self: flex-end;
}
.fb-reply-form button:hover { opacity: .85; }
.fb-reply-form button:disabled { opacity: .4; cursor: default; }
/* 删除按钮 */
.fb-delete-btn {
font-size: 10px;
color: #dc2626;
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-weight: bold;
transition: opacity .15s;
}
.fb-delete-btn:hover { opacity: .75; }
/* 卡片底部操作区 */
.fb-card-footer-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.fb-vote-btn {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: bold;
padding: 2px 8px;
border: 1px solid #cde;
border-radius: 4px;
background: #f6faff;
color: #6b7280;
cursor: pointer;
transition: all .15s;
}
.fb-vote-btn:hover {
border-color: #5a8fc0;
color: #336699;
background: #eef4fb;
}
.fb-vote-btn.voted {
background: #336699;
color: #fff;
border-color: #336699;
}
.fb-vote-btn.voted:hover {
opacity: .85;
}
.fb-vote-btn:disabled {
opacity: .4;
cursor: default;
}
/* 底部操作区 */
#feedback-bottom-bar {
flex-shrink: 0;
padding: 8px 12px;
background: #eef4fb;
border-top: 1px solid #cde;
text-align: center;
}
#feedback-submit-btn {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
padding: 7px 20px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: opacity .15s;
width: 100%;
}
#feedback-submit-btn:hover { opacity: .85; }
/* 空状态 */
.fb-empty {
text-align: center;
color: #9ca3af;
padding: 40px 0;
font-size: 13px;
}
.fb-empty-icon {
font-size: 36px;
display: block;
margin-bottom: 10px;
}
/* 加载中 */
.fb-loading {
text-align: center;
color: #6366f1;
padding: 30px 0;
font-size: 13px;
}
/* 加载更多指示 */
#feedback-loader {
text-align: center;
padding: 10px 0;
font-size: 12px;
color: #9ca3af;
}
#feedback-loader.hidden { display: none; }
/* ── 提交反馈表单(内嵌弹窗) ── */
#feedback-write-overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(0,0,0,.35);
z-index: 10;
justify-content: center;
align-items: center;
}
#feedback-write-overlay.active { display: flex; }
#feedback-write-panel {
background: #fff;
border-radius: 8px;
width: 500px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,.3);
overflow: hidden;
}
#feedback-write-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#feedback-write-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#feedback-write-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#feedback-write-close:hover { opacity: 1; }
#feedback-write-body {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
}
.fb-form-row {
margin-bottom: 10px;
}
.fb-form-row label {
display: block;
font-size: 12px;
font-weight: bold;
color: #336699;
margin-bottom: 4px;
}
.fb-form-row select,
.fb-form-row input[type="text"],
.fb-form-row textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid #cde;
border-radius: 4px;
font-size: 12px;
outline: none;
transition: border-color .2s;
box-sizing: border-box;
}
.fb-form-row select:focus,
.fb-form-row input[type="text"]:focus,
.fb-form-row textarea:focus {
border-color: #336699;
}
.fb-form-row textarea {
resize: vertical;
min-height: 80px;
}
.fb-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
.fb-form-actions button {
padding: 5px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: none;
transition: opacity .15s;
}
.fb-form-actions button:hover { opacity: .85; }
.fb-form-actions button:disabled { opacity: .4; cursor: default; }
.fb-form-submit {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
}
.fb-form-cancel {
background: #e5e7eb;
color: #555;
}
</style>
<div id="feedback-modal"
data-feedback-data-url="{{ route('feedback.data') }}"
data-feedback-more-url="{{ route('feedback.more') }}"
data-feedback-store-url="{{ route('feedback.store') }}"
data-feedback-vote-url-template="{{ route('feedback.vote', '__ID__') }}"
data-feedback-reply-url-template="{{ route('feedback.reply', '__ID__') }}"
data-feedback-destroy-url-template="{{ route('feedback.destroy', '__ID__') }}">
<div id="feedback-modal-inner">
{{-- 标题栏 --}}
<div id="feedback-modal-header">
<div id="feedback-modal-title">💬 用户反馈</div>
<span id="feedback-modal-close" data-feedback-modal-close></span>
</div>
{{-- Toast --}}
<div id="feedback-toast"></div>
{{-- Tab 导航 --}}
<div id="feedback-tabs">
<button class="feedback-tab active" data-feedback-tab="all">📋 全部</button>
<button class="feedback-tab" data-feedback-tab="bug">🐛 Bug 报告</button>
<button class="feedback-tab" data-feedback-tab="suggestion">💡 功能建议</button>
</div>
{{-- 反馈列表 --}}
<div id="feedback-list">
<div class="fb-loading">加载中…</div>
</div>
{{-- 加载更多指示 --}}
<div id="feedback-loader" class="hidden"></div>
{{-- 底部提交按钮 --}}
<div id="feedback-bottom-bar">
<button id="feedback-submit-btn" data-feedback-write-btn>📝 提交反馈</button>
</div>
{{-- 提交反馈表单(内嵌遮罩弹窗) --}}
<div id="feedback-write-overlay">
<div id="feedback-write-panel">
<div id="feedback-write-header">
<div id="feedback-write-title">📝 提交反馈</div>
<span id="feedback-write-close" data-feedback-write-close></span>
</div>
<div id="feedback-write-body">
<form id="feedback-form" method="POST">
@csrf
<div class="fb-form-row">
<label>反馈类型 <span style="color:#dc2626;">*</span></label>
<select name="type" id="fb-type">
<option value="bug">🐛 Bug 报告</option>
<option value="suggestion">💡 功能建议</option>
</select>
</div>
<div class="fb-form-row">
<label>标题 <span style="color:#dc2626;">*</span></label>
<input type="text" name="title" id="fb-title" maxlength="200" placeholder="一句话描述…" required>
</div>
<div class="fb-form-row">
<label>详细描述 <span style="color:#dc2626;">*</span></label>
<textarea name="content" id="fb-content" rows="5" maxlength="2000" required placeholder="请详细描述您遇到的问题或建议…"></textarea>
</div>
<div class="fb-form-actions">
<button type="button" class="fb-form-cancel" data-fb-form-cancel>取消</button>
<button type="submit" class="fb-form-submit">✈️ 提交反馈</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,438 @@
{{--
文件功能:星光留言板模态弹窗(仿商店弹窗样式)
供聊天室工具栏"留言"按钮使用,替代跳转留言板页面。
依赖 CSS:与商店弹窗一致的蓝白风格
依赖 JSresources/js/chat-room/guestbook.js
--}}
<style>
/* 留言板弹窗遮罩 */
#guestbook-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9999;
justify-content: center;
align-items: center;
}
/* 弹窗主体 */
#guestbook-modal-inner {
background: #fff;
border-radius: 8px;
width: 720px;
max-width: 95vw;
max-height: 84vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
overflow: hidden;
position: relative;
}
/* 标题栏 */
#guestbook-modal-header {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
#guestbook-modal-title {
font-size: 14px;
font-weight: bold;
flex: 1;
}
#guestbook-modal-close {
cursor: pointer;
font-size: 18px;
opacity: .8;
transition: opacity .15s;
line-height: 1;
}
#guestbook-modal-close:hover {
opacity: 1;
}
/* Toast */
#guestbook-toast {
display: none;
margin: 6px 12px 0;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
/* Tab 导航 */
#guestbook-tabs {
display: flex;
border-bottom: 1px solid #cde;
flex-shrink: 0;
background: #eef4fb;
}
.guestbook-tab {
flex: 1;
padding: 8px 6px;
background: transparent;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: bold;
transition: all .2s;
}
.guestbook-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
.guestbook-tab:hover { color: #5a8fc0; }
/* 留言列表容器 */
#guestbook-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
background: #f6faff;
}
/* 留言卡片 */
.gb-card {
background: #fff;
border: 1px solid #d0e4f5;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
transition: border-color .2s, box-shadow .2s;
}
.gb-card:hover {
border-color: #5a8fc0;
box-shadow: 0 2px 8px rgba(51, 102, 153, .18);
}
.gb-card.gb-secret {
border-color: #f9c7d3;
background: #fff5f7;
}
.gb-card.gb-secret:hover {
border-color: #e91e63;
box-shadow: 0 2px 8px rgba(233, 30, 99, .12);
}
.gb-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
}
.gb-card-author {
display: flex;
align-items: center;
gap: 6px;
}
.gb-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #dbeafe;
color: #336699;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
flex-shrink: 0;
}
.gb-avatar.secret {
background: #fce4ec;
color: #c62828;
}
.gb-who {
font-weight: bold;
color: #225588;
}
.gb-arrow {
color: #9ca3af;
font-size: 11px;
}
.gb-towho {
font-weight: 600;
color: #336699;
}
.gb-towho.public {
color: #9ca3af;
font-style: italic;
}
.gb-secret-badge {
background: #fce4ec;
color: #c62828;
font-size: 10px;
font-weight: bold;
padding: 1px 6px;
border-radius: 8px;
margin-left: 4px;
}
.gb-time {
color: #9ca3af;
font-size: 11px;
}
.gb-body {
font-size: 13px;
color: #374151;
line-height: 1.6;
word-break: break-word;
padding: 2px 0 4px;
}
.gb-card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.gb-btn {
font-size: 11px;
padding: 3px 10px;
border-radius: 4px;
border: none;
cursor: pointer;
font-weight: bold;
transition: opacity .15s;
}
.gb-btn:hover { opacity: .8; }
.gb-btn-reply {
background: #eef4fb;
color: #336699;
border: 1px solid #cde;
}
.gb-btn-reply:hover { background: #dbeafe; }
.gb-btn-delete {
background: #fee2e2;
color: #dc2626;
border: 1px solid #fecaca;
}
.gb-btn-delete:hover { background: #fecaca; }
/* 写留言按钮 */
#guestbook-write-btn-wrap {
flex-shrink: 0;
padding: 8px 12px;
background: #eef4fb;
border-top: 1px solid #cde;
text-align: center;
}
#guestbook-write-btn {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
border: none;
border-radius: 4px;
padding: 7px 20px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: opacity .15s;
width: 100%;
}
#guestbook-write-btn:hover { opacity: .85; }
/* 写留言表单(内嵌) */
#guestbook-write-form {
display: none;
flex-shrink: 0;
padding: 10px 12px;
background: #fff;
border-top: 1px solid #cde;
}
#guestbook-write-form.active { display: block; }
.gb-form-row {
margin-bottom: 8px;
}
.gb-form-row label {
display: block;
font-size: 12px;
font-weight: bold;
color: #336699;
margin-bottom: 4px;
}
.gb-form-row select,
.gb-form-row textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid #cde;
border-radius: 4px;
font-size: 12px;
outline: none;
transition: border-color .2s;
box-sizing: border-box;
}
.gb-form-row select:focus,
.gb-form-row textarea:focus {
border-color: #336699;
}
.gb-form-row textarea {
resize: vertical;
min-height: 60px;
}
.gb-form-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #555;
}
.gb-form-checkbox input[type="checkbox"] {
accent-color: #336699;
}
.gb-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.gb-form-actions button {
padding: 5px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: none;
transition: opacity .15s;
}
.gb-form-actions button:hover { opacity: .85; }
.gb-form-submit {
background: linear-gradient(135deg, #336699, #5a8fc0);
color: #fff;
}
.gb-form-cancel {
background: #e5e7eb;
color: #555;
}
/* 空状态 */
.gb-empty {
text-align: center;
color: #9ca3af;
padding: 40px 0;
font-size: 13px;
}
.gb-empty-icon {
font-size: 36px;
display: block;
margin-bottom: 10px;
}
/* 加载中 */
.gb-loading {
text-align: center;
color: #6366f1;
padding: 30px 0;
font-size: 13px;
}
/* 分页 */
#guestbook-pager {
flex-shrink: 0;
padding: 8px 12px;
background: #eef4fb;
border-top: 1px solid #cde;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
font-size: 12px;
}
#guestbook-pager button {
padding: 3px 10px;
border: 1px solid #cde;
border-radius: 4px;
background: #fff;
color: #336699;
cursor: pointer;
font-size: 11px;
font-weight: bold;
transition: all .15s;
}
#guestbook-pager button:hover {
background: #dbeafe;
}
#guestbook-pager button:disabled {
opacity: .4;
cursor: default;
}
#guestbook-pager span {
color: #6b7280;
}
</style>
<div id="guestbook-modal"
data-guestbook-data-url="{{ route('guestbook.data') }}"
data-guestbook-store-url="{{ route('guestbook.store') }}"
data-guestbook-destroy-url-template="{{ route('guestbook.destroy', '__ID__') }}">
<div id="guestbook-modal-inner">
{{-- 标题栏 --}}
<div id="guestbook-modal-header">
<div id="guestbook-modal-title">✉️ 星光留言板</div>
<span id="guestbook-modal-close" data-guestbook-modal-close></span>
</div>
{{-- Toast --}}
<div id="guestbook-toast"></div>
{{-- Tab 导航 --}}
<div id="guestbook-tabs">
<button class="guestbook-tab active" data-guestbook-tab="public">🌍 公共留言墙</button>
<button class="guestbook-tab" data-guestbook-tab="inbox">📥 我收件的</button>
<button class="guestbook-tab" data-guestbook-tab="outbox">📤 我发出的</button>
</div>
{{-- 留言列表 --}}
<div id="guestbook-list">
<div class="gb-loading">加载中…</div>
</div>
{{-- 写留言表单(内嵌) --}}
<div id="guestbook-write-form">
<form id="guestbook-form" method="POST">
@csrf
<div class="gb-form-row">
<label>📬 收件人 <span style="font-weight:normal;color:#9ca3af;">(留空则为公共留言)</span></label>
<select name="towho" id="gb-towho">
<option value="">🌍 公共留言(所有人可见)</option>
</select>
</div>
<div class="gb-form-row">
<label class="gb-form-checkbox">
<input type="checkbox" name="secret" value="1"> 🔒 悄悄话(仅双方可见)
</label>
</div>
<div class="gb-form-row">
<label>📝 留言内容 <span style="color:#dc2626;">*</span></label>
<textarea name="text_body" id="gb-text-body" rows="3" required placeholder="相逢何必曾相识,留下您的足迹吧..."></textarea>
</div>
<div class="gb-form-actions">
<button type="button" class="gb-form-cancel" data-gb-form-cancel>取消</button>
<button type="submit" class="gb-form-submit">✈️ 发送飞鸽</button>
</div>
</form>
</div>
{{-- 写留言按钮 --}}
<div id="guestbook-write-btn-wrap">
<button id="guestbook-write-btn" data-guestbook-write-btn>✏️ 写新留言</button>
</div>
{{-- 分页 --}}
<div id="guestbook-pager" style="display:none;">
<button id="gb-page-prev" data-gb-page="prev"> 上一页</button>
<span id="gb-page-info"></span>
<button id="gb-page-next" data-gb-page="next">下一页 </button>
</div>
</div>
</div>
@@ -4,8 +4,7 @@
第二行:输入框 + 发送按钮
frame.blade.php 拆分,便于独立维护
依赖变量:$user, $room, $levelKick, $levelMute, $levelBan, $levelBanip,
$roomPermissionMap, $hasRoomManagementPermission
依赖变量:$user, $room, $roomPermissionMap, $hasRoomManagementPermission
--}}
@php
@@ -26,8 +26,8 @@
<div class="tool-btn" data-toolbar-action="avatar" title="修改头像">头像</div>
<div class="tool-btn" data-toolbar-action="settings" title="个人设置">设置
</div>
<div class="tool-btn" data-toolbar-url="{{ route('feedback.index') }}" title="反馈">反馈</div>
<div class="tool-btn" data-toolbar-url="{{ route('guestbook.index') }}" title="留言板/私信">留言</div>
<div class="tool-btn" data-toolbar-action="feedback" title="反馈">反馈</div>
<div class="tool-btn" data-toolbar-action="guestbook" title="留言板/私信">留言</div>
<div class="tool-btn" data-toolbar-url="{{ route('guide') }}" title="规则/帮助">规则</div>
@if ($user->id === 1 || $user->activePosition()->exists())
@@ -44,10 +44,10 @@
</div>
{{-- ═══════════ 头像选择弹窗 ═══════════ --}}
<div id="avatar-picker-modal"
<div id="avatar-picker-modal" data-avatar-picker-overlay
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:9999; justify-content:center; align-items:center;">
<div
<div data-avatar-picker-panel
style="background:#fff; width:600px; max-height:80vh; border-radius:6px; overflow:hidden;
box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column;">
{{-- 标题栏 --}}
@@ -341,6 +341,40 @@
background: #f6faff;
}
/* 装扮列表区 — 与商品列表同风格 */
#shop-decorations-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: none;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
align-content: start;
background: #f6faff;
}
/* Tab 导航 */
#shop-tabs {
display: flex;
border-bottom: 1px solid #cde;
flex-shrink: 0;
background: #eef4fb;
}
.shop-tab {
flex: 1;
padding: 8px 6px;
background: transparent;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: bold;
transition: all .2s;
}
.shop-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
.shop-tab:hover { color: #5a8fc0; }
/* 分组标题 — 独占一整行 */
.shop-group-header {
grid-column: 1 / -1;
@@ -404,6 +438,38 @@
flex: 1;
}
.shop-validity {
margin-top: 2px;
font-size: 10px;
line-height: 1.4;
color: #6b7280;
font-weight: normal;
}
/* 装扮Tab购买说明 */
.decoration-note {
font-size: 11px;
color: #b45309;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 10px;
grid-column: 1 / -1;
}
/* 装扮卡片状态行(独立一行,显示在商品名下方) */
.decoration-status-line {
margin-top: 4px;
}
/* 装扮卡片状态标签 */
.decoration-status {
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
}
.decoration-status.active { background: #065f46; color: #6ee7b7; }
.decoration-expiry { font-size: 10px; color: #9ca3af; margin-top: 2px; }
.shop-btn {
display: flex;
align-items: center;
@@ -598,11 +664,22 @@
{{-- Toast --}}
<div id="shop-toast"></div>
{{-- Tab 导航 --}}
<div id="shop-tabs">
<button class="shop-tab active" data-shop-tab="items">特效道具</button>
<button class="shop-tab" data-shop-tab="decorations">个人装扮</button>
</div>
{{-- 商品网格 --}}
<div id="shop-items-list">
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
</div>
{{-- 装扮网格 --}}
<div id="shop-decorations-list">
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
</div>
{{-- 改名内嵌遮罩 --}}
<div id="shop-rename-overlay">
<div id="shop-rename-box">
File diff suppressed because it is too large Load Diff
@@ -9,8 +9,8 @@
#shop-panel {
display: none;
position: absolute;
/* 顶部 tab 栏高度约 26px,底部状态栏约 22px */
top: 26px;
/* 顶部 tab 栏高度约 56px,底部状态栏约 22px */
top: 56px;
left: 0;
right: 0;
bottom: 22px;
@@ -19,6 +19,9 @@
z-index: 10;
}
.shop-tab.active { color: #e2e8f0 !important; border-bottom-color: #818cf8 !important; }
.shop-tab:hover { color: #c7d2fe; }
#shop-balance-bar {
padding: 6px 8px;
background: linear-gradient(135deg, #1e1b4b, #312e81);
@@ -133,6 +136,14 @@
line-height: 1.35;
}
.shop-validity {
margin-top: 2px;
color: #8b93a7;
font-size: 9px;
font-weight: normal;
line-height: 1.35;
}
/* 购买按钮 */
.shop-btn {
display: inline-flex;
@@ -241,6 +252,37 @@
margin-top: 5px;
min-height: 14px;
}
/* ── 装扮卡片状态标签 ────────────── */
.decoration-status {
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
margin-left: 6px;
}
.decoration-status.active { background: #065f46; color: #6ee7b7; }
.decoration-expiry { font-size: 9px; color: #9ca3af; margin-top: 2px; }
.decoration-note {
grid-column: 1 / -1;
margin: 0 0 6px;
padding: 7px 8px;
border: 1px solid rgba(251, 191, 36, .35);
border-radius: 8px;
background: rgba(120, 53, 15, .28);
color: #fde68a;
font-size: 10px;
line-height: 1.45;
}
#shop-decorations-list {
flex: 1;
overflow-y: auto;
padding: 6px 5px;
scrollbar-width: thin;
scrollbar-color: #4338ca #0f0c29;
display: none;
}
</style>
<div id="shop-panel"
@@ -254,14 +296,25 @@
<span id="shop-week-badge"></span>
</div>
{{-- Tab 导航 --}}
<div id="shop-tabs" style="display:flex; border-bottom: 1px solid #3730a3; flex-shrink: 0;">
<button class="shop-tab active" data-shop-tab="items" style="flex:1; padding: 6px; background: transparent; border: none; color: #a5b4fc; font-size: 11px; cursor: pointer; border-bottom: 2px solid transparent;">特效道具</button>
<button class="shop-tab" data-shop-tab="decorations" style="flex:1; padding: 6px; background: transparent; border: none; color: #6b7280; font-size: 11px; cursor: pointer; border-bottom: 2px solid transparent;">个人装扮</button>
</div>
{{-- Toast --}}
<div id="shop-toast"></div>
{{-- 商品列表 --}}
{{-- 特效道具列表 --}}
<div id="shop-items-list">
<div style="text-align:center;color:#6366f1;padding:20px 0;font-size:11px;">加载中…</div>
</div>
{{-- 个人装扮列表 --}}
<div id="shop-decorations-list">
<div style="text-align:center;color:#6366f1;padding:20px 0;font-size:11px;">加载中…</div>
</div>
{{-- 改名弹框 --}}
<div id="rename-modal">
<div id="rename-modal-inner">
@@ -418,9 +418,10 @@
$canWarnUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_WARN] ?? false) === true);
$canKickUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_KICK] ?? false) === true);
$canMuteUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_MUTE] ?? false) === true);
$canFreezeUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_FREEZE] ?? false) === true);
$canBanUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BAN] ?? false) === true);
$canBanIpUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BANIP] ?? false) === true);
$canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true);
$hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canFreezeUser;
$hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canBanUser || $canBanIpUser;
$hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel;
@endphp
@if ($hasUserModerationPermission || $hasPositionActions)
@@ -463,10 +464,16 @@
x-on:click="isMuting = !isMuting">🔇 禁言
</button>
@endif
@if ($canFreezeUser)
@if ($canBanUser)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #dbeafe; border: 1px solid #3b82f6; cursor: pointer;"
x-on:click="freezeUser()">🧊 冻结
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #b91c1c; cursor: pointer;"
x-on:click="banUser()"> 封号
</button>
@endif
@if ($canBanIpUser)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #ffedd5; border: 1px solid #c2410c; cursor: pointer;"
x-on:click="banIpUser()">🌐 封IP
</button>
@endif
+15 -14
View File
@@ -62,12 +62,6 @@
$charmSame = Sysparam::getValue('charm_same_sex', '1');
$charmLimit = Sysparam::getValue('charm_hourly_limit', '20');
// 管理权限等级
$levelWarn = (int) Sysparam::getValue('level_warn', '5');
$levelMute = (int) Sysparam::getValue('level_mute', '8');
$levelKick = (int) Sysparam::getValue('level_kick', '10');
$levelFreeze = (int) Sysparam::getValue('level_freeze', '14');
// 排行榜配置
$lbLimit = (int) Sysparam::getValue('leaderboard_limit', '50');
@@ -480,35 +474,42 @@
<section id="sec-admin" class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
<h2 class="text-xl font-bold text-red-700 mb-4 flex items-center gap-2">🛡️ 管理权限</h2>
<p class="text-sm text-gray-600 mb-4">等级越高,可使用的管理功能越多。双击用户名片可执行以下操作:</p>
<p class="text-sm text-gray-600 mb-4">聊天室管理动作已统一按职务权限控制。被任命到具备对应权限的职务后,双击用户名片可执行以下操作:</p>
<div class="space-y-2">
<div class="flex items-center gap-3 bg-yellow-50 rounded-lg px-4 py-3">
<span class="text-lg">⚠️</span>
<div>
<span class="font-bold text-gray-800">警告用户</span>
<span class="text-xs text-gray-500 ml-2"> LV.{{ $levelWarn }} 以上</span>
<span class="text-xs text-gray-500 ml-2">具备“警告用户”职务权限</span>
</div>
</div>
<div class="flex items-center gap-3 bg-indigo-50 rounded-lg px-4 py-3">
<span class="text-lg">🔇</span>
<div>
<span class="font-bold text-gray-800">禁言用户</span>
<span class="text-xs text-gray-500 ml-2"> LV.{{ $levelMute }} 以上</span>
<span class="text-xs text-gray-500 ml-2">具备“禁言用户”职务权限</span>
</div>
</div>
<div class="flex items-center gap-3 bg-red-50 rounded-lg px-4 py-3">
<span class="text-lg">🚫</span>
<div>
<span class="font-bold text-gray-800">踢出用户</span>
<span class="text-xs text-gray-500 ml-2"> LV.{{ $levelKick }} 以上</span>
<span class="text-xs text-gray-500 ml-2">具备“踢出用户”职务权限</span>
</div>
</div>
<div class="flex items-center gap-3 bg-blue-50 rounded-lg px-4 py-3">
<span class="text-lg">🧊</span>
<div class="flex items-center gap-3 bg-rose-50 rounded-lg px-4 py-3">
<span class="text-lg"></span>
<div>
<span class="font-bold text-gray-800">冻结账号</span>
<span class="text-xs text-gray-500 ml-2"> LV.{{ $levelFreeze }} 以上</span>
<span class="font-bold text-gray-800">封号用户</span>
<span class="text-xs text-gray-500 ml-2">具备“封号用户”职务权限</span>
</div>
</div>
<div class="flex items-center gap-3 bg-orange-50 rounded-lg px-4 py-3">
<span class="text-lg">🌐</span>
<div>
<span class="font-bold text-gray-800"> IP / 查看管理员网络信息</span>
<span class="text-xs text-gray-500 ml-2">需具备“封IP”职务权限</span>
</div>
</div>
</div>
+13 -11
View File
@@ -50,8 +50,8 @@ Route::middleware('guest')->group(function () {
Route::post('/reset-password', [PasswordResetController::class, 'update'])->name('password.update');
});
// 处理退出登录
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
// 处理退出登录(同时接受 GET 和 POST,避免 POST 后刷新页面导致 404)
Route::match(['get', 'post'], '/logout', [AuthController::class, 'logout'])->name('logout');
// 登录失效后用于收口离场清理的签名地址,不依赖当前会话。
Route::get('/room/{id}/leave-expired/{user}', [ChatController::class, 'expiredLeave'])
@@ -93,6 +93,7 @@ Route::middleware(['chat.auth'])->group(function () {
// ---- 第十阶段:站内信与留言板系统 ----
Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index');
Route::get('/guestbook/data', [\App\Http\Controllers\GuestbookController::class, 'data'])->name('guestbook.data');
Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store');
Route::delete('/guestbook/{id}', [\App\Http\Controllers\GuestbookController::class, 'destroy'])->name('guestbook.destroy');
@@ -109,11 +110,6 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/user/unbind-wechat', [UserController::class, 'unbindWechat'])->name('user.unbind_wechat');
Route::post('/user/send-email-code', [\App\Http\Controllers\Api\VerificationController::class, 'sendEmailCode'])->name('user.send_email_code');
Route::put('/user/password', [UserController::class, 'changePassword'])->name('user.update_password');
Route::post('/user/{username}/kick', [UserController::class, 'kick'])->name('user.kick');
Route::post('/user/{username}/mute', [UserController::class, 'mute'])->name('user.mute');
Route::post('/user/{username}/ban', [UserController::class, 'ban'])->name('user.ban');
Route::post('/user/{username}/banip', [UserController::class, 'banIp'])->name('user.banip');
// ---- 好友系统 ----
Route::get('/friends', [\App\Http\Controllers\FriendController::class, 'index'])->name('friend.index');
Route::get('/friend/{username}/status', [\App\Http\Controllers\FriendController::class, 'status'])->name('friend.status');
@@ -329,7 +325,8 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/command/warn', [AdminCommandController::class, 'warn'])->name('command.warn');
Route::post('/command/kick', [AdminCommandController::class, 'kick'])->name('command.kick');
Route::post('/command/mute', [AdminCommandController::class, 'mute'])->name('command.mute');
Route::post('/command/freeze', [AdminCommandController::class, 'freeze'])->name('command.freeze');
Route::post('/command/ban', [AdminCommandController::class, 'ban'])->name('command.ban');
Route::post('/command/banip', [AdminCommandController::class, 'banIp'])->name('command.banip');
Route::post('/command/reward', [AdminCommandController::class, 'reward'])->name('command.reward');
Route::get('/command/reward-quota', [AdminCommandController::class, 'rewardQuota'])->name('command.reward_quota');
Route::get('/command/whispers/{username}', [AdminCommandController::class, 'viewWhispers'])->name('command.whispers');
@@ -369,6 +366,8 @@ Route::middleware(['chat.auth'])->group(function () {
// ---- 用户反馈(独立前台页面 /feedback----
// 反馈列表页
Route::get('/feedback', [FeedbackController::class, 'index'])->name('feedback.index');
// 第一页数据(供聊天室模态弹窗用)
Route::get('/feedback/data', [FeedbackController::class, 'data'])->name('feedback.data');
// 懒加载接口:scroll 到底追加更多反馈
Route::get('/feedback/more', [FeedbackController::class, 'loadMore'])->name('feedback.more');
// 提交新反馈
@@ -441,9 +440,9 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
// 大卡片通知广播(仅超级管理员,安全隔离:普通用户无此接口)
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');
// 等级经验阈值配置
Route::get('/level-exp-configs', [\App\Http\Controllers\Admin\LevelExpConfigController::class, 'index'])->name('level-exp-configs.index');
Route::put('/level-exp-configs', [\App\Http\Controllers\Admin\LevelExpConfigController::class, 'update'])->name('level-exp-configs.update');
// 微信机器人配置
Route::get('/wechat-bot', [\App\Http\Controllers\Admin\WechatBotController::class, 'edit'])->name('wechat_bot.edit');
@@ -583,6 +582,9 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
// 层级 2:仅站长(id=1)可进行以下操作
// ──────────────────────────────────────────────────────────────
Route::middleware(['chat.site_owner'])->group(function () {
// 聊天室参数(含保存)
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');
// 用户编辑 & 删除
Route::put('/users/{user}', [\App\Http\Controllers\Admin\UserManagerController::class, 'update'])->name('users.update');
+148 -3
View File
@@ -370,6 +370,7 @@ class ChatControllerTest extends TestCase
$user = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::USER_WARN,
PositionPermissionRegistry::USER_MUTE,
PositionPermissionRegistry::USER_BAN,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
@@ -377,7 +378,9 @@ class ChatControllerTest extends TestCase
$response->assertOk();
$response->assertSee('⚠️ 警告', false);
$response->assertSee('🔇 禁言', false);
$response->assertSee('⛔ 封号', false);
$response->assertDontSee('🚫 踢出', false);
$response->assertDontSee('🌐 封IP', false);
$response->assertDontSee('🧊 冻结', false);
}
@@ -395,6 +398,8 @@ class ChatControllerTest extends TestCase
$response->assertDontSee('⚠️ 警告', false);
$response->assertDontSee('🚫 踢出', false);
$response->assertDontSee('🔇 禁言', false);
$response->assertDontSee('⛔ 封号', false);
$response->assertDontSee('🌐 封IP', false);
$response->assertDontSee('🧊 冻结', false);
}
@@ -563,9 +568,54 @@ class ChatControllerTest extends TestCase
}
/**
* 测试定向消息仅广播到发送方与接收方私有频道
* 测试历史消息会让旁观用户看到普通定向发言,但不会泄露悄悄话
*/
public function test_targeted_message_event_uses_private_user_channels(): void
public function test_room_history_keeps_non_secret_targeted_messages_visible_to_others(): void
{
$room = Room::create(['room_name' => 'histtg']);
$sender = User::factory()->create(['username' => 'history-sender']);
$receiver = User::factory()->create(['username' => 'history-receiver']);
$observer = User::factory()->create(['username' => 'history-observer']);
$chatState = app(\App\Services\ChatStateService::class);
$chatState->pushMessage($room->id, [
'id' => 1,
'room_id' => $room->id,
'from_user' => $sender->username,
'to_user' => $receiver->username,
'content' => '公开对你说',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
]);
$chatState->pushMessage($room->id, [
'id' => 2,
'room_id' => $room->id,
'from_user' => $sender->username,
'to_user' => $receiver->username,
'content' => '旁人不可见悄悄话',
'is_secret' => true,
'font_color' => '#000000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
]);
$response = $this->actingAs($observer)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertViewHas('historyMessages', function (array $messages): bool {
$contents = collect($messages)->pluck('content');
return $contents->contains('公开对你说')
&& ! $contents->contains('旁人不可见悄悄话');
});
}
/**
* 测试悄悄话消息仅广播到发送方与接收方私有频道。
*/
public function test_secret_message_event_uses_private_user_channels(): void
{
$sender = User::factory()->create(['username' => 'sender-user']);
$receiver = User::factory()->create(['username' => 'receiver-user']);
@@ -592,7 +642,30 @@ class ChatControllerTest extends TestCase
}
/**
* 测试公共消息仍广播到房间 Presence 频道。
* 测试普通定向消息仍广播到房间 Presence 频道。
*/
public function test_non_secret_targeted_message_event_uses_room_presence_channel(): void
{
$event = new MessageSent(3, [
'room_id' => 3,
'from_user' => 'tester',
'to_user' => 'receiver-user',
'content' => '公开对你说',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
]);
$channels = $event->broadcastOn();
$this->assertCount(1, $channels);
$this->assertInstanceOf(PresenceChannel::class, $channels[0]);
$this->assertSame('presence-room.3', $channels[0]->name);
}
/**
* 测试对大家消息仍广播到房间 Presence 频道。
*/
public function test_public_message_event_still_uses_room_presence_channel(): void
{
@@ -716,6 +789,8 @@ class ChatControllerTest extends TestCase
$room = Room::create(['room_name' => 'annsafe']);
$user = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
], [
'has_received_new_gift' => true,
]);
$this->actingAs($user)->get(route('chat.room', $room->id));
@@ -794,6 +869,39 @@ class ChatControllerTest extends TestCase
$this->assertGreaterThanOrEqual(0, $user->exp_num); // Might be 1 depending on sysparam
}
/**
* 测试低经验新人心跳后不会被降到 0 级,刷新后仍可进入默认房间。
*/
public function test_low_exp_newbie_keeps_minimum_level_after_heartbeat(): void
{
Sysparam::updateOrCreate(['alias' => 'exp_per_heartbeat'], ['body' => '0']);
Sysparam::updateOrCreate(['alias' => 'jjb_per_heartbeat'], ['body' => '0']);
Sysparam::updateOrCreate(['alias' => 'auto_event_chance'], ['body' => '0']);
Cache::flush();
$room = Room::create([
'room_name' => 'newhb',
'permit_level' => 1,
'door_open' => true,
]);
$user = User::factory()->create([
'user_level' => 1,
'exp_num' => 0,
'has_received_new_gift' => true,
]);
$this->actingAs($user)->get(route('chat.room', $room->id))->assertOk();
$heartbeatResponse = $this->actingAs($user)->postJson(route('chat.heartbeat', $room->id));
$heartbeatResponse->assertOk();
$heartbeatResponse->assertJsonPath('data.user_level', 1);
$this->assertSame(1, $user->fresh()->user_level);
Redis::del("room:{$room->id}:alive:{$user->username}");
$this->actingAs($user)->get(route('chat.room', $room->id))->assertOk();
}
/**
* 测试显式退房会清理 Redis 在线状态。
*/
@@ -987,6 +1095,43 @@ class ChatControllerTest extends TestCase
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
}
/**
* 测试新人首次进房时首屏历史包含礼包公告、AI 欢迎和普通进场播报。
*/
public function test_newbie_first_join_keeps_bonus_ai_and_entry_welcome_messages(): void
{
$room = Room::create(['room_name' => 'newbie']);
$user = User::factory()->create([
'jjb' => 0,
'has_received_new_gift' => false,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$history = collect($response->viewData('historyMessages'));
$initialWelcomeMessages = collect($response->viewData('initialWelcomeMessages'));
$newbieBonusMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'newbie_bonus');
$aiWelcomeMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ai_newbie_welcome');
$entryMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'entry_broadcast');
$this->assertNotNull($newbieBonusMessage);
$this->assertSame('系统公告', $newbieBonusMessage['from_user']);
$this->assertStringContainsString('6666 金币新人大礼包', $newbieBonusMessage['content']);
$this->assertNotNull($aiWelcomeMessage);
$this->assertSame('AI小班长', $aiWelcomeMessage['from_user']);
$this->assertSame('大家', $aiWelcomeMessage['to_user']);
$this->assertNotNull($entryMessage);
$this->assertSame($user->username, $entryMessage['welcome_user']);
$this->assertTrue($user->fresh()->has_received_new_gift);
$this->assertSame(6666, (int) $user->fresh()->jjb);
$this->assertSame(
['newbie_bonus', 'ai_newbie_welcome', 'entry_broadcast'],
$initialWelcomeMessages->pluck('welcome_kind')->all(),
);
}
/**
* 测试可以获取所有房间的在线人数状态。
*/
@@ -409,14 +409,14 @@ class AdminCommandControllerTest extends TestCase
}
/**
* 测试冻结操作会给目标用户写入带 toast 的私聊提示。
* 测试封号操作会给目标用户写入带 toast 的私聊提示。
*/
public function test_freeze_message_contains_toast_notification_for_receiver(): void
public function test_ban_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$response = $this->actingAs($admin)->postJson(route('command.freeze'), [
$response = $this->actingAs($admin)->postJson(route('command.ban'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '严重违规',
@@ -424,17 +424,49 @@ class AdminCommandControllerTest extends TestCase
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "冻结 {$target->username} 的账号",
'message' => "封禁 {$target->username} 的账号",
]);
$this->assertSame(-1, (int) $target->fresh()->user_level);
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已冻结你的账号');
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已封禁你的账号');
$this->assertNotNull($privateMessage);
$this->assertSame('🧊 账号已冻结', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('🧊', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#3b82f6', $privateMessage['toast_notification']['color'] ?? null);
$this->assertSame(' 账号已封禁', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#991b1b', $privateMessage['toast_notification']['color'] ?? null);
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 测试封IP操作会给目标用户写入带 toast 的私聊提示。
*/
public function test_banip_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$target->forceFill(['last_ip' => '192.168.1.100'])->save();
$response = $this->actingAs($admin)->postJson(route('command.banip'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '恶意刷屏',
]);
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "已封禁 {$target->username} 的 IP 与账号",
]);
$this->assertSame(-1, (int) $target->fresh()->user_level);
$this->assertSame(1, Redis::sismember('banned_ips', '192.168.1.100'));
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已封禁你的 IP 并冻结账号');
$this->assertNotNull($privateMessage);
$this->assertSame('🌐 IP 已封禁', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('🌐', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#7c2d12', $privateMessage['toast_notification']['color'] ?? null);
Queue::assertPushed(SaveMessageJob::class, 2);
}
@@ -466,6 +498,64 @@ class AdminCommandControllerTest extends TestCase
]);
}
/**
* 测试缺少封号权限的职务用户会被拒绝。
*/
public function test_position_user_without_ban_permission_cannot_ban_target(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '无权封号房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.ban'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '测试',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '当前职务无权封禁用户',
]);
}
/**
* 测试缺少封IP权限的职务用户会被拒绝。
*/
public function test_position_user_without_banip_permission_cannot_ban_target_ip(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '无权封IP房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.banip'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '测试',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '当前职务无权封禁 IP 用户',
]);
}
/**
* 测试不能处理部门位阶更高的目标用户。
*/
@@ -645,7 +735,8 @@ class AdminCommandControllerTest extends TestCase
PositionPermissionRegistry::USER_WARN,
PositionPermissionRegistry::USER_MUTE,
PositionPermissionRegistry::USER_KICK,
PositionPermissionRegistry::USER_FREEZE,
PositionPermissionRegistry::USER_BAN,
PositionPermissionRegistry::USER_BANIP,
]);
$admin->load('activePosition.position.department');
$target = User::factory()->create([
@@ -61,6 +61,8 @@ class AdminDashboardControllerTest extends TestCase
$response->assertOk();
$response->assertSee('当前在线人数');
$response->assertSee('2');
$response->assertDontSee('队列监控面板');
$response->assertDontSee('打开 Horizon 控制台');
}
/**
@@ -0,0 +1,167 @@
<?php
/**
* 文件功能:后台等级经验阈值配置页测试
*
* 覆盖独立菜单页的展示、保存与非法阈值拦截,
* 确保 levelexp 已从通用系统参数页中拆分出来单独维护。
*/
namespace Tests\Feature\Feature;
use App\Models\Sysparam;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证后台等级经验阈值独立配置页的核心行为。
*/
class AdminLevelExpConfigControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:验证独立配置页会按列表模式展示每一级阈值。
*/
public function test_level_exp_index_displays_threshold_rows(): void
{
$this->seedLevelExpParam();
$admin = $this->createSuperAdmin();
$response = $this->actingAs($admin)->get(route('admin.level-exp-configs.index'));
$response->assertOk();
$response->assertSee('等级经验阈值管理');
$response->assertSee('第 1 级');
$response->assertSee('第 3 级');
$response->assertSee('10');
$response->assertSee('150');
$response->assertSee('最高可配置到 99 级');
}
/**
* 方法功能:验证独立配置页可保存新的等级经验阈值。
*/
public function test_level_exp_update_persists_thresholds(): void
{
$this->seedLevelExpParam();
$admin = $this->createSuperAdmin();
$response = $this->actingAs($admin)->put(route('admin.level-exp-configs.update'), [
'thresholds' => ['20', '80', '180', '360'],
]);
$response->assertRedirect(route('admin.level-exp-configs.index'));
$response->assertSessionHas('success');
$this->assertDatabaseHas('sysparam', [
'alias' => 'levelexp',
'body' => '20,80,180,360',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'maxlevel',
'body' => '99',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'superlevel',
'body' => '100',
]);
}
/**
* 方法功能:验证阈值必须严格递增,防止错误配置写入。
*/
public function test_level_exp_update_requires_strictly_increasing_thresholds(): void
{
$this->seedLevelExpParam();
$admin = $this->createSuperAdmin();
$response = $this->from(route('admin.level-exp-configs.index'))
->actingAs($admin)
->put(route('admin.level-exp-configs.update'), [
'thresholds' => ['20', '18', '100'],
]);
$response->assertRedirect(route('admin.level-exp-configs.index'));
$response->assertSessionHasErrors('thresholds');
$this->assertDatabaseHas('sysparam', [
'alias' => 'levelexp',
'body' => '10,50,150',
]);
}
/**
* 方法功能:验证等级数量不能超过用户最高可达等级。
*/
public function test_level_exp_update_requires_threshold_count_not_exceed_maxlevel(): void
{
$this->seedLevelExpParam();
Sysparam::updateOrCreate(
['alias' => 'maxlevel'],
['body' => '2', 'guidetxt' => '用户最高可达等级']
);
$admin = $this->createSuperAdmin();
$response = $this->from(route('admin.level-exp-configs.index'))
->actingAs($admin)
->put(route('admin.level-exp-configs.update'), [
'thresholds' => ['20', '80', '180'],
]);
$response->assertRedirect(route('admin.level-exp-configs.index'));
$response->assertSessionHasErrors('thresholds');
$this->assertDatabaseHas('sysparam', [
'alias' => 'levelexp',
'body' => '10,50,150',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'maxlevel',
'body' => '2',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'superlevel',
'body' => '100',
]);
}
/**
* 方法功能:创建可访问后台页面的超级管理员。
*/
private function createSuperAdmin(): User
{
return User::factory()->create([
'user_level' => 100,
]);
}
/**
* 方法功能:初始化等级经验阈值参数。
*/
private function seedLevelExpParam(): void
{
$rows = [
'levelexp' => [
'body' => '10,50,150',
'guidetxt' => '按列表逐级维护升级所需的累计经验阈值',
],
'maxlevel' => [
'body' => '99',
'guidetxt' => '用户最高可达等级',
],
'superlevel' => [
'body' => '100',
'guidetxt' => '管理员级别(= 最高等级 + 1,拥有最高权限的等级阈值)',
],
];
foreach ($rows as $alias => $payload) {
Sysparam::updateOrCreate(
['alias' => $alias],
$payload
);
}
}
}
@@ -0,0 +1,45 @@
<?php
/**
* 文件功能:运维工具页面展示测试
*
* 覆盖运维工具页中 Horizon 控制台入口的展示,
* 并验证该入口已从后台仪表盘迁移到运维工具页面。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Tests\Feature\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证运维工具页面的核心展示内容。
*/
class AdminOpsControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:验证运维工具页会展示 Horizon 控制台入口。
*/
public function test_ops_page_displays_horizon_console_entry(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'user_level' => 100,
]);
$response = $this->actingAs($siteOwner)->get(route('admin.ops.index'));
$response->assertOk();
$response->assertSee('队列监控面板');
$response->assertSee('打开 Horizon 控制台');
$response->assertSee('/horizon');
}
}
@@ -47,6 +47,8 @@ class AdminPositionPermissionTest extends TestCase
'permissions' => [
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST,
PositionPermissionRegistry::USER_BAN,
PositionPermissionRegistry::USER_BANIP,
],
]);
@@ -59,6 +61,8 @@ class AdminPositionPermissionTest extends TestCase
$this->assertSame([
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST,
PositionPermissionRegistry::USER_BAN,
PositionPermissionRegistry::USER_BANIP,
], $position->permissions);
}
@@ -195,7 +199,9 @@ class AdminPositionPermissionTest extends TestCase
$response->assertSee('默认礼包总量');
$response->assertSee('默认礼包份数');
$response->assertSee('警告用户');
$response->assertSee('冻结用户');
$response->assertSee('封号用户');
$response->assertSee('封IP');
$response->assertSee('聊天室管理动作已统一按职务权限控制');
}
/**
@@ -38,6 +38,16 @@ class AdminSystemControllerTest extends TestCase
$response->assertDontSee('vip_payment_app_secret');
$response->assertDontSee('wechat_bot_config');
$response->assertDontSee('chatbot_max_gold');
$response->assertDontSee('levelexp');
$response->assertDontSee('level_warn');
$response->assertDontSee('level_mute');
$response->assertDontSee('level_kick');
$response->assertDontSee('level_announcement');
$response->assertDontSee('level_ban');
$response->assertDontSee('level_banip');
$response->assertDontSee('level_freeze');
$response->assertSee('maxlevel');
$response->assertSee('superlevel');
}
/**
@@ -51,6 +61,16 @@ class AdminSystemControllerTest extends TestCase
$response = $this->actingAs($admin)->put(route('admin.system.update'), [
'sys_name' => '新版聊天室',
'sys_notice' => '新的公共公告',
'levelexp' => '20,80,180',
'level_warn' => '40',
'level_mute' => '50',
'level_kick' => '60',
'level_announcement' => '65',
'level_ban' => '80',
'level_banip' => '90',
'level_freeze' => '95',
'maxlevel' => '88',
'superlevel' => '666',
'smtp_host' => 'attacker.smtp.example',
'vip_payment_app_secret' => 'tampered-secret',
'wechat_bot_config' => '{"api":{"bot_key":"stolen"}}',
@@ -69,6 +89,46 @@ class AdminSystemControllerTest extends TestCase
'alias' => 'sys_notice',
'body' => '新的公共公告',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'levelexp',
'body' => '10,50,150',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_warn',
'body' => '5',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_mute',
'body' => '50',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_kick',
'body' => '60',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_announcement',
'body' => '60',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_ban',
'body' => '80',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_banip',
'body' => '90',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'level_freeze',
'body' => '14',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'maxlevel',
'body' => '88',
]);
$this->assertDatabaseHas('sysparam', [
'alias' => 'superlevel',
'body' => '89',
]);
// 敏感配置必须保持原值,不能被通用系统页伪造请求覆盖。
$this->assertDatabaseHas('sysparam', [
@@ -92,12 +152,44 @@ class AdminSystemControllerTest extends TestCase
]);
}
/**
* 验证非站长的高等级后台用户不能访问系统参数页。
*/
public function test_non_site_owner_cannot_access_system_page(): void
{
$this->seedSystemParams();
$admin = User::factory()->create([
'user_level' => 100,
]);
$this->actingAs($admin)
->get(route('admin.system.edit'))
->assertForbidden();
}
/**
* 验证非站长的高等级后台用户看不到系统参数菜单入口。
*/
public function test_non_site_owner_dashboard_hides_system_menu_link(): void
{
$this->seedSystemParams();
$admin = User::factory()->create([
'user_level' => 100,
]);
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
$response->assertOk();
$response->assertDontSee('⚙️ 聊天室参数', false);
}
/**
* 创建可访问后台通用系统页的超级管理员账号。
*/
private function createSuperAdmin(): User
{
return User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
}
@@ -128,6 +220,16 @@ class AdminSystemControllerTest extends TestCase
return [
'sys_name' => '原始聊天室',
'sys_notice' => '原始公告',
'levelexp' => '10,50,150',
'level_warn' => '5',
'level_mute' => '50',
'level_kick' => '60',
'level_announcement' => '60',
'level_ban' => '80',
'level_banip' => '90',
'level_freeze' => '14',
'maxlevel' => '99',
'superlevel' => '100',
'smtp_host' => 'owner.smtp.example',
'vip_payment_app_secret' => 'owner-secret',
'wechat_bot_config' => '{"api":{"bot_key":"owner-only"}}',
+88 -124
View File
@@ -8,11 +8,15 @@
namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Models\Department;
use App\Models\Position;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserCurrencyLog;
use App\Models\UserPosition;
use App\Services\ChatUserPresenceService;
use App\Support\PositionPermissionRegistry;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
@@ -39,10 +43,6 @@ class UserControllerTest extends TestCase
Redis::flushall();
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
Sysparam::updateOrCreate(['alias' => 'level_kick'], ['body' => '15']);
Sysparam::updateOrCreate(['alias' => 'level_mute'], ['body' => '15']);
Sysparam::updateOrCreate(['alias' => 'level_ban'], ['body' => '15']);
Sysparam::updateOrCreate(['alias' => 'level_banip'], ['body' => '15']);
Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']); // Allow email changing in tests
}
@@ -294,6 +294,51 @@ class UserControllerTest extends TestCase
->assertJsonPath('data.bank_jjb_can_reveal', false);
}
/**
* 测试拥有封IP职务权限的用户可以查看名片中的管理员网络信息。
*/
public function test_position_user_with_banip_permission_can_view_admin_network_info(): void
{
$viewer = $this->createUserWithPositionPermissions([
PositionPermissionRegistry::USER_BANIP,
]);
$target = User::factory()->create([
'username' => 'nettarget',
'first_ip' => '10.0.0.1',
'previous_ip' => '10.0.0.2',
'last_ip' => '10.0.0.3',
]);
$response = $this->actingAs($viewer)->getJson("/user/{$target->username}");
$response->assertOk()
->assertJsonPath('data.first_ip', '10.0.0.1')
->assertJsonPath('data.last_ip', '10.0.0.2')
->assertJsonPath('data.login_ip', '10.0.0.3');
}
/**
* 测试普通用户查看别人名片时不会拿到管理员网络信息字段。
*/
public function test_user_without_banip_permission_cannot_view_admin_network_info(): void
{
$viewer = User::factory()->create();
$target = User::factory()->create([
'username' => 'hidden-net',
'first_ip' => '10.0.1.1',
'previous_ip' => '10.0.1.2',
'last_ip' => '10.0.1.3',
]);
$response = $this->actingAs($viewer)->getJson("/user/{$target->username}");
$response->assertOk()
->assertJsonMissingPath('data.first_ip')
->assertJsonMissingPath('data.last_ip')
->assertJsonMissingPath('data.login_ip')
->assertJsonMissingPath('data.location');
}
/**
* 测试不改邮箱时可以正常更新个人资料。
*/
@@ -510,126 +555,6 @@ class UserControllerTest extends TestCase
$this->assertTrue(Hash::check('newpassword123', $user->password));
}
public function test_admin_can_kick_user()
{
$admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
$target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
$this->actingAs($admin);
$response = $this->postJson("/user/{$target->username}/kick", [
'room_id' => $room->id,
]);
$response->assertStatus(200)
->assertJsonPath('status', 'success');
}
public function test_low_level_user_cannot_kick()
{
$user = User::factory()->create(['username' => 'user', 'user_level' => 1]);
$target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
$this->actingAs($user);
$response = $this->postJson("/user/{$target->username}/kick", [
'room_id' => $room->id,
]);
$response->assertStatus(403);
}
public function test_room_master_can_kick()
{
$user = User::factory()->create(['username' => 'user', 'user_level' => 2]);
$target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'user']); // Master is 'user'
$this->actingAs($user);
$response = $this->postJson("/user/{$target->username}/kick", [
'room_id' => $room->id,
]);
if ($response->status() !== 200) {
dump($response->json());
}
$response->assertStatus(200);
}
public function test_cannot_kick_higher_level()
{
$admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
$superadmin = User::factory()->create(['username' => 'superadmin', 'user_level' => 100]);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
$this->actingAs($admin);
$response = $this->postJson("/user/{$superadmin->username}/kick", [
'room_id' => $room->id,
]);
$response->assertStatus(403);
}
public function test_admin_can_mute_user()
{
$admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
$target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
Redis::shouldReceive('setex')->once();
$this->actingAs($admin);
$response = $this->postJson("/user/{$target->username}/mute", [
'room_id' => $room->id,
'duration' => 10,
]);
$response->assertStatus(200);
}
public function test_admin_can_ban_user()
{
$admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
$target = User::factory()->create(['username' => 'target', 'user_level' => 1]);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
$this->actingAs($admin);
$response = $this->postJson("/user/{$target->username}/ban", [
'room_id' => $room->id,
]);
$response->assertStatus(200);
$target->refresh();
$this->assertEquals(-1, $target->user_level);
}
public function test_admin_can_ban_ip()
{
$admin = User::factory()->create(['username' => 'admin', 'user_level' => 20]);
$target = User::factory()->create(['username' => 'target', 'user_level' => 1, 'last_ip' => '192.168.1.100']);
$room = Room::create(['id' => 1, 'room_name' => 'Test Room', 'room_owner' => 'someone']);
Redis::shouldReceive('sadd')->with('banned_ips', '192.168.1.100')->once();
$this->actingAs($admin);
$response = $this->postJson("/user/{$target->username}/banip", [
'room_id' => $room->id,
]);
$response->assertStatus(200);
$target->refresh();
$this->assertEquals(-1, $target->user_level);
}
/**
* 让指定用户先进入聊天室,满足“仅在线用户可设置状态”的前置条件。
*/
@@ -646,4 +571,43 @@ class UserControllerTest extends TestCase
return $room;
}
/**
* 创建带指定职务权限的测试用户。
*
* @param list<string> $permissions
*/
private function createUserWithPositionPermissions(array $permissions): User
{
$user = User::factory()->create([
'user_level' => 88,
]);
$department = Department::create([
'name' => '资料权限部'.$user->id,
'rank' => 88,
'color' => '#4f46e5',
'sort_order' => 1,
'description' => '资料权限测试部门',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '资料权限职务'.$user->id,
'icon' => '🛡️',
'rank' => 88,
'level' => 88,
'sort_order' => 1,
'permissions' => $permissions,
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_at' => now(),
'is_active' => true,
]);
return $user->fresh();
}
}
+13
View File
@@ -7,6 +7,7 @@ export default defineConfig({
laravel({
input: [
"resources/css/app.css",
"resources/css/chat.css",
"resources/js/admin-login.js",
"resources/js/app.js",
"resources/js/chat.js",
@@ -19,6 +20,18 @@ export default defineConfig({
}),
tailwindcss(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules")) {
return "vendor";
}
},
},
},
sourcemap: false,
},
server: {
watch: {
ignored: ["**/storage/framework/views/**"],