22 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
35 changed files with 3959 additions and 3206 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>
@@ -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,
]);
}
}
+5
View File
@@ -125,6 +125,11 @@ 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'];
+33 -10
View File
@@ -52,12 +52,14 @@ class DecorationService
* 购买装扮:扣金币、写购买记录、更新 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): array
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
{
// 根据商品类型映射到对应槽位
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
@@ -65,14 +67,29 @@ class DecorationService
return ['ok' => false, 'message' => '未知装扮类型'];
}
$totalPrice = $item->price * $quantity;
// 校验金币余额
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
if ($user->jjb < $totalPrice) {
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
}
// 计算过期时间(至少 1 天)
$days = max(1, (int) ($item->duration_days ?? 1));
$expiresAt = Carbon::now()->addDays($days);
$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) {
@@ -84,11 +101,11 @@ class DecorationService
};
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
// ① 通过统一积分服务扣除金币(含流水记录)
$this->currencyService->change(
$user, 'gold', -$item->price, $source,
"购买装扮:{$item->name}{$days}天)"
$user, 'gold', -$totalPrice, $source,
"购买装扮:{$item->name}{$totalDays}天)"
);
// ② 写入购买记录(用于后台统计与用户回溯)
@@ -96,11 +113,11 @@ class DecorationService
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'price_paid' => $totalPrice,
'expires_at' => $expiresAt,
]);
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加
$decorations = $this->getActiveDecorations($user);
$decorations[$slot] = [
'style' => $item->slug,
@@ -113,13 +130,19 @@ class DecorationService
// 重新读取最新余额,避免缓存脏数据
$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} 已激活({$days}天有效)",
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
'balance_after' => $balanceAfter,
'slot' => $slot,
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
'quantity' => $quantity,
];
}
+4 -4
View File
@@ -49,10 +49,10 @@ class ShopService
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
// ── 个人装扮购买(委托给 DecorationService)───────────────
'msg_bubble' => $this->decorationService->purchase($user, $item),
'msg_name_color' => $this->decorationService->purchase($user, $item),
'msg_text_color' => $this->decorationService->purchase($user, $item),
'avatar_frame' => $this->decorationService->purchase($user, $item),
'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' => '未知商品类型'],
};
}
+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;
}
}
+472 -684
View File
File diff suppressed because it is too large Load Diff
+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>');
}
+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;
};
}
+86 -3
View File
@@ -68,7 +68,7 @@ export function buildChatMessageContent(msg, fontColor, textColorClass) {
<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}"
<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}
@@ -202,8 +202,25 @@ export function appendMessage(msg, renderBatch = null) {
} 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 parsedContent = parseBracketUsers(msg.content, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${parsedContent} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
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 === "系统公告") {
@@ -440,6 +457,23 @@ export function enqueueChatMessage(msg) {
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;
/**
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
*/
@@ -449,6 +483,55 @@ export function flushQueuedChatMessages() {
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));
@@ -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) => {
+54 -13
View File
@@ -336,10 +336,10 @@ export function renderDecorations(data) {
const items = Array.isArray(data.items) ? data.items : [];
list.innerHTML = "";
// 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框
// 购买说明:已激活同款支持叠加天数,不同款式替换时旧装扮作废不退款
const note = document.createElement("div");
note.className = "decoration-note";
note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。";
note.innerHTML = "📌 购买说明:类型只生效一款;已激活的同款续购自动叠加天数,无需一次买满;购买不同款式时旧装扮自动作废且不退款。";
list.appendChild(note);
DECORATION_GROUPS.forEach((group) => {
@@ -548,18 +548,52 @@ async function confirmAndBuyItem(item) {
}
}
// 个性装扮支持多份购买(叠加天数),弹出数量选择
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 replacementText = DECORATION_TYPE_TO_SLOT[item.type]
? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。"
: "";
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`;
const 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;
};
/**
* 兼容全局弹窗组件缺失时的原生确认。
*
@@ -645,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);
}
/**
@@ -659,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",
@@ -675,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;
}
@@ -692,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) {
@@ -717,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);
}
}
/**
+2
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]?.();
+5 -2
View File
@@ -138,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>
@@ -223,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;">
{{-- 标题栏 --}}
+3
View File
@@ -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');
// 提交新反馈
+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/**"],