53 Commits

Author SHA1 Message Date
pllx a2b09da730 新增:个性装扮支持多份购买,同款续购自动叠加天数 2026-04-28 13:07:10 +08:00
pllx 243e06915e 删除没必要文件 2026-04-28 13:00:21 +08:00
pllx 2ee6ecc601 清理:移除已追踪的 AI 配置目录(.agents/.codex/.gemini/.hermes/.junie)和 AI 生成文件(AGENTS.md/GEMINI.md) 2026-04-28 12:01:36 +08:00
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
74 changed files with 9389 additions and 6833 deletions
-150
View File
@@ -1,150 +0,0 @@
---
trigger: always_on
---
> **技术栈**Laravel 12 · PHP 8.4 · Laravel Reverb (WebSocket) · Redis · MySQL 8.0 · Laravel Horizon
> **原项目**`/Users/pllx/Web/chat/hp0709`VBScript ASP + MS Access 聊天室)
> **目标域名**`http://chatroom.test`Herd 自动配置)
---
## 一、环境版本要求
| 组件 | 版本 |
| --------------------- | -------------------------- |
| **PHP** | 8.4.5+ |
| **Laravel Framework** | v12.x |
| **Laravel Reverb** | latestWebSocket 服务器) |
| **Laravel Horizon** | v5(Redis 队列可视化管理) |
| **PHPUnit** | v11(测试框架) |
| **Node.js** | 20.x LTS |
| **MySQL** | 8.0+ |
| **Redis** | 7.x |
---
## 二、代码规范(强制执行)
### 2.1 Laravel Pint 格式化
```bash
# 提交代码前必须运行,修复格式问题
vendor/bin/pint --dirty
# 检查格式问题(不修复)
vendor/bin/pint --test
# 格式化整个项目
vendor/bin/pint
```
### 2.2 PHP 8.4 类型系统(必须遵守)
```php
// ✅ 正确:构造函数属性提升 (Constructor Property Promotion)
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
) {}
}
// ❌ 错误:不允许无参空构造函数
class SomeClass
{
public function __construct() {} // 禁止!
}
// ✅ 正确:显式返回类型 + 参数类型提示
public function send(SendMessageRequest $request): JsonResponse
{
// ...
}
// ✅ 正确:使用 PHP 8.4 新特性
// 联合类型
public function findUser(int|string $id): User|null {}
// readonly 属性
class MessageDto
{
public function __construct(
public readonly string $content,
public readonly string $fromUser,
public readonly int $roomId,
) {}
}
```
### 2.3 Laravel 12 中间件配置(重要)
> [!IMPORTANT]
> Laravel 12 已废弃 `Kernel.php`,中间件在 `bootstrap/app.php` 中配置。
```php
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // API 路由
channels: __DIR__.'/../routes/channels.php', // WebSocket 频道
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// 注册聊天室登录验证中间件
$middleware->alias([
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
]);
// Session 中间件(Web 路由自动携带)
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
```
### 2.4 中文注释规范(每个文件必须)
```php
<?php
/**
* 文件功能:[本文件的业务职责描述]
*
* 对应原 ASP 文件:[原文件名.asp]
*
* @package App\[命名空间]
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Services;
class ChatStateService
{
/**
* 用户进入聊天房间,将其信息写入 Redis。
*
* 替代原 ASP 的 Application("_user_list") 字符串拼接操作。
*
* @param int $roomId 房间 ID
* @param string $username 用户名
* @param array $userInfo 用户信息(等级、头像、性别等)
*/
public function userJoin(int $roomId, string $username, array $userInfo): void
{
// 将用户信息序列化后存入 Redis HashKey 为 "room:{房间ID}:users"
$this->redis->hset("room:{$roomId}:users", $username, json_encode($userInfo));
}
}
```
### 2.5 迁移文件注意事项
同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错;
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
-12
View File
@@ -1,12 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[cleanup]
script = '''
php artisan reverb:start
php artisan horizon
'''
-14
View File
@@ -1,14 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[[actions]]
name = "启动ws"
icon = "tool"
command = '''
php artisan reverb:start
php artisan horizon
'''
-20
View File
@@ -1,20 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
},
"herd": {
"command": "php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+6
View File
@@ -15,6 +15,8 @@
/.github
/.gemini
/.agents
/.codex
/.hermes
/auth.json
/node_modules
/public/build
@@ -30,3 +32,7 @@ vendor.zip
test-captcha.php
public/.user.ini
dump.rdb
# AI 生成文件
AGENTS.md
GEMINI.md
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
-20
View File
@@ -1,20 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Users/pllx/Web/Herd/chatroom/artisan",
"boost:mcp"
]
},
"herd": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
-1004
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
# 🎮 聊天室游戏开发进度
> 更新时间:2026-03-04
---
## ✅ 已完成
### 🎲 百家乐(Baccarat
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`baccarat_rounds` + `baccarat_bets`
- **模型**`BaccaratRound` / `BaccaratBet`
- **队列 Job**`OpenBaccaratRoundJob` (开局) + `CloseBaccaratRoundJob` (摇骰结算)
- **事件**`BaccaratRoundOpened` / `BaccaratRoundSettled`PresenceChannel 广播)
- **控制器**`BaccaratController``/baccarat/current` / `/baccarat/bet` / `/baccarat/history`
- **前端**`chat/partials/baccarat-panel.blade.php`(倒计时/押注/骰子动画/趋势)
- **货币来源**`CurrencySource::BACCARAT_BET` / `BACCARAT_WIN`
- **后台配置**`game_configs` 表,管理员可配置开关/间隔/赔率/押注范围
### 🎰 老虎机(Slot Machine
- **类型**:玩家随时主动触发(即时游戏)
- **数据库**`slot_machine_logs`
- **模型**`SlotMachineLog`8种带权重图案、判奖逻辑)
- **控制器**`SlotMachineController``/slot/info` / `/slot/spin` / `/slot/history`
- **赔率**:三7×100(全服广播)/ 三钻×50 / 三同×10 / 两同×2 / 三骷髅诅咒(扣双倍)
- **聊天通知**:中奖发私信通知;三7全服公屏广播
- **前端**`chat/partials/slot-machine.blade.php`(三列滚轮动画/逐列停止/可拖动FAB)
- **货币来源**`CurrencySource::SLOT_SPIN` / `SLOT_WIN` / `SLOT_CURSE`
- **后台配置**`game_configs` 表,可配置每次消耗/每日次数上限/各赔率
### 📦 神秘箱子(Mystery Box
- **类型**:系统定时自动投放 + 管理员手动投放(即时广播暗号,先到先得)
- **数据库**`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
- **模型**`MysteryBox` / `MysteryBoxClaim`
- **队列 Job**`DropMysteryBoxJob`(投放 + 公屏广播暗号 + 派发 ExpireJob/ `ExpireMysteryBoxJob`(到期处理)
- **控制器**`MysteryBoxController``/mystery-box/status` 状态查询 / `/mystery-box/claim` 领取)
- **前端**`chat/partials/mystery-box.blade.php`(5秒轮询检测 + 可拖动FAB + 快捷输入面板)
- **领取方式**:① 聊天框直接输入暗号发送(前端拦截,不发普通消息)② 点击悬浮FAB打开面板输入
- **箱子类型**:普通箱(500\~2000金)/ 稀有箱(5000\~20000金)/ 黑化箱(陷阱,倒扣200\~1000金)
- **货币来源**`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
- **后台配置**`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
### 🐎 赛马竞猜(Horse Racing
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`horse_races` + `horse_bets`
- **模型**`HorseRace` / `HorseBet`
- **队列 Job**`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算)
- **事件**`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`PresenceChannel 广播)
- **控制器**`HorseRaceController``/horse-race/current` / `/horse-race/bet` / `/horse-race/history`
- **广播**`horse.opened` / `horse.progress` / `horse.settled`
- **前端**`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB)
- **货币来源**`CurrencySource::HORSE_BET` / `HORSE_WIN`
- **后台配置**`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置
### 🔮 神秘占卜(Fortune Telling
- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币)
- **数据库**`fortune_logs`
- **模型**`FortuneLog`55+ 条签文内嵌在模型中)
- **控制器**`FortuneTellingController``/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史)
- **前端**`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB)
- **每日限制**:免费 N 次(可配置),额外次数消耗金币
- **广播**:暂无实时广播(占卜结果仅展示给本人)
- **货币来源**`CurrencySource::FORTUNE_COST`
- **后台配置**`game_configs` 表,免费次数/额外消耗/各签概率均可配置
---
## 🕐 待开发
---
## 📌 通用待办(所有游戏共用)
- [x] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据(点击"加载实时统计"异步加载各游戏汇总卡片)
- [x] 各游戏历史记录在后台可查(管理员视角,新增 `/admin/game-history/` 路由组,支持百家乐/老虎机/赛马/神秘箱子/占卜各自的历史记录列表及详情页,含筛选分页)
- [x] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了
- [ ] 百家乐/老虎机 全面测试(多用户并发下注)
---
## 🔧 已修复的 Bug
1. **百家乐广播频道**`Channel``PresenceChannel`,解决前端收不到 WebSocket 事件
2. **百家乐余额检查**`$user->gold``$user->jjb`(字段名错误)
3. **老虎机积分日志**:普通中奖/诅咒发私信通知;三7全服广播
4. **老虎机FAB**:支持拖动 + localStorage 位置持久化
5. **星海小博士随机事件**:改走 `UserCurrencyService.change()`,补写流水日志
6. **百家乐结算UI**:骰子改数字方块(跨平台);中奖/未中奖卡片重设计
7. **全部 FAB 拖动统一**:百家乐 FAB 改为 Alpine.js `baccaratFab()` 组件,与老虎机 `slotFab()` 完全一致,位置持久化存 localStorage
8. **Alpine.js 初始化顺序**`frame.blade.php` 中 Alpine CDN 补加 `defer`,解决所有组件 `is not defined` 错误
9. **神秘箱子暗号领取**:改为主动尝试模式(不依赖5秒轮询),聊天框输入暗号即可触发领取;`claim()` 暗号统一转大写
10. **神秘箱子流水记录**`change()` 调用补上 `room_id` 参数,确保积分统计页面可按房间筛选
11. **后台弹窗**:游戏管理页所有 `alert/confirm` 替换为全局 `window.adminDialog`(毛玻璃弹窗)
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
+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);
}
/**
@@ -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',
+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}」。",
};
+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(),
];
+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;
}
+255
View File
@@ -0,0 +1,255 @@
<?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 装扮商品
* @param int $quantity 购买份数
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
*/
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
{
// 根据商品类型映射到对应槽位
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
if (! $slot) {
return ['ok' => false, 'message' => '未知装扮类型'];
}
$totalPrice = $item->price * $quantity;
// 校验金币余额
if ($user->jjb < $totalPrice) {
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
}
// 计算过期时间(至少 1 天)
$days = max(1, (int) ($item->duration_days ?? 1));
$totalDays = $days * $quantity;
// 检查同一槽位是否已激活相同样式 → 叠加天数
$decorations = $this->getActiveDecorations($user);
$isSameActive = ! empty($decorations[$slot])
&& ($decorations[$slot]['style'] ?? '') === $item->slug;
if ($isSameActive) {
// 在现有到期时间上追加天数
$existingExpires = Carbon::parse($decorations[$slot]['expires_at']);
$expiresAt = $existingExpires->copy()->addDays($totalDays);
} else {
$expiresAt = Carbon::now()->addDays($totalDays);
}
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
$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, $totalPrice, $totalDays, $expiresAt, $source) {
// ① 通过统一积分服务扣除金币(含流水记录)
$this->currencyService->change(
$user, 'gold', -$totalPrice, $source,
"购买装扮:{$item->name}{$totalDays}天)"
);
// ② 写入购买记录(用于后台统计与用户回溯)
UserPurchase::create([
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $totalPrice,
'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;
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
$displayDays = $isSameActive
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
: $totalDays;
return [
'ok' => true,
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
'balance_after' => $balanceAfter,
'slot' => $slot,
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
'quantity' => $quantity,
];
}
/**
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
*
* 采用懒过期策略,在每次读取时检查 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, $quantity),
'msg_name_color' => $this->decorationService->purchase($user, $item, $quantity),
'msg_text_color' => $this->decorationService->purchase($user, $item, $quantity),
'avatar_frame' => $this->decorationService->purchase($user, $item, $quantity),
default => ['ok' => false, 'message' => '未知商品类型'],
};
}
@@ -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) => {
+265 -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,14 +548,52 @@ async function confirmAndBuyItem(item) {
}
}
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""} 吗?`;
// 个性装扮支持多份购买(叠加天数),弹出数量选择
if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") {
quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1;
if (quantity === null || quantity === undefined) {
return;
}
}
const validityText = buildValidityText(item);
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\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);
}
}
/**
* 通用数量输入弹窗。
*
* @param {string} hint 提示文案
* @param {number} [minVal=1] 最小值
* @param {number} [maxVal=99] 最大值
* @returns {Promise<number|null>} 返回数量,用户取消返回 null
*/
window.promptQuantity = async (hint, minVal = 1, maxVal = 99) => {
if (window.chatDialog?.prompt) {
const result = await window.chatDialog.prompt(hint, "1", "购买数量");
if (result === null || result === undefined) return null;
const val = parseInt(result, 10);
if (isNaN(val) || val < minVal || val > maxVal) {
window.chatDialog?.alert?.(`请输入 ${minVal}~${maxVal} 之间的整数`);
return null;
}
return val;
}
const result = window.prompt(`${hint}\n数量(${minVal}~${maxVal}):`, "1");
if (result === null) return null;
const val = parseInt(result, 10);
if (isNaN(val) || val < minVal || val > maxVal) {
alert(`请输入 ${minVal}~${maxVal} 之间的整数`);
return null;
}
return val;
};
/**
* 兼容全局弹窗组件缺失时的原生确认。
*
@@ -401,7 +636,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 +679,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 +693,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 +709,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 +726,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 +735,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 +751,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 +876,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;
+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 };
@@ -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>
+9 -2
View File
@@ -117,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,
@@ -134,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>
@@ -219,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>
@@ -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">
+5 -2
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');
@@ -365,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');
// 提交新反馈
+143 -3
View File
@@ -568,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']);
@@ -597,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
{
@@ -721,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));
@@ -799,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 在线状态。
*/
@@ -992,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(),
);
}
/**
* 测试可以获取所有房间的在线人数状态。
*/
+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/**"],