Compare commits
53 Commits
32af6abeb2
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a2b09da730 | |||
| 243e06915e | |||
| 2ee6ecc601 | |||
| f0137f3fa3 | |||
| b15e42891d | |||
| 214a422504 | |||
| f16f10fe82 | |||
| a3daf3f074 | |||
| d0a38352a5 | |||
| c06e265c0d | |||
| 6ae7a4a82b | |||
| 792b0765fd | |||
| 3e0fb33a9b | |||
| e7049b5f5b | |||
| 62371a7c64 | |||
| 540d8bf6ff | |||
| bf2d63f125 | |||
| 4f22fd552a | |||
| 790730e2c2 | |||
| eeb9dfbade | |||
| 1c067e452b | |||
| e50502d8f6 | |||
| e8b4dcc968 | |||
| e177ad6d4d | |||
| f17f171f4b | |||
| d10a354370 | |||
| efb03f90b8 | |||
| 3ecafd01ea | |||
| d82aa1c434 | |||
| 3db8e4ab82 | |||
| 10d158b38a | |||
| ea02c36ea6 | |||
| 2c8cb21206 | |||
| c0cb7f5ead | |||
| 83c312196c | |||
| 66206fa521 | |||
| ee62a3add8 | |||
| 3c749969b4 | |||
| 277cb617da | |||
| dd9ae46c04 | |||
| 3d8e270df4 | |||
| 8db1a252d7 | |||
| 3e85cb67bc | |||
| 40a0849151 | |||
| 442ca0e1e2 | |||
| 3f2eb7d48b | |||
| c9d4d3dbf4 | |||
| f6bc8a83c3 | |||
| a09927f6fd | |||
| 16b709d1da | |||
| a16a8fb9f4 | |||
| bcb762df77 | |||
| ffccfa26e9 |
@@ -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** | latest(WebSocket 服务器) |
|
||||
| **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 Hash,Key 为 "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
|
||||
@@ -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
|
||||
'''
|
||||
@@ -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
|
||||
'''
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/reverb (REVERB) - v1
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- laravel-echo (ECHO) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
# Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
|
||||
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -1,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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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`(毛玻璃弹窗)
|
||||
@@ -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>
|
||||
@@ -264,7 +264,7 @@ class AiHeartbeatCommand extends Command
|
||||
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
||||
|
||||
$this->broadcastSystemMessage('签到播报', $content, '#0f766e');
|
||||
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,6 +143,21 @@ enum CurrencySource: string
|
||||
/** 查看别人隐藏信息扣费 */
|
||||
case USER_INFO_REVEAL = 'user_info_reveal';
|
||||
|
||||
/** 购买消息装扮消耗(气泡,扣除金币) */
|
||||
case MSG_BUBBLE_BUY = 'msg_bubble_buy';
|
||||
|
||||
/** 购买昵称颜色装扮消耗(扣除金币) */
|
||||
case MSG_NAME_COLOR_BUY = 'msg_name_color_buy';
|
||||
|
||||
/** 购买文字颜色装扮消耗(扣除金币) */
|
||||
case MSG_TEXT_COLOR_BUY = 'msg_text_color_buy';
|
||||
|
||||
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币)—— 旧版兼容,新购买不再使用 */
|
||||
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||
|
||||
/** 购买头像框消耗(扣除金币) */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -190,6 +205,11 @@ enum CurrencySource: string
|
||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||
self::VIDEO_REWARD => '看视频奖励',
|
||||
self::USER_INFO_REVEAL => '信息查看付费',
|
||||
self::MSG_BUBBLE_BUY => '消息气泡购买',
|
||||
self::MSG_NAME_COLOR_BUY => '昵称颜色购买',
|
||||
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
|
||||
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ class MessageSent implements ShouldBroadcastNow
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
* 创建消息广播事件实例。
|
||||
*
|
||||
* @param int $roomId 房间ID
|
||||
* @param int $roomId 房间 ID
|
||||
* @param array $message 发送的消息数据
|
||||
*/
|
||||
public function __construct(
|
||||
@@ -39,8 +39,8 @@ class MessageSent implements ShouldBroadcastNow
|
||||
/**
|
||||
* 获取消息应广播到的频道。
|
||||
*
|
||||
* 公共消息走房间 Presence 频道;
|
||||
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
|
||||
* 公共消息和普通定向发言走房间 Presence 频道;
|
||||
* 悄悄话只发给发送方与接收方的私有用户频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
@@ -78,9 +78,7 @@ class MessageSent implements ShouldBroadcastNow
|
||||
*/
|
||||
private function shouldBroadcastPrivately(): bool
|
||||
{
|
||||
$toUser = trim((string) ($this->message['to_user'] ?? ''));
|
||||
|
||||
return $toUser !== '' && $toUser !== '大家';
|
||||
return (bool) ($this->message['is_secret'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,7 @@ class ShopItemController extends Controller
|
||||
'icon' => 'required|string|max:20',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'price' => 'required|integer|min:0',
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,'.ShopItem::TYPE_SIGN_REPAIR,
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
|
||||
'duration_days' => 'nullable|integer|min:0',
|
||||
'duration_minutes' => 'nullable|integer|min:0',
|
||||
'intimacy_bonus' => 'nullable|integer|min:0',
|
||||
|
||||
@@ -85,19 +85,22 @@ class ChatBotController extends Controller
|
||||
|
||||
$reply = $result['reply'];
|
||||
|
||||
// 检查 AI 是否决定给用户发金币
|
||||
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
|
||||
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
|
||||
// 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额])
|
||||
if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
|
||||
$aiGoldAmount = (int) $matches[1];
|
||||
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
|
||||
$reply = trim($reply);
|
||||
|
||||
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
||||
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
|
||||
// 校验 AI 给出的金额在合理范围内
|
||||
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
|
||||
|
||||
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
|
||||
$dailyCount = (int) Redis::get($redisKey);
|
||||
|
||||
if ($dailyCount < $maxDailyRewards) {
|
||||
$goldAmount = rand(100, $maxGold);
|
||||
|
||||
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
|
||||
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
|
||||
|
||||
@@ -118,13 +118,20 @@ class ChatController extends Controller
|
||||
$newbieEffect = null;
|
||||
$initialPresenceTheme = null;
|
||||
$initialWelcomeMessage = null;
|
||||
$initialWelcomeMessages = [];
|
||||
|
||||
if (! $isAlreadyInRoom) {
|
||||
// 广播 UserJoined 事件,通知房间内的其他人
|
||||
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
|
||||
|
||||
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
|
||||
if (! $user->has_received_new_gift) {
|
||||
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
||||
// 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
|
||||
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
||||
|
||||
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
|
||||
$user->refresh();
|
||||
$isNewbie = ! $user->has_received_new_gift;
|
||||
if ($isNewbie) {
|
||||
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
|
||||
@@ -142,21 +149,47 @@ class ChatController extends Controller
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'newbie_bonus',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($id, $newbieMsg);
|
||||
broadcast(new MessageSent($id, $newbieMsg));
|
||||
SaveMessageJob::dispatch($newbieMsg);
|
||||
$initialWelcomeMessages[] = $newbieMsg;
|
||||
|
||||
// 广播烟花特效给此时已在房间的其他用户
|
||||
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
|
||||
|
||||
// 传给前端,让新人自己的屏幕上也燃放烟花
|
||||
$newbieEffect = 'fireworks';
|
||||
}
|
||||
|
||||
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
|
||||
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
||||
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
||||
// AI小班长发送欢迎消息,附带聊天室简单介绍
|
||||
$aiWelcomeTemplates = [
|
||||
"🫡 呀!新战友【{$user->username}】来了!我是本聊天室的 AI小班长——女兵班长在此!咱们这儿能聊天交友、玩百家乐/赛马/钓鱼/五子棋,还能每日签到领金币、逛商店买道具。有啥不懂的随时问我,班长罩着你!",
|
||||
"✨ 欢迎新兵【{$user->username}】入伍!我是 AI小班长,你的贴心兵姐姐~ 本聊天室可以群聊私聊、玩各种小游戏(百家乐/赛马/钓鱼/五子棋)、每日签到赚金币。新人有 6666 金币见面礼哦!有问题喊班长!",
|
||||
"🐻 立正!欢迎【{$user->username}】同志加入!我是这里的 AI小班长,负责带新兵熟悉环境。本聊天室可聊天交友、玩游戏(百家乐/赛马/钓鱼/五子棋/老虎机)、签到领福利、商店买道具。别客气,把这儿当家!",
|
||||
"💪 热烈欢迎【{$user->username}】!我是 AI小班长,你的战友兼客服兵姐姐。这儿可以聊天、玩游戏(百家乐/赛马/钓鱼)、签到打卡、逛商店,功能多多!有不懂的随时 @我,班长在线答疑!",
|
||||
];
|
||||
$aiWelcomeContent = $aiWelcomeTemplates[array_rand($aiWelcomeTemplates)];
|
||||
|
||||
$aiWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => 'AI小班长',
|
||||
'to_user' => '大家',
|
||||
'content' => $aiWelcomeContent,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'ai_newbie_welcome',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($id, $aiWelcomeMsg);
|
||||
broadcast(new MessageSent($id, $aiWelcomeMsg));
|
||||
SaveMessageJob::dispatch($aiWelcomeMsg);
|
||||
$initialWelcomeMessages[] = $aiWelcomeMsg;
|
||||
}
|
||||
|
||||
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||
@@ -172,6 +205,7 @@ class ChatController extends Controller
|
||||
'font_color' => $color,
|
||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'entry_broadcast',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -183,6 +217,7 @@ class ChatController extends Controller
|
||||
|
||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||
$initialWelcomeMessages[] = $generalWelcomeMsg;
|
||||
|
||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||
@@ -213,8 +248,8 @@ class ChatController extends Controller
|
||||
return $fromUser === $username || $toUser === $username;
|
||||
}
|
||||
|
||||
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
|
||||
return $fromUser === $username || $toUser === $username;
|
||||
// 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
|
||||
return true;
|
||||
}));
|
||||
|
||||
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
|
||||
@@ -281,6 +316,7 @@ class ChatController extends Controller
|
||||
'newbieEffect' => $newbieEffect,
|
||||
'initialPresenceTheme' => $initialPresenceTheme,
|
||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||
'initialWelcomeMessages' => $initialWelcomeMessages,
|
||||
'historyMessages' => $historyMessages,
|
||||
'pendingProposal' => $pendingProposalData,
|
||||
'pendingDivorce' => $pendingDivorceData,
|
||||
@@ -413,6 +449,10 @@ class ChatController extends Controller
|
||||
$messageData = array_merge($messageData, $imagePayload);
|
||||
}
|
||||
|
||||
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
|
||||
$messageData = array_merge($messageData, $decorations);
|
||||
|
||||
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
|
||||
$this->chatState->pushMessage($id, $messageData);
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ class DailySignInController extends Controller
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '签到播报',
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
|
||||
'is_secret' => false,
|
||||
|
||||
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
|
||||
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈第一页数据(JSON API)
|
||||
* 供聊天室模态弹窗使用,格式与 loadMore 一致
|
||||
*
|
||||
* @param Request $request 含 type 筛选参数
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->input('type'); // bug|suggestion|null(全部)
|
||||
|
||||
$query = FeedbackItem::with(['replies'])
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($type && in_array($type, ['bug', 'suggestion'])) {
|
||||
$query->ofType($type);
|
||||
}
|
||||
|
||||
$items = $query->limit(self::PAGE_SIZE)->get();
|
||||
|
||||
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
|
||||
->whereIn('feedback_id', $items->pluck('id'))
|
||||
->pluck('feedback_id')
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->formatItems($items, $myVotedIds),
|
||||
'last_id' => $items->last()?->id ?? 0,
|
||||
'has_more' => $items->count() === self::PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载更多反馈(JSON API)
|
||||
* 支持按类型筛选(bug / suggestion)
|
||||
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
|
||||
*/
|
||||
private function formatItem(FeedbackItem $item, bool $voted): array
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
$isOwner = $item->user_id === $user->id;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'type' => $item->type,
|
||||
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
|
||||
'username' => $item->username,
|
||||
'created_at' => $item->created_at->diffForHumans(),
|
||||
'voted' => $voted,
|
||||
'is_owner' => $isOwner,
|
||||
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
|
||||
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
|
||||
'id' => $r->id,
|
||||
'username' => $r->username,
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
|
||||
use App\Models\Guestbook;
|
||||
use App\Models\User;
|
||||
use App\Services\MessageFilterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
|
||||
|
||||
return back()->with('success', '该行留言已被抹除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$tab = $request->input('tab', 'public');
|
||||
$page = (int) $request->input('page', 1);
|
||||
$user = Auth::user();
|
||||
|
||||
$query = Guestbook::query()->orderByDesc('id');
|
||||
|
||||
if ($tab === 'inbox') {
|
||||
$query->where('towho', $user->username);
|
||||
} elseif ($tab === 'outbox') {
|
||||
$query->where('who', $user->username);
|
||||
} else {
|
||||
$query->where(function ($q) use ($user) {
|
||||
$q->where('secret', 0)
|
||||
->orWhere('who', $user->username)
|
||||
->orWhere('towho', $user->username);
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = 15;
|
||||
$total = $query->count();
|
||||
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
|
||||
|
||||
$items = $messages->map(function ($msg) use ($user) {
|
||||
$isSecret = (bool) $msg->secret;
|
||||
$isToMe = $msg->towho === $user->username;
|
||||
$isFromMe = $msg->who === $user->username;
|
||||
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
|
||||
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'who' => $msg->who,
|
||||
'towho' => $msg->towho ?: '',
|
||||
'secret' => $isSecret,
|
||||
'text_body' => $msg->text_body,
|
||||
'post_time' => $msg->post_time?->diffForHumans() ?? '',
|
||||
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
|
||||
'is_to_me' => $isToMe,
|
||||
'is_from_me' => $isFromMe,
|
||||
'can_delete' => $canDelete,
|
||||
'who_avatar' => mb_substr($msg->who, 0, 1),
|
||||
];
|
||||
});
|
||||
|
||||
// 获取所有用户名列表(供发信选择器使用)
|
||||
$users = User::where('username', '!=', $user->username)
|
||||
->orderBy('username')
|
||||
->pluck('username');
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'has_more' => ($page * $perPage) < $total,
|
||||
'users' => $users,
|
||||
'tab' => $tab,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\DecorationService;
|
||||
use App\Services\ShopService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -73,6 +75,8 @@ class ShopController extends Controller
|
||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
||||
'sign_repair_card_item' => $signRepairCard,
|
||||
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
|
||||
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,6 +125,27 @@ class ShopController extends Controller
|
||||
'total_price' => $result['total_price'] ?? $item->price,
|
||||
];
|
||||
|
||||
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
|
||||
if ($item->type === 'auto_fishing') {
|
||||
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
}
|
||||
|
||||
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||
if (! empty($result['slot'])) {
|
||||
$response['slot'] = $result['slot'];
|
||||
$response['style'] = $result['style'];
|
||||
$response['expires_at'] = $result['expires_at'];
|
||||
|
||||
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
|
||||
$freshUser = $user->fresh();
|
||||
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
|
||||
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
|
||||
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
|
||||
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
@@ -205,6 +230,7 @@ class ShopController extends Controller
|
||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!",
|
||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||
};
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
*/
|
||||
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
|
||||
{
|
||||
$diceStr = "《{$round->dice1}》《{$round->dice2}》《{$round->dice3}》";
|
||||
$diceStr = "[{$round->dice1}][{$round->dice2}][{$round->dice3}]";
|
||||
|
||||
$resultText = match ($round->result) {
|
||||
'big' => "🔵 大({$round->total_points} 点)",
|
||||
|
||||
@@ -213,7 +213,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
||||
'to_user' => $username,
|
||||
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
|
||||
'is_secret' => true,
|
||||
'font_color' => '#f59e0b',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
'toast_notification' => [
|
||||
@@ -252,7 +252,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#f59e0b',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -88,7 +88,7 @@ class OpenHorseRaceJob implements ShouldQueue
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#f59e0b',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => $now->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -78,7 +78,7 @@ class RunHorseRaceJob implements ShouldQueue
|
||||
'to_user' => '大家',
|
||||
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#336699',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 类功能:读取和计算系统参数,提供等级经验阈值等全局配置能力。
|
||||
*/
|
||||
class Sysparam extends Model
|
||||
{
|
||||
/** @var string 表名 */
|
||||
@@ -74,7 +77,8 @@ class Sysparam extends Model
|
||||
// 不超过最大等级
|
||||
$maxLevel = (int) static::getValue('maxlevel', '99');
|
||||
|
||||
return min($level, $maxLevel);
|
||||
// 聊天室普通用户最低等级为 1,避免低经验新人被自动存点降成 0 后无法进入默认房间。
|
||||
return max(1, min($level, $maxLevel));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,6 +109,7 @@ class User extends Authenticatable
|
||||
'q3_time' => 'datetime',
|
||||
'has_received_new_gift' => 'boolean',
|
||||
'chat_preferences' => 'array',
|
||||
'active_decorations' => 'array',
|
||||
'daily_status_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class AiChatService
|
||||
// 动态获取由 guide 页面提取出的最新纯文本规则
|
||||
$guideRulesText = $this->getDynamicGuideRules();
|
||||
|
||||
// 动态获取金币福利上限,告知 AI 自行决定金额
|
||||
$maxGold = (int) \App\Models\Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
|
||||
return <<<PROMPT
|
||||
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"。
|
||||
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
|
||||
@@ -106,9 +109,9 @@ class AiChatService
|
||||
$guideRulesText
|
||||
|
||||
【发金币福利特权】
|
||||
每天每个用户只能向你讨要一次金币福利(100-5000枚随机)。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
|
||||
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD]。
|
||||
系统程序看到这个符号后会自动为用户发放随机金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
|
||||
每天每个用户只能向你讨要一次金币福利。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
|
||||
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD:金额](例如:[ACTION:GIVE_GOLD:888])。金额由你根据聊天内容、对方态度和你的心情自行决定,最低 100 枚,最高 {$maxGold} 枚。讨得真诚、聊得投缘的可以多给些,敷衍了事的少给些。
|
||||
系统程序看到这个符号后会自动为用户发放你指定的金币数并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
|
||||
|
||||
【交流要求】
|
||||
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
|
||||
|
||||
@@ -220,8 +220,9 @@ class ChatStateService
|
||||
|
||||
foreach ($messages as $msgJson) {
|
||||
$msg = json_decode($msgJson, true);
|
||||
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
|
||||
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
|
||||
// 只清理普通进出播报,避免误删新人礼包公告和 AI 小班长新人欢迎。
|
||||
$welcomeKind = $msg['welcome_kind'] ?? 'entry_broadcast';
|
||||
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username && $welcomeKind === 'entry_broadcast') {
|
||||
continue;
|
||||
}
|
||||
$filtered[] = $msgJson;
|
||||
|
||||
@@ -64,6 +64,15 @@ class ChatUserPresenceService
|
||||
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
|
||||
}
|
||||
|
||||
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
|
||||
if (! empty($decorations['avatar_frame'])) {
|
||||
$payload['avatar_frame'] = $decorations['avatar_frame'];
|
||||
}
|
||||
if (! empty($decorations['name_color'])) {
|
||||
$payload['name_color'] = $decorations['name_color'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户个人装扮统一管理服务
|
||||
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
|
||||
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
|
||||
*
|
||||
* 当前支持的装扮槽位(存储在 active_decorations 的 key):
|
||||
* - bubble : 消息气泡边框样式
|
||||
* - name_color : 昵称颜色效果
|
||||
* - avatar_frame: 头像装饰边框
|
||||
* - text_color : 消息文字颜色特效
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPurchase;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
|
||||
*/
|
||||
class DecorationService
|
||||
{
|
||||
/**
|
||||
* 商店商品 type → 装扮槽位映射。
|
||||
* 以后新增装扮类型,在此加一行即可。
|
||||
*/
|
||||
private const TYPE_TO_SLOT = [
|
||||
'msg_bubble' => 'bubble',
|
||||
'msg_name_color' => 'name_color',
|
||||
'avatar_frame' => 'avatar_frame',
|
||||
'msg_text_color' => 'text_color',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param UserCurrencyService $currencyService 统一积分变更服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||
*
|
||||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||
* 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param ShopItem $item 装扮商品
|
||||
* @param int $quantity 购买份数
|
||||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
|
||||
{
|
||||
// 根据商品类型映射到对应槽位
|
||||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||
if (! $slot) {
|
||||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||
}
|
||||
|
||||
$totalPrice = $item->price * $quantity;
|
||||
|
||||
// 校验金币余额
|
||||
if ($user->jjb < $totalPrice) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
// 计算过期时间(至少 1 天)
|
||||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||
$totalDays = $days * $quantity;
|
||||
|
||||
// 检查同一槽位是否已激活相同样式 → 叠加天数
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$isSameActive = ! empty($decorations[$slot])
|
||||
&& ($decorations[$slot]['style'] ?? '') === $item->slug;
|
||||
|
||||
if ($isSameActive) {
|
||||
// 在现有到期时间上追加天数
|
||||
$existingExpires = Carbon::parse($decorations[$slot]['expires_at']);
|
||||
$expiresAt = $existingExpires->copy()->addDays($totalDays);
|
||||
} else {
|
||||
$expiresAt = Carbon::now()->addDays($totalDays);
|
||||
}
|
||||
|
||||
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
|
||||
$source = match ($item->type) {
|
||||
'msg_bubble' => CurrencySource::MSG_BUBBLE_BUY,
|
||||
'msg_name_color' => CurrencySource::MSG_NAME_COLOR_BUY,
|
||||
'msg_text_color' => CurrencySource::MSG_TEXT_COLOR_BUY,
|
||||
'avatar_frame' => CurrencySource::AVATAR_FRAME_BUY,
|
||||
default => CurrencySource::MSG_DECORATION_BUY,
|
||||
};
|
||||
|
||||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
|
||||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$totalPrice, $source,
|
||||
"购买装扮:{$item->name}({$totalDays}天)"
|
||||
);
|
||||
|
||||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $totalPrice,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加)
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$decorations[$slot] = [
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
$user->active_decorations = $decorations;
|
||||
$user->save();
|
||||
});
|
||||
|
||||
// 重新读取最新余额,避免缓存脏数据
|
||||
$balanceAfter = (int) $user->fresh()->jjb;
|
||||
|
||||
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
|
||||
$displayDays = $isSameActive
|
||||
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
|
||||
: $totalDays;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
|
||||
'balance_after' => $balanceAfter,
|
||||
'slot' => $slot,
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
|
||||
*
|
||||
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
|
||||
* 如果清理了过期数据会自动写回 users.active_decorations。
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
|
||||
*/
|
||||
public function getActiveDecorations(User $user): array
|
||||
{
|
||||
$decorations = $user->active_decorations ?? [];
|
||||
|
||||
// 非数组(null 或格式异常)直接返回空
|
||||
if (! is_array($decorations)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$changed = false;
|
||||
|
||||
foreach ($decorations as $slot => $data) {
|
||||
// 格式校验:必须包含 expires_at 字段
|
||||
if (! is_array($data) || empty($data['expires_at'])) {
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析过期时间并与当前时间比较
|
||||
try {
|
||||
$expiresAt = Carbon::parse($data['expires_at']);
|
||||
if ($expiresAt->isPast()) {
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 时间格式异常也视为无效,清理之
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 有过期项被清理时写回数据库
|
||||
if ($changed) {
|
||||
$user->active_decorations = $decorations;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return $decorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息广播 payload 需要携带的装扮字段。
|
||||
*
|
||||
* 消息广播需要气泡样式(msg_bubble)、昵称颜色(msg_name_color)、文字颜色特效(msg_text_color)
|
||||
* 以及头像框(avatar_frame),前端据此渲染发送者头像的装饰边框。
|
||||
*
|
||||
* @param User $user 消息发送者
|
||||
* @return array{msg_bubble?:string, msg_name_color?:string, msg_text_color?:string, avatar_frame?:string}
|
||||
*/
|
||||
public function getDecorationsForMessage(User $user): array
|
||||
{
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$result = [];
|
||||
|
||||
if (! empty($decorations['bubble']['style'])) {
|
||||
$result['msg_bubble'] = $decorations['bubble']['style'];
|
||||
}
|
||||
if (! empty($decorations['name_color']['style'])) {
|
||||
$result['msg_name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
if (! empty($decorations['text_color']['style'])) {
|
||||
$result['msg_text_color'] = $decorations['text_color']['style'];
|
||||
}
|
||||
if (! empty($decorations['avatar_frame']['style'])) {
|
||||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户 Presence 载荷需要携带的装扮字段。
|
||||
*
|
||||
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
|
||||
* 气泡样式仅作用于消息,不需要在用户列表中展示。
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @return array{avatar_frame?:string, name_color?:string}
|
||||
*/
|
||||
public function getDecorationsForPresence(User $user): array
|
||||
{
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$result = [];
|
||||
|
||||
if (! empty($decorations['avatar_frame']['style'])) {
|
||||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||
}
|
||||
if (! empty($decorations['name_color']['style'])) {
|
||||
$result['name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShopService
|
||||
{
|
||||
/**
|
||||
* @param DecorationService $decorationService 装扮服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DecorationService $decorationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买商品入口:扣金币、按类型分发处理
|
||||
*
|
||||
@@ -41,6 +48,11 @@ class ShopService
|
||||
'ring' => $this->buyRing($user, $item),
|
||||
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
|
||||
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
|
||||
// ── 个人装扮购买(委托给 DecorationService)───────────────
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'msg_text_color' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
default => ['ok' => false, 'message' => '未知商品类型'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户表增加 active_decorations JSON 列
|
||||
* 用于存储用户当前激活的个人装扮信息(气泡/昵称颜色/头像框)及过期时间。
|
||||
*
|
||||
* JSON 结构示例:
|
||||
* {
|
||||
* "bubble": {"style": "msg_bubble_golden", "expires_at": "2026-05-04T12:00:00+08:00"},
|
||||
* "name_color": {"style": "msg_name_rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
|
||||
* "avatar_frame":{"style": "avatar_frame_dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
|
||||
* }
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 新增 active_decorations 列(可空 JSON)。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('active_decorations')->nullable()
|
||||
->comment('用户当前激活的装扮:bubble 气泡 / name_color 昵称颜色 / avatar_frame 头像框,含过期时间');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除 active_decorations 列。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('active_decorations');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为店铺商品类型加入个人装扮(消息气泡、昵称颜色、头像框)。
|
||||
*
|
||||
* 支持用户在商店购买有期限的个人装扮道具,购买后自动激活并在聊天和用户列表中展示。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 扩展 shop_items.type ENUM,加入三种装扮类型。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:移除装扮类型值,将已有装扮商品标记为 one_time。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
// 将已有的装扮商品类型回退为 one_time,避免 ENUM 收缩报错
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` IN ('msg_bubble','msg_name_color','avatar_frame')");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为店铺商品类型加入消息文字颜色特效装扮(msg_text_color)。
|
||||
*
|
||||
* 用户在商店购买文字颜色特效后,消息正文呈现动态色彩效果(七彩、流光、霓虹等)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 扩展 shop_items.type ENUM,加入 msg_text_color 类型。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:移除 msg_text_color,将已有该类型商品标记为 one_time。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'msg_text_color'");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,9 @@ namespace Database\Seeders;
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* 类功能:初始化聊天室商店的特效道具、功能卡与个人装扮商品。
|
||||
*/
|
||||
class ShopItemSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
@@ -86,10 +89,73 @@ class ShopItemSeeder extends Seeder
|
||||
['name' => '改名卡', 'slug' => 'rename_card', 'icon' => '🎭',
|
||||
'description' => '使用后可修改一次昵称(旧名保留30天黑名单,不可被他人注册)。注意:历史聊天记录中的旧名不会更改。',
|
||||
'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 30],
|
||||
|
||||
// ── 消息气泡装扮 ──────────────────────────
|
||||
['name' => '鎏金流光气泡', 'slug' => 'msg_bubble_golden', 'icon' => '🟡',
|
||||
'description' => '浅金底纹、左侧金线和流光扫过,发言更醒目但不刺眼。',
|
||||
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 50],
|
||||
['name' => '樱语花笺气泡', 'slug' => 'msg_bubble_sakura', 'icon' => '🌸',
|
||||
'description' => '粉白信笺底色配花点纹理,适合温柔、浪漫的发言氛围。',
|
||||
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 51],
|
||||
['name' => '星河微光气泡', 'slug' => 'msg_bubble_star', 'icon' => '🌌',
|
||||
'description' => '淡蓝星河底纹和微光星点,保留清爽阅读感。',
|
||||
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 52],
|
||||
['name' => '霓虹彩带气泡', 'slug' => 'msg_bubble_rainbow', 'icon' => '🌈',
|
||||
'description' => '顶部流动彩带和浅色底框,发言有动态高光。',
|
||||
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 53],
|
||||
['name' => '皇冠礼赞气泡', 'slug' => 'msg_bubble_crown', 'icon' => '👑',
|
||||
'description' => '金色礼赞底纹、皇冠角标和侧边金线,突出尊贵发言。',
|
||||
'price' => 5000, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 54],
|
||||
|
||||
// ── 昵称颜色装扮 ──────────────────────────
|
||||
['name' => '金色昵称', 'slug' => 'msg_name_golden', 'icon' => '🥇',
|
||||
'description' => '让你的昵称闪耀金光。',
|
||||
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 60],
|
||||
['name' => '渐变昵称', 'slug' => 'msg_name_rainbow', 'icon' => '🎨',
|
||||
'description' => '彩虹渐变色昵称,五彩斑斓。',
|
||||
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 61],
|
||||
['name' => '发光昵称', 'slug' => 'msg_name_glow', 'icon' => '✨',
|
||||
'description' => '昵称带柔和发光效果,暗夜中最亮的星。',
|
||||
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 62],
|
||||
['name' => '火焰昵称', 'slug' => 'msg_name_flame', 'icon' => '🔥',
|
||||
'description' => '火焰色脉动昵称,热情似火。',
|
||||
'price' => 3000, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 63],
|
||||
|
||||
// ── 消息文字颜色特效装扮 ─────────────────
|
||||
['name' => '七彩文字', 'slug' => 'msg_text_rainbow', 'icon' => '🌈',
|
||||
'description' => '文字色彩在彩虹七色间平滑流动,发言自带虹光特效。',
|
||||
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 65],
|
||||
['name' => '流光文字', 'slug' => 'msg_text_shimmer', 'icon' => '💫',
|
||||
'description' => '一道流光从左到右扫过文字表面,如金属般闪亮。',
|
||||
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 66],
|
||||
['name' => '霓虹文字', 'slug' => 'msg_text_neon', 'icon' => '💜',
|
||||
'description' => '紫蓝霓虹光晕在文字周围脉动呼吸,像灯牌一样醒目。',
|
||||
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 67],
|
||||
['name' => '火焰文字', 'slug' => 'msg_text_flame', 'icon' => '🔥',
|
||||
'description' => '橙红烈火在文字中上下跃动,热情燃烧般的视觉冲击。',
|
||||
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 68],
|
||||
['name' => '冰蓝文字', 'slug' => 'msg_text_ice', 'icon' => '❄️',
|
||||
'description' => '冰蓝晶光在文字表面流转,如同冰晶折射出的冷艳光泽。',
|
||||
'price' => 8000, 'type' => 'msg_text_color', 'duration_days' => 1, 'sort_order' => 69],
|
||||
|
||||
// ── 头像框装扮 ────────────────────────────
|
||||
['name' => '月银守护头像框', 'slug' => 'avatar_frame_silver', 'icon' => '🥈',
|
||||
'description' => '银白金属光泽外框,低调但比普通头像更精致。',
|
||||
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 70],
|
||||
['name' => '金辉勋章头像框', 'slug' => 'avatar_frame_gold', 'icon' => '🥇',
|
||||
'description' => '金色勋章质感外框,带柔和光晕。',
|
||||
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 71],
|
||||
['name' => '星轨环绕头像框', 'slug' => 'avatar_frame_star', 'icon' => '⭐',
|
||||
'description' => '星轨渐变环绕头像旋转,适合高调展示。',
|
||||
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 72],
|
||||
['name' => '龙焰御守头像框', 'slug' => 'avatar_frame_dragon', 'icon' => '🐉',
|
||||
'description' => '红金御守质感外框,带虚线纹理和强烈光晕。',
|
||||
'price' => 8888, 'type' => 'avatar_frame', 'duration_days' => 1, 'sort_order' => 73],
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
ShopItem::firstOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]);
|
||||
// 商品名和描述可能随视觉样式迭代,Seeder 重跑时需要同步更新展示文案。
|
||||
ShopItem::updateOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# 第一阶段:消息装扮 & 头像框消费系统 — 实施方案
|
||||
|
||||
> 目标:为聊天室新增持续性的金币消费出口,通过「消息装扮」和「头像框」两个高社交可见度的功能,让金币有处可花、花得有面子。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能范围
|
||||
|
||||
### 1.1 消息气泡样式(msg_bubble)
|
||||
用户购买后,发出的消息气泡带特殊边框/背景样式,房间内所有人可见。
|
||||
|
||||
| 商品 | 价格 | 时长 | 效果描述 |
|
||||
|------|------|------|----------|
|
||||
| 金色气泡 | 300 金币 | 1 天 | 消息框带金色渐变边框 + 微光 |
|
||||
| 樱花气泡 | 500 金币 | 3 天 | 粉白边框 + 飘落花瓣 |
|
||||
| 星际气泡 | 800 金币 | 7 天 | 深蓝渐变边框 + 星光 |
|
||||
| 彩虹气泡 | 1500 金币 | 7 天 | 流动彩虹边框 |
|
||||
| 皇冠气泡 | 3000 金币 | 30 天 | 皇家风格金边 + 皇冠徽标 |
|
||||
|
||||
### 1.2 昵称颜色效果(msg_name_color)
|
||||
购买后,自己的昵称在聊天消息和用户列表中显示为特殊颜色。
|
||||
|
||||
| 商品 | 价格 | 时长 | 效果描述 |
|
||||
|------|------|------|----------|
|
||||
| 金色昵称 | 200 金币 | 1 天 | 昵称文字变为金色 #fbbf24 |
|
||||
| 渐变色昵称 | 500 金币 | 3 天 | 彩虹渐变色(CSS gradient text) |
|
||||
| 发光昵称 | 800 金币 | 7 天 | 文字发光效果(text-shadow glow) |
|
||||
| 火焰昵称 | 1500 金币 | 7 天 | 红橙火焰色 + 脉动动画 |
|
||||
|
||||
### 1.3 头像框(avatar_frame)
|
||||
购买后,用户头像外围显示装饰边框,在用户列表中展示。
|
||||
|
||||
| 商品 | 价格 | 时长 | 效果描述 |
|
||||
|------|------|------|----------|
|
||||
| 银色边框 | 500 金币 | 7 天 | 银色圆环边框 |
|
||||
| 金色边框 | 1000 金币 | 7 天 | 金色圆环边框 |
|
||||
| 星光边框 | 2000 金币 | 14 天 | 星光闪烁旋转边框 |
|
||||
| 龙纹边框 | 5000 金币 | 30 天 | 龙纹缠绕高级边框 |
|
||||
|
||||
---
|
||||
|
||||
## 二、架构设计
|
||||
|
||||
### 2.1 整体思路
|
||||
|
||||
遵循项目现有的 `ShopService → UserCurrencyService` 消费模式,最小化架构改动:
|
||||
|
||||
```
|
||||
用户点击购买 → ShopController::buy()
|
||||
→ ShopService::buyItem() [新增 msg_bubble / msg_name_color / avatar_frame 分支]
|
||||
→ DB::transaction:
|
||||
① UserCurrencyService::change() 扣金币 + 写流水
|
||||
② UserPurchase::create() 写购买记录
|
||||
③ 更新 users.active_decorations JSON 字段
|
||||
→ 返回购买结果 + 新余额
|
||||
|
||||
发送消息时 → ChatController::send()
|
||||
→ 读取 users.active_decorations
|
||||
→ 写入 messageData 广播 payload
|
||||
|
||||
用户列表 → ChatUserPresenceService::build()
|
||||
→ 读取 users.active_decorations.avatar_frame
|
||||
→ 写入 presence payload
|
||||
```
|
||||
|
||||
### 2.2 数据存储方案
|
||||
|
||||
**不使用新表**,在现有两张表上扩展:
|
||||
|
||||
**`users` 表** — 新增 1 个字段用于缓存当前激活的装扮(避免每次发消息都 JOIN user_purchases):
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN active_decorations JSON NULL
|
||||
COMMENT '当前激活的装扮,格式: {"bubble":{"style":"golden","expires_at":"..."},"name_color":{...},"avatar_frame":{...}}';
|
||||
```
|
||||
|
||||
示例数据:
|
||||
```json
|
||||
{
|
||||
"bubble": {"style": "golden", "expires_at": "2026-05-04T12:00:00+08:00"},
|
||||
"name_color": {"style": "rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
|
||||
"avatar_frame": {"style": "dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
|
||||
}
|
||||
```
|
||||
|
||||
**`shop_items` 表** — 新增商品行,利用现有的 `type`、`duration_days` 字段(无需改表结构)。
|
||||
|
||||
**`user_purchases` 表** — 利用现有表记录所有购买历史(无需改表结构)。
|
||||
|
||||
### 2.3 装扮过期策略
|
||||
|
||||
**懒过期(Lazy Expiration)**:每次读取 `active_decorations` 时检查过期时间,自动清理。
|
||||
|
||||
在以下时机触发检查和清理:
|
||||
- 用户发送消息时(`ChatController::send()`)
|
||||
- 构建在线用户 payload 时(`ChatUserPresenceService::build()`)
|
||||
- 查询商店数据时(`ShopController::items()`)
|
||||
|
||||
过期清理逻辑在一个统一的 helper 方法中:
|
||||
```php
|
||||
// DecorationService::getActiveDecorations(User $user): array
|
||||
// 读取 users.active_decorations JSON
|
||||
// 过滤掉已过期的条目
|
||||
// 如果有变化,写回 users.active_decorations
|
||||
// 返回干净的激活装扮列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、后端改动清单
|
||||
|
||||
### 3.1 数据库 Migration
|
||||
|
||||
**新文件**:`database/migrations/xxxx_xx_xx_add_active_decorations_to_users_table.php`
|
||||
|
||||
```php
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('active_decorations')->nullable()
|
||||
->comment('当前激活的装扮: bubble/name_color/avatar_frame');
|
||||
});
|
||||
```
|
||||
|
||||
### 3.2 CurrencySource 枚举
|
||||
|
||||
**文件**:`app/Enums/CurrencySource.php` — 新增两个 case:
|
||||
|
||||
```php
|
||||
/** 购买消息装扮(气泡/昵称颜色等) */
|
||||
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||
|
||||
/** 购买头像框 */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
```
|
||||
|
||||
同时在 `label()` 方法中添加对应的中文映射。
|
||||
|
||||
### 3.3 DecorationService(新服务)
|
||||
|
||||
**文件**:`app/Services/DecorationService.php`
|
||||
|
||||
```php
|
||||
class DecorationService
|
||||
{
|
||||
/**
|
||||
* 购买装扮:扣金币 + 写记录 + 更新 active_decorations
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item): array;
|
||||
|
||||
/**
|
||||
* 获取用户当前激活的装扮(自动清理过期项)
|
||||
* 返回: ['bubble' => [...], 'name_color' => [...], 'avatar_frame' => [...]]
|
||||
*/
|
||||
public function getActiveDecorations(User $user): array;
|
||||
|
||||
/**
|
||||
* 判断装扮类型之间是否互斥(同类型新购买覆盖旧的)
|
||||
*/
|
||||
public function isSameCategory(string $typeA, string $typeB): bool;
|
||||
}
|
||||
```
|
||||
|
||||
**`purchase()` 核心逻辑**:
|
||||
1. 计算总价(`$item->price`)
|
||||
2. 检查金币余额
|
||||
3. `DB::transaction`:
|
||||
- 通过 `UserCurrencyService` 扣金币
|
||||
- 创建 `UserPurchase` 记录(status=active, expires_at=now+days)
|
||||
- 读取当前 `active_decorations`,同类型覆盖写入,不同类型合并
|
||||
- `$user->active_decorations = $merged; $user->save();`
|
||||
4. 返回结果
|
||||
|
||||
**`getActiveDecorations()` 核心逻辑**:
|
||||
1. 读取 `$user->active_decorations`(JSON → array)
|
||||
2. 遍历每个 slot,检查 `expires_at` 是否过期
|
||||
3. 如果有过期项被清理,写回数据库
|
||||
4. 返回干净的数组
|
||||
|
||||
### 3.4 ShopService 扩展
|
||||
|
||||
**文件**:`app/Services/ShopService.php`
|
||||
|
||||
在 `buyItem()` 的 `match ($item->type)` 中新增三个分支:
|
||||
|
||||
```php
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item),
|
||||
```
|
||||
|
||||
注入 `DecorationService` 依赖。
|
||||
|
||||
### 3.5 ShopController 扩展
|
||||
|
||||
**文件**:`app/Http/Controllers/ShopController.php`
|
||||
|
||||
在 `items()` 方法的返回数据中增加:
|
||||
|
||||
```php
|
||||
'active_decorations' => $this->decorationService->getActiveDecorations($user),
|
||||
```
|
||||
|
||||
### 3.6 消息广播增强
|
||||
|
||||
**文件**:`app/Http/Controllers/ChatController.php`
|
||||
|
||||
在 `send()` 方法中,构造 `$messageData` 时增加装饰字段:
|
||||
|
||||
```php
|
||||
$decorations = app(DecorationService::class)->getActiveDecorations($user);
|
||||
|
||||
// 在 $messageData 中追加
|
||||
if (!empty($decorations['bubble'])) {
|
||||
$messageData['msg_bubble'] = $decorations['bubble']['style'];
|
||||
}
|
||||
if (!empty($decorations['name_color'])) {
|
||||
$messageData['msg_name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
```
|
||||
|
||||
这样每条消息的 WebSocket 广播 payload 中就带上了装扮信息。
|
||||
|
||||
### 3.7 在线用户展示增强
|
||||
|
||||
**文件**:`app/Services/ChatUserPresenceService.php`
|
||||
|
||||
在 `build()` 方法的 $payload 数组中增加:
|
||||
|
||||
```php
|
||||
$decorations = app(DecorationService::class)->getActiveDecorations($user);
|
||||
if (!empty($decorations['avatar_frame'])) {
|
||||
$payload['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||
}
|
||||
if (!empty($decorations['name_color'])) {
|
||||
$payload['name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
```
|
||||
|
||||
### 3.8 Shop Seeder 扩展
|
||||
|
||||
**文件**:`database/seeders/ShopItemSeeder.php`
|
||||
|
||||
新增装扮商品数据(sort_order 50-80 范围,排在改名卡之后)。
|
||||
|
||||
### 3.9 User Model
|
||||
|
||||
**文件**:`app/Models/User.php`
|
||||
|
||||
在 `$fillable` 和 `$casts` 中增加:
|
||||
|
||||
```php
|
||||
'active_decorations' => 'array', // 已在 $casts 中
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、前端改动清单
|
||||
|
||||
### 4.1 商店面板改造
|
||||
|
||||
**文件**:`resources/js/chat-room/shop-controls.js`
|
||||
|
||||
1. 在 `SHOP_GROUPS` 数组中新增三个分组:
|
||||
```js
|
||||
{ label: "💬 消息气泡", type: "msg_bubble" },
|
||||
{ label: "🎨 昵称颜色", type: "msg_name_color" },
|
||||
{ label: "🖼️ 头像框", type: "avatar_frame" },
|
||||
```
|
||||
|
||||
2. 渲染装扮卡片时显示:
|
||||
- 当前是否已激活同类装扮
|
||||
- 剩余有效时间
|
||||
- 购买/续费按钮
|
||||
|
||||
3. 购买成功后更新本地装扮状态,无需刷新整个列表。
|
||||
|
||||
**文件**:`resources/views/chat/partials/shop-panel.blade.php`
|
||||
|
||||
可能需要微调样式以适配新卡片。
|
||||
|
||||
### 4.2 消息渲染改造
|
||||
|
||||
**文件**:`resources/views/chat/partials/scripts.blade.php`
|
||||
|
||||
在 `appendMessage()` 函数中(约第 2105 行附近),根据消息 payload 中的装扮字段应用样式:
|
||||
|
||||
```javascript
|
||||
// 在构建消息 HTML 时
|
||||
let bubbleClass = '';
|
||||
let nameColorStyle = '';
|
||||
|
||||
if (msg.msg_bubble) {
|
||||
bubbleClass = `msg-bubble--${msg.msg_bubble}`;
|
||||
}
|
||||
if (msg.msg_name_color) {
|
||||
nameColorStyle = `style="color: var(--name-${msg.msg_name_color})"`;
|
||||
}
|
||||
|
||||
// 消息容器加上 bubbleClass
|
||||
// 发送者名字处加上 nameColorStyle
|
||||
```
|
||||
|
||||
**新增 CSS 样式**(可放在 `scripts.blade.php` 的 `<style>` 块中,或独立 CSS 文件):
|
||||
|
||||
```css
|
||||
/* ── 消息气泡样式 ────────────────────── */
|
||||
.msg-bubble--golden { border: 2px solid #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,.4); }
|
||||
.msg-bubble--sakura { border: 2px solid #f9a8d4; background: linear-gradient(135deg, #fce7f3, #fff1f2); }
|
||||
.msg-bubble--star { border: 2px solid #6366f1; background: linear-gradient(135deg, #1e1b4b, #312e81); }
|
||||
.msg-bubble--rainbow { border: 2px solid transparent; background-clip: padding-box;
|
||||
border-image: linear-gradient(90deg, red, orange, yellow, green, blue, purple) 1; }
|
||||
.msg-bubble--crown { border: 3px solid #fbbf24; box-shadow: 0 0 12px rgba(251,191,36,.6); }
|
||||
|
||||
/* ── 昵称颜色 ────────────────────────── */
|
||||
.msg-name--golden { color: #fbbf24 !important; }
|
||||
.msg-name--rainbow { background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.msg-name--glow { color: #e2e8f0; text-shadow: 0 0 6px #6366f1, 0 0 12px #818cf8; }
|
||||
.msg-name--flame { color: #f97316; text-shadow: 0 0 4px #ef4444; animation: name-flame 1.5s infinite; }
|
||||
|
||||
@keyframes name-flame {
|
||||
0%, 100% { text-shadow: 0 0 4px #ef4444; }
|
||||
50% { text-shadow: 0 0 8px #fbbf24, 0 0 12px #ef4444; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 用户列表渲染改造
|
||||
|
||||
**文件**:`resources/views/chat/partials/scripts.blade.php`
|
||||
|
||||
在 `_renderUserListToContainer()` 函数中(约第 1777 行附近),为有头像框的用户增加装饰层:
|
||||
|
||||
```javascript
|
||||
// 构建用户条目 HTML 时
|
||||
let avatarFrameHtml = '';
|
||||
if (user.avatar_frame) {
|
||||
avatarFrameHtml = `<span class="avatar-frame avatar-frame--${user.avatar_frame}"></span>`;
|
||||
}
|
||||
// 将 avatarFrameHtml 放在头像 img 的外层
|
||||
```
|
||||
|
||||
**新增 CSS**:
|
||||
```css
|
||||
/* ── 头像框样式 ──────────────────────── */
|
||||
.avatar-frame-wrapper { position: relative; display: inline-block; }
|
||||
.avatar-frame {
|
||||
position: absolute; top: -3px; left: -3px; right: -3px; bottom: -3px;
|
||||
border-radius: 50%; pointer-events: none; z-index: 5;
|
||||
}
|
||||
.avatar-frame--silver { border: 2px solid #9ca3af; }
|
||||
.avatar-frame--gold { border: 2px solid #fbbf24; box-shadow: 0 0 4px rgba(251,191,36,.5); }
|
||||
.avatar-frame--star { border: 2px solid #fbbf24; animation: frame-spin 3s linear infinite; }
|
||||
.avatar-frame--dragon { border: 2px solid #dc2626; box-shadow: 0 0 6px rgba(220,38,38,.6); }
|
||||
|
||||
@keyframes frame-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 移动端适配
|
||||
|
||||
**文件**:`resources/js/chat-room/mobile-drawer.js`
|
||||
|
||||
移动端用户列表渲染调用 `window._renderUserListToContainer()`,头像框改动自动生效,无需额外修改。
|
||||
|
||||
---
|
||||
|
||||
## 五、任务拆分与实施顺序
|
||||
|
||||
| 序号 | 任务 | 涉及文件 | 预估工作量 |
|
||||
|------|------|----------|-----------|
|
||||
| 1 | 创建 `active_decorations` migration | 1 个新 migration 文件 | 0.5h |
|
||||
| 2 | 新增 `CurrencySource` 枚举值 | `CurrencySource.php` | 0.2h |
|
||||
| 3 | 创建 `DecorationService` | 1 个新 Service 文件 | 2h |
|
||||
| 4 | 扩展 `ShopService::buyItem()` | `ShopService.php` | 0.5h |
|
||||
| 5 | 扩展 `ShopController::items()` | `ShopController.php` | 0.3h |
|
||||
| 6 | 增强消息广播 payload | `ChatController.php` | 0.5h |
|
||||
| 7 | 增强在线用户 presence | `ChatUserPresenceService.php` | 0.3h |
|
||||
| 8 | 扩展 Seeder 添加装扮商品 | `ShopItemSeeder.php` | 0.5h |
|
||||
| 9 | 前端商店面板改造 | `shop-controls.js` + blade | 2h |
|
||||
| 10 | 前端消息渲染改造 | `scripts.blade.php` | 3h |
|
||||
| 11 | 前端用户列表头像框 | `scripts.blade.php` | 1.5h |
|
||||
| 12 | CSS 样式编写 | `scripts.blade.php` 或独立 CSS | 1.5h |
|
||||
| 13 | 联调测试 | — | 2h |
|
||||
|
||||
**建议分两轮交付**:
|
||||
|
||||
**第一轮(任务 1-8,后端先行)**:后端全部完成,可通过 API / 数据库直接验证购买、扣金币、流水记录、过期清理逻辑。
|
||||
|
||||
**第二轮(任务 9-13,前端展示)**:前端商店界面、消息渲染、用户列表展示。
|
||||
|
||||
---
|
||||
|
||||
## 六、价格平衡分析
|
||||
|
||||
以活跃用户的日均金币收入(心跳 + 签到 + 视频 ≈ 3000-15000 金币/天)为基准:
|
||||
|
||||
| 装扮类型 | 最低价 | 日均摊成本 | 占日收入比例 |
|
||||
|----------|--------|-----------|-------------|
|
||||
| 金色气泡(1天) | 300 | 300/天 | 10%-20%(轻度用户) |
|
||||
| 樱花气泡(3天) | 500 | 167/天 | 5%-10% |
|
||||
| 彩虹气泡(7天) | 1500 | 214/天 | 7%-15% |
|
||||
| 金色昵称(1天) | 200 | 200/天 | 7%-13% |
|
||||
| 发光昵称(7天) | 800 | 114/天 | 4%-8% |
|
||||
| 银色头像框(7天) | 500 | 71/天 | 2%-5% |
|
||||
| 龙纹头像框(30天) | 5000 | 167/天 | 5%-10% |
|
||||
|
||||
如果同时购买气泡+昵称+头像框(全部最低档):300 + 200 + 71 ≈ 571 金币/天,约占轻度用户日收入的 20-40%,**合理且有适度压力**。
|
||||
|
||||
---
|
||||
|
||||
## 七、后续扩展预留
|
||||
|
||||
当前设计中已预留扩展空间:
|
||||
|
||||
1. **装扮互斥覆盖**:`DecorationService::purchase()` 中同类型新购买会自动覆盖旧的(旧装扮不退款),这是有意为之的消耗设计。
|
||||
|
||||
2. **新装扮类型**:只需在 `DecorationService` 中增加新的 slot key(如 `text_effect`、`enter_effect`),无需改数据库。
|
||||
|
||||
3. **稀有/限定装扮**:可在 seeder 中添加 `is_active=false` 的商品,通过活动/任务发放。
|
||||
|
||||
4. **装扮赠送**:未来可在 `ShopController::buy()` 中扩展 recipient 参数,允许购买装扮送给他人(复用现有礼物赠送的消息广播模式)。
|
||||
|
||||
---
|
||||
|
||||
## 八、验收标准
|
||||
|
||||
1. ✅ 用户可在商店面板看到装扮分类和商品列表
|
||||
2. ✅ 购买装扮扣金币正确,流水记录 source 为 `msg_decoration_buy` / `avatar_frame_buy`
|
||||
3. ✅ 购买成功后 `users.active_decorations` JSON 字段正确更新
|
||||
4. ✅ 同类型新装扮覆盖旧装扮
|
||||
5. ✅ 过期装扮在下次读取时自动清理
|
||||
6. ✅ 消息广播 payload 包含 `msg_bubble` / `msg_name_color` 字段
|
||||
7. ✅ 消息气泡正确渲染对应样式
|
||||
8. ✅ 昵称颜色正确渲染
|
||||
9. ✅ 用户列表头像正确渲染头像框
|
||||
10. ✅ 移动端展示正常
|
||||
11. ✅ 金币不足时提示错误,不会扣成负数
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
php artisan migrate # 添加 active_decorations 列
|
||||
php artisan db:seed --class=ShopItemSeeder # 导入装扮商品数据
|
||||
npm run build # 重新编译前端资源
|
||||
@@ -144,3 +144,5 @@
|
||||
transform: translateX(140%);
|
||||
}
|
||||
}
|
||||
|
||||
@import './chat-decorations.css';
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/* ========== 消息气泡装扮:在原版逐行消息基础上增加纹理、角标和轻量动效 ========== */
|
||||
.msg-line[class*="msg-bubble--"] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 24px;
|
||||
margin: 4px 0;
|
||||
padding: 5px 12px 5px 14px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(51, 102, 153, .16);
|
||||
background: rgba(255, 255, 255, .72);
|
||||
box-shadow: 0 1px 4px rgba(51, 102, 153, .12);
|
||||
}
|
||||
|
||||
.msg-line[class*="msg-bubble--"]::before,
|
||||
.msg-line[class*="msg-bubble--"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.msg-line[class*="msg-bubble--"] > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.msg-bubble--golden {
|
||||
border-color: rgba(217, 119, 6, .32) !important;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(245, 158, 11, .32) 0 4px, transparent 4px),
|
||||
radial-gradient(circle at 28px 8px, rgba(255, 255, 255, .85), transparent 10px),
|
||||
linear-gradient(135deg, #fff8df 0%, #fffdf5 56%, #fff1c2 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(217, 119, 6, .18);
|
||||
}
|
||||
|
||||
.msg-bubble--golden::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -36px;
|
||||
width: 36px;
|
||||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .72), transparent);
|
||||
animation: msg-bubble-shine 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.msg-bubble--sakura {
|
||||
border-color: rgba(244, 114, 182, .32) !important;
|
||||
background:
|
||||
radial-gradient(circle at 18px 10px, rgba(244, 114, 182, .42) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 44px 20px, rgba(251, 207, 232, .86) 0 3px, transparent 4px),
|
||||
linear-gradient(135deg, #fff7fb 0%, #fff 48%, #ffe4f1 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(244, 114, 182, .14);
|
||||
}
|
||||
|
||||
.msg-bubble--star {
|
||||
border-color: rgba(79, 70, 229, .32) !important;
|
||||
background:
|
||||
radial-gradient(circle at 20px 9px, rgba(255, 255, 255, .9) 0 1px, transparent 2px),
|
||||
radial-gradient(circle at 76px 20px, rgba(99, 102, 241, .36) 0 2px, transparent 3px),
|
||||
linear-gradient(135deg, #eef2ff 0%, #f8fbff 54%, #dbeafe 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .8), 0 2px 10px rgba(79, 70, 229, .16);
|
||||
}
|
||||
|
||||
.msg-bubble--star::before {
|
||||
right: 10px;
|
||||
top: 5px;
|
||||
width: 42px;
|
||||
height: 16px;
|
||||
background: radial-gradient(circle, rgba(67, 56, 202, .42) 0 1px, transparent 2px);
|
||||
background-size: 11px 8px;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.msg-bubble--rainbow {
|
||||
border-color: rgba(59, 130, 246, .22) !important;
|
||||
background:
|
||||
linear-gradient(#ffffffd9, #ffffffd9) padding-box,
|
||||
linear-gradient(120deg, rgba(239, 68, 68, .16), rgba(245, 158, 11, .16), rgba(34, 197, 94, .16), rgba(59, 130, 246, .16), rgba(168, 85, 247, .16)) border-box !important;
|
||||
box-shadow: 0 2px 10px rgba(59, 130, 246, .14);
|
||||
}
|
||||
|
||||
.msg-bubble--rainbow::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);
|
||||
background-size: 180% 100%;
|
||||
animation: msg-bubble-rainbow 4.2s linear infinite;
|
||||
}
|
||||
|
||||
.msg-bubble--crown {
|
||||
border-color: rgba(180, 83, 9, .34) !important;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(180, 83, 9, .24) 0 4px, transparent 4px),
|
||||
radial-gradient(circle at right 12px top 8px, rgba(251, 191, 36, .36), transparent 18px),
|
||||
linear-gradient(135deg, #fff7d6 0%, #fffdfa 46%, #fde68a 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 3px 12px rgba(180, 83, 9, .22);
|
||||
}
|
||||
|
||||
.msg-bubble--crown::after {
|
||||
content: "♛";
|
||||
top: 2px;
|
||||
right: 8px;
|
||||
z-index: 0;
|
||||
color: rgba(180, 83, 9, .26);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@keyframes msg-bubble-shine {
|
||||
0%, 62% { transform: translateX(0); opacity: 0; }
|
||||
72% { opacity: .82; }
|
||||
100% { transform: translateX(280px); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes msg-bubble-rainbow {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: 180% 50%; }
|
||||
}
|
||||
|
||||
/* ========== 昵称颜色 ========== */
|
||||
.msg-name--golden { color: #fbbf24 !important; font-weight: 700; }
|
||||
.msg-name--rainbow {
|
||||
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg-name--glow {
|
||||
color: #e2e8f0 !important;
|
||||
text-shadow: 0 0 6px #818cf8, 0 0 14px #6366f1;
|
||||
}
|
||||
.msg-name--flame {
|
||||
color: #f97316 !important;
|
||||
font-weight: 700;
|
||||
animation: name-flame 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes name-flame {
|
||||
0%, 100% { text-shadow: 0 0 4px #ef4444; }
|
||||
50% { text-shadow: 0 0 10px #fbbf24, 0 0 16px #ef4444; }
|
||||
}
|
||||
|
||||
/* ========== 消息文字颜色特效 ========== */
|
||||
.msg-text--rainbow {
|
||||
background: linear-gradient(90deg,
|
||||
#ef4444, #f97316, #eab308, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
|
||||
background-size: 300% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 600;
|
||||
animation: text-rainbow 3.5s linear infinite;
|
||||
}
|
||||
@keyframes text-rainbow {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 300% 50%; }
|
||||
}
|
||||
|
||||
.msg-text--shimmer {
|
||||
background: linear-gradient(110deg,
|
||||
#6b7280 0%, #9ca3af 18%, #f3f4f6 28%, #d1d5db 36%,
|
||||
#6b7280 52%, #9ca3af 66%, #f9fafb 74%, #6b7280 100%);
|
||||
background-size: 300% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 600;
|
||||
animation: text-shimmer 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes text-shimmer {
|
||||
0% { background-position: 100% 50%; }
|
||||
100% { background-position: -200% 50%; }
|
||||
}
|
||||
|
||||
.msg-text--neon {
|
||||
color: #a855f7 !important;
|
||||
font-weight: 600;
|
||||
text-shadow:
|
||||
0 0 7px #a855f7,
|
||||
0 0 14px #7c3aed,
|
||||
0 0 28px #6366f1,
|
||||
0 0 42px #4f46e5;
|
||||
animation: text-neon 2s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes text-neon {
|
||||
0% { text-shadow: 0 0 7px #a855f7, 0 0 14px #7c3aed, 0 0 28px #6366f1; }
|
||||
100% { text-shadow: 0 0 14px #c084fc, 0 0 28px #a855f7, 0 0 48px #7c3aed, 0 0 64px #6366f1; }
|
||||
}
|
||||
|
||||
.msg-text--flame {
|
||||
background: linear-gradient(180deg, #fef3c7 0%, #f59e0b 28%, #ea580c 60%, #dc2626 100%);
|
||||
background-size: 100% 200%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
animation: text-flame 1.2s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes text-flame {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 0% 100%; }
|
||||
}
|
||||
|
||||
.msg-text--ice {
|
||||
background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 25%, #38bdf8 50%, #bae6fd 75%, #e0f2fe 100%);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 600;
|
||||
animation: text-ice 3s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(56, 189, 248, 0.5));
|
||||
}
|
||||
@keyframes text-ice {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* ========== 头像框 ========== */
|
||||
.avatar-frame-wrapper {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-item .avatar-frame-wrapper .user-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
mix-blend-mode: normal;
|
||||
overflow: hidden;
|
||||
/* clip-path 硬裁剪保证头像在任何情况下都是正圆形,不受外部 border-radius 覆盖影响 */
|
||||
clip-path: circle(50% at 50% 50%);
|
||||
/* 头像置于头像框下方,框边缘可遮挡头像外围;
|
||||
提高特异性覆盖 chat.css 中的 .user-item .user-head */
|
||||
}
|
||||
|
||||
.avatar-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
/* 使用 mask 在 44px 圆上挖出 4px 宽的环形:中心透明(头像可见),边缘渐变色可见 */
|
||||
-webkit-mask-image: radial-gradient(circle closest-side at center, transparent 18px, black 19px);
|
||||
mask-image: radial-gradient(circle closest-side at center, transparent 18px, black 19px);
|
||||
}
|
||||
|
||||
.avatar-frame::before,
|
||||
.avatar-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar-frame--silver {
|
||||
background: conic-gradient(from 25deg, #ffffff, #94a3b8, #e2e8f0, #64748b, #ffffff);
|
||||
box-shadow: 0 0 0 1px rgba(148, 163, 184, .38), 0 2px 8px rgba(100, 116, 139, .24);
|
||||
}
|
||||
|
||||
.avatar-frame--silver::before,
|
||||
.avatar-frame--gold::before,
|
||||
.avatar-frame--star::before,
|
||||
.avatar-frame--dragon::before {
|
||||
inset: 4px;
|
||||
border-radius: 50%;
|
||||
/* 旧方案依靠 ::before 实心圆遮挡渐变背景形成圆环;
|
||||
现改用 mask 挖空中心,::before 不再需要遮挡 */
|
||||
}
|
||||
|
||||
.avatar-frame--gold {
|
||||
background: conic-gradient(from -20deg, #fff7ad, #f59e0b, #fff1a6, #b45309, #fff7ad);
|
||||
box-shadow: 0 0 0 1px rgba(217, 119, 6, .34), 0 0 12px rgba(245, 158, 11, .38);
|
||||
}
|
||||
|
||||
.avatar-frame--star {
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, #ffffff 0 2px, transparent 3px),
|
||||
conic-gradient(from 0deg, #fef08a, #818cf8, #ffffff, #fbbf24, #818cf8, #fef08a);
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, .48);
|
||||
animation: frame-rotate 4s linear infinite;
|
||||
}
|
||||
|
||||
.avatar-frame--star::after {
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 2px, transparent 3px);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.avatar-frame--dragon {
|
||||
background:
|
||||
conic-gradient(from 45deg, #7f1d1d, #f59e0b, #ef4444, #991b1b, #f59e0b, #7f1d1d);
|
||||
box-shadow: 0 0 14px rgba(239, 68, 68, .42), 0 0 0 1px rgba(127, 29, 29, .38);
|
||||
}
|
||||
|
||||
.avatar-frame--dragon::after {
|
||||
inset: 5px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(254, 202, 202, .82);
|
||||
}
|
||||
|
||||
@keyframes frame-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ========== 紧凑版头像框:用于聊天消息中的小头像(16px) ========== */
|
||||
.avatar-frame-wrapper-sm {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
line-height: 0;
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper-sm .avatar-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
-webkit-mask-image: radial-gradient(circle closest-side at center, transparent 8px, black 9px);
|
||||
mask-image: radial-gradient(circle closest-side at center, transparent 8px, black 9px);
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper-sm .avatar-frame::before,
|
||||
.avatar-frame-wrapper-sm .avatar-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper-sm .avatar-frame--silver::before,
|
||||
.avatar-frame-wrapper-sm .avatar-frame--gold::before,
|
||||
.avatar-frame-wrapper-sm .avatar-frame--star::before,
|
||||
.avatar-frame-wrapper-sm .avatar-frame--dragon::before {
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
/* mask 已处理环形 */
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper-sm .avatar-frame--dragon::after {
|
||||
inset: 3px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(254, 202, 202, .82);
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper-sm .avatar-frame--star::after {
|
||||
inset: -1px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 1px, transparent 2px);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper-sm img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
mix-blend-mode: normal;
|
||||
overflow: hidden;
|
||||
clip-path: circle(50% at 50% 50%);
|
||||
/* 头像置于框下方 */
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
/**
|
||||
* 文件功能:聊天室主界面样式表
|
||||
* 复刻原版海军蓝聊天室色系 (CHAT.CSS)
|
||||
* 包含布局、消息窗格、输入工具栏、用户列表、弹窗等所有聊天界面样式
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
原版海军蓝聊天室色系 (CHAT.CSS 复刻)
|
||||
═══════════════════════════════════════════════════ */
|
||||
:root {
|
||||
--bg-main: #EAF2FF;
|
||||
--bg-bar: #b0d8ff;
|
||||
--bg-header: #4488aa;
|
||||
--bg-toolbar: #5599aa;
|
||||
--text-link: #336699;
|
||||
--text-navy: #112233;
|
||||
--text-white: #ffffff;
|
||||
--border-blue: #336699;
|
||||
--scrollbar-base: #b0d8ff;
|
||||
--scrollbar-arrow: #336699;
|
||||
--scrollbar-track: #EAF2FF;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif;
|
||||
font-size: 10pt;
|
||||
background: var(--bg-main);
|
||||
color: var(--text-navy);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局链接样式 */
|
||||
a {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #009900;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 - 模拟原版 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-base);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--scrollbar-arrow);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: var(--scrollbar-base);
|
||||
}
|
||||
|
||||
/* ── 主布局 ─────────────────────────────────────── */
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
/* 防止整体滚动 */
|
||||
}
|
||||
|
||||
/* 左侧主区域 */
|
||||
.chat-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
/* 约束 Flex 子项高度,防止长历史记录撑破 100vh 导致输入栏被挤出屏幕 */
|
||||
}
|
||||
|
||||
/* 竖向工具条 */
|
||||
.chat-toolbar {
|
||||
width: 28px;
|
||||
background: var(--bg-toolbar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 58px;
|
||||
/* 与标题栏+公告栏底部对齐 */
|
||||
padding-bottom: 75px;
|
||||
/* 与输入栏顶部对齐 */
|
||||
border-left: 1px solid var(--border-blue);
|
||||
border-right: 1px solid var(--border-blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-toolbar .tool-btn {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
font-size: 11px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.chat-toolbar .tool-btn:hover {
|
||||
color: #fff;
|
||||
background: #5599cc;
|
||||
}
|
||||
|
||||
/* 右侧用户列表面板 */
|
||||
.chat-right {
|
||||
width: 175px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-blue);
|
||||
background: var(--bg-main);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 标题栏 ─────────────────────────────────────── */
|
||||
.room-title-bar {
|
||||
background: var(--bg-header);
|
||||
color: var(--text-white);
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-blue);
|
||||
}
|
||||
|
||||
.room-title-bar .room-name {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-title-bar .room-desc {
|
||||
color: #cee8ff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 公告/祝福语滚动条(复刻原版 marquee) */
|
||||
.room-announcement-bar {
|
||||
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid var(--border-blue);
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 消息窗格 ───────────────────────────────────── */
|
||||
.message-panes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 5px 8px;
|
||||
background: var(--bg-main);
|
||||
font-size: 10pt;
|
||||
line-height: 170%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-pane.say1 {
|
||||
border-bottom: 2px solid var(--border-blue);
|
||||
}
|
||||
|
||||
.message-pane.say2 {
|
||||
display: block;
|
||||
flex: 0.4;
|
||||
border-top: 2px solid var(--border-blue);
|
||||
}
|
||||
|
||||
.message-pane.say1 {
|
||||
flex: 0.6;
|
||||
}
|
||||
|
||||
|
||||
/* 消息行样式 */
|
||||
.msg-line {
|
||||
margin: 2px 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.msg-line .msg-time {
|
||||
font-size: 9px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.msg-line .msg-user {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.msg-line .msg-user:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.msg-line .msg-action {
|
||||
color: #009900;
|
||||
}
|
||||
|
||||
.msg-line .msg-secret {
|
||||
color: #cc00cc;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg-line .msg-content {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.msg-line.sys-msg {
|
||||
color: #cc0000;
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
/* ── 底部输入工具栏 ─────────────────────────────── */
|
||||
.input-bar {
|
||||
height: auto;
|
||||
min-height: 75px;
|
||||
background: var(--bg-bar);
|
||||
border-top: 2px solid var(--border-blue);
|
||||
padding: 3px 6px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* 欢迎语下拉浮层 */
|
||||
.welcome-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 9000;
|
||||
background: #fff;
|
||||
border: 1px solid #b0c8e0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.welcome-menu-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #224466;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid #eef4fb;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.welcome-menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.welcome-menu-item:hover {
|
||||
background: #ddeeff;
|
||||
color: #003366;
|
||||
}
|
||||
|
||||
|
||||
.input-bar select,
|
||||
.input-bar input[type="text"],
|
||||
.input-bar input[type="checkbox"] {
|
||||
font-size: 12px;
|
||||
border: 1px solid navy;
|
||||
color: #224466;
|
||||
padding: 1px 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-bar select {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.input-bar .input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-bar .say-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
font-size: 12px;
|
||||
border: 1px solid navy;
|
||||
color: #224466;
|
||||
padding: 3px 5px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar .say-input:focus {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 3px rgba(0, 102, 204, 0.3);
|
||||
}
|
||||
|
||||
.input-bar .send-btn {
|
||||
background: var(--bg-header);
|
||||
color: white;
|
||||
border: 1px solid var(--border-blue);
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.input-bar .send-btn:hover {
|
||||
background: #336699;
|
||||
}
|
||||
|
||||
.input-bar .send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-bar label {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ── 右侧用户列表 Tab 栏 ──────────────────────── */
|
||||
.right-tabs {
|
||||
display: flex;
|
||||
background: var(--bg-bar);
|
||||
border-bottom: 2px solid navy;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right-tabs .tab-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
color: navy;
|
||||
background: var(--bg-bar);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.right-tabs .tab-btn.active {
|
||||
background: navy;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.right-tabs .tab-btn:hover:not(.active) {
|
||||
background: #99c8ee;
|
||||
}
|
||||
|
||||
/* 用户列表内容 */
|
||||
.user-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px dotted #cde;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.user-item:hover {
|
||||
background: #d0e8ff;
|
||||
}
|
||||
|
||||
.user-item .user-head {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
background: transparent;
|
||||
mix-blend-mode: multiply;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-item .user-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-link);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-item .user-badge-slot {
|
||||
flex-shrink: 0;
|
||||
max-width: 78px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-item .user-sex {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-item .user-level {
|
||||
font-size: 9px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.user-badge-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-hover-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10030;
|
||||
max-width: min(220px, calc(100vw - 20px));
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #244d77;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(to bottom, #fffef2, #fff6c9);
|
||||
color: #243b53;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 6px 18px rgba(17, 34, 51, 0.24);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-hover-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #fff9d8;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.chat-hover-tooltip[data-side="right"]::after {
|
||||
left: -4px;
|
||||
border-left: 1px solid #244d77;
|
||||
border-bottom: 1px solid #244d77;
|
||||
}
|
||||
|
||||
.chat-hover-tooltip[data-side="left"]::after {
|
||||
right: -4px;
|
||||
border-top: 1px solid #244d77;
|
||||
border-right: 1px solid #244d77;
|
||||
}
|
||||
|
||||
/* 在线人数统计 */
|
||||
.online-stats {
|
||||
background: var(--bg-bar);
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
padding: 3px;
|
||||
border-top: 1px solid var(--border-blue);
|
||||
color: navy;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 用户名片弹窗 ───────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: white;
|
||||
border: 2px solid var(--border-blue);
|
||||
border-radius: 8px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, var(--bg-header), var(--border-blue));
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-body .profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-body .profile-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--bg-bar);
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.modal-body .profile-info h4 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--text-navy);
|
||||
}
|
||||
|
||||
.modal-body .profile-info .level-badge {
|
||||
display: inline-block;
|
||||
background: var(--bg-bar);
|
||||
color: var(--border-blue);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.modal-body .profile-info .sex-badge {
|
||||
font-size: 11px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.modal-body .profile-detail {
|
||||
background: #f5f9ff;
|
||||
border: 1px solid #e0ecff;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-actions button,
|
||||
.modal-actions a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-kick {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border-color: #fcc;
|
||||
}
|
||||
|
||||
.btn-kick:hover {
|
||||
background: #fcc;
|
||||
}
|
||||
|
||||
.btn-mute {
|
||||
background: #fff8e6;
|
||||
color: #b86e00;
|
||||
border-color: #ffe0a0;
|
||||
}
|
||||
|
||||
.btn-mute:hover {
|
||||
background: #ffe0a0;
|
||||
}
|
||||
|
||||
.btn-mail {
|
||||
background: #f0e8ff;
|
||||
color: #6633cc;
|
||||
border-color: #d8c8ff;
|
||||
}
|
||||
|
||||
.btn-mail:hover {
|
||||
background: #d8c8ff;
|
||||
}
|
||||
|
||||
.btn-whisper {
|
||||
background: #e8f5ff;
|
||||
color: var(--text-link);
|
||||
border-color: var(--bg-bar);
|
||||
}
|
||||
|
||||
.btn-whisper:hover {
|
||||
background: var(--bg-bar);
|
||||
}
|
||||
|
||||
/* 禁言输入行 */
|
||||
.mute-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.mute-form input {
|
||||
width: 60px;
|
||||
border: 1px solid #ffe0a0;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mute-form button {
|
||||
background: #b86e00;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 头像选择器 ─────────────────────────────────── */
|
||||
.avatar-option {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 3px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.avatar-option:hover {
|
||||
border-color: #88bbdd;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.avatar-option.selected {
|
||||
border-color: #336699;
|
||||
box-shadow: 0 0 6px rgba(51, 102, 153, 0.5);
|
||||
}
|
||||
|
||||
/* 送花礼物弹跳动画 */
|
||||
@keyframes giftBounce {
|
||||
0% {
|
||||
transform: scale(0.3) rotate(-15deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.3) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(0.9) rotate(-3deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
手机端响应式布局(≤ 640px)
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (max-width: 640px) {
|
||||
|
||||
/* 隐藏中间工具条和右侧面板,让消息区占满全宽 */
|
||||
.chat-toolbar,
|
||||
.chat-right {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 输入框最小宽度适配手机屏 */
|
||||
.input-bar .say-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── 右下角浮动按钮组 ── */
|
||||
#mobile-fabs {
|
||||
position: fixed;
|
||||
top: 35%;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.mobile-fab {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(51, 102, 153, 0.4);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: #336699;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-fab:active {
|
||||
transform: scale(0.92);
|
||||
background: rgba(51, 102, 153, 0.15);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ── 抽屉遮罩 ── */
|
||||
#mobile-drawer-mask {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 600;
|
||||
}
|
||||
|
||||
#mobile-drawer-mask.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── 抽屉通用容器(顶部向下滑入) ── */
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 700;
|
||||
background: #fff;
|
||||
border-bottom: 2px solid var(--border-blue);
|
||||
border-radius: 0 0 14px 14px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-drawer.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 抽屉顶部标题栏 */
|
||||
.mobile-drawer-header {
|
||||
background: var(--bg-header);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-drawer-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ── 工具条抽屉:横向网格排列按钮 ── */
|
||||
#mobile-drawer-toolbar .mobile-drawer-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border-blue);
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#mobile-drawer-toolbar .mobile-tool-btn {
|
||||
background: var(--bg-toolbar);
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
padding: 14px 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
writing-mode: horizontal-tb;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
#mobile-drawer-toolbar .mobile-tool-btn:active {
|
||||
background: #5599cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── 名单/房间抽屉:限定高度,内部 flex 子项才能正确贡献高度 ── */
|
||||
#mobile-drawer-users {
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
#mobile-drawer-users .mobile-right-clone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 抽屉内的 tabs 复用样式 */
|
||||
#mobile-drawer-users .right-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#mobile-drawer-users .user-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#mobile-drawer-users .online-stats {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
|
||||
#mob-online-users-list .user-item {
|
||||
padding: 7px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 现有弹窗(settings / avatar / shop)手机端自适应 ── */
|
||||
/* 防止固定像素宽度的 modal 内容区超出手机屏宽 */
|
||||
#settings-modal > div,
|
||||
#avatar-picker-modal > div {
|
||||
width: 92vw !important;
|
||||
max-width: 420px !important;
|
||||
max-height: 85vh !important;
|
||||
}
|
||||
|
||||
#shop-modal {
|
||||
padding: 12px 6px !important;
|
||||
}
|
||||
|
||||
#shop-modal-inner {
|
||||
width: 92vw !important;
|
||||
max-width: 420px !important;
|
||||
max-height: 85vh !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* ── 手机端隐藏房间介绍 ── */
|
||||
.room-desc {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── 手机端隐藏输入栏中不常用的控件(动作/字色/字号/禁音/分屏) ── */
|
||||
/* 用 :has() 选择器精准定位含对应 input/select 的 label */
|
||||
.input-row label:has(#action),
|
||||
.input-row label:has(#font_color),
|
||||
.input-row label:has(#font_size_select),
|
||||
.input-row label:has(#sound_muted) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端(> 640px)不显示浮动按钮和抽屉组件 */
|
||||
@media (min-width: 641px) {
|
||||
#mobile-fabs,
|
||||
#mobile-drawer-mask,
|
||||
#mobile-drawer-toolbar,
|
||||
#mobile-drawer-users {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
+501
-618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
// 聊天室管理员命令模块:设置公告、公屏讲话、清屏、刷新全员、特效触发。
|
||||
// 从 Blade 内联脚本迁移至 Vite 管理。
|
||||
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置房间公告。
|
||||
*/
|
||||
function promptAnnouncement() {
|
||||
const fullText = document.getElementById("announcement-text")?.textContent?.trim() || "";
|
||||
const pureText = fullText.replace(/ ——\S+ \d{2}-\d{2} \d{2}:\d{2}$/, "").trim();
|
||||
window.chatDialog
|
||||
.prompt("请输入新的房间公告/祝福语:", pureText, "设置公告", "#336699")
|
||||
.then((newText) => {
|
||||
if (newText === null || newText.trim() === "") return;
|
||||
|
||||
fetch(`/room/${window.chatContext.roomId}/announcement`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ announcement: newText.trim() }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.status === "success") {
|
||||
const marquee = document.getElementById("announcement-text");
|
||||
if (marquee) marquee.textContent = data.announcement;
|
||||
window.chatDialog.alert("公告已更新!", "提示", "#16a34a");
|
||||
} else {
|
||||
window.chatDialog.alert(data.message || "更新失败", "操作失败", "#cc4444");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
window.chatDialog.alert("设置公告失败:" + e.message, "操作失败", "#cc4444");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 站长公屏讲话。
|
||||
*/
|
||||
function promptAnnounceMessage() {
|
||||
window.chatDialog
|
||||
.prompt("请输入公屏讲话内容:", "", "📢 公屏讲话", "#7c3aed")
|
||||
.then((content) => {
|
||||
if (!content || !content.trim()) return;
|
||||
|
||||
fetch("/command/announce", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: content.trim(),
|
||||
room_id: window.chatContext.roomId,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.status !== "success") {
|
||||
window.chatDialog.alert(data.message || "发送失败", "操作失败", "#cc4444");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
window.chatDialog.alert("发送失败:" + e.message, "操作失败", "#cc4444");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员全员清屏。
|
||||
*/
|
||||
function adminClearScreen() {
|
||||
window.chatDialog
|
||||
.confirm("确定要清除所有人的聊天记录吗?(悄悄话将保留)", "全员清屏", "#dc2626")
|
||||
.then((ok) => {
|
||||
if (!ok) return;
|
||||
|
||||
fetch("/command/clear-screen", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ room_id: window.chatContext.roomId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.status !== "success") {
|
||||
window.chatDialog.alert(data.message || "清屏失败", "操作失败", "#cc4444");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
window.chatDialog.alert("清屏失败:" + e.message, "操作失败", "#cc4444");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员触发全屏特效。
|
||||
*
|
||||
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
||||
*/
|
||||
function triggerEffect(type) {
|
||||
const roomId = window.chatContext?.roomId;
|
||||
if (!roomId) return;
|
||||
fetch("/command/effect", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
},
|
||||
body: JSON.stringify({ room_id: roomId, type }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== "success")
|
||||
window.chatDialog.alert(data.message, "操作失败", "#cc4444");
|
||||
})
|
||||
.catch((err) => console.error("特效触发失败:", err));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择特效后关闭菜单并触发。
|
||||
*
|
||||
* @param {string} type 特效类型
|
||||
*/
|
||||
function selectEffect(type) {
|
||||
const menu = document.getElementById("admin-menu");
|
||||
if (menu) menu.style.display = "none";
|
||||
triggerEffect(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站长通知当前房间所有在线用户刷新页面。
|
||||
*/
|
||||
async function refreshAllBrowsers() {
|
||||
if (!window.chatContext?.isSiteOwner || !window.chatContext?.refreshAllUrl) {
|
||||
window.chatDialog?.alert("仅站长可执行全员刷新。", "无权限", "#cc4444");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await window.chatDialog?.confirm(
|
||||
"确定通知当前房间所有在线用户刷新页面吗?\n适用于功能更新后强制同步最新按钮与权限状态。",
|
||||
"♻️ 刷新全员",
|
||||
"#0f766e",
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(window.chatContext.refreshAllUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_id: window.chatContext.roomId,
|
||||
reason: "功能更新,站长要求刷新页面",
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
window.chatToast?.show({
|
||||
title: "已发送刷新通知",
|
||||
message: data.message,
|
||||
icon: "♻️",
|
||||
color: "#0f766e",
|
||||
duration: 3500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.chatDialog?.alert(data.message || "发送刷新通知失败", "操作失败", "#cc4444");
|
||||
} catch (_error) {
|
||||
window.chatDialog?.alert("网络异常,请稍后再试", "错误", "#cc4444");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行管理菜单中的快捷操作,并在执行前关闭菜单。
|
||||
*
|
||||
* @param {string} action 管理动作类型
|
||||
*/
|
||||
function runAdminAction(action) {
|
||||
const menu = document.getElementById("admin-menu");
|
||||
if (menu) menu.style.display = "none";
|
||||
|
||||
switch (action) {
|
||||
case "announcement":
|
||||
promptAnnouncement();
|
||||
break;
|
||||
case "announce-message":
|
||||
promptAnnounceMessage();
|
||||
break;
|
||||
case "admin-clear":
|
||||
adminClearScreen();
|
||||
break;
|
||||
case "red-packet":
|
||||
window.sendRedPacket?.();
|
||||
break;
|
||||
case "loss-cover":
|
||||
window.openAdminBaccaratLossCoverModal?.();
|
||||
break;
|
||||
case "refresh-all":
|
||||
refreshAllBrowsers();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换管理菜单显示/隐藏。
|
||||
*/
|
||||
function toggleAdminMenu(event) {
|
||||
event.stopPropagation();
|
||||
const menu = document.getElementById("admin-menu");
|
||||
const welcomeMenu = document.getElementById("welcome-menu");
|
||||
const blockMenu = document.getElementById("block-menu");
|
||||
const featureMenu = document.getElementById("feature-menu");
|
||||
const dailyStatusEditor = document.getElementById("daily-status-editor-overlay");
|
||||
|
||||
if (!menu) return;
|
||||
|
||||
[welcomeMenu, blockMenu, featureMenu, dailyStatusEditor].forEach((el) => {
|
||||
if (el) el.style.display = "none";
|
||||
});
|
||||
|
||||
menu.style.display = menu.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
// 挂载到 window 供 Blade 脚本及其他模块使用。
|
||||
window.promptAnnouncement = promptAnnouncement;
|
||||
window.promptAnnounceMessage = promptAnnounceMessage;
|
||||
window.adminClearScreen = adminClearScreen;
|
||||
window.triggerEffect = triggerEffect;
|
||||
window.selectEffect = selectEffect;
|
||||
window.refreshAllBrowsers = refreshAllBrowsers;
|
||||
window.runAdminAction = runAdminAction;
|
||||
window.toggleAdminMenu = toggleAdminMenu;
|
||||
|
||||
export {
|
||||
promptAnnouncement,
|
||||
promptAnnounceMessage,
|
||||
adminClearScreen,
|
||||
triggerEffect,
|
||||
selectEffect,
|
||||
refreshAllBrowsers,
|
||||
runAdminAction,
|
||||
toggleAdminMenu,
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
// 聊天室管理菜单事件绑定,替代 input-bar 中的管理类内联 onclick。
|
||||
// 管理动作业务逻辑已迁至 admin-commands.js。
|
||||
|
||||
import "./admin-commands.js";
|
||||
|
||||
let adminMenuEventsBound = false;
|
||||
|
||||
/**
|
||||
* 绑定管理菜单、管理动作与全屏特效选择事件。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function bindAdminMenuControls() {
|
||||
if (adminMenuEventsBound || typeof document === "undefined") {
|
||||
@@ -22,33 +23,26 @@ export function bindAdminMenuControls() {
|
||||
if (menuToggle) {
|
||||
event.preventDefault();
|
||||
window.toggleAdminMenu?.(event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const adminAction = event.target.closest("[data-chat-admin-action]");
|
||||
if (adminAction) {
|
||||
event.preventDefault();
|
||||
|
||||
// 管理菜单只负责入口分发,权限校验和实际动作仍由后端与原有全局函数负责。
|
||||
const action = adminAction.getAttribute("data-chat-admin-action") || "";
|
||||
if (action && typeof window.runAdminAction === "function") {
|
||||
window.runAdminAction(action);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const effectButton = event.target.closest("[data-chat-admin-effect]");
|
||||
if (effectButton) {
|
||||
event.preventDefault();
|
||||
|
||||
// 特效按钮只触发管理员发起请求,实际播放仍由 chat:effect 广播和 EffectManager 处理。
|
||||
const effect = effectButton.getAttribute("data-chat-admin-effect") || "";
|
||||
if (effect && typeof window.selectEffect === "function") {
|
||||
window.selectEffect(effect);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
// 聊天室 WebSocket 事件监听,从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
// 所有事件委托通过 window.addEventListener 注册,依赖 window.chatState 共享状态。
|
||||
|
||||
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
||||
import { normalizeDailyStatus } from "./preferences-status.js";
|
||||
import { enqueueChatMessage } from "./message-renderer.js";
|
||||
|
||||
// ── 事件注册标记 ──
|
||||
let chatEventsBound = false;
|
||||
|
||||
// ── 辅助函数 ──
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return window.chatState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
|
||||
*/
|
||||
function initChatWebSocket() {
|
||||
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
|
||||
window.initChat(window.chatContext.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 禁言逻辑 ──
|
||||
function handleMutedEvent(e) {
|
||||
const state = getState();
|
||||
const d = e.detail;
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||||
now.getSeconds().toString().padStart(2, "0");
|
||||
|
||||
const isMe = d.username === window.chatContext?.username;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg-line";
|
||||
div.innerHTML = `<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
|
||||
|
||||
const targetContainer = isMe
|
||||
? document.getElementById("say2")
|
||||
: (state?.container);
|
||||
if (targetContainer) {
|
||||
targetContainer.appendChild(div);
|
||||
targetContainer.scrollTop = targetContainer.scrollHeight;
|
||||
}
|
||||
|
||||
if (isMe && d.mute_time > 0) {
|
||||
state.isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
|
||||
const contentInput = document.getElementById("content");
|
||||
const operatorName = d.operator || "管理员";
|
||||
if (contentInput) {
|
||||
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
|
||||
contentInput.disabled = true;
|
||||
setTimeout(() => {
|
||||
state.isMutedUntil = 0;
|
||||
contentInput.placeholder = "在这里输入聊天内容,按 Enter 发送...";
|
||||
contentInput.disabled = false;
|
||||
const unmuteDiv = document.createElement("div");
|
||||
unmuteDiv.className = "msg-line";
|
||||
unmuteDiv.innerHTML = '<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
|
||||
const say2 = document.getElementById("say2");
|
||||
if (say2) {
|
||||
say2.appendChild(unmuteDiv);
|
||||
say2.scrollTop = say2.scrollHeight;
|
||||
}
|
||||
}, d.mute_time * 60 * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Echo 级监听器 ──
|
||||
|
||||
/**
|
||||
* 注册全员清屏监听(ScreenCleared)。
|
||||
*/
|
||||
function setupScreenClearedListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
setTimeout(setupScreenClearedListener, 500);
|
||||
return;
|
||||
}
|
||||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||||
.listen("ScreenCleared", (e) => {
|
||||
const operator = e.operator;
|
||||
const safeOperator = escapeHtml(String(operator || ""));
|
||||
|
||||
const say1 = document.getElementById("chat-messages-container");
|
||||
if (say1) say1.innerHTML = "";
|
||||
|
||||
const say2 = document.getElementById("chat-messages-container2");
|
||||
if (say2) {
|
||||
const items = say2.querySelectorAll(".msg-line");
|
||||
items.forEach((item) => {
|
||||
if (!item.querySelector(".msg-secret")) {
|
||||
item.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
if (state) {
|
||||
state.lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null;
|
||||
}
|
||||
|
||||
const sysDiv = document.createElement("div");
|
||||
sysDiv.className = "msg-line";
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||||
now.getSeconds().toString().padStart(2, "0");
|
||||
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
||||
if (say1) {
|
||||
say1.appendChild(sysDiv);
|
||||
say1.scrollTop = say1.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册房间级"刷新全员"监听(BrowserRefreshRequested)。
|
||||
*/
|
||||
function setupRoomBrowserRefreshListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
setTimeout(setupRoomBrowserRefreshListener, 500);
|
||||
return;
|
||||
}
|
||||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||||
.listen("BrowserRefreshRequested", (e) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("chat:browser-refresh-requested", { detail: e })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册开发日志发布通知监听(仅 Room 1)。
|
||||
*/
|
||||
function setupChangelogPublishedListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
setTimeout(setupChangelogPublishedListener, 500);
|
||||
return;
|
||||
}
|
||||
if (window.chatContext.roomId !== 1) return;
|
||||
|
||||
window.Echo.join("room.1")
|
||||
.listen(".ChangelogPublished", (e) => {
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||||
now.getSeconds().toString().padStart(2, "0");
|
||||
const safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ""));
|
||||
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ""));
|
||||
const changelogRoute = window.chatContext?.changelogUrl || "/changelog";
|
||||
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, changelogRoute));
|
||||
|
||||
const sysDiv = document.createElement("div");
|
||||
sysDiv.className = "msg-line";
|
||||
sysDiv.style.cssText =
|
||||
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;";
|
||||
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
|
||||
📋 【版本更新】v${safeVersion} · ${safeTitle}
|
||||
<a href="${safeUrl}" target="_blank" rel="noopener"
|
||||
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
|
||||
查看详情 →
|
||||
</a>
|
||||
</span><span class="msg-time">(${timeStr})</span>`;
|
||||
|
||||
const say1 = document.getElementById("chat-messages-container");
|
||||
if (say1) {
|
||||
say1.appendChild(sysDiv);
|
||||
say1.scrollTop = say1.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册五子棋 PvP 邀请通知监听。
|
||||
*/
|
||||
function setupGomokuInviteListener() {
|
||||
if (!window.Echo || !window.chatContext) {
|
||||
setTimeout(setupGomokuInviteListener, 500);
|
||||
return;
|
||||
}
|
||||
window.Echo.join(`room.${window.chatContext.roomId}`)
|
||||
.listen(".gomoku.invite", (e) => {
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||||
now.getSeconds().toString().padStart(2, "0");
|
||||
|
||||
const isSelf = (e.inviter_name === window.chatContext.username);
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg-line";
|
||||
div.style.cssText =
|
||||
"background:linear-gradient(135deg,#e8eef8,#f0f4fc); border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;";
|
||||
const safeInviterName = escapeHtml(e.inviter_name);
|
||||
const gomokuGameId = Number.parseInt(e.game_id, 10) || 0;
|
||||
|
||||
const acceptBtn = isSelf
|
||||
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
|
||||
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
|
||||
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
|
||||
cursor:pointer; font-family:inherit; transition:all .15s;">
|
||||
⤴️ 打开面板
|
||||
</button>`
|
||||
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
|
||||
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
|
||||
border-radius:12px; background:#336699; color:#fff; font-size:12px;
|
||||
cursor:pointer; font-family:inherit; transition:opacity .15s;">
|
||||
⚔️ 接受挑战
|
||||
</button>`;
|
||||
|
||||
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
|
||||
♟️ 【五子棋】<b>${safeInviterName}</b> 发起了随机对战!${isSelf ? "(等待中)" : ""}
|
||||
</span>${acceptBtn}
|
||||
<span class="msg-time">(${timeStr})</span>`;
|
||||
|
||||
const say1 = document.getElementById("chat-messages-container");
|
||||
if (say1) {
|
||||
say1.appendChild(div);
|
||||
say1.scrollTop = say1.scrollHeight;
|
||||
}
|
||||
|
||||
if (!isSelf) {
|
||||
setTimeout(() => {
|
||||
const btn = document.getElementById(`gomoku-accept-${e.game_id}`);
|
||||
if (btn) {
|
||||
btn.textContent = "已超时";
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = ".5";
|
||||
btn.style.cursor = "not-allowed";
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
})
|
||||
.listen(".gomoku.finished", (e) => {
|
||||
if (e.mode !== "pvp") return;
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||||
now.getSeconds().toString().padStart(2, "0");
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg-line";
|
||||
div.style.cssText =
|
||||
"background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;";
|
||||
|
||||
const reason = { win: "获胜", draw: "平局", resign: "认输", timeout: "超时" }[e.reason] || "结束";
|
||||
let text = "";
|
||||
if (e.winner === 0) {
|
||||
text = `♟️ 五子棋对局以<b>平局</b>结束!`;
|
||||
} else {
|
||||
text = `♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>(${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
|
||||
}
|
||||
|
||||
div.innerHTML = `<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
|
||||
const say1 = document.getElementById("chat-messages-container");
|
||||
if (say1) {
|
||||
say1.appendChild(div);
|
||||
say1.scrollTop = say1.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 主事件绑定 ──
|
||||
|
||||
/**
|
||||
* 绑定所有聊天室 WebSocket 事件监听,仅执行一次。
|
||||
*/
|
||||
export function bindChatEvents() {
|
||||
if (chatEventsBound || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
chatEventsBound = true;
|
||||
|
||||
// WebSocket 初始化
|
||||
document.addEventListener("DOMContentLoaded", initChatWebSocket);
|
||||
|
||||
// chat:here — Presence 初始用户列表
|
||||
window.addEventListener("chat:here", (e) => {
|
||||
const state = getState();
|
||||
if (!state) return;
|
||||
|
||||
const users = e.detail;
|
||||
state.onlineUsers = {};
|
||||
users.forEach((u) => {
|
||||
window.hydrateOnlineUserPayload(u.username, u);
|
||||
});
|
||||
|
||||
// 注入 AI 小班长
|
||||
if (window.chatContext?.chatBotEnabled && window.chatContext.botUser) {
|
||||
window.hydrateOnlineUserPayload("AI小班长", window.chatContext.botUser);
|
||||
}
|
||||
|
||||
// 同步当前用户状态
|
||||
if (typeof window.setOnlineUserDailyStatus === "function" && typeof window.getCurrentUserDailyStatus === "function") {
|
||||
window.setOnlineUserDailyStatus(window.chatContext?.username, window.getCurrentUserDailyStatus());
|
||||
}
|
||||
if (typeof window.syncDailyStatusUi === "function") {
|
||||
window.syncDailyStatusUi();
|
||||
}
|
||||
window.scheduleRenderUserList(0);
|
||||
});
|
||||
|
||||
// chat:bot-toggled — AI 小班长动态开关
|
||||
window.addEventListener("chat:bot-toggled", (e) => {
|
||||
const detail = e.detail;
|
||||
if (window.chatContext) {
|
||||
window.chatContext.chatBotEnabled = detail.isOnline;
|
||||
}
|
||||
|
||||
if (detail.isOnline && detail.user && detail.user.username) {
|
||||
window.hydrateOnlineUserPayload(detail.user.username, detail.user);
|
||||
if (window.chatContext) window.chatContext.botUser = detail.user;
|
||||
} else {
|
||||
const state = getState();
|
||||
if (state) delete state.onlineUsers["AI小班长"];
|
||||
if (window.chatContext) window.chatContext.botUser = null;
|
||||
}
|
||||
window.scheduleRenderUserList?.();
|
||||
});
|
||||
|
||||
// chat:user-status-updated — 用户每日状态更新
|
||||
window.addEventListener("chat:user-status-updated", (e) => {
|
||||
const username = e.detail?.username;
|
||||
const payload = e.detail?.user;
|
||||
if (!username || !payload) return;
|
||||
|
||||
window.hydrateOnlineUserPayload(username, payload);
|
||||
|
||||
if (username === window.chatContext?.username) {
|
||||
if (window.chatContext) {
|
||||
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
|
||||
}
|
||||
if (typeof window.syncDailyStatusUi === "function") {
|
||||
window.syncDailyStatusUi();
|
||||
}
|
||||
}
|
||||
window.scheduleRenderUserList?.();
|
||||
});
|
||||
|
||||
// chat:joining — 用户进入
|
||||
window.addEventListener("chat:joining", (e) => {
|
||||
const user = e.detail;
|
||||
window.hydrateOnlineUserPayload(user.username, user);
|
||||
window.scheduleRenderUserList?.();
|
||||
});
|
||||
|
||||
// chat:leaving — 用户离开
|
||||
window.addEventListener("chat:leaving", (e) => {
|
||||
const user = e.detail;
|
||||
const state = getState();
|
||||
if (state) delete state.onlineUsers[user.username];
|
||||
window.scheduleRenderUserList?.();
|
||||
});
|
||||
|
||||
// chat:message — 新消息
|
||||
window.addEventListener("chat:message", (e) => {
|
||||
const msg = e.detail;
|
||||
if (msg.is_secret && msg.from_user !== window.chatContext?.username && msg.to_user !== window.chatContext?.username) {
|
||||
return;
|
||||
}
|
||||
enqueueChatMessage(msg);
|
||||
|
||||
if (msg.action === "vip_presence" && typeof window.showVipPresenceBanner === "function") {
|
||||
window.showVipPresenceBanner(msg);
|
||||
}
|
||||
|
||||
// 若消息携带 toast_notification 字段且当前用户是接收者,弹右下角小卡片
|
||||
if (msg.toast_notification && msg.to_user === window.chatContext?.username) {
|
||||
const t = msg.toast_notification;
|
||||
window.chatToast?.show({
|
||||
title: t.title || "通知",
|
||||
message: t.message || "",
|
||||
icon: t.icon || "💬",
|
||||
color: t.color || "#336699",
|
||||
duration: t.duration ?? 8000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// chat:kicked — 被踢出房间
|
||||
window.addEventListener("chat:kicked", (e) => {
|
||||
if (e.detail.username === window.chatContext?.username) {
|
||||
const roomsIndexUrl = window.chatContext?.roomsIndexUrl || "/rooms";
|
||||
window.chatDialog?.alert(
|
||||
"您已被管理员踢出房间!" + (e.detail.reason ? " 原因:" + e.detail.reason : ""),
|
||||
"系统通知",
|
||||
"#cc4444"
|
||||
);
|
||||
window.location.href = roomsIndexUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// chat:muted — 禁言事件
|
||||
window.addEventListener("chat:muted", handleMutedEvent);
|
||||
|
||||
// chat:title-updated — 房间标题更新
|
||||
window.addEventListener("chat:title-updated", (e) => {
|
||||
const display = document.getElementById("room-title-display");
|
||||
if (display) display.innerText = e.detail.title;
|
||||
});
|
||||
|
||||
// chat:browser-refresh-requested — 全员刷新通知
|
||||
window.addEventListener("chat:browser-refresh-requested", (e) => {
|
||||
const detail = e.detail || {};
|
||||
const operatorName = escapeHtml(String(detail.operator || "站长"));
|
||||
const reasonText = escapeHtml(String(detail.reason || "页面功能已更新,请重新载入。"));
|
||||
|
||||
window.chatToast?.show({
|
||||
title: "页面即将刷新",
|
||||
message: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
|
||||
icon: "♻️",
|
||||
color: "#0f766e",
|
||||
duration: 2200,
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
});
|
||||
|
||||
// chat:user-browser-refresh-requested — 目标用户定向刷新
|
||||
window.addEventListener("chat:user-browser-refresh-requested", (e) => {
|
||||
const detail = e.detail || {};
|
||||
const operatorName = escapeHtml(String(detail.operator || "管理员"));
|
||||
const reasonText = escapeHtml(String(detail.reason || "你的权限状态已发生变化,页面即将刷新。"));
|
||||
|
||||
window.chatToast?.show({
|
||||
title: "权限同步中",
|
||||
message: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
|
||||
icon: "🔄",
|
||||
color: "#7c3aed",
|
||||
duration: 2600,
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// chat:effect — 全屏特效事件
|
||||
window.addEventListener("chat:effect", (e) => {
|
||||
const type = e.detail?.type;
|
||||
const target = e.detail?.target_username;
|
||||
const operator = e.detail?.operator;
|
||||
const myName = window.chatContext?.username;
|
||||
|
||||
if (type && typeof EffectManager !== "undefined") {
|
||||
if (!target || target === myName || operator === myName) {
|
||||
EffectManager.play(type);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Echo 级监听器(延迟绑定,等待 Echo 就绪)
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setupScreenClearedListener();
|
||||
setupRoomBrowserRefreshListener();
|
||||
setupChangelogPublishedListener();
|
||||
setupGomokuInviteListener();
|
||||
});
|
||||
}
|
||||
|
||||
export { initChatWebSocket };
|
||||
@@ -0,0 +1,223 @@
|
||||
// 聊天室共享运行时状态,桥接 Blade 闭包作用域与 Vite 模块。
|
||||
// 所有需要跨模块共享的可变状态集中在此管理,通过 window.chatState 访问。
|
||||
|
||||
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
|
||||
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
|
||||
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
|
||||
export const PUBLIC_MESSAGE_NODE_LIMIT = 600;
|
||||
export const PRIVATE_MESSAGE_NODE_LIMIT = 300;
|
||||
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
|
||||
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
|
||||
export const HEARTBEAT_INTERVAL = 60 * 1000;
|
||||
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "系统", "欢迎", "系统播报", "神秘箱子"];
|
||||
|
||||
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说")
|
||||
export const ACTION_TEXT_MAP = {
|
||||
"微笑": { type: "emotion", word: "微笑着" },
|
||||
"大笑": { type: "emotion", word: "大笑着" },
|
||||
"愤怒": { type: "emotion", word: "愤怒地" },
|
||||
"哭泣": { type: "emotion", word: "哭泣着" },
|
||||
"害羞": { type: "emotion", word: "害羞地" },
|
||||
"鄙视": { type: "emotion", word: "鄙视地" },
|
||||
"得意": { type: "emotion", word: "得意地" },
|
||||
"疑惑": { type: "emotion", word: "疑惑地" },
|
||||
"同情": { type: "emotion", word: "同情地" },
|
||||
"无奈": { type: "emotion", word: "无奈地" },
|
||||
"拳打": { type: "verb", word: "拳打了" },
|
||||
"飞吻": { type: "verb", word: "飞吻了" },
|
||||
"偷看": { type: "verb", word: "偷看了" },
|
||||
};
|
||||
|
||||
// ── DOM 引用(惰性获取,避免模块加载时 DOM 未就绪)──
|
||||
function getContainer() { return document.getElementById("chat-messages-container"); }
|
||||
function getContainer2() { return document.getElementById("chat-messages-container2"); }
|
||||
function getUserList() { return document.getElementById("online-users-list"); }
|
||||
function getToUserSelect() { return document.getElementById("to_user"); }
|
||||
function getOnlineCount() { return document.getElementById("online-count"); }
|
||||
function getOnlineCountBottom() { return document.getElementById("online-count-bottom"); }
|
||||
|
||||
// ── 可变状态 ──
|
||||
let onlineUsers = {};
|
||||
let autoScroll = true;
|
||||
let maxMsgId = 0;
|
||||
let pendingChatMessages = [];
|
||||
let chatMessageFlushTimer = null;
|
||||
let userListRenderTimer = null;
|
||||
let userFilterRenderTimer = null;
|
||||
let userBadgeRotationTick = 0;
|
||||
let lastAutosaveNode = null;
|
||||
let roomsRefreshTimer = null;
|
||||
let roomsOnlineStatusCache = null;
|
||||
let roomsOnlineStatusCacheAt = 0;
|
||||
let isMutedUntil = 0;
|
||||
let imeComposing = false;
|
||||
let isSending = false;
|
||||
let sendStartedAt = 0;
|
||||
let leaveRequestInFlight = false;
|
||||
let heartbeatFailCount = 0;
|
||||
const MAX_HEARTBEAT_FAILS = 3;
|
||||
|
||||
// 偏好状态
|
||||
let blockedSystemSenders = new Set();
|
||||
let initialChatPreferences = null;
|
||||
|
||||
// ── 访问器 ──
|
||||
function getOnlineUsers() { return onlineUsers; }
|
||||
function setOnlineUsers(v) {
|
||||
onlineUsers = v;
|
||||
window.onlineUsers = onlineUsers;
|
||||
}
|
||||
|
||||
function getAutoScroll() { return autoScroll; }
|
||||
function setAutoScroll(v) { autoScroll = Boolean(v); }
|
||||
|
||||
function getMaxMsgId() { return maxMsgId; }
|
||||
function setMaxMsgId(v) { if (v > maxMsgId) maxMsgId = v; }
|
||||
|
||||
function getBlockedSystemSenders() { return blockedSystemSenders; }
|
||||
function setBlockedSystemSenders(v) { blockedSystemSenders = v; }
|
||||
|
||||
function getIsMutedUntil() { return isMutedUntil; }
|
||||
function setIsMutedUntil(v) { isMutedUntil = v; }
|
||||
|
||||
// ── 构建聊天状态对象 ──
|
||||
const chatState = {
|
||||
// 常量
|
||||
BLOCKABLE_SYSTEM_SENDERS,
|
||||
BLOCKED_SYSTEM_SENDERS_STORAGE_KEY,
|
||||
CHAT_SOUND_MUTED_STORAGE_KEY,
|
||||
PUBLIC_MESSAGE_NODE_LIMIT,
|
||||
PRIVATE_MESSAGE_NODE_LIMIT,
|
||||
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
|
||||
ROOMS_ONLINE_STATUS_CACHE_TTL,
|
||||
HEARTBEAT_INTERVAL,
|
||||
SYSTEM_USERS,
|
||||
ACTION_TEXT_MAP,
|
||||
MAX_HEARTBEAT_FAILS,
|
||||
|
||||
// DOM 引用
|
||||
get container() { return getContainer(); },
|
||||
get container2() { return getContainer2(); },
|
||||
get userList() { return getUserList(); },
|
||||
get toUserSelect() { return getToUserSelect(); },
|
||||
get onlineCount() { return getOnlineCount(); },
|
||||
get onlineCountBottom() { return getOnlineCountBottom(); },
|
||||
|
||||
// 在线用户
|
||||
get onlineUsers() { return onlineUsers; },
|
||||
set onlineUsers(v) {
|
||||
onlineUsers = v;
|
||||
window.onlineUsers = onlineUsers;
|
||||
},
|
||||
|
||||
// 自动滚屏
|
||||
get autoScroll() { return autoScroll; },
|
||||
set autoScroll(v) { autoScroll = Boolean(v); },
|
||||
|
||||
// 最大消息 ID
|
||||
get maxMsgId() { return maxMsgId; },
|
||||
set maxMsgId(v) { maxMsgId = v; },
|
||||
trackMaxMsgId(v) { if (v > maxMsgId) maxMsgId = v; },
|
||||
|
||||
// 消息队列
|
||||
get pendingChatMessages() { return pendingChatMessages; },
|
||||
set pendingChatMessages(v) { pendingChatMessages = v; },
|
||||
get chatMessageFlushTimer() { return chatMessageFlushTimer; },
|
||||
set chatMessageFlushTimer(v) { chatMessageFlushTimer = v; },
|
||||
|
||||
// 用户列表渲染
|
||||
get userListRenderTimer() { return userListRenderTimer; },
|
||||
set userListRenderTimer(v) { userListRenderTimer = v; },
|
||||
get userFilterRenderTimer() { return userFilterRenderTimer; },
|
||||
set userFilterRenderTimer(v) { userFilterRenderTimer = v; },
|
||||
get userBadgeRotationTick() { return userBadgeRotationTick; },
|
||||
set userBadgeRotationTick(v) { userBadgeRotationTick = v; },
|
||||
|
||||
// 存点节点
|
||||
get lastAutosaveNode() { return lastAutosaveNode; },
|
||||
set lastAutosaveNode(v) { lastAutosaveNode = v; },
|
||||
|
||||
// 房间在线状态缓存
|
||||
get roomsRefreshTimer() { return roomsRefreshTimer; },
|
||||
set roomsRefreshTimer(v) { roomsRefreshTimer = v; },
|
||||
get roomsOnlineStatusCache() { return roomsOnlineStatusCache; },
|
||||
set roomsOnlineStatusCache(v) { roomsOnlineStatusCache = v; },
|
||||
get roomsOnlineStatusCacheAt() { return roomsOnlineStatusCacheAt; },
|
||||
set roomsOnlineStatusCacheAt(v) { roomsOnlineStatusCacheAt = v; },
|
||||
|
||||
// 禁言
|
||||
get isMutedUntil() { return isMutedUntil; },
|
||||
set isMutedUntil(v) { isMutedUntil = v; },
|
||||
|
||||
// 发送锁
|
||||
get imeComposing() { return imeComposing; },
|
||||
set imeComposing(v) { imeComposing = v; },
|
||||
get isSending() { return isSending; },
|
||||
set isSending(v) { isSending = v; },
|
||||
get sendStartedAt() { return sendStartedAt; },
|
||||
set sendStartedAt(v) { sendStartedAt = v; },
|
||||
|
||||
// 退出房间
|
||||
get leaveRequestInFlight() { return leaveRequestInFlight; },
|
||||
set leaveRequestInFlight(v) { leaveRequestInFlight = v; },
|
||||
|
||||
// 心跳计数
|
||||
get heartbeatFailCount() { return heartbeatFailCount; },
|
||||
set heartbeatFailCount(v) { heartbeatFailCount = v; },
|
||||
|
||||
// 偏好
|
||||
get blockedSystemSenders() { return blockedSystemSenders; },
|
||||
set blockedSystemSenders(v) { blockedSystemSenders = v; },
|
||||
get initialChatPreferences() { return initialChatPreferences; },
|
||||
set initialChatPreferences(v) { initialChatPreferences = v; },
|
||||
|
||||
// 重置所有状态(用于测试或强制同步)
|
||||
reset() {
|
||||
onlineUsers = {};
|
||||
window.onlineUsers = onlineUsers;
|
||||
autoScroll = true;
|
||||
maxMsgId = 0;
|
||||
pendingChatMessages = [];
|
||||
chatMessageFlushTimer = null;
|
||||
userListRenderTimer = null;
|
||||
userFilterRenderTimer = null;
|
||||
userBadgeRotationTick = 0;
|
||||
lastAutosaveNode = null;
|
||||
roomsRefreshTimer = null;
|
||||
roomsOnlineStatusCache = null;
|
||||
roomsOnlineStatusCacheAt = 0;
|
||||
isMutedUntil = 0;
|
||||
imeComposing = false;
|
||||
isSending = false;
|
||||
sendStartedAt = 0;
|
||||
leaveRequestInFlight = false;
|
||||
heartbeatFailCount = 0;
|
||||
blockedSystemSenders = new Set();
|
||||
},
|
||||
};
|
||||
|
||||
// 挂载到 window 供 Blade 脚本及其他模块使用
|
||||
window.chatState = chatState;
|
||||
|
||||
// 向后兼容 Blade 中已暴露的 window 接口
|
||||
export function hydrateOnlineUserPayload(username, payload) {
|
||||
const nextPayload = { ...(onlineUsers[username] || {}) };
|
||||
// 清除旧状态字段
|
||||
delete nextPayload.daily_status_key;
|
||||
delete nextPayload.daily_status_label;
|
||||
delete nextPayload.daily_status_icon;
|
||||
delete nextPayload.daily_status_group;
|
||||
delete nextPayload.daily_status_expires_at;
|
||||
onlineUsers[username] = { ...nextPayload, ...payload };
|
||||
window.onlineUsers = onlineUsers;
|
||||
}
|
||||
window.hydrateOnlineUserPayload = hydrateOnlineUserPayload;
|
||||
|
||||
export function renderUserList() {
|
||||
// 占位:实际渲染逻辑由 user-list.js 挂载到 window.chatState.renderUserList
|
||||
if (typeof window.renderUserList === "function") {
|
||||
window.renderUserList();
|
||||
}
|
||||
}
|
||||
|
||||
export { onlineUsers, autoScroll, maxMsgId };
|
||||
@@ -1,40 +1,347 @@
|
||||
// 聊天输入区事件绑定,逐步替代底部输入栏内联提交事件。
|
||||
// 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截。
|
||||
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
|
||||
let chatComposerEventsBound = false;
|
||||
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return window.chatState;
|
||||
}
|
||||
|
||||
function getDraftStorageKey() {
|
||||
return `chat_draft_${window.chatContext?.roomId ?? '0'}_${window.chatContext?.userId ?? '0'}`;
|
||||
}
|
||||
|
||||
// ── 草稿管理 ──
|
||||
|
||||
function persistChatDraft(value = null) {
|
||||
try {
|
||||
const contentInput = document.getElementById("content");
|
||||
const draft = value ?? contentInput?.value ?? "";
|
||||
if (draft === "") {
|
||||
sessionStorage.removeItem(getDraftStorageKey());
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(getDraftStorageKey(), draft);
|
||||
} catch (_) {
|
||||
// 会话存储不可用时静默降级
|
||||
}
|
||||
}
|
||||
|
||||
function loadChatDraft() {
|
||||
try {
|
||||
return sessionStorage.getItem(getDraftStorageKey()) || "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 消息发送 ──
|
||||
|
||||
/**
|
||||
* 绑定聊天表单提交事件。
|
||||
* 发送主流程仍由 Blade 主脚本的 sendMessage 维护,这里只统一 submit 入口。
|
||||
*
|
||||
* @returns {void}
|
||||
* 将当前输入区状态整理为一份稳定快照。
|
||||
*/
|
||||
function collectChatComposerState() {
|
||||
const contentInput = document.getElementById("content");
|
||||
const submitBtn = document.getElementById("send-btn");
|
||||
const imageInput = document.getElementById("chat_image");
|
||||
const toUserSelect = document.getElementById("to_user");
|
||||
const actionSelect = document.getElementById("action");
|
||||
const fontColorInput = document.getElementById("font_color");
|
||||
const secretCheckbox = document.getElementById("is_secret");
|
||||
const contentRaw = contentInput?.value ?? "";
|
||||
const selectedImage = imageInput?.files?.[0] ?? null;
|
||||
|
||||
return {
|
||||
contentInput,
|
||||
submitBtn,
|
||||
imageInput,
|
||||
contentRaw,
|
||||
content: contentRaw.trim(),
|
||||
selectedImage,
|
||||
toUser: toUserSelect?.value || "大家",
|
||||
action: actionSelect?.value || "",
|
||||
fontColor: fontColorInput?.value || "",
|
||||
isSecret: Boolean(secretCheckbox?.checked),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前聊天快照构造稳定的 multipart 请求体。
|
||||
*/
|
||||
function buildChatMessageFormData(composerState) {
|
||||
const formData = new FormData();
|
||||
formData.append("content", composerState.contentRaw);
|
||||
formData.append("to_user", composerState.toUser);
|
||||
formData.append("action", composerState.action);
|
||||
formData.append("font_color", composerState.fontColor);
|
||||
|
||||
if (composerState.isSecret) {
|
||||
formData.append("is_secret", "1");
|
||||
}
|
||||
if (composerState.selectedImage) {
|
||||
formData.append("image", composerState.selectedImage);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理聊天图片选择后的前端状态展示。
|
||||
*/
|
||||
function handleChatImageSelected(input) {
|
||||
const file = input?.files?.[0] ?? null;
|
||||
if (!file) return;
|
||||
// 用户选择图片后,立即触发自动发送
|
||||
sendMessage(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理当前选中的聊天图片。
|
||||
*/
|
||||
function clearSelectedChatImage(resetInput = false) {
|
||||
const imageInput = document.getElementById("chat_image");
|
||||
if (resetInput && imageInput) {
|
||||
imageInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面从后台恢复后,同步草稿、图片提示和发送锁状态。
|
||||
*/
|
||||
function syncChatComposerAfterResume() {
|
||||
const state = getState();
|
||||
const contentInput = document.getElementById("content");
|
||||
if (!contentInput) return;
|
||||
|
||||
const savedDraft = loadChatDraft();
|
||||
if (contentInput.value === "" && savedDraft !== "") {
|
||||
contentInput.value = savedDraft;
|
||||
} else if (contentInput.value !== "") {
|
||||
persistChatDraft(contentInput.value);
|
||||
}
|
||||
|
||||
const imageInput = document.getElementById("chat_image");
|
||||
if (!imageInput?.files?.length) {
|
||||
clearSelectedChatImage();
|
||||
}
|
||||
|
||||
if (state) {
|
||||
state.imeComposing = false;
|
||||
}
|
||||
|
||||
if (state && state.isSending && Date.now() - state.sendStartedAt > 15000) {
|
||||
const submitBtn = document.getElementById("send-btn");
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
state.isSending = false;
|
||||
state.sendStartedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息(内带防重入锁,避免快速连按 Enter 重复提交)。
|
||||
*/
|
||||
async function sendMessage(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
const state = getState();
|
||||
if (state?.isSending) return;
|
||||
|
||||
if (state) {
|
||||
state.isSending = true;
|
||||
state.sendStartedAt = Date.now();
|
||||
}
|
||||
|
||||
// 前端禁言检查
|
||||
if (state && state.isMutedUntil > Date.now()) {
|
||||
const remaining = Math.ceil((state.isMutedUntil - Date.now()) / 1000);
|
||||
const remainMin = Math.ceil(remaining / 60);
|
||||
const muteDiv = document.createElement("div");
|
||||
muteDiv.className = "msg-line";
|
||||
muteDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">【提示】您正在禁言中,还需等待约 ${remainMin} 分钟(${remaining} 秒)后方可发言。</span>`;
|
||||
const say2 = document.getElementById("say2");
|
||||
if (say2) {
|
||||
say2.appendChild(muteDiv);
|
||||
say2.scrollTop = say2.scrollHeight;
|
||||
}
|
||||
if (state) {
|
||||
state.isSending = false;
|
||||
state.sendStartedAt = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const composerState = collectChatComposerState();
|
||||
const { contentInput, submitBtn, content, contentRaw, selectedImage, toUser } = composerState;
|
||||
|
||||
if (!content && !selectedImage) {
|
||||
contentInput?.focus();
|
||||
if (state) {
|
||||
state.isSending = false;
|
||||
state.sendStartedAt = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AI 小助手私聊转发
|
||||
if (toUser === "AI小班长" && content && typeof window.sendToChatBot === "function") {
|
||||
window.sendToChatBot(content, composerState.isSecret);
|
||||
}
|
||||
|
||||
// ── 神秘箱子暗号拦截 ──
|
||||
const passcodePattern = /^[A-Z0-9]{4,8}$/;
|
||||
if (!selectedImage && passcodePattern.test(content.trim())) {
|
||||
if (state) {
|
||||
state.isSending = false;
|
||||
state.sendStartedAt = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const claimRes = await fetch("/mystery-box/claim", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ passcode: content.trim() }),
|
||||
});
|
||||
const claimData = await claimRes.json();
|
||||
|
||||
if (claimData.ok) {
|
||||
contentInput.value = "";
|
||||
persistChatDraft("");
|
||||
contentInput.focus();
|
||||
window._mysteryBoxActive = false;
|
||||
window._mysteryBoxPasscode = null;
|
||||
|
||||
const isPositive = (claimData.reward ?? 1) >= 0;
|
||||
window.chatDialog?.alert(
|
||||
claimData.message || "开箱成功!",
|
||||
isPositive ? "🎉 恭喜!" : "☠️ 中了陷阱!",
|
||||
isPositive ? "#10b981" : "#ef4444"
|
||||
);
|
||||
if (window.__chatUser && claimData.balance !== undefined) {
|
||||
window.__chatUser.jjb = claimData.balance;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// 网络错误时静默回退正常发送
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
const formData = buildChatMessageFormData({ ...composerState, contentRaw });
|
||||
|
||||
try {
|
||||
const response = await fetch(window.chatContext.sendUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === "success") {
|
||||
contentInput.value = "";
|
||||
persistChatDraft("");
|
||||
clearSelectedChatImage(true);
|
||||
contentInput.focus();
|
||||
} else {
|
||||
window.chatDialog?.alert(
|
||||
"发送失败: " + (data.message || JSON.stringify(data.errors)),
|
||||
"操作失败",
|
||||
"#cc4444"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
window.chatDialog?.alert("网络连接错误,消息发送失败!", "网络错误", "#cc4444");
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
if (state) {
|
||||
state.isSending = false;
|
||||
state.sendStartedAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 事件绑定 ──
|
||||
|
||||
/**
|
||||
* 绑定聊天输入区的所有事件:submit、IME、keydown、草稿、焦点恢复。
|
||||
*/
|
||||
export function bindChatComposerControls() {
|
||||
if (chatComposerEventsBound || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
chatComposerEventsBound = true;
|
||||
|
||||
// 表单提交
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !form.matches("[data-chat-form]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (typeof window.sendMessage === "function") {
|
||||
void window.sendMessage(event);
|
||||
}
|
||||
});
|
||||
|
||||
// 输入框事件绑定
|
||||
const contentInput = document.getElementById("content");
|
||||
if (contentInput) {
|
||||
contentInput.addEventListener("input", function () {
|
||||
persistChatDraft(this.value);
|
||||
});
|
||||
|
||||
// IME 组词开始
|
||||
contentInput.addEventListener("compositionstart", () => {
|
||||
const state = getState();
|
||||
if (state) state.imeComposing = true;
|
||||
});
|
||||
|
||||
// IME 组词结束
|
||||
contentInput.addEventListener("compositionend", () => {
|
||||
setTimeout(() => {
|
||||
const state = getState();
|
||||
if (state) state.imeComposing = false;
|
||||
}, 10);
|
||||
});
|
||||
|
||||
// Enter 发送
|
||||
contentInput.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const state = getState();
|
||||
if (state?.imeComposing) return;
|
||||
sendMessage(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 页面恢复事件
|
||||
syncChatComposerAfterResume();
|
||||
window.addEventListener("pageshow", syncChatComposerAfterResume);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "visible") {
|
||||
syncChatComposerAfterResume();
|
||||
}
|
||||
});
|
||||
window.addEventListener("focus", function () {
|
||||
setTimeout(syncChatComposerAfterResume, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置聊天动作并把焦点带回输入框。
|
||||
* 该入口兼容旧模板可能存在的 `setAction(...)` 调用,切换右侧标签仍交给 Blade 里的 switchTab 处理。
|
||||
*
|
||||
* @param {string} action 动作名称
|
||||
* @param {(tab:string) => void} switchTabHandler 右侧标签切换函数
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setChatComposerAction(
|
||||
action,
|
||||
@@ -47,7 +354,6 @@ export function setChatComposerAction(
|
||||
actionSelect.value = action;
|
||||
}
|
||||
|
||||
// 右侧在线名单切换仍在 Blade 主脚本内,模块只通过兼容入口调用。
|
||||
if (typeof switchTabHandler === "function") {
|
||||
switchTabHandler("users");
|
||||
}
|
||||
@@ -56,3 +362,15 @@ export function setChatComposerAction(
|
||||
contentInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 挂载到 window ──
|
||||
window.sendMessage = sendMessage;
|
||||
window.handleChatImageSelected = handleChatImageSelected;
|
||||
window.collectChatComposerState = collectChatComposerState;
|
||||
window.buildChatMessageFormData = buildChatMessageFormData;
|
||||
window.clearSelectedChatImage = clearSelectedChatImage;
|
||||
window.persistChatDraft = persistChatDraft;
|
||||
window.loadChatDraft = loadChatDraft;
|
||||
window.syncChatComposerAfterResume = syncChatComposerAfterResume;
|
||||
|
||||
export { sendMessage, handleChatImageSelected, clearSelectedChatImage, collectChatComposerState, buildChatMessageFormData };
|
||||
|
||||
@@ -1,93 +1,531 @@
|
||||
// 每日签到弹窗事件代理,先迁移按钮与遮罩事件,签到业务仍由 Blade 主脚本维护。
|
||||
// 每日签到完整模块:事件代理、API 请求与日历渲染全部由 Vite 管理。
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
|
||||
let dailySignInEventsBound = false;
|
||||
|
||||
/**
|
||||
* 调用每日签到存量全局函数。
|
||||
*
|
||||
* @param {string} functionName 全局函数名
|
||||
* @param {...unknown} args 参数
|
||||
* @returns {void}
|
||||
*/
|
||||
function callDailySignInGlobal(functionName, ...args) {
|
||||
// 当前模块只负责 data-* 事件到旧函数的桥接,接口请求和日历渲染暂不迁移。
|
||||
if (typeof window[functionName] === "function") {
|
||||
window[functionName](...args);
|
||||
}
|
||||
// ── 状态(全局共享,兼容 Blade 中 window.dailySignInState 引用)──
|
||||
window.dailySignInState = window.dailySignInState || {
|
||||
month: null,
|
||||
prevMonth: null,
|
||||
nextMonth: null,
|
||||
repairCardItem: null,
|
||||
repairCardCount: 0,
|
||||
rewardRules: [],
|
||||
status: null,
|
||||
};
|
||||
|
||||
// ── 辅助函数 ──────────────────────────────────────────
|
||||
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取每日签到月份翻页目标。
|
||||
*
|
||||
* @param {"prev"|"next"|string} direction 翻页方向
|
||||
* @returns {string|null}
|
||||
* 从服务端响应中提取最新金币余额。
|
||||
*/
|
||||
function resolveDailySignInMonth(direction) {
|
||||
if (direction === "prev") {
|
||||
return window.dailySignInState?.prevMonth || null;
|
||||
}
|
||||
function resolveDailySignInGoldBalance(data) {
|
||||
const candidates = [
|
||||
data?.data?.user?.jjb,
|
||||
data?.data?.user?.gold,
|
||||
data?.data?.presence?.jjb,
|
||||
data?.data?.presence?.gold,
|
||||
data?.data?.my_jjb,
|
||||
data?.data?.new_jjb,
|
||||
data?.data?.balance,
|
||||
data?.my_jjb,
|
||||
data?.new_jjb,
|
||||
data?.balance,
|
||||
];
|
||||
|
||||
if (direction === "next") {
|
||||
return window.dailySignInState?.nextMonth || null;
|
||||
for (const candidate of candidates) {
|
||||
const amount = Number(candidate);
|
||||
if (Number.isFinite(amount)) return amount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件。
|
||||
*
|
||||
* @returns {void}
|
||||
* 从签到响应中提取当前用户最新在线载荷。
|
||||
*/
|
||||
export function bindDailySignInControls() {
|
||||
if (dailySignInEventsBound || typeof document === "undefined") {
|
||||
function resolveDailySignInPresencePayload(data) {
|
||||
const candidates = [
|
||||
data?.data?.presence,
|
||||
data?.data?.online_user,
|
||||
data?.data?.onlineUser,
|
||||
data?.data?.user_payload,
|
||||
data?.data?.userPayload,
|
||||
data?.data?.user,
|
||||
data?.presence,
|
||||
data?.online_user,
|
||||
data?.onlineUser,
|
||||
];
|
||||
|
||||
return candidates.find(p => p && typeof p === 'object') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从签到响应中提取签到身份字段。
|
||||
*/
|
||||
function resolveDailySignInIdentityPayload(data) {
|
||||
const identity = data?.data?.identity || data?.data?.sign_identity || data?.identity || data?.sign_identity;
|
||||
|
||||
if (!identity || typeof identity !== 'object') return {};
|
||||
|
||||
return {
|
||||
sign_identity_key: identity.key ?? identity.sign_identity_key ?? identity.code ?? '',
|
||||
sign_identity_label: identity.label ?? identity.name ?? identity.sign_identity_label ?? '',
|
||||
sign_identity_icon: identity.icon ?? identity.sign_identity_icon ?? '',
|
||||
sign_identity_color: identity.color ?? identity.sign_identity_color ?? undefined,
|
||||
sign_identity_bg_color: identity.bg_color ?? identity.background_color ?? identity.sign_identity_bg_color ?? undefined,
|
||||
sign_identity_border_color: identity.border_color ?? identity.sign_identity_border_color ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将签到成功结果同步到金币余额与在线名单。
|
||||
*/
|
||||
function applyDailySignInResult(data) {
|
||||
const balance = resolveDailySignInGoldBalance(data);
|
||||
const payload = resolveDailySignInPresencePayload(data);
|
||||
const identityPayload = resolveDailySignInIdentityPayload(data);
|
||||
const username = window.chatContext?.username;
|
||||
|
||||
if (balance !== null && window.chatContext) {
|
||||
window.chatContext.userJjb = balance;
|
||||
window.chatContext.myGold = balance;
|
||||
}
|
||||
|
||||
if (username) {
|
||||
// hydrateOnlineUserPayload 由 Blade 主脚本暴露在 window 上供 Vite 模块桥接调用。
|
||||
if (typeof window.hydrateOnlineUserPayload === "function") {
|
||||
window.hydrateOnlineUserPayload(username, {
|
||||
...(payload || {}),
|
||||
...identityPayload,
|
||||
username,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 通知 Blade 主脚本刷新在线用户列表。
|
||||
if (typeof window.renderUserList === "function") {
|
||||
window.renderUserList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染函数 ──────────────────────────────────────────
|
||||
|
||||
function getState() {
|
||||
return window.dailySignInState;
|
||||
}
|
||||
|
||||
function renderDailySignInStatus() {
|
||||
const status = getState().status || {};
|
||||
const streakEl = document.getElementById('daily-sign-streak');
|
||||
const previewEl = document.getElementById('daily-sign-preview');
|
||||
const cardCountEl = document.getElementById('daily-sign-card-count');
|
||||
const cardPriceEl = document.getElementById('daily-sign-card-price');
|
||||
const claimBtn = document.getElementById('daily-sign-claim-btn');
|
||||
const buyBtn = document.getElementById('daily-sign-buy-card-btn');
|
||||
const cardItem = getState().repairCardItem;
|
||||
|
||||
if (streakEl) streakEl.textContent = `连续 ${Number(status.current_streak_days || 0)} 天`;
|
||||
if (previewEl) {
|
||||
const rule = status.preview_rule || {};
|
||||
const parts = [];
|
||||
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward} 金币`);
|
||||
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward} 经验`);
|
||||
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward} 魅力`);
|
||||
previewEl.textContent = status.signed_today ? '今日已签到' : `今日可领:${parts.join(' + ') || '签到奖励'}`;
|
||||
}
|
||||
if (cardCountEl) cardCountEl.textContent = `补签卡 ${getState().repairCardCount || 0} 张`;
|
||||
if (cardPriceEl) {
|
||||
cardPriceEl.textContent = cardItem
|
||||
? `${cardItem.icon || '🗓️'} ${cardItem.name}:${Number(cardItem.price || 0).toLocaleString()} 金币`
|
||||
: '补签卡暂未上架';
|
||||
}
|
||||
if (claimBtn) {
|
||||
claimBtn.disabled = !!status.signed_today;
|
||||
claimBtn.textContent = status.signed_today ? '今日已签到' : '今日签到';
|
||||
claimBtn.style.opacity = status.signed_today ? '0.55' : '1';
|
||||
claimBtn.style.cursor = status.signed_today ? 'not-allowed' : 'pointer';
|
||||
}
|
||||
if (buyBtn) {
|
||||
buyBtn.disabled = !cardItem?.id;
|
||||
buyBtn.style.opacity = cardItem?.id ? '1' : '0.55';
|
||||
buyBtn.style.cursor = cardItem?.id ? 'pointer' : 'not-allowed';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDailySignInCalendar(payload) {
|
||||
const grid = document.getElementById('daily-sign-calendar-grid');
|
||||
const label = document.getElementById('daily-sign-month-label');
|
||||
|
||||
if (!grid) return;
|
||||
if (label) label.textContent = payload.month_label || payload.month || '本月';
|
||||
|
||||
const days = Array.isArray(payload.days) ? payload.days : [];
|
||||
grid.innerHTML = '';
|
||||
|
||||
const firstWeekday = Number(days[0]?.weekday || 0);
|
||||
for (let i = 0; i < firstWeekday; i += 1) {
|
||||
const blank = document.createElement('div');
|
||||
blank.className = 'daily-sign-day blank';
|
||||
grid.appendChild(blank);
|
||||
}
|
||||
|
||||
days.forEach(day => {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'daily-sign-day';
|
||||
if (day.signed) cell.classList.add('signed');
|
||||
if (day.can_makeup) cell.classList.add('missed');
|
||||
if (day.is_today) cell.classList.add('today');
|
||||
if (day.is_future) cell.classList.add('future');
|
||||
|
||||
const stateText = day.signed
|
||||
? `${day.is_makeup ? '补签' : '已签'} ${day.streak_days || ''}天`
|
||||
: (day.is_future ? '未到' : (day.is_today ? '今天' : '漏签'));
|
||||
|
||||
cell.innerHTML = `<span class="day-num">${day.day}</span><span class="day-state">${escapeHtml(stateText)}</span>`;
|
||||
cell.title = day.reward_text || stateText;
|
||||
if (day.can_makeup) cell.dataset.dailySignMakeup = day.date;
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDailySignInRewardRules() {
|
||||
const list = document.getElementById('daily-sign-rewards-list');
|
||||
const progress = document.getElementById('daily-sign-reward-progress');
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const currentDays = Number(getState().status?.current_streak_days || 0);
|
||||
const rules = getState().rewardRules || [];
|
||||
|
||||
if (progress) progress.textContent = `当前 ${currentDays} 天`;
|
||||
|
||||
if (!rules.length) {
|
||||
list.innerHTML = '<div style="font-size:12px;color:#94a3b8;padding:4px;">暂无奖励规则</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
dailySignInEventsBound = true;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Element)) {
|
||||
return;
|
||||
list.innerHTML = rules.map(rule => {
|
||||
const streakDays = Number(rule.streak_days || 0);
|
||||
const parts = [];
|
||||
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward}金`);
|
||||
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward}经验`);
|
||||
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward}魅力`);
|
||||
|
||||
const icon = escapeHtml(rule.identity_badge_icon || '✅');
|
||||
const name = escapeHtml(rule.identity_badge_name || '签到奖励');
|
||||
const color = escapeHtml(rule.identity_badge_color || '#0f766e');
|
||||
const activeClass = currentDays >= streakDays ? ' active' : '';
|
||||
const distanceText = currentDays >= streakDays ? '已达成' : `还差 ${Math.max(streakDays - currentDays, 0)} 天`;
|
||||
const rewardText = escapeHtml(parts.join(' + ') || '签到记录');
|
||||
|
||||
return `
|
||||
<div class="daily-sign-reward-card${activeClass}" title="${name} · ${rewardText} · ${escapeHtml(distanceText)}">
|
||||
<div class="daily-sign-reward-title">
|
||||
<span>第 ${streakDays} 天</span>
|
||||
<span style="color:${color};">${icon}</span>
|
||||
</div>
|
||||
<div class="daily-sign-reward-name">${name}</div>
|
||||
<div class="daily-sign-reward-desc">${rewardText}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── API 请求函数 ──────────────────────────────────────
|
||||
|
||||
async function loadDailySignInStatus() {
|
||||
const statusUrl = window.chatContext?.dailySignInStatusUrl;
|
||||
if (!statusUrl) return;
|
||||
|
||||
const response = await fetch(statusUrl, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf() },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error') {
|
||||
throw new Error(data?.message || '签到状态加载失败');
|
||||
}
|
||||
|
||||
getState().status = data.data || {};
|
||||
renderDailySignInStatus();
|
||||
}
|
||||
|
||||
async function loadDailySignInCalendar(month) {
|
||||
const calendarUrl = window.chatContext?.dailySignInCalendarUrl;
|
||||
if (!calendarUrl) return;
|
||||
|
||||
const url = new URL(calendarUrl, window.location.origin);
|
||||
if (month) url.searchParams.set('month', month);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf() },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error') {
|
||||
throw new Error(data?.message || '签到日历加载失败');
|
||||
}
|
||||
|
||||
const payload = data.data || {};
|
||||
const state = getState();
|
||||
state.month = payload.month || month || null;
|
||||
state.prevMonth = payload.prev_month || null;
|
||||
state.nextMonth = payload.next_month || null;
|
||||
state.repairCardItem = payload.sign_repair_card_item || null;
|
||||
state.repairCardCount = Number(payload.makeup_card_count || 0);
|
||||
state.rewardRules = Array.isArray(payload.reward_rules) ? payload.reward_rules : [];
|
||||
renderDailySignInCalendar(payload);
|
||||
renderDailySignInStatus();
|
||||
renderDailySignInRewardRules();
|
||||
}
|
||||
|
||||
// ── 公开操作 ──────────────────────────────────────────
|
||||
|
||||
async function openDailySignInModal() {
|
||||
const modal = document.getElementById('daily-sign-modal');
|
||||
|
||||
if (!window.chatContext?.dailySignInCalendarUrl || !modal) {
|
||||
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
await Promise.all([
|
||||
loadDailySignInStatus(),
|
||||
loadDailySignInCalendar(getState().month),
|
||||
]);
|
||||
}
|
||||
|
||||
function closeDailySignInModal() {
|
||||
const modal = document.getElementById('daily-sign-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
async function quickDailySignIn() {
|
||||
await openDailySignInModal();
|
||||
}
|
||||
|
||||
async function claimDailySignInFromModal() {
|
||||
const claimUrl = window.chatContext?.dailySignInClaimUrl;
|
||||
|
||||
if (!claimUrl) {
|
||||
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(claimUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrf(),
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ room_id: window.chatContext?.roomId ?? null }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error' || data?.ok === false) {
|
||||
throw new Error(data?.message || '签到失败');
|
||||
}
|
||||
|
||||
applyDailySignInResult(data);
|
||||
await Promise.all([
|
||||
loadDailySignInStatus(),
|
||||
loadDailySignInCalendar(getState().month),
|
||||
]);
|
||||
renderDailySignInRewardRules();
|
||||
window.chatToast?.show({
|
||||
title: '签到成功',
|
||||
message: data?.message || '今日签到奖励已到账。',
|
||||
icon: data?.data?.sign_identity_icon || data?.data?.identity?.icon || '✅',
|
||||
color: '#16a34a',
|
||||
duration: 3200,
|
||||
});
|
||||
} catch (error) {
|
||||
window.chatDialog?.alert(error.message || '签到失败,请稍后重试。', '签到失败', '#cc4444');
|
||||
}
|
||||
}
|
||||
|
||||
async function makeupDailySignIn(targetDate) {
|
||||
const makeupUrl = window.chatContext?.dailySignInMakeupUrl;
|
||||
if (!makeupUrl) return;
|
||||
|
||||
const ok = await window.chatDialog?.confirm(`确认使用 1 张补签卡补签 ${targetDate} 吗?`, '确认补签');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(makeupUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrf(),
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ target_date: targetDate, room_id: window.chatContext?.roomId ?? null }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error' || data?.ok === false) {
|
||||
const firstError = data?.errors ? Object.values(data.errors).flat()[0] : null;
|
||||
throw new Error(firstError || data?.message || '补签失败');
|
||||
}
|
||||
|
||||
applyDailySignInResult(data);
|
||||
await Promise.all([
|
||||
loadDailySignInStatus(),
|
||||
loadDailySignInCalendar(getState().month),
|
||||
]);
|
||||
renderDailySignInRewardRules();
|
||||
window.chatToast?.show({
|
||||
title: '补签成功',
|
||||
message: data?.message || '补签已完成。',
|
||||
icon: '🗓️',
|
||||
color: '#0f766e',
|
||||
duration: 3200,
|
||||
});
|
||||
} catch (error) {
|
||||
window.chatDialog?.alert(error.message || '补签失败,请稍后重试。', '补签失败', '#cc4444');
|
||||
}
|
||||
}
|
||||
|
||||
async function promptSignRepairQuantity(item) {
|
||||
const unitPrice = Number(item?.price || 0);
|
||||
const ruleText = item?.description || '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。';
|
||||
const promptPromise = window.chatDialog?.prompt(
|
||||
`请输入要购买的补签卡数量(1-99):\n单价 ${unitPrice.toLocaleString()} 金币\n说明:${ruleText}`,
|
||||
'1',
|
||||
'购买补签卡',
|
||||
'#0f766e',
|
||||
);
|
||||
const inputEl = document.getElementById('global-dialog-input');
|
||||
const previousInputStyle = inputEl?.getAttribute('style') || '';
|
||||
|
||||
if (inputEl) {
|
||||
inputEl.style.minHeight = '40px';
|
||||
inputEl.style.height = '40px';
|
||||
inputEl.style.resize = 'none';
|
||||
inputEl.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
const rawQuantity = await promptPromise;
|
||||
|
||||
if (inputEl) inputEl.setAttribute('style', previousInputStyle);
|
||||
if (rawQuantity === null || rawQuantity === undefined) return null;
|
||||
|
||||
const quantity = Number.parseInt(String(rawQuantity).trim(), 10);
|
||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
|
||||
window.chatDialog?.alert('购买数量必须是 1 到 99 之间的整数。', '数量不正确', '#cc4444');
|
||||
return null;
|
||||
}
|
||||
|
||||
return quantity;
|
||||
}
|
||||
|
||||
async function buyDailySignRepairCard() {
|
||||
const item = getState().repairCardItem;
|
||||
|
||||
if (!item?.id) {
|
||||
window.chatDialog?.alert('补签卡暂未上架。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = await promptSignRepairQuantity(item);
|
||||
if (quantity === null) return;
|
||||
|
||||
const totalPrice = Number(item.price || 0) * quantity;
|
||||
const ok = await window.chatDialog?.confirm(
|
||||
`确认花费 ${totalPrice.toLocaleString()} 金币购买【${item.name}】× ${quantity} 吗?\n说明:补签卡只能补签本月未签到日期。`,
|
||||
'购买补签卡',
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
if (typeof window.buyItem === 'function') {
|
||||
window.buyItem(item.id, item.name, item.price, 'all', '', quantity);
|
||||
setTimeout(() => {
|
||||
loadDailySignInCalendar(getState().month);
|
||||
loadDailySignInStatus();
|
||||
}, 900);
|
||||
return;
|
||||
}
|
||||
|
||||
window.openShopModal?.();
|
||||
}
|
||||
|
||||
// ── 暴露到 window(兼容 Blade 存量引用)───────────────
|
||||
|
||||
window.openDailySignInModal = openDailySignInModal;
|
||||
window.closeDailySignInModal = closeDailySignInModal;
|
||||
window.quickDailySignIn = quickDailySignIn;
|
||||
window.loadDailySignInCalendar = loadDailySignInCalendar;
|
||||
window.claimDailySignInFromModal = claimDailySignInFromModal;
|
||||
window.makeupDailySignIn = makeupDailySignIn;
|
||||
window.promptSignRepairQuantity = promptSignRepairQuantity;
|
||||
window.buyDailySignRepairCard = buyDailySignRepairCard;
|
||||
|
||||
// ── 事件绑定 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 读取每日签到月份翻页目标。
|
||||
*/
|
||||
function resolveDailySignInMonth(direction) {
|
||||
if (direction === "prev") return getState().prevMonth || null;
|
||||
if (direction === "next") return getState().nextMonth || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定每日签到弹窗遮罩、关闭、签到、补签卡和月份切换事件。
|
||||
*/
|
||||
export function bindDailySignInControls() {
|
||||
if (dailySignInEventsBound || typeof document === "undefined") return;
|
||||
|
||||
dailySignInEventsBound = true;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
|
||||
const overlay = event.target.closest("[data-daily-sign-modal-overlay]");
|
||||
// 只在点击遮罩本身时关闭,避免点击弹窗内容区误触关闭。
|
||||
if (overlay && event.target === overlay) {
|
||||
callDailySignInGlobal("closeDailySignInModal");
|
||||
closeDailySignInModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-daily-sign-close]")) {
|
||||
event.preventDefault();
|
||||
callDailySignInGlobal("closeDailySignInModal");
|
||||
closeDailySignInModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-daily-sign-claim]")) {
|
||||
event.preventDefault();
|
||||
callDailySignInGlobal("claimDailySignInFromModal");
|
||||
claimDailySignInFromModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-daily-sign-buy-repair-card]")) {
|
||||
event.preventDefault();
|
||||
callDailySignInGlobal("buyDailySignRepairCard");
|
||||
buyDailySignRepairCard();
|
||||
return;
|
||||
}
|
||||
|
||||
const makeupButton = event.target.closest("[data-daily-sign-makeup]");
|
||||
if (makeupButton) {
|
||||
event.preventDefault();
|
||||
// 日历格子由 Blade 主脚本动态生成,这里只读取日期并转发补签旧函数。
|
||||
callDailySignInGlobal("makeupDailySignIn", makeupButton.getAttribute("data-daily-sign-makeup") || "");
|
||||
makeupDailySignIn(makeupButton.getAttribute("data-daily-sign-makeup") || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const monthButton = event.target.closest("[data-daily-sign-month]");
|
||||
if (monthButton) {
|
||||
event.preventDefault();
|
||||
// 月份状态仍由 window.dailySignInState 维护,模块只读取方向并转发旧加载函数。
|
||||
callDailySignInGlobal("loadDailySignInCalendar", resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
|
||||
loadDailySignInCalendar(resolveDailySignInMonth(monthButton.getAttribute("data-daily-sign-month") || ""));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function nl2br(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/\n/g, '<br>');
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 聊天室心跳与存点模块:定时存点、手动存点、退出房间、掉线检测。
|
||||
* 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { pruneMessageContainer } from "./message-renderer.js";
|
||||
|
||||
const MAX_HEARTBEAT_FAILS = 3;
|
||||
const HEARTBEAT_INTERVAL = 60 * 1000;
|
||||
|
||||
let heartbeatInterval = null;
|
||||
let heartbeatFailCount = 0;
|
||||
let leaveRequestInFlight = false;
|
||||
|
||||
/**
|
||||
* 获取 CSRF Token。
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function csrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取共享状态对象。
|
||||
*
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function getState() {
|
||||
return window.chatState;
|
||||
}
|
||||
|
||||
// ── 存点功能(手动 + 自动)─────────────────────
|
||||
|
||||
/**
|
||||
* 执行一次存点请求,向服务端同步在线状态并获取经验/金币。
|
||||
*
|
||||
* @param {boolean} silent 静默模式(true=仅心跳,不显示存点提示)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveExp(silent = false) {
|
||||
if (!window.chatContext?.heartbeatUrl) return;
|
||||
|
||||
const state = getState();
|
||||
|
||||
try {
|
||||
const response = await fetch(window.chatContext.heartbeatUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// 检测登录态失效
|
||||
if (response.status === 401 || response.status === 419) {
|
||||
await notifyExpiredLeave();
|
||||
window.chatDialog?.alert(
|
||||
"⚠️ 您的登录已失效(可能超时或在其他设备登录),请重新登录。",
|
||||
"连接警告",
|
||||
"#b45309"
|
||||
);
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === "success") {
|
||||
heartbeatFailCount = 0;
|
||||
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
|
||||
now.getMinutes().toString().padStart(2, "0") + ":" +
|
||||
now.getSeconds().toString().padStart(2, "0");
|
||||
const d = data.data;
|
||||
const identitySummary = d.identity_summary ? `${d.identity_summary} · ` : "";
|
||||
|
||||
let levelInfo = "";
|
||||
if (d.is_max_level) {
|
||||
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb} · 已满级 ✓`;
|
||||
} else {
|
||||
levelInfo = `⏰ 手动存点 · ${identitySummary}LV.${d.user_level} · 经验 ${d.exp_num} · 金币 ${d.jjb}`;
|
||||
}
|
||||
|
||||
// 本次获得的奖励提示
|
||||
let gainInfo = "";
|
||||
if (d.exp_gain > 0 || d.jjb_gain > 0) {
|
||||
const parts = [];
|
||||
if (d.exp_gain > 0) parts.push(`经验+${d.exp_gain}`);
|
||||
if (d.jjb_gain > 0) parts.push(`金币+${d.jjb_gain}`);
|
||||
gainInfo = ` 本次获得:${parts.join(",")}`;
|
||||
}
|
||||
|
||||
// 升级通知
|
||||
if (data.data.leveled_up) {
|
||||
const upDiv = document.createElement("div");
|
||||
upDiv.className = "msg-line";
|
||||
upDiv.innerHTML = `<span style="color: #d97706; font-weight: bold;">【系统】恭喜!你的经验值已达 ${d.exp_num},等级突破至 LV.${d.user_level}!🌟</span><span class="msg-time">(${timeStr})</span>`;
|
||||
const container2 = state?.container2;
|
||||
if (container2) {
|
||||
container2.appendChild(upDiv);
|
||||
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// 存点消息输出到包厢窗口
|
||||
if (!silent) {
|
||||
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
|
||||
if (container2) {
|
||||
const detailDiv = document.createElement("div");
|
||||
detailDiv.className = "msg-line";
|
||||
detailDiv.dataset.autosave = "1";
|
||||
detailDiv.innerHTML = `<span style="color: green;">${escapeHtml(levelInfo + gainInfo)}</span><span class="msg-time">(${timeStr})</span>`;
|
||||
|
||||
// 手动存点和自动存点共用同一条界面占位,避免包厢窗口堆积旧存点通知
|
||||
if (state) {
|
||||
state.lastAutosaveNode?.remove();
|
||||
state.lastAutosaveNode = detailDiv;
|
||||
}
|
||||
container2.appendChild(detailDiv);
|
||||
pruneMessageContainer(container2, window.chatState?.PRIVATE_MESSAGE_NODE_LIMIT || 300);
|
||||
if (state?.autoScroll) container2.scrollTop = container2.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("存点失败", e);
|
||||
heartbeatFailCount++;
|
||||
|
||||
if (heartbeatFailCount >= MAX_HEARTBEAT_FAILS) {
|
||||
window.chatDialog?.alert(
|
||||
"⚠️ 与服务器的连接已断开,请检查网络后重新登录。",
|
||||
"连接警告",
|
||||
"#b45309"
|
||||
);
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
const container2 = state?.container2 || document.getElementById("chat-messages-container2");
|
||||
if (container2) {
|
||||
const sysDiv = document.createElement("div");
|
||||
sysDiv.className = "msg-line";
|
||||
sysDiv.innerHTML = '<span style="color: red;">【系统】存点失败,请稍后重试</span>';
|
||||
container2.appendChild(sysDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 退出房间 ──
|
||||
|
||||
/**
|
||||
* 主动退出房间并关闭页面。
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function leaveRoom() {
|
||||
if (leaveRequestInFlight) return;
|
||||
leaveRequestInFlight = true;
|
||||
|
||||
try {
|
||||
await fetch(window.chatContext.leaveUrl + "?explicit=1", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf(),
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// 弹出窗口直接关闭,如果不是弹出窗口则跳回首页
|
||||
window.close();
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知服务端登录已过期(用于 401/419 响应时)。
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function notifyExpiredLeave() {
|
||||
if (leaveRequestInFlight) return;
|
||||
leaveRequestInFlight = true;
|
||||
|
||||
try {
|
||||
if (!window.chatContext?.expiredLeaveUrl) return;
|
||||
|
||||
await fetch(window.chatContext.expiredLeaveUrl, {
|
||||
method: "GET",
|
||||
headers: { "Accept": "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 定时器管理 ──
|
||||
|
||||
/**
|
||||
* 启动心跳定时器(每 60 秒自动存点)。
|
||||
*/
|
||||
export function startHeartbeat() {
|
||||
stopHeartbeat(); // 防止重复启动
|
||||
|
||||
// 首次心跳延迟 10 秒,让 WebSocket 先连接
|
||||
const initialTimer = window.setTimeout(() => saveExp(true), 10000);
|
||||
const intervalTimer = window.setInterval(() => saveExp(true), HEARTBEAT_INTERVAL);
|
||||
|
||||
heartbeatInterval = { initial: initialTimer, interval: intervalTimer };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳定时器。
|
||||
*/
|
||||
export function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
window.clearTimeout(heartbeatInterval.initial);
|
||||
window.clearInterval(heartbeatInterval.interval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 挂载到 window ──
|
||||
window.saveExp = saveExp;
|
||||
window.leaveRoom = leaveRoom;
|
||||
window.notifyExpiredLeave = notifyExpiredLeave;
|
||||
window.startHeartbeat = startHeartbeat;
|
||||
window.stopHeartbeat = stopHeartbeat;
|
||||
|
||||
export { saveExp, leaveRoom, notifyExpiredLeave, HEARTBEAT_INTERVAL, MAX_HEARTBEAT_FAILS };
|
||||
@@ -56,11 +56,17 @@ function restoreHistoryMessages(initialState) {
|
||||
* @returns {void}
|
||||
*/
|
||||
function appendWelcomeMessage(initialState) {
|
||||
if (!initialState.welcomeMessage) {
|
||||
const messages = Array.isArray(initialState.welcomeMessages) && initialState.welcomeMessages.length > 0
|
||||
? initialState.welcomeMessages
|
||||
: (initialState.welcomeMessage ? [initialState.welcomeMessage] : []);
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => appendInitialMessage(initialState.welcomeMessage), 220);
|
||||
window.setTimeout(() => {
|
||||
// 本次进房欢迎绕过历史清屏过滤,确保新人礼包和 AI 欢迎能在当前屏看到。
|
||||
messages.forEach((message) => appendInitialMessage(message));
|
||||
}, 220);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// 聊天消息渲染引擎:构建消息内容、追加消息、批量渲染与裁剪。
|
||||
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
|
||||
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
||||
import { isExpiredChatImageMessage } from "./message-utils.js";
|
||||
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
|
||||
import { escapePresenceText } from "./vip-presence.js";
|
||||
import {
|
||||
BLOCKABLE_SYSTEM_SENDERS,
|
||||
PUBLIC_MESSAGE_NODE_LIMIT,
|
||||
PRIVATE_MESSAGE_NODE_LIMIT,
|
||||
CHAT_MESSAGE_FLUSH_BATCH_SIZE,
|
||||
SYSTEM_USERS,
|
||||
ACTION_TEXT_MAP,
|
||||
} from "./chat-state.js";
|
||||
|
||||
// ── 游戏标签判断 ──
|
||||
const GAME_LABEL_PREFIXES = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"];
|
||||
function isGameLabel(name) {
|
||||
if (GAME_LABEL_PREFIXES.some((p) => name.startsWith(p))) return true;
|
||||
if (name.includes(" ")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 构建自然语序的动作串 ──
|
||||
function buildActionStr(action, fromHtml, toHtml, verb = "说") {
|
||||
const info = ACTION_TEXT_MAP[action];
|
||||
if (!info) return `${fromHtml}对${toHtml}${escapeHtml(String(action || ""))}${verb}:`;
|
||||
if (info.type === "emotion") return `${fromHtml}${info.word}对${toHtml}${verb}:`;
|
||||
return `${fromHtml}${info.word}${toHtml},${verb}:`;
|
||||
}
|
||||
|
||||
// ── 可点击用户名 ──
|
||||
function clickableUser(uName, color, extraClass = "") {
|
||||
const safeName = escapeHtml(uName);
|
||||
if (uName === "AI小班长") {
|
||||
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
|
||||
}
|
||||
if (SYSTEM_USERS.includes(uName) || isGameLabel(uName)) {
|
||||
return `<span class="msg-user${extraClass}" style="color: ${color};">${safeName}</span>`;
|
||||
}
|
||||
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
|
||||
}
|
||||
|
||||
// ── 解析内容中【用户名】为可点击标记 ──
|
||||
function parseBracketUsers(content, color = "#000099") {
|
||||
return content.replace(/【([^】]+)】/g, (_match, uName) => {
|
||||
return "【" + clickableUser(uName, color) + "】";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建聊天消息的内容 HTML。
|
||||
*/
|
||||
export function buildChatMessageContent(msg, fontColor, textColorClass) {
|
||||
const rawContent = msg.content || "";
|
||||
|
||||
if (msg.message_type === "image" && !isExpiredChatImageMessage(msg)) {
|
||||
const fullUrl = escapeHtml(msg.image_url || "");
|
||||
const thumbUrl = escapeHtml(msg.image_thumb_url || "");
|
||||
const imageName = escapeHtml(msg.image_original_name || "聊天图片");
|
||||
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
|
||||
const captionHtml = rawContent
|
||||
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; max-width:220px; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
|
||||
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}" data-chat-image-lightbox-open
|
||||
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
|
||||
<img src="${thumbUrl}" alt="${imageName}" loading="lazy" decoding="async"
|
||||
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
|
||||
</a>
|
||||
${captionHtml}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
if (msg.message_type === "expired_image" || isExpiredChatImageMessage(msg)) {
|
||||
const captionColorStyle = textColorClass ? "" : `color:${fontColor};`;
|
||||
const captionHtml = rawContent
|
||||
? `<span class="msg-content${textColorClass || ""}" style="display:inline-block; ${captionColorStyle} line-height:1.55;">${rawContent}</span>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
|
||||
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
|
||||
${captionHtml}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加消息到聊天窗格(原版风格:非气泡模式,逐行显示)。
|
||||
*
|
||||
* @param {Object} msg 消息对象
|
||||
* @param {Object|null} renderBatch 批量渲染上下文
|
||||
*/
|
||||
export function appendMessage(msg, renderBatch = null) {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
state.trackMaxMsgId(msg.id || 0);
|
||||
|
||||
const isMe = msg.from_user === window.chatContext?.username;
|
||||
const fontColor = msg.font_color || "#000000";
|
||||
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
|
||||
const shouldHideByBlock = blockRuleKey ? state.blockedSystemSenders.has(blockRuleKey) : false;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg-line";
|
||||
if (msg?.from_user) {
|
||||
div.dataset.fromUser = msg.from_user;
|
||||
}
|
||||
if (blockRuleKey) {
|
||||
div.dataset.blockKey = blockRuleKey;
|
||||
}
|
||||
|
||||
// ── 消息气泡装扮 ──
|
||||
if (msg.msg_bubble) {
|
||||
const bubbleStyle = msg.msg_bubble.replace(/^msg_bubble_/, "");
|
||||
div.classList.add("msg-bubble--" + bubbleStyle);
|
||||
}
|
||||
|
||||
const timeStr = msg.sent_at || "";
|
||||
let timeStrOverride = false;
|
||||
|
||||
let nameClass = "";
|
||||
if (msg.msg_name_color) {
|
||||
nameClass = " msg-name--" + msg.msg_name_color.replace(/^msg_name_/, "");
|
||||
}
|
||||
|
||||
let textColorClass = "";
|
||||
if (msg.msg_text_color) {
|
||||
textColorClass = " msg-text--" + msg.msg_text_color.replace(/^msg_text_/, "");
|
||||
}
|
||||
|
||||
// 用户头像
|
||||
const senderInfo = state.onlineUsers[msg.from_user];
|
||||
const senderHead = (senderInfo && senderInfo.headface) || "1.gif";
|
||||
let headImgSrc = senderHead.startsWith("storage/") ? "/" + senderHead : `/images/headface/${senderHead}`;
|
||||
if (msg.from_user.endsWith("播报") || msg.from_user === "星海小博士" || msg.from_user === "系统传音" || msg.from_user === "系统公告") {
|
||||
headImgSrc = "/images/bugle.png";
|
||||
}
|
||||
|
||||
// ── 头像框装扮 ──
|
||||
let avatarFrameClass = null;
|
||||
const avatarFrameRaw = msg.avatar_frame || (senderInfo && senderInfo.avatar_frame);
|
||||
if (avatarFrameRaw) {
|
||||
avatarFrameClass = "avatar-frame--" + avatarFrameRaw.replace(/^avatar_frame_/, "");
|
||||
}
|
||||
|
||||
let headImg = "";
|
||||
if (avatarFrameClass) {
|
||||
headImg = '<span class="avatar-frame-wrapper-sm">' +
|
||||
'<span class="avatar-frame ' + avatarFrameClass + '"></span>' +
|
||||
'<img src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
|
||||
'</span>';
|
||||
} else {
|
||||
headImg = '<img src="' + headImgSrc + '" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src=\'/images/headface/1.gif\'">';
|
||||
}
|
||||
|
||||
const messageBodyHtml = buildChatMessageContent(msg, fontColor, textColorClass);
|
||||
let html = "";
|
||||
|
||||
// ── 消息路由 ──
|
||||
if (msg.action === "system_welcome") {
|
||||
div.style.cssText = "margin: 3px 0;";
|
||||
const iconImg = `<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
|
||||
const parsedContent = parseBracketUsers(msg.content);
|
||||
html = `${iconImg} ${parsedContent}`;
|
||||
} else if (msg.action === "vip_presence") {
|
||||
const accent = msg.presence_color || "#f59e0b";
|
||||
div.style.cssText =
|
||||
`background: linear-gradient(135deg, #ffffff, ${accent}08); border: 2px solid ${accent}44; border-radius: 16px; padding: 12px 16px; margin: 8px 0; box-shadow: 0 4px 15px ${accent}15; position: relative; overflow: hidden;`;
|
||||
|
||||
const icon = escapeHtml(msg.presence_icon || "👑");
|
||||
const levelName = escapeHtml(msg.presence_level_name || "尊贵会员");
|
||||
const typeLabel = msg.presence_type === "leave"
|
||||
? "华丽离场"
|
||||
: (msg.presence_type === "purchase" ? "荣耀开通" : "荣耀入场");
|
||||
const safeText = escapePresenceText(msg.presence_text || "");
|
||||
|
||||
html = `
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
|
||||
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
|
||||
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
|
||||
</div>
|
||||
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
|
||||
</div>
|
||||
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
|
||||
</div>
|
||||
`;
|
||||
timeStrOverride = true;
|
||||
} else if (msg.action === "欢迎") {
|
||||
div.style.cssText =
|
||||
"background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);";
|
||||
const userName = msg.from_user;
|
||||
const rawContent = msg.content || "";
|
||||
const colonIndex = rawContent.indexOf(":");
|
||||
let clickablePrefix = "";
|
||||
let bodyPart = rawContent;
|
||||
if (colonIndex !== -1) {
|
||||
const prefixStr = rawContent.substring(0, colonIndex);
|
||||
bodyPart = rawContent.substring(colonIndex);
|
||||
const lastIdx = prefixStr.lastIndexOf(userName);
|
||||
if (lastIdx !== -1) {
|
||||
clickablePrefix =
|
||||
prefixStr.substring(0, lastIdx) +
|
||||
clickableUser(userName, "#1d4ed8", nameClass);
|
||||
} else {
|
||||
clickablePrefix = prefixStr;
|
||||
}
|
||||
}
|
||||
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
|
||||
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
|
||||
timeStrOverride = true;
|
||||
} else if (SYSTEM_USERS.includes(msg.from_user)) {
|
||||
if (msg.from_user === "系统公告") {
|
||||
div.style.cssText =
|
||||
"background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);";
|
||||
const parsedContent = parseBracketUsers(msg.content, "#dc2626");
|
||||
html = `<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
|
||||
timeStrOverride = true;
|
||||
} else if (msg.from_user === "系统传音") {
|
||||
const content = msg.content || "";
|
||||
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
|
||||
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
|
||||
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
|
||||
const isPlainNotification =
|
||||
content.includes("【百家乐】") ||
|
||||
content.includes("【赛马】") ||
|
||||
content.includes("神秘箱子") ||
|
||||
content.includes("【双色球") ||
|
||||
content.includes("【五子棋】") ||
|
||||
content.includes("【老虎机】") ||
|
||||
content.includes("购买了");
|
||||
|
||||
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
|
||||
let plainAccentContent = parseBracketUsers(msg.content);
|
||||
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
|
||||
} else if (isPlainNotification) {
|
||||
let parsedContent = parseBracketUsers(msg.content);
|
||||
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}:</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`;
|
||||
} else {
|
||||
div.style.cssText =
|
||||
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;";
|
||||
let sysTranContent = parseBracketUsers(msg.content);
|
||||
html = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
|
||||
}
|
||||
} else if (msg.from_user === "系统" && msg.to_user && msg.to_user !== "大家") {
|
||||
div.style.cssText =
|
||||
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;";
|
||||
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
|
||||
} else {
|
||||
let giftHtml = "";
|
||||
if (msg.gift_image) {
|
||||
giftHtml = `<img src="${msg.gift_image}" alt="${msg.gift_name || ""}" style="display:inline-block;width:40px;height:40px;vertical-align:middle;margin-left:6px;animation:giftBounce 0.6s ease-in-out;">`;
|
||||
}
|
||||
let parsedContent = parseBracketUsers(msg.content);
|
||||
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}:</span><span class="msg-content${textColorClass}" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
|
||||
}
|
||||
} else if (msg.is_secret) {
|
||||
if (msg.from_user === "系统") {
|
||||
div.style.cssText =
|
||||
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;";
|
||||
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
|
||||
} else {
|
||||
const fromHtml = clickableUser(msg.from_user, "#cc00cc", nameClass);
|
||||
const toHtml = clickableUser(msg.to_user, "#cc00cc");
|
||||
const verbStr = msg.action ?
|
||||
buildActionStr(msg.action, fromHtml, toHtml, "悄悄说") :
|
||||
`${fromHtml}对${toHtml}悄悄说:`;
|
||||
html = `${headImg}<span class="msg-secret">${verbStr}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-style: italic;">${messageBodyHtml}</span>`;
|
||||
}
|
||||
} else if (msg.to_user && msg.to_user !== "大家") {
|
||||
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
|
||||
const toHtml = clickableUser(msg.to_user, "#000099");
|
||||
const verbStr = msg.action ?
|
||||
buildActionStr(msg.action, fromHtml, toHtml) :
|
||||
`${fromHtml}对${toHtml}说:`;
|
||||
html = `${headImg}${verbStr}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
|
||||
} else {
|
||||
const fromHtml = clickableUser(msg.from_user, "#000099", nameClass);
|
||||
const everyoneHtml = clickableUser("大家", "#000099");
|
||||
const verbStr = msg.action ?
|
||||
buildActionStr(msg.action, fromHtml, everyoneHtml) :
|
||||
`${fromHtml}对${everyoneHtml}说:`;
|
||||
html = `${headImg}${verbStr}<span class="msg-content${textColorClass}" style="color: ${fontColor}">${messageBodyHtml}</span>`;
|
||||
}
|
||||
|
||||
if (!timeStrOverride) {
|
||||
html += ` <span class="msg-time">(${timeStr})</span>`;
|
||||
}
|
||||
div.innerHTML = html;
|
||||
|
||||
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
|
||||
if (shouldHideByBlock) {
|
||||
div.dataset.blockHidden = "1";
|
||||
div.style.display = "none";
|
||||
}
|
||||
|
||||
// 后端下发的带有 welcome_user 的系统欢迎/离开消息,替换同类旧消息
|
||||
if (msg.welcome_user) {
|
||||
const welcomeKind = msg.welcome_kind || "entry_broadcast";
|
||||
div.setAttribute("data-system-user", msg.welcome_user);
|
||||
div.setAttribute("data-system-welcome-kind", welcomeKind);
|
||||
const removeSameWelcome = (root) => {
|
||||
root?.querySelectorAll("[data-system-user]").forEach((el) => {
|
||||
if (el.dataset.systemUser === msg.welcome_user && (el.dataset.systemWelcomeKind || "entry_broadcast") === welcomeKind) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
removeSameWelcome(state.container);
|
||||
removeSameWelcome(renderBatch?.publicFragment);
|
||||
removeSameWelcome(renderBatch?.privateFragment);
|
||||
}
|
||||
|
||||
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
|
||||
const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username;
|
||||
|
||||
// 存点通知标记
|
||||
const isAutoSave = (msg.from_user === "系统" || msg.from_user === "") &&
|
||||
msg.content && (msg.content.includes("自动存点") || msg.content.includes("手动存点"));
|
||||
if (isAutoSave) {
|
||||
div.dataset.autosave = "1";
|
||||
}
|
||||
|
||||
if (isRelatedToMe) {
|
||||
if (isAutoSave) {
|
||||
state.lastAutosaveNode?.remove();
|
||||
state.lastAutosaveNode = div;
|
||||
}
|
||||
if (renderBatch) {
|
||||
renderBatch.privateFragment.appendChild(div);
|
||||
renderBatch.shouldPrunePrivate = true;
|
||||
renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || state.autoScroll;
|
||||
return;
|
||||
}
|
||||
const container2 = state.container2;
|
||||
if (container2) {
|
||||
container2.appendChild(div);
|
||||
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
|
||||
if (state.autoScroll) {
|
||||
container2.scrollTop = container2.scrollHeight;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (renderBatch) {
|
||||
renderBatch.publicFragment.appendChild(div);
|
||||
renderBatch.shouldPrunePublic = true;
|
||||
renderBatch.shouldScrollPublic = renderBatch.shouldScrollPublic || state.autoScroll;
|
||||
return;
|
||||
}
|
||||
const container = state.container;
|
||||
if (container) {
|
||||
container.appendChild(div);
|
||||
pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
|
||||
if (state.autoScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪聊天窗口内过旧的消息节点,防止长时间在线后 DOM 无限膨胀。
|
||||
*/
|
||||
export function pruneMessageContainer(targetContainer, maxNodes) {
|
||||
if (!targetContainer || targetContainer.childElementCount <= maxNodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = window.chatState;
|
||||
while (targetContainer.childElementCount > maxNodes) {
|
||||
const firstNode = targetContainer.firstElementChild;
|
||||
if (state && firstNode === state.lastAutosaveNode) {
|
||||
state.lastAutosaveNode = null;
|
||||
}
|
||||
firstNode?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建聊天消息批量渲染上下文。
|
||||
*/
|
||||
export function createChatMessageRenderBatch() {
|
||||
return {
|
||||
publicFragment: document.createDocumentFragment(),
|
||||
privateFragment: document.createDocumentFragment(),
|
||||
shouldPrunePublic: false,
|
||||
shouldPrunePrivate: false,
|
||||
shouldScrollPublic: false,
|
||||
shouldScrollPrivate: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交批量渲染的聊天消息,并把裁剪和滚动合并到每批一次。
|
||||
*/
|
||||
export function commitChatMessageRenderBatch(renderBatch) {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
const hasPublicMessages = renderBatch.publicFragment.childNodes.length > 0;
|
||||
const hasPrivateMessages = renderBatch.privateFragment.childNodes.length > 0;
|
||||
|
||||
if (hasPublicMessages) {
|
||||
const container = state.container;
|
||||
if (container) container.appendChild(renderBatch.publicFragment);
|
||||
}
|
||||
if (hasPrivateMessages) {
|
||||
const container2 = state.container2;
|
||||
if (container2) container2.appendChild(renderBatch.privateFragment);
|
||||
}
|
||||
if (renderBatch.shouldPrunePublic) {
|
||||
const container = state.container;
|
||||
if (container) pruneMessageContainer(container, PUBLIC_MESSAGE_NODE_LIMIT);
|
||||
}
|
||||
if (renderBatch.shouldPrunePrivate) {
|
||||
const container2 = state.container2;
|
||||
if (container2) pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
|
||||
}
|
||||
if (renderBatch.shouldScrollPublic) {
|
||||
const container = state.container;
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
if (renderBatch.shouldScrollPrivate) {
|
||||
const container2 = state.container2;
|
||||
if (container2) container2.scrollTop = container2.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将广播消息放入轻量队列,避免连续广播时同步挤占同一帧的 DOM 渲染。
|
||||
*/
|
||||
export function enqueueChatMessage(msg) {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
state.trackMaxMsgId(msg.id || 0);
|
||||
state.pendingChatMessages.push(msg);
|
||||
|
||||
if (state.chatMessageFlushTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
|
||||
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为普通用户聊天消息(非系统/游戏通知)。
|
||||
*/
|
||||
function isUserChatMessage(msg) {
|
||||
if (!msg || !msg.from_user) return false;
|
||||
const u = msg.from_user;
|
||||
if (SYSTEM_USERS.includes(u)) return false;
|
||||
if (u.endsWith("播报")) return false;
|
||||
if (u === "百家乐" || u === "跑马") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 后台恢复时系统通知最多保留条数 */
|
||||
const MAX_SYSTEM_BURST = 20;
|
||||
/** 后台恢复时超过该时间的系统通知直接丢弃(分钟) */
|
||||
const MAX_SYSTEM_AGE_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
|
||||
*/
|
||||
export function flushQueuedChatMessages() {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
state.chatMessageFlushTimer = null;
|
||||
|
||||
// 大批量消息堆积(后台标签页恢复)时,保留所有用户聊天记录,
|
||||
// 但过时的系统/游戏通知只保留最近 MAX_SYSTEM_BURST 条。
|
||||
if (state.pendingChatMessages.length > MAX_SYSTEM_BURST + 30) {
|
||||
const now = Date.now();
|
||||
const maxAge = MAX_SYSTEM_AGE_MINUTES * 60 * 1000;
|
||||
const totalSystem = state.pendingChatMessages.filter((m) => !isUserChatMessage(m)).length;
|
||||
let systemSeen = 0;
|
||||
let dropped = 0;
|
||||
|
||||
const filtered = state.pendingChatMessages.filter((msg) => {
|
||||
if (isUserChatMessage(msg)) return true;
|
||||
|
||||
systemSeen++;
|
||||
|
||||
// 超过10分钟的系统通知直接丢弃
|
||||
let msgTime = 0;
|
||||
if (msg.sent_at) {
|
||||
msgTime = new Date(msg.sent_at.replace(" ", "T")).getTime();
|
||||
}
|
||||
if (msgTime > 0 && now - msgTime > maxAge) {
|
||||
dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从旧到新遍历,只保留最后 MAX_SYSTEM_BURST 条系统通知
|
||||
const remainingAfter = totalSystem - systemSeen;
|
||||
if (remainingAfter >= MAX_SYSTEM_BURST) {
|
||||
dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (dropped > 0) {
|
||||
const container = state.container;
|
||||
if (container) {
|
||||
const notice = document.createElement("div");
|
||||
notice.className = "msg-line msg-burst-notice";
|
||||
notice.style.cssText =
|
||||
"text-align:center;padding:6px 0;margin:4px 0;font-size:12px;color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;";
|
||||
notice.textContent = `⏫ 省略了 ${dropped} 条系统通知`;
|
||||
container.appendChild(notice);
|
||||
}
|
||||
}
|
||||
|
||||
state.pendingChatMessages = filtered;
|
||||
}
|
||||
|
||||
const batch = state.pendingChatMessages.splice(0, CHAT_MESSAGE_FLUSH_BATCH_SIZE);
|
||||
const renderBatch = createChatMessageRenderBatch();
|
||||
batch.forEach((msg) => appendMessage(msg, renderBatch));
|
||||
commitChatMessageRenderBatch(renderBatch);
|
||||
|
||||
if (state.pendingChatMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleFlush = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
|
||||
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
|
||||
}
|
||||
|
||||
// ── 挂载到 window 供 Blade 脚本及其他模块使用 ──
|
||||
window.appendMessage = appendMessage;
|
||||
window.buildChatMessageContent = buildChatMessageContent;
|
||||
window.pruneMessageContainer = pruneMessageContainer;
|
||||
window.createChatMessageRenderBatch = createChatMessageRenderBatch;
|
||||
window.commitChatMessageRenderBatch = commitChatMessageRenderBatch;
|
||||
window.enqueueChatMessage = enqueueChatMessage;
|
||||
window.flushQueuedChatMessages = flushQueuedChatMessages;
|
||||
|
||||
export { clickableUser, buildActionStr, parseBracketUsers };
|
||||
@@ -307,6 +307,9 @@ export function handleFeatureLocalClear(onLocalClear) {
|
||||
|
||||
if (typeof onLocalClear === "function") {
|
||||
onLocalClear();
|
||||
} else if (typeof window.localClearScreen === "function") {
|
||||
// 默认调用聊天室清屏函数,将当前可见消息全部移除。
|
||||
window.localClearScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,3 +466,390 @@ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlocke
|
||||
|
||||
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息内容识别其对应的屏蔽规则键。
|
||||
*
|
||||
* @param {Record<string, unknown>} msg 消息对象
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveBlockedSystemSenderKey(msg) {
|
||||
const fromUser = String(msg?.from_user || "");
|
||||
const content = String(msg?.content || "");
|
||||
|
||||
if (fromUser === "钓鱼播报") {
|
||||
return "钓鱼播报";
|
||||
}
|
||||
|
||||
if (fromUser === "神秘箱子") {
|
||||
return "神秘箱子";
|
||||
}
|
||||
|
||||
if (fromUser === "星海小博士") {
|
||||
return "星海小博士";
|
||||
}
|
||||
|
||||
// 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以"系统传音"发送,但正文里带有"钓鱼播报"字样。
|
||||
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("钓鱼播报") || content.includes("自动钓鱼模式"))) {
|
||||
return "钓鱼播报";
|
||||
}
|
||||
|
||||
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("神秘箱子")) {
|
||||
return "神秘箱子";
|
||||
}
|
||||
|
||||
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("百家乐")) {
|
||||
return "百家乐";
|
||||
}
|
||||
|
||||
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("赛马") || content.includes("跑马"))) {
|
||||
return "跑马";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 偏好持久化 ──
|
||||
|
||||
/**
|
||||
* 构建当前聊天室偏好快照。
|
||||
*
|
||||
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
|
||||
*/
|
||||
export function buildChatPreferencesPayload() {
|
||||
const state = window.chatState;
|
||||
return {
|
||||
blocked_system_senders: state ? Array.from(state.blockedSystemSenders) : [],
|
||||
sound_muted: isSoundMuted(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将聊天室偏好写入本地缓存,供刷新前快速恢复与迁移兜底。
|
||||
*/
|
||||
export function persistChatPreferencesToLocal() {
|
||||
const state = window.chatState;
|
||||
if (state) {
|
||||
persistBlockedSystemSenders(state.blockedSystemSenders);
|
||||
}
|
||||
setSoundMuted(isSoundMuted());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前聊天室偏好保存到当前登录账号。
|
||||
*/
|
||||
export async function saveChatPreferences() {
|
||||
const payload = buildChatPreferencesPayload();
|
||||
persistChatPreferencesToLocal();
|
||||
|
||||
if (!window.chatContext?.chatPreferencesUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(window.chatContext.chatPreferencesUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]')?.content ?? "",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("save chat preferences failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.status === "success") {
|
||||
window.chatContext.chatPreferences = normalizeChatPreferences(data.data || payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("聊天室偏好保存失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 屏蔽 UI 同步 ──
|
||||
|
||||
/**
|
||||
* 同步屏蔽菜单中的复选框状态。
|
||||
*/
|
||||
export function syncBlockedSystemSenderCheckboxes() {
|
||||
const state = window.chatState;
|
||||
const blockedSet = state ? state.blockedSystemSenders : new Set();
|
||||
|
||||
const checkboxMap = {
|
||||
"block-sender-fishing": "钓鱼播报",
|
||||
"block-sender-doctor": "星海小博士",
|
||||
"block-sender-baccarat": "百家乐",
|
||||
"block-sender-horse-race": "跑马",
|
||||
"block-sender-mystery-box": "神秘箱子",
|
||||
};
|
||||
|
||||
Object.entries(checkboxMap).forEach(([id, sender]) => {
|
||||
const checkbox = document.getElementById(id);
|
||||
if (checkbox) {
|
||||
checkbox.checked = blockedSet.has(sender);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量切换当前已渲染消息的显示状态。
|
||||
*
|
||||
* @param {string} blockKey 屏蔽规则键
|
||||
* @param {boolean} hidden true = 隐藏,false = 恢复显示
|
||||
*/
|
||||
export function setRenderedMessagesVisibilityBySender(blockKey, hidden) {
|
||||
const state = window.chatState;
|
||||
[state?.container, state?.container2].forEach(targetContainer => {
|
||||
if (!targetContainer) return;
|
||||
|
||||
targetContainer.querySelectorAll("[data-block-key]").forEach(node => {
|
||||
if (node.dataset.blockKey === blockKey) {
|
||||
if (hidden) {
|
||||
node.dataset.blockHidden = "1";
|
||||
node.style.display = "none";
|
||||
} else if (node.dataset.blockHidden === "1") {
|
||||
node.removeAttribute("data-block-hidden");
|
||||
node.style.display = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!hidden && state?.autoScroll) {
|
||||
const container = state.container;
|
||||
const container2 = state.container2;
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
if (container2) container2.scrollTop = container2.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定系统播报项的屏蔽状态,并在勾选后立即清理当前窗口。
|
||||
*
|
||||
* @param {string} sender 系统播报发送者/规则键
|
||||
* @param {boolean} blocked 是否屏蔽
|
||||
*/
|
||||
export function toggleBlockedSystemSender(sender, blocked) {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
if (!BLOCKABLE_SYSTEM_SENDERS.includes(sender)) return;
|
||||
|
||||
if (blocked) {
|
||||
state.blockedSystemSenders.add(sender);
|
||||
setRenderedMessagesVisibilityBySender(sender, true);
|
||||
} else {
|
||||
state.blockedSystemSenders.delete(sender);
|
||||
setRenderedMessagesVisibilityBySender(sender, false);
|
||||
}
|
||||
|
||||
persistBlockedSystemSenders(state.blockedSystemSenders);
|
||||
syncBlockedSystemSenderCheckboxes();
|
||||
void saveChatPreferences();
|
||||
}
|
||||
|
||||
// ── 挂载到 window:偏好持久化 ──
|
||||
window.saveChatPreferences = saveChatPreferences;
|
||||
window.syncBlockedSystemSenderCheckboxes = syncBlockedSystemSenderCheckboxes;
|
||||
window.setRenderedMessagesVisibilityBySender = setRenderedMessagesVisibilityBySender;
|
||||
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
|
||||
window.persistChatPreferencesToLocal = persistChatPreferencesToLocal;
|
||||
window.buildChatPreferencesPayload = buildChatPreferencesPayload;
|
||||
|
||||
// ── 挂载到 window:菜单/浮层控制(供 bindBlockMenuControls 事件代理调用)──
|
||||
window.toggleBlockMenu = toggleBlockMenu;
|
||||
window.toggleFeatureMenu = toggleFeatureMenu;
|
||||
window.closeFeatureMenu = closeFeatureMenu;
|
||||
window.openDailyStatusEditor = openDailyStatusEditor;
|
||||
window.closeDailyStatusEditor = closeDailyStatusEditor;
|
||||
window.handleFeatureLocalClear = handleFeatureLocalClear;
|
||||
|
||||
// ── 每日状态 UI 同步 ──
|
||||
|
||||
/**
|
||||
* 获取当前登录用户仍然有效的每日状态。
|
||||
*
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getCurrentUserDailyStatus() {
|
||||
return normalizeDailyStatus(window.chatContext?.currentDailyStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户在线载荷中的状态字段,避免合并时残留旧状态。
|
||||
*
|
||||
* @param {Record<string, unknown>} payload 用户在线载荷
|
||||
*/
|
||||
export function removeDailyStatusFields(payload) {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
delete payload.daily_status_key;
|
||||
delete payload.daily_status_label;
|
||||
delete payload.daily_status_icon;
|
||||
delete payload.daily_status_group;
|
||||
delete payload.daily_status_expires_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将状态写回指定用户的在线载荷。
|
||||
*
|
||||
* @param {string} username 用户名
|
||||
* @param {Object|null} status 标准化后的状态对象
|
||||
*/
|
||||
export function setOnlineUserDailyStatus(username, status) {
|
||||
const onlineUsers = window.chatState?.onlineUsers || window.onlineUsers || {};
|
||||
if (!username || !onlineUsers[username]) return;
|
||||
|
||||
removeDailyStatusFields(onlineUsers[username]);
|
||||
if (!status) return;
|
||||
|
||||
onlineUsers[username].daily_status_key = status.key;
|
||||
onlineUsers[username].daily_status_label = status.label;
|
||||
onlineUsers[username].daily_status_icon = status.icon;
|
||||
onlineUsers[username].daily_status_group = status.group;
|
||||
onlineUsers[username].daily_status_expires_at = status.expires_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步状态按钮文字与图标。
|
||||
*/
|
||||
function syncDailyStatusTrigger() {
|
||||
const shortcutIcon = document.getElementById("daily-status-shortcut-icon");
|
||||
const shortcutLabel = document.getElementById("daily-status-shortcut-label");
|
||||
const activeStatus = getCurrentUserDailyStatus();
|
||||
|
||||
if (shortcutIcon) shortcutIcon.textContent = activeStatus?.icon || "🙂";
|
||||
if (shortcutLabel) shortcutLabel.textContent = activeStatus?.label || "状态";
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步状态面板中当前选中项的高亮样式。
|
||||
*/
|
||||
function syncDailyStatusMenuSelection() {
|
||||
const activeKey = getCurrentUserDailyStatus()?.key || "";
|
||||
|
||||
document.querySelectorAll("#daily-status-editor-overlay .daily-status-item").forEach((button) => {
|
||||
const selected = button.dataset.statusKey === activeKey;
|
||||
button.style.borderColor = selected ? "#6366f1" : "#e5e7eb";
|
||||
button.style.background = selected ? "linear-gradient(180deg,#eef2ff 0%,#e0e7ff 100%)" : "#ffffffcc";
|
||||
button.style.color = selected ? "#312e81" : "#334155";
|
||||
button.style.boxShadow = selected ? "0 8px 18px rgba(99,102,241,.18)" : "none";
|
||||
button.style.transform = selected ? "translateY(-1px)" : "translateY(0)";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步聊天室状态相关 UI(按钮、面板高亮、聊天上下文)。
|
||||
*/
|
||||
export function syncDailyStatusUi() {
|
||||
const activeStatus = getCurrentUserDailyStatus();
|
||||
if (window.chatContext) window.chatContext.currentDailyStatus = activeStatus;
|
||||
|
||||
syncDailyStatusTrigger();
|
||||
syncDailyStatusMenuSelection();
|
||||
}
|
||||
|
||||
// ── 每日状态更新与清除 ──
|
||||
|
||||
/**
|
||||
* 向服务端发送每日状态更新请求。
|
||||
*
|
||||
* @param {string} statusKey 状态键值
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateDailyStatus(statusKey) {
|
||||
const url = window.chatContext?.dailyStatusUpdateUrl;
|
||||
if (!url || !statusKey) return;
|
||||
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ daily_status_key: statusKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("update daily status failed");
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.status === "success" && window.chatContext) {
|
||||
window.chatContext.currentDailyStatus = data.data ?? null;
|
||||
}
|
||||
|
||||
closeDailyStatusEditor();
|
||||
syncDailyStatusUi();
|
||||
|
||||
// 让在线用户列表同步当前用户的最新状态
|
||||
const username = window.chatContext?.username;
|
||||
if (username) {
|
||||
setOnlineUserDailyStatus(username, getCurrentUserDailyStatus());
|
||||
}
|
||||
|
||||
if (typeof window.renderUserList === "function") {
|
||||
window.renderUserList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("每日状态更新失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前登录用户的每日状态。
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function clearDailyStatus() {
|
||||
const url = window.chatContext?.dailyStatusUpdateUrl;
|
||||
if (!url) return;
|
||||
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": csrf,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ daily_status_key: null }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("clear daily status failed");
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.status === "success" && window.chatContext) {
|
||||
window.chatContext.currentDailyStatus = null;
|
||||
}
|
||||
|
||||
closeDailyStatusEditor();
|
||||
syncDailyStatusUi();
|
||||
|
||||
// 移除当前用户在线载荷中的状态字段
|
||||
const username = window.chatContext?.username;
|
||||
if (username) {
|
||||
setOnlineUserDailyStatus(username, null);
|
||||
}
|
||||
|
||||
if (typeof window.renderUserList === "function") {
|
||||
window.renderUserList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("每日状态清除失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 挂载到 window:每日状态 ──
|
||||
window.getCurrentUserDailyStatus = getCurrentUserDailyStatus;
|
||||
window.setOnlineUserDailyStatus = setOnlineUserDailyStatus;
|
||||
window.syncDailyStatusUi = syncDailyStatusUi;
|
||||
window.updateDailyStatus = updateDailyStatus;
|
||||
window.clearDailyStatus = clearDailyStatus;
|
||||
|
||||
@@ -703,6 +703,11 @@ export function bindProfileControls() {
|
||||
if (event.target.closest("[data-settings-modal-overlay]")) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
|
||||
// ── 头像选择弹窗:点击遮罩层关闭 ──
|
||||
if (event.target.closest("[data-avatar-picker-overlay]") && !event.target.closest("[data-avatar-picker-panel]")) {
|
||||
closeAvatarPicker();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
|
||||
@@ -47,9 +47,40 @@ const SHOP_GROUPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const DECORATION_GROUPS = [
|
||||
{
|
||||
label: "💬 消息气泡",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_bubble",
|
||||
},
|
||||
{
|
||||
label: "🎨 昵称颜色",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_name_color",
|
||||
},
|
||||
{
|
||||
label: "🌈 文字颜色",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_text_color",
|
||||
},
|
||||
{
|
||||
label: "🖼️ 头像框",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "avatar_frame",
|
||||
},
|
||||
];
|
||||
|
||||
const DECORATION_TYPE_TO_SLOT = {
|
||||
msg_bubble: "bubble",
|
||||
msg_name_color: "name_color",
|
||||
msg_text_color: "text_color",
|
||||
avatar_frame: "avatar_frame",
|
||||
};
|
||||
|
||||
let shopControlEventsBound = false;
|
||||
let shopLoaded = false;
|
||||
let giftItem = null;
|
||||
let activeDecorations = {};
|
||||
|
||||
/**
|
||||
* 读取商店根节点上由 Blade 注入的接口地址。
|
||||
@@ -106,6 +137,7 @@ export function openShopModal() {
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
bindShopTabs();
|
||||
if (!shopLoaded) {
|
||||
shopLoaded = true;
|
||||
fetchShopData();
|
||||
@@ -122,6 +154,46 @@ export function closeShopModal() {
|
||||
if (modal) {
|
||||
modal.style.display = "none";
|
||||
}
|
||||
shopLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定商店 Tab 切换逻辑。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindShopTabs() {
|
||||
const tabsContainer = document.getElementById("shop-tabs");
|
||||
if (!tabsContainer || tabsContainer.dataset.shopTabsBound) {
|
||||
return;
|
||||
}
|
||||
tabsContainer.dataset.shopTabsBound = "1";
|
||||
|
||||
tabsContainer.addEventListener("click", (event) => {
|
||||
const tab = event.target.closest("[data-shop-tab]");
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabName = tab.dataset.shopTab;
|
||||
const itemsList = document.getElementById("shop-items-list");
|
||||
const decorationsList = document.getElementById("shop-decorations-list");
|
||||
|
||||
// 切换当前 Tab 的选中状态。
|
||||
tabsContainer.querySelectorAll(".shop-tab").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shopTab === tabName);
|
||||
});
|
||||
|
||||
// 切换对应商品列表,装扮列表需要显式恢复 grid 布局。
|
||||
if (tabName === "items") {
|
||||
if (itemsList) itemsList.style.display = "";
|
||||
if (decorationsList) decorationsList.style.display = "none";
|
||||
} else {
|
||||
if (itemsList) itemsList.style.display = "none";
|
||||
// 装扮列表在 CSS 中为 display:none,需显式设置为 grid 才能覆盖
|
||||
if (decorationsList) decorationsList.style.display = "grid";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +208,7 @@ export async function fetchShopData() {
|
||||
});
|
||||
const data = await response.json();
|
||||
renderShop(data);
|
||||
renderDecorations(data);
|
||||
} catch (error) {
|
||||
showShopToast("⚠ 加载失败,请重试", false);
|
||||
}
|
||||
@@ -245,6 +318,90 @@ export function renderShop(data) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染个人装扮商品列表。
|
||||
*
|
||||
* @param {Record<string, any>} data 商店接口数据
|
||||
* @returns {void}
|
||||
*/
|
||||
export function renderDecorations(data) {
|
||||
const list = document.getElementById("shop-decorations-list");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录当前激活装扮,渲染时只给当前款式显示"已激活"。
|
||||
activeDecorations = data.active_decorations || {};
|
||||
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
list.innerHTML = "";
|
||||
|
||||
// 购买说明:已激活同款支持叠加天数,不同款式替换时旧装扮作废不退款。
|
||||
const note = document.createElement("div");
|
||||
note.className = "decoration-note";
|
||||
note.innerHTML = "📌 购买说明:同类型只生效一款;已激活的同款续购自动叠加天数,无需一次买满;购买不同款式时旧装扮自动作废且不退款。";
|
||||
list.appendChild(note);
|
||||
|
||||
DECORATION_GROUPS.forEach((group) => {
|
||||
const groupItems = items.filter((item) => item.type === group.type);
|
||||
if (!groupItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 分组标题(独占一整行),描述文字内嵌到标题中避免单独占一格
|
||||
const header = document.createElement("div");
|
||||
header.className = "shop-group-header";
|
||||
header.innerHTML = `${escapeHtml(group.label)}${group.desc ? ` <span>${escapeHtml(group.desc)}</span>` : ''}`;
|
||||
list.appendChild(header);
|
||||
|
||||
groupItems.forEach((item) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "shop-card";
|
||||
|
||||
// active_decorations 由后端按槽位名索引,先把商品 type 映射到对应槽位。
|
||||
const slot = DECORATION_TYPE_TO_SLOT[item.type] || null;
|
||||
const activeDeco = slot ? (activeDecorations[slot] || null) : null;
|
||||
const isActiveStyle = activeDeco && activeDeco.style === item.slug;
|
||||
const expiresAt = activeDeco ? activeDeco.expires_at : null;
|
||||
let daysLeft = "";
|
||||
if (isActiveStyle && expiresAt) {
|
||||
const remaining = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
daysLeft = remaining > 0 ? `剩余 ${remaining} 天` : "即将过期";
|
||||
}
|
||||
|
||||
const button = document.createElement("button");
|
||||
if (isActiveStyle) {
|
||||
button.className = "shop-btn";
|
||||
button.textContent = "续费 💰 " + Number(item.price || 0).toLocaleString();
|
||||
} else {
|
||||
button.className = "shop-btn";
|
||||
button.textContent = "购买 💰 " + Number(item.price || 0).toLocaleString();
|
||||
}
|
||||
button.addEventListener("click", () => confirmAndBuyItem(item));
|
||||
|
||||
// 仅当前已激活的款式显示状态标签,同槽位其他款式保持普通购买状态。
|
||||
const statusHtml = isActiveStyle
|
||||
? `<span class="decoration-status active">已激活${daysLeft ? ' · ' + daysLeft : ''}</span>`
|
||||
: "";
|
||||
|
||||
const validityHtml = buildValidityHtml(item);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="shop-card-top">
|
||||
<span class="shop-card-icon">${escapeHtml(item.icon)}</span>
|
||||
<span class="shop-card-name">${escapeHtml(item.name)}</span>
|
||||
</div>
|
||||
${statusHtml ? `<div class="decoration-status-line">${statusHtml}</div>` : ""}
|
||||
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
|
||||
${validityHtml}
|
||||
${isActiveStyle && expiresAt ? `<div class="decoration-expiry">⏳ 到期:${escapeHtml(expiresAt.replace('T', ' ').slice(0, 16))}</div>` : ""}
|
||||
`;
|
||||
card.appendChild(button);
|
||||
list.appendChild(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个商品卡片,并挂载购买或使用按钮事件。
|
||||
*
|
||||
@@ -302,9 +459,7 @@ function buildShopCardHtml(item, options) {
|
||||
<span style="position:absolute;top:-4px;right:-6px;background:#f43f5e;color:#fff;font-size:9px;font-weight:800;min-width:15px;height:15px;border-radius:8px;text-align:center;line-height:15px;padding:0 2px;">${options.ownedQty}</span>
|
||||
</span>`
|
||||
: `<span class="shop-card-icon">${escapeHtml(item.icon)}</span>`;
|
||||
const durationLabel = options.isAutoFishing && Number(item.duration_minutes || 0) > 0
|
||||
? `<div style="font-size:9px;margin-top:3px;color:#7c3aed;">⏱ 有效期 ${escapeHtml(formatMinutes(item.duration_minutes))}</div>`
|
||||
: "";
|
||||
const validityHtml = buildValidityHtml(item);
|
||||
const ringBonus = options.isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0)
|
||||
? `<div style="font-size:9px;margin-top:3px;display:flex;gap:8px;">
|
||||
${Number(item.intimacy_bonus || 0) > 0 ? `<span style="color:#f43f5e;">💞 亲密 +${Number(item.intimacy_bonus || 0)}</span>` : ""}
|
||||
@@ -319,10 +474,52 @@ function buildShopCardHtml(item, options) {
|
||||
</div>
|
||||
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
|
||||
${ringBonus}
|
||||
${durationLabel}
|
||||
${validityHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成购买前展示的有效期或生效方式文案。
|
||||
*
|
||||
* @param {Record<string, any>} item 商品数据
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildValidityText(item) {
|
||||
if (Number(item.duration_days || 0) > 0) {
|
||||
return `有效期:${Number(item.duration_days)} 天`;
|
||||
}
|
||||
|
||||
if (Number(item.duration_minutes || 0) > 0) {
|
||||
return `有效期:${formatMinutes(item.duration_minutes)}`;
|
||||
}
|
||||
|
||||
if (item.type === "instant") {
|
||||
return "购买后立即播放 1 次";
|
||||
}
|
||||
|
||||
if (item.type === "ring") {
|
||||
return "购买后存入背包,求婚时消耗";
|
||||
}
|
||||
|
||||
if (["one_time", "sign_repair"].includes(item.type)) {
|
||||
return "购买后存入背包,使用时消耗";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成商品卡片里的有效期标签 HTML。
|
||||
*
|
||||
* @param {Record<string, any>} item 商品数据
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildValidityHtml(item) {
|
||||
const text = buildValidityText(item);
|
||||
|
||||
return text ? `<div class="shop-validity">⏱ ${escapeHtml(text)}</div>` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分钟数,供自动钓鱼卡有效期展示。
|
||||
*
|
||||
@@ -351,14 +548,52 @@ async function confirmAndBuyItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""} 吗?`;
|
||||
// 个性装扮支持多份购买(叠加天数),弹出数量选择
|
||||
if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") {
|
||||
quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1;
|
||||
if (quantity === null || quantity === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const validityText = buildValidityText(item);
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\n\n确定购买吗?`;
|
||||
const confirmed = await confirmShopPurchase(confirmMessage);
|
||||
|
||||
if (confirmed) {
|
||||
buyItem(item.id, item.name, item.price, "all", "", quantity);
|
||||
buyItem(item.id, item.name, item.price, "all", "", quantity, item.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数量输入弹窗。
|
||||
*
|
||||
* @param {string} hint 提示文案
|
||||
* @param {number} [minVal=1] 最小值
|
||||
* @param {number} [maxVal=99] 最大值
|
||||
* @returns {Promise<number|null>} 返回数量,用户取消返回 null
|
||||
*/
|
||||
window.promptQuantity = async (hint, minVal = 1, maxVal = 99) => {
|
||||
if (window.chatDialog?.prompt) {
|
||||
const result = await window.chatDialog.prompt(hint, "1", "购买数量");
|
||||
if (result === null || result === undefined) return null;
|
||||
const val = parseInt(result, 10);
|
||||
if (isNaN(val) || val < minVal || val > maxVal) {
|
||||
window.chatDialog?.alert?.(`请输入 ${minVal}~${maxVal} 之间的整数`);
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
const result = window.prompt(`${hint}\n数量(${minVal}~${maxVal}):`, "1");
|
||||
if (result === null) return null;
|
||||
const val = parseInt(result, 10);
|
||||
if (isNaN(val) || val < minVal || val > maxVal) {
|
||||
alert(`请输入 ${minVal}~${maxVal} 之间的整数`);
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容全局弹窗组件缺失时的原生确认。
|
||||
*
|
||||
@@ -401,7 +636,8 @@ export function openGiftDialog(item) {
|
||||
}
|
||||
|
||||
if (itemName) {
|
||||
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()})`;
|
||||
const validityText = buildValidityText(item);
|
||||
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}${validityText ? ` · ${validityText}` : ""})`;
|
||||
}
|
||||
|
||||
if (dialog) {
|
||||
@@ -443,7 +679,7 @@ export function confirmGift() {
|
||||
}
|
||||
|
||||
closeGiftDialog();
|
||||
buyItem(item.id, item.name, item.price, recipient, message);
|
||||
buyItem(item.id, item.name, item.price, recipient, message, 1, item.type);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,7 +693,7 @@ export function confirmGift() {
|
||||
* @param {number} quantity 购买数量
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function buyItem(itemId, name, price, recipient, message, quantity = 1) {
|
||||
export async function buyItem(itemId, name, price, recipient, message, quantity = 1, itemType = '') {
|
||||
try {
|
||||
const response = await fetch(getShopUrls().buy, {
|
||||
method: "POST",
|
||||
@@ -473,7 +709,7 @@ export async function buyItem(itemId, name, price, recipient, message, quantity
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
handleBuySuccess(data, name);
|
||||
handleBuySuccess(data, name, itemType);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -490,7 +726,7 @@ export async function buyItem(itemId, name, price, recipient, message, quantity
|
||||
* @param {string} itemName 商品名称
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleBuySuccess(data, itemName) {
|
||||
function handleBuySuccess(data, itemName, itemType = '') {
|
||||
const balance = document.getElementById("shop-jjb");
|
||||
|
||||
if (data.jjb !== undefined && balance) {
|
||||
@@ -499,6 +735,14 @@ function handleBuySuccess(data, itemName) {
|
||||
|
||||
showShopToast(`✅ ${itemName} 购买成功!`, true);
|
||||
|
||||
// 装扮购买成功后先更新本地缓存,随后再拉接口刷新完整状态。
|
||||
if (data.slot && data.style) {
|
||||
activeDecorations[data.slot] = {
|
||||
style: data.style,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// 购买者本地也要立即看到特效,广播只负责其他在线用户。
|
||||
if (data.play_effect && window.EffectManager) {
|
||||
window.EffectManager.play(data.play_effect);
|
||||
@@ -507,8 +751,15 @@ function handleBuySuccess(data, itemName) {
|
||||
shopLoaded = false;
|
||||
setTimeout(() => {
|
||||
fetchShopData();
|
||||
shopLoaded = true;
|
||||
}, 1000);
|
||||
}, 300);
|
||||
|
||||
// 自动钓鱼卡购买后立即开启自动钓鱼
|
||||
if (itemType === "auto_fishing" && typeof window.checkAndAutoStartFishing === "function") {
|
||||
if (!window.chatContext) window.chatContext = {};
|
||||
window.chatContext.autoFishingMinutesLeft = Number(data.auto_fishing_minutes_left || 1);
|
||||
window.chatContext.fishingCooldownSeconds = 0;
|
||||
setTimeout(() => window.checkAndAutoStartFishing(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -625,6 +876,7 @@ function exposeShopGlobals() {
|
||||
window.loadShop = loadShop;
|
||||
window.fetchShopData = fetchShopData;
|
||||
window.renderShop = renderShop;
|
||||
window.renderDecorations = renderDecorations;
|
||||
window.openGiftDialog = openGiftDialog;
|
||||
window.closeGiftDialog = closeGiftDialog;
|
||||
window.confirmGift = confirmGift;
|
||||
|
||||
@@ -21,6 +21,8 @@ export function runToolbarAction(action) {
|
||||
friend: () => window.openFriendPanel?.(),
|
||||
avatar: () => window.openAvatarPicker?.(),
|
||||
settings: () => window.openSettingsModal?.(),
|
||||
guestbook: () => window.openGuestbookModal?.(),
|
||||
feedback: () => window.openFeedbackModal?.(),
|
||||
};
|
||||
|
||||
actions[action]?.();
|
||||
@@ -59,6 +61,9 @@ function confirmToolbarLeaveRoom() {
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
// ── 挂载到 window ──
|
||||
window.runFeatureShortcut = runFeatureShortcut;
|
||||
|
||||
export function bindToolbarControls() {
|
||||
if (toolbarEventsBound || typeof document === "undefined") {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// 聊天室在线用户列表渲染:名单、搜索、徽标轮换。
|
||||
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { normalizeDailyStatus } from "./preferences-status.js";
|
||||
|
||||
// ── 每日状态解析 ──
|
||||
function resolveUserDailyStatus(user) {
|
||||
return normalizeDailyStatus(user);
|
||||
}
|
||||
|
||||
// ── 构建职务 / 管理员徽标 ──
|
||||
function buildUserPrimaryBadgeHtml(user, username) {
|
||||
if (user.position_icon) {
|
||||
const posTitle = (user.position_name || "在职") + " · " + username;
|
||||
const safePosTitle = escapeHtml(String(posTitle));
|
||||
const safePositionIcon = escapeHtml(String(user.position_icon || "🎖️"));
|
||||
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
|
||||
}
|
||||
if (user.is_admin) {
|
||||
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── 构建用户 VIP 徽标 ──
|
||||
function buildUserVipBadgeHtml(user) {
|
||||
if (!user.vip_icon) {
|
||||
return "";
|
||||
}
|
||||
const vipColor = user.vip_color || "#f59e0b";
|
||||
const safeVipTitle = escapeHtml(String(user.vip_name || "VIP"));
|
||||
const safeVipIcon = escapeHtml(String(user.vip_icon || "👑"));
|
||||
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
|
||||
}
|
||||
|
||||
// ── 构建状态徽标 ──
|
||||
function buildUserStatusBadgeHtml(user) {
|
||||
const status = resolveUserDailyStatus(user);
|
||||
if (!status) {
|
||||
return "";
|
||||
}
|
||||
const safeIcon = escapeHtml(status.icon);
|
||||
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
|
||||
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
|
||||
}
|
||||
|
||||
// ── 构建签到身份徽标 ──
|
||||
function buildUserSignIdentityBadgeHtml(user) {
|
||||
const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? "");
|
||||
const identityIcon = String(user.sign_identity_icon ?? "");
|
||||
if (!identityKey || !identityIcon) {
|
||||
return "";
|
||||
}
|
||||
const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? "");
|
||||
const safeIcon = escapeHtml(identityIcon);
|
||||
const safeTooltip = escapeHtml(identityLabel ? `签到 · ${identityLabel}` : "签到身份");
|
||||
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safeTooltip}">${safeIcon}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按轮换节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。
|
||||
*/
|
||||
export function buildUserBadgeHtml(user, username) {
|
||||
const state = window.chatState;
|
||||
const tick = state ? state.userBadgeRotationTick : 0;
|
||||
|
||||
const badges = [
|
||||
buildUserSignIdentityBadgeHtml(user),
|
||||
buildUserStatusBadgeHtml(user),
|
||||
buildUserPrimaryBadgeHtml(user, username),
|
||||
buildUserVipBadgeHtml(user),
|
||||
].filter(Boolean);
|
||||
|
||||
if (badges.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return badges[tick % badges.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
|
||||
*/
|
||||
export function refreshRenderedUserBadges(scope = document) {
|
||||
const state = window.chatState;
|
||||
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
|
||||
|
||||
scope.querySelectorAll(".user-item[data-username]").forEach((item) => {
|
||||
const username = item.dataset.username;
|
||||
const badgeSlot = item.querySelector(".user-badge-slot");
|
||||
if (!username || !badgeSlot) {
|
||||
return;
|
||||
}
|
||||
badgeSlot.innerHTML = buildUserBadgeHtml(onlineUsers[username] || {}, username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心渲染函数:将在线用户渲染到指定容器(桌面端名单区和手机端抽屉共用)。
|
||||
*/
|
||||
export function renderUserListToContainer(targetContainer, sortBy, keyword) {
|
||||
if (!targetContainer) return;
|
||||
|
||||
const state = window.chatState;
|
||||
const onlineUsers = state ? state.onlineUsers : (window.onlineUsers || {});
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// 在列表顶部添加"大家"条目
|
||||
const allDiv = document.createElement("div");
|
||||
allDiv.className = "user-item";
|
||||
allDiv.dataset.userListEveryone = "1";
|
||||
allDiv.innerHTML = '<span class="user-name" style="padding-left: 4px; color: navy;">大家</span>';
|
||||
fragment.appendChild(allDiv);
|
||||
|
||||
// 构建用户数组并排序
|
||||
let userArr = [];
|
||||
for (let username in onlineUsers) {
|
||||
userArr.push({ username, ...onlineUsers[username] });
|
||||
}
|
||||
|
||||
if (sortBy === "name") {
|
||||
userArr.sort((a, b) => a.username.localeCompare(b.username, "zh"));
|
||||
} else if (sortBy === "level") {
|
||||
userArr.sort((a, b) => (b.user_level || 0) - (a.user_level || 0));
|
||||
}
|
||||
|
||||
userArr.forEach((user) => {
|
||||
const username = user.username;
|
||||
|
||||
// 搜索过滤
|
||||
if (keyword && !username.toLowerCase().includes(keyword)) return;
|
||||
|
||||
const item = document.createElement("div");
|
||||
item.className = "user-item";
|
||||
item.dataset.username = username;
|
||||
item.dataset.userListEntry = "1";
|
||||
|
||||
const headface = (user.headface || "1.gif");
|
||||
const headImgSrc = headface.startsWith("storage/") ? "/" + headface : "/images/headface/" + headface;
|
||||
|
||||
const badges = buildUserBadgeHtml(user, username);
|
||||
|
||||
// 女生名字使用玫粉色
|
||||
const nameColor = (user.sex == 2) ? "color:#e91e8c;" : "";
|
||||
|
||||
// 昵称颜色装扮
|
||||
let userNameExtraClass = "";
|
||||
if (user.name_color) {
|
||||
userNameExtraClass = " msg-name--" + user.name_color.replace(/^msg_name_/, "");
|
||||
}
|
||||
|
||||
// 头像框装扮
|
||||
let avatarHtml = "";
|
||||
if (user.avatar_frame) {
|
||||
const frameClass = "avatar-frame--" + user.avatar_frame.replace(/^avatar_frame_/, "");
|
||||
avatarHtml = '<span class="avatar-frame-wrapper">' +
|
||||
'<span class="avatar-frame ' + frameClass + '"></span>' +
|
||||
'<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
|
||||
'</span>';
|
||||
} else {
|
||||
avatarHtml = '<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">';
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
${avatarHtml}
|
||||
<span class="user-name${userNameExtraClass}" style="${nameColor}">${username}</span>
|
||||
<span class="user-badge-slot">${badges}</span>
|
||||
`;
|
||||
|
||||
// 具体点击、双击与手机双触发由 Vite 的 right-panel.js 统一事件委托处理
|
||||
fragment.appendChild(item);
|
||||
});
|
||||
|
||||
targetContainer.replaceChildren(fragment);
|
||||
refreshRenderedUserBadges(targetContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染完整用户列表(含下拉选单与在线计数)。
|
||||
*/
|
||||
export function renderUserList() {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
if (state.userListRenderTimer) {
|
||||
window.clearTimeout(state.userListRenderTimer);
|
||||
state.userListRenderTimer = null;
|
||||
}
|
||||
|
||||
const userList = state.userList;
|
||||
const toUserSelect = state.toUserSelect;
|
||||
|
||||
// 获取排序方式和搜索词
|
||||
const sortSelect = document.getElementById("user-sort-select");
|
||||
const sortBy = sortSelect ? sortSelect.value : "default";
|
||||
const searchInput = document.getElementById("user-search-input");
|
||||
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
|
||||
|
||||
// 调用核心渲染
|
||||
if (userList) {
|
||||
renderUserListToContainer(userList, sortBy, keyword);
|
||||
}
|
||||
|
||||
// 下拉框重建
|
||||
if (toUserSelect) {
|
||||
const selectFragment = document.createDocumentFragment();
|
||||
const everyoneOption = document.createElement("option");
|
||||
everyoneOption.value = "大家";
|
||||
everyoneOption.textContent = "大家";
|
||||
selectFragment.appendChild(everyoneOption);
|
||||
|
||||
for (let username in state.onlineUsers) {
|
||||
if (username !== window.chatContext?.username) {
|
||||
const option = document.createElement("option");
|
||||
option.value = username;
|
||||
option.textContent = username === "AI小班长" ? "🤖 AI小班长" : username;
|
||||
selectFragment.appendChild(option);
|
||||
}
|
||||
}
|
||||
toUserSelect.replaceChildren(selectFragment);
|
||||
}
|
||||
|
||||
// 在线计数
|
||||
const count = Object.keys(state.onlineUsers).length;
|
||||
const onlineCount = state.onlineCount;
|
||||
const onlineCountBottom = state.onlineCountBottom;
|
||||
if (onlineCount) onlineCount.innerText = count;
|
||||
if (onlineCountBottom) onlineCountBottom.innerText = count;
|
||||
const footer = document.getElementById("online-count-footer");
|
||||
if (footer) footer.innerText = count;
|
||||
|
||||
// 派发用户列表更新事件,供手机端抽屉同步
|
||||
window.dispatchEvent(new Event("chatroom:users-updated"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并高频在线名单变动,避免 Presence 连续进出时重复重建名单 DOM。
|
||||
*/
|
||||
export function scheduleRenderUserList(delay = 120) {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
if (state.userListRenderTimer) {
|
||||
window.clearTimeout(state.userListRenderTimer);
|
||||
}
|
||||
state.userListRenderTimer = window.setTimeout(() => {
|
||||
state.userListRenderTimer = null;
|
||||
renderUserList();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索/过滤用户列表(仅操作 DOM 可见性,不重建)。
|
||||
*/
|
||||
export function filterUserList() {
|
||||
const searchInput = document.getElementById("user-search-input");
|
||||
const keyword = searchInput ? searchInput.value.trim().toLowerCase() : "";
|
||||
const state = window.chatState;
|
||||
const userList = state?.userList || document.getElementById("online-users-list");
|
||||
if (!userList) return;
|
||||
|
||||
const items = userList.querySelectorAll(".user-item");
|
||||
items.forEach((item) => {
|
||||
if (!keyword) {
|
||||
item.style.display = "";
|
||||
return;
|
||||
}
|
||||
const name = (item.dataset.username || item.textContent || "").toLowerCase();
|
||||
item.style.display = name.includes(keyword) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度用户列表搜索过滤,避免每个按键都同步扫描名单 DOM。
|
||||
*/
|
||||
export function scheduleFilterUserList() {
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
if (state.userFilterRenderTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleFilter = window.requestAnimationFrame || ((callback) => window.setTimeout(callback, 16));
|
||||
state.userFilterRenderTimer = scheduleFilter(() => {
|
||||
state.userFilterRenderTimer = null;
|
||||
filterUserList();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 徽标旋转定时器 ──
|
||||
let badgeRotationInterval = null;
|
||||
|
||||
export function startBadgeRotation() {
|
||||
if (badgeRotationInterval) return;
|
||||
|
||||
badgeRotationInterval = window.setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
|
||||
const state = window.chatState;
|
||||
if (!state) return;
|
||||
|
||||
state.userBadgeRotationTick = (state.userBadgeRotationTick + 1) % 4;
|
||||
|
||||
if (state.userList) {
|
||||
refreshRenderedUserBadges(state.userList);
|
||||
}
|
||||
const mobileUsersList = document.getElementById("mob-online-users-list");
|
||||
if (mobileUsersList?.offsetParent !== null) {
|
||||
refreshRenderedUserBadges(mobileUsersList);
|
||||
}
|
||||
|
||||
// 同步每日状态 UI
|
||||
if (typeof window.syncDailyStatusUi === "function") {
|
||||
window.syncDailyStatusUi();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function stopBadgeRotation() {
|
||||
if (badgeRotationInterval) {
|
||||
window.clearInterval(badgeRotationInterval);
|
||||
badgeRotationInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 挂载到 window ──
|
||||
window.renderUserList = renderUserList;
|
||||
window.renderUserListToContainer = renderUserListToContainer;
|
||||
window.filterUserList = filterUserList;
|
||||
window.scheduleFilterUserList = scheduleFilterUserList;
|
||||
window.scheduleRenderUserList = scheduleRenderUserList;
|
||||
window.refreshRenderedUserBadges = refreshRenderedUserBadges;
|
||||
window.buildUserBadgeHtml = buildUserBadgeHtml;
|
||||
window._renderUserListToContainer = renderUserListToContainer;
|
||||
|
||||
export {
|
||||
buildUserPrimaryBadgeHtml,
|
||||
buildUserVipBadgeHtml,
|
||||
buildUserStatusBadgeHtml,
|
||||
buildUserSignIdentityBadgeHtml,
|
||||
resolveUserDailyStatus,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
// 会员进退场豪华横幅模块,从 Blade 内联脚本迁移至 Vite 管理。
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
|
||||
/**
|
||||
* 转义会员横幅文本,避免横幅层被注入 HTML。
|
||||
*/
|
||||
function escapePresenceText(text) {
|
||||
return escapeHtml(String(text ?? "")).replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据不同的会员横幅风格返回渐变与光影配置。
|
||||
*/
|
||||
function getVipPresenceStyleConfig(style, color) {
|
||||
const fallback = color || "#f59e0b";
|
||||
|
||||
const map = {
|
||||
aurora: {
|
||||
gradient: `linear-gradient(135deg, #f59e0b, #fbbf24, #fef3c7)`,
|
||||
glow: `rgba(251, 191, 36, 0.4)`,
|
||||
accent: "#78350f",
|
||||
},
|
||||
storm: {
|
||||
gradient: `linear-gradient(135deg, #0ea5e9, #7dd3fc, #f0f9ff)`,
|
||||
glow: `rgba(125, 211, 252, 0.4)`,
|
||||
accent: "#0369a1",
|
||||
},
|
||||
royal: {
|
||||
gradient: `linear-gradient(135deg, #d97706, #fcd34d, #fffbeb)`,
|
||||
glow: `rgba(252, 211, 77, 0.4)`,
|
||||
accent: "#92400e",
|
||||
},
|
||||
cosmic: {
|
||||
gradient: `linear-gradient(135deg, #db2777, #f472b6, #fdf2f8)`,
|
||||
glow: `rgba(244, 114, 182, 0.4)`,
|
||||
accent: "#9d174d",
|
||||
},
|
||||
farewell: {
|
||||
gradient: `linear-gradient(135deg, #ea580c, #fb923c, #fff7ed)`,
|
||||
glow: `rgba(251, 146, 60, 0.4)`,
|
||||
accent: "#9a3412",
|
||||
},
|
||||
};
|
||||
|
||||
return map[style] || map.aurora;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示会员进退场豪华横幅。
|
||||
*
|
||||
* @param {Record<string, any>} payload 携带 presence_text / presence_type / presence_icon 等字段的消息载荷
|
||||
* @returns {void}
|
||||
*/
|
||||
function showVipPresenceBanner(payload) {
|
||||
if (!payload || !payload.presence_text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = document.getElementById("vip-presence-banner");
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
const styleConfig = getVipPresenceStyleConfig(
|
||||
payload.presence_banner_style,
|
||||
payload.presence_color,
|
||||
);
|
||||
const bannerTypeLabel =
|
||||
payload.presence_type === "leave"
|
||||
? "离场提示"
|
||||
: payload.presence_type === "purchase"
|
||||
? "开通喜报"
|
||||
: "闪耀登场";
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.id = "vip-presence-banner";
|
||||
banner.className = "vip-presence-banner";
|
||||
banner.innerHTML = `
|
||||
<div class="vip-presence-banner__glow" style="background:${styleConfig.glow};"></div>
|
||||
<div class="vip-presence-banner__card" style="background:${styleConfig.gradient}; border-color:${payload.presence_color || "#fff"};">
|
||||
<div class="vip-presence-banner__meta">
|
||||
<span class="vip-presence-banner__icon">${escapeHtml(payload.presence_icon || "👑")}</span>
|
||||
<span class="vip-presence-banner__level">${escapeHtml(payload.presence_level_name || "尊贵会员")}</span>
|
||||
<span class="vip-presence-banner__type">${bannerTypeLabel}</span>
|
||||
</div>
|
||||
<div class="vip-presence-banner__text" style="color:${styleConfig.accent};">
|
||||
${escapePresenceText(payload.presence_text)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.classList.add("is-leaving");
|
||||
setTimeout(() => banner.remove(), 700);
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
// 挂载到 window 供 Blade 脚本及其他模块使用。
|
||||
window.showVipPresenceBanner = showVipPresenceBanner;
|
||||
|
||||
export { showVipPresenceBanner, getVipPresenceStyleConfig, escapePresenceText };
|
||||
@@ -2,10 +2,31 @@
|
||||
|
||||
let welcomeMenuEventsBound = false;
|
||||
|
||||
/**
|
||||
* 切换欢迎语下拉浮层的显示/隐藏。
|
||||
*/
|
||||
function toggleWelcomeMenu(event) {
|
||||
event.stopPropagation();
|
||||
const menu = document.getElementById("welcome-menu");
|
||||
const adminMenu = document.getElementById("admin-menu");
|
||||
const blockMenu = document.getElementById("block-menu");
|
||||
const featureMenu = document.getElementById("feature-menu");
|
||||
const dailyStatusEditor = document.getElementById("daily-status-editor-overlay");
|
||||
|
||||
if (!menu) return;
|
||||
|
||||
[adminMenu, blockMenu, featureMenu, dailyStatusEditor].forEach((el) => {
|
||||
if (el) el.style.display = "none";
|
||||
});
|
||||
|
||||
menu.style.display = menu.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
// 挂载到 window 供 Blade 脚本及其他模块使用。
|
||||
window.toggleWelcomeMenu = toggleWelcomeMenu;
|
||||
|
||||
/**
|
||||
* 绑定欢迎语菜单按钮、菜单内点击拦截与模板发送事件。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function bindWelcomeMenuControls() {
|
||||
if (welcomeMenuEventsBound || typeof document === "undefined") {
|
||||
@@ -19,32 +40,28 @@ export function bindWelcomeMenuControls() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 欢迎语菜单外部点击关闭仍由主脚本处理,这里只负责菜单按钮与菜单内部。
|
||||
const toggleButton = event.target.closest("[data-chat-welcome-menu-toggle]");
|
||||
if (toggleButton) {
|
||||
event.preventDefault();
|
||||
window.toggleWelcomeMenu?.(event);
|
||||
|
||||
toggleWelcomeMenu(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = event.target.closest("[data-chat-welcome-menu]");
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
if (!menu) return;
|
||||
|
||||
// 阻止菜单内部点击冒泡,避免选择模板时被外层关闭逻辑抢先处理。
|
||||
// 阻止菜单内部点击冒泡。
|
||||
event.stopPropagation();
|
||||
|
||||
const item = event.target.closest("[data-chat-welcome-template]");
|
||||
if (!item || !menu.contains(item)) {
|
||||
return;
|
||||
}
|
||||
if (!item || !menu.contains(item)) return;
|
||||
|
||||
const template = item.getAttribute("data-chat-welcome-template") || "";
|
||||
// 模板内容只从 data 属性读取,实际发送仍交给旧的 sendWelcomeTpl。
|
||||
// sendWelcomeTpl 仍由 Blade 维护(依赖 sendMessage),这里通过 window 调用。
|
||||
if (template && typeof window.sendWelcomeTpl === "function") {
|
||||
window.sendWelcomeTpl(template);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { toggleWelcomeMenu };
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
'ring' => ['label' => '求婚戒指', 'color' => 'bg-rose-100 text-rose-700'],
|
||||
'auto_fishing' => ['label' => '自动钓鱼卡', 'color' => 'bg-emerald-100 text-emerald-700'],
|
||||
'sign_repair' => ['label' => '签到补签卡', 'color' => 'bg-teal-100 text-teal-700'],
|
||||
'msg_bubble' => ['label' => '消息气泡', 'color' => 'bg-violet-100 text-violet-700'],
|
||||
'msg_name_color' => ['label' => '昵称颜色', 'color' => 'bg-pink-100 text-pink-700'],
|
||||
'msg_text_color' => ['label' => '文字颜色', 'color' => 'bg-cyan-100 text-cyan-700'],
|
||||
'avatar_frame' => ['label' => '头像框', 'color' => 'bg-amber-100 text-amber-700'],
|
||||
];
|
||||
$isSuperAdmin = Auth::id() === 1;
|
||||
@endphp
|
||||
@@ -287,6 +291,10 @@
|
||||
<option value="ring">ring — 求婚戒指</option>
|
||||
<option value="auto_fishing">auto_fishing — 自动钓鱼卡</option>
|
||||
<option value="sign_repair">sign_repair — 签到补签卡</option>
|
||||
<option value="msg_bubble">msg_bubble — 消息气泡</option>
|
||||
<option value="msg_name_color">msg_name_color — 昵称颜色</option>
|
||||
<option value="msg_text_color">msg_text_color — 文字颜色</option>
|
||||
<option value="avatar_frame">avatar_frame — 头像框</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,11 +117,15 @@
|
||||
'envelopeStatusUrlTemplate' => '/wedding/__ID__/envelope-status',
|
||||
],
|
||||
'earnRewardUrl' => route('earn.video_reward'),
|
||||
'roomsOnlineStatusUrl' => route('chat.rooms-online-status'),
|
||||
'changelogUrl' => route('changelog.index'),
|
||||
'roomsIndexUrl' => route('rooms.index'),
|
||||
'chatImageRetentionDays' => 3,
|
||||
'initialState' => [
|
||||
'historyMessages' => $historyMessages ?? [],
|
||||
'localClearStorageKey' => "local_clear_msg_id_{$room->id}",
|
||||
'welcomeMessage' => $initialWelcomeMessage ?? null,
|
||||
'welcomeMessages' => $initialWelcomeMessages ?? [],
|
||||
'entryEffect' => $newbieEffect ?: ($initialPresenceTheme['presence_effect'] ?? ($weekEffect ?? null)),
|
||||
'presenceTheme' => $initialPresenceTheme ?? null,
|
||||
'pendingProposal' => $pendingProposal ?? null,
|
||||
@@ -134,9 +138,8 @@
|
||||
// 兼容底部存量同步脚本,完整 URL 工厂由 Vite 的 chat-room/context.js 继续补齐。
|
||||
window.chatContext = JSON.parse(document.getElementById('chat-context-data')?.textContent || '{}');
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
@vite(['resources/css/app.css', 'resources/css/chat.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
<script defer src="/js/alpinejs.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}?v={{ filemtime(public_path('css/chat.css')) }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -219,6 +222,10 @@
|
||||
{{-- 节日福利弹窗 --}}
|
||||
@include('chat.partials.holiday-modal')
|
||||
@include('chat.partials.daily-sign-in-modal')
|
||||
{{-- 留言板弹窗 --}}
|
||||
@include('chat.partials.guestbook-modal')
|
||||
{{-- 反馈弹窗 --}}
|
||||
@include('chat.partials.feedback-modal')
|
||||
|
||||
{{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS) ═══════════ --}}
|
||||
{{-- deferChatGameBootstrap 已迁移到 resources/js/chat-room/game-bootstrap.js --}}
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
{{--
|
||||
文件功能:用户反馈模态弹窗(仿留言板弹窗样式)
|
||||
供聊天室工具栏"反馈"按钮使用,替代跳转反馈页面。
|
||||
|
||||
依赖 CSS:与留言板弹窗一致的蓝白风格
|
||||
依赖 JS:resources/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:与商店弹窗一致的蓝白风格
|
||||
依赖 JS:resources/js/chat-room/guestbook.js
|
||||
--}}
|
||||
|
||||
<style>
|
||||
/* 留言板弹窗遮罩 */
|
||||
#guestbook-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 弹窗主体 */
|
||||
#guestbook-modal-inner {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
width: 720px;
|
||||
max-width: 95vw;
|
||||
max-height: 84vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
#guestbook-modal-header {
|
||||
background: linear-gradient(135deg, #336699, #5a8fc0);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#guestbook-modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#guestbook-modal-close {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
opacity: .8;
|
||||
transition: opacity .15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#guestbook-modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
#guestbook-toast {
|
||||
display: none;
|
||||
margin: 6px 12px 0;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tab 导航 */
|
||||
#guestbook-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #cde;
|
||||
flex-shrink: 0;
|
||||
background: #eef4fb;
|
||||
}
|
||||
.guestbook-tab {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: bold;
|
||||
transition: all .2s;
|
||||
}
|
||||
.guestbook-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
|
||||
.guestbook-tab:hover { color: #5a8fc0; }
|
||||
|
||||
/* 留言列表容器 */
|
||||
#guestbook-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
/* 留言卡片 */
|
||||
.gb-card {
|
||||
background: #fff;
|
||||
border: 1px solid #d0e4f5;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.gb-card:hover {
|
||||
border-color: #5a8fc0;
|
||||
box-shadow: 0 2px 8px rgba(51, 102, 153, .18);
|
||||
}
|
||||
.gb-card.gb-secret {
|
||||
border-color: #f9c7d3;
|
||||
background: #fff5f7;
|
||||
}
|
||||
.gb-card.gb-secret:hover {
|
||||
border-color: #e91e63;
|
||||
box-shadow: 0 2px 8px rgba(233, 30, 99, .12);
|
||||
}
|
||||
|
||||
.gb-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.gb-card-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.gb-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #dbeafe;
|
||||
color: #336699;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gb-avatar.secret {
|
||||
background: #fce4ec;
|
||||
color: #c62828;
|
||||
}
|
||||
.gb-who {
|
||||
font-weight: bold;
|
||||
color: #225588;
|
||||
}
|
||||
.gb-arrow {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gb-towho {
|
||||
font-weight: 600;
|
||||
color: #336699;
|
||||
}
|
||||
.gb-towho.public {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
.gb-secret-badge {
|
||||
background: #fce4ec;
|
||||
color: #c62828;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.gb-time {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gb-body {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
.gb-card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.gb-btn {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.gb-btn:hover { opacity: .8; }
|
||||
.gb-btn-reply {
|
||||
background: #eef4fb;
|
||||
color: #336699;
|
||||
border: 1px solid #cde;
|
||||
}
|
||||
.gb-btn-reply:hover { background: #dbeafe; }
|
||||
.gb-btn-delete {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.gb-btn-delete:hover { background: #fecaca; }
|
||||
|
||||
/* 写留言按钮 */
|
||||
#guestbook-write-btn-wrap {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
background: #eef4fb;
|
||||
border-top: 1px solid #cde;
|
||||
text-align: center;
|
||||
}
|
||||
#guestbook-write-btn {
|
||||
background: linear-gradient(135deg, #336699, #5a8fc0);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 7px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
width: 100%;
|
||||
}
|
||||
#guestbook-write-btn:hover { opacity: .85; }
|
||||
|
||||
/* 写留言表单(内嵌) */
|
||||
#guestbook-write-form {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #cde;
|
||||
}
|
||||
#guestbook-write-form.active { display: block; }
|
||||
|
||||
.gb-form-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gb-form-row label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #336699;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.gb-form-row select,
|
||||
.gb-form-row textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #cde;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gb-form-row select:focus,
|
||||
.gb-form-row textarea:focus {
|
||||
border-color: #336699;
|
||||
}
|
||||
.gb-form-row textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.gb-form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
.gb-form-checkbox input[type="checkbox"] {
|
||||
accent-color: #336699;
|
||||
}
|
||||
|
||||
.gb-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.gb-form-actions button {
|
||||
padding: 5px 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.gb-form-actions button:hover { opacity: .85; }
|
||||
.gb-form-submit {
|
||||
background: linear-gradient(135deg, #336699, #5a8fc0);
|
||||
color: #fff;
|
||||
}
|
||||
.gb-form-cancel {
|
||||
background: #e5e7eb;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.gb-empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gb-empty-icon {
|
||||
font-size: 36px;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.gb-loading {
|
||||
text-align: center;
|
||||
color: #6366f1;
|
||||
padding: 30px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
#guestbook-pager {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
background: #eef4fb;
|
||||
border-top: 1px solid #cde;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#guestbook-pager button {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid #cde;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #336699;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
transition: all .15s;
|
||||
}
|
||||
#guestbook-pager button:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
#guestbook-pager button:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
#guestbook-pager span {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="guestbook-modal"
|
||||
data-guestbook-data-url="{{ route('guestbook.data') }}"
|
||||
data-guestbook-store-url="{{ route('guestbook.store') }}"
|
||||
data-guestbook-destroy-url-template="{{ route('guestbook.destroy', '__ID__') }}">
|
||||
<div id="guestbook-modal-inner">
|
||||
{{-- 标题栏 --}}
|
||||
<div id="guestbook-modal-header">
|
||||
<div id="guestbook-modal-title">✉️ 星光留言板</div>
|
||||
<span id="guestbook-modal-close" data-guestbook-modal-close>✕</span>
|
||||
</div>
|
||||
|
||||
{{-- Toast --}}
|
||||
<div id="guestbook-toast"></div>
|
||||
|
||||
{{-- Tab 导航 --}}
|
||||
<div id="guestbook-tabs">
|
||||
<button class="guestbook-tab active" data-guestbook-tab="public">🌍 公共留言墙</button>
|
||||
<button class="guestbook-tab" data-guestbook-tab="inbox">📥 我收件的</button>
|
||||
<button class="guestbook-tab" data-guestbook-tab="outbox">📤 我发出的</button>
|
||||
</div>
|
||||
|
||||
{{-- 留言列表 --}}
|
||||
<div id="guestbook-list">
|
||||
<div class="gb-loading">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 写留言表单(内嵌) --}}
|
||||
<div id="guestbook-write-form">
|
||||
<form id="guestbook-form" method="POST">
|
||||
@csrf
|
||||
<div class="gb-form-row">
|
||||
<label>📬 收件人 <span style="font-weight:normal;color:#9ca3af;">(留空则为公共留言)</span></label>
|
||||
<select name="towho" id="gb-towho">
|
||||
<option value="">🌍 公共留言(所有人可见)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gb-form-row">
|
||||
<label class="gb-form-checkbox">
|
||||
<input type="checkbox" name="secret" value="1"> 🔒 悄悄话(仅双方可见)
|
||||
</label>
|
||||
</div>
|
||||
<div class="gb-form-row">
|
||||
<label>📝 留言内容 <span style="color:#dc2626;">*</span></label>
|
||||
<textarea name="text_body" id="gb-text-body" rows="3" required placeholder="相逢何必曾相识,留下您的足迹吧..."></textarea>
|
||||
</div>
|
||||
<div class="gb-form-actions">
|
||||
<button type="button" class="gb-form-cancel" data-gb-form-cancel>取消</button>
|
||||
<button type="submit" class="gb-form-submit">✈️ 发送飞鸽</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 写留言按钮 --}}
|
||||
<div id="guestbook-write-btn-wrap">
|
||||
<button id="guestbook-write-btn" data-guestbook-write-btn>✏️ 写新留言</button>
|
||||
</div>
|
||||
|
||||
{{-- 分页 --}}
|
||||
<div id="guestbook-pager" style="display:none;">
|
||||
<button id="gb-page-prev" data-gb-page="prev">‹ 上一页</button>
|
||||
<span id="gb-page-info"></span>
|
||||
<button id="gb-page-next" data-gb-page="next">下一页 ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,8 +26,8 @@
|
||||
<div class="tool-btn" data-toolbar-action="avatar" title="修改头像">头像</div>
|
||||
<div class="tool-btn" data-toolbar-action="settings" title="个人设置">设置
|
||||
</div>
|
||||
<div class="tool-btn" data-toolbar-url="{{ route('feedback.index') }}" title="反馈">反馈</div>
|
||||
<div class="tool-btn" data-toolbar-url="{{ route('guestbook.index') }}" title="留言板/私信">留言</div>
|
||||
<div class="tool-btn" data-toolbar-action="feedback" title="反馈">反馈</div>
|
||||
<div class="tool-btn" data-toolbar-action="guestbook" title="留言板/私信">留言</div>
|
||||
<div class="tool-btn" data-toolbar-url="{{ route('guide') }}" title="规则/帮助">规则</div>
|
||||
|
||||
@if ($user->id === 1 || $user->activePosition()->exists())
|
||||
@@ -44,10 +44,10 @@
|
||||
</div>
|
||||
|
||||
{{-- ═══════════ 头像选择弹窗 ═══════════ --}}
|
||||
<div id="avatar-picker-modal"
|
||||
<div id="avatar-picker-modal" data-avatar-picker-overlay
|
||||
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
|
||||
background:rgba(0,0,0,0.5); z-index:9999; justify-content:center; align-items:center;">
|
||||
<div
|
||||
<div data-avatar-picker-panel
|
||||
style="background:#fff; width:600px; max-height:80vh; border-radius:6px; overflow:hidden;
|
||||
box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column;">
|
||||
{{-- 标题栏 --}}
|
||||
@@ -341,6 +341,40 @@
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
/* 装扮列表区 — 与商品列表同风格 */
|
||||
#shop-decorations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
display: none;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
/* Tab 导航 */
|
||||
#shop-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #cde;
|
||||
flex-shrink: 0;
|
||||
background: #eef4fb;
|
||||
}
|
||||
.shop-tab {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: bold;
|
||||
transition: all .2s;
|
||||
}
|
||||
.shop-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
|
||||
.shop-tab:hover { color: #5a8fc0; }
|
||||
|
||||
/* 分组标题 — 独占一整行 */
|
||||
.shop-group-header {
|
||||
grid-column: 1 / -1;
|
||||
@@ -404,6 +438,38 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shop-validity {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 装扮Tab购买说明 */
|
||||
.decoration-note {
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 10px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* 装扮卡片状态行(独立一行,显示在商品名下方) */
|
||||
.decoration-status-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 装扮卡片状态标签 */
|
||||
.decoration-status {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.decoration-status.active { background: #065f46; color: #6ee7b7; }
|
||||
.decoration-expiry { font-size: 10px; color: #9ca3af; margin-top: 2px; }
|
||||
|
||||
.shop-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -598,11 +664,22 @@
|
||||
{{-- Toast --}}
|
||||
<div id="shop-toast"></div>
|
||||
|
||||
{{-- Tab 导航 --}}
|
||||
<div id="shop-tabs">
|
||||
<button class="shop-tab active" data-shop-tab="items">特效道具</button>
|
||||
<button class="shop-tab" data-shop-tab="decorations">个人装扮</button>
|
||||
</div>
|
||||
|
||||
{{-- 商品网格 --}}
|
||||
<div id="shop-items-list">
|
||||
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 装扮网格 --}}
|
||||
<div id="shop-decorations-list">
|
||||
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 改名内嵌遮罩 --}}
|
||||
<div id="shop-rename-overlay">
|
||||
<div id="shop-rename-box">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@
|
||||
#shop-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
/* 顶部 tab 栏高度约 26px,底部状态栏约 22px */
|
||||
top: 26px;
|
||||
/* 顶部 tab 栏高度约 56px,底部状态栏约 22px */
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
@@ -19,6 +19,9 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.shop-tab.active { color: #e2e8f0 !important; border-bottom-color: #818cf8 !important; }
|
||||
.shop-tab:hover { color: #c7d2fe; }
|
||||
|
||||
#shop-balance-bar {
|
||||
padding: 6px 8px;
|
||||
background: linear-gradient(135deg, #1e1b4b, #312e81);
|
||||
@@ -133,6 +136,14 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.shop-validity {
|
||||
margin-top: 2px;
|
||||
color: #8b93a7;
|
||||
font-size: 9px;
|
||||
font-weight: normal;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* 购买按钮 */
|
||||
.shop-btn {
|
||||
display: inline-flex;
|
||||
@@ -241,6 +252,37 @@
|
||||
margin-top: 5px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* ── 装扮卡片状态标签 ────────────── */
|
||||
.decoration-status {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.decoration-status.active { background: #065f46; color: #6ee7b7; }
|
||||
.decoration-expiry { font-size: 9px; color: #9ca3af; margin-top: 2px; }
|
||||
|
||||
.decoration-note {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 0 6px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(251, 191, 36, .35);
|
||||
border-radius: 8px;
|
||||
background: rgba(120, 53, 15, .28);
|
||||
color: #fde68a;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#shop-decorations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 5px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4338ca #0f0c29;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="shop-panel"
|
||||
@@ -254,14 +296,25 @@
|
||||
<span id="shop-week-badge"></span>
|
||||
</div>
|
||||
|
||||
{{-- Tab 导航 --}}
|
||||
<div id="shop-tabs" style="display:flex; border-bottom: 1px solid #3730a3; flex-shrink: 0;">
|
||||
<button class="shop-tab active" data-shop-tab="items" style="flex:1; padding: 6px; background: transparent; border: none; color: #a5b4fc; font-size: 11px; cursor: pointer; border-bottom: 2px solid transparent;">特效道具</button>
|
||||
<button class="shop-tab" data-shop-tab="decorations" style="flex:1; padding: 6px; background: transparent; border: none; color: #6b7280; font-size: 11px; cursor: pointer; border-bottom: 2px solid transparent;">个人装扮</button>
|
||||
</div>
|
||||
|
||||
{{-- Toast --}}
|
||||
<div id="shop-toast"></div>
|
||||
|
||||
{{-- 商品列表 --}}
|
||||
{{-- 特效道具列表 --}}
|
||||
<div id="shop-items-list">
|
||||
<div style="text-align:center;color:#6366f1;padding:20px 0;font-size:11px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 个人装扮列表 --}}
|
||||
<div id="shop-decorations-list">
|
||||
<div style="text-align:center;color:#6366f1;padding:20px 0;font-size:11px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 改名弹框 --}}
|
||||
<div id="rename-modal">
|
||||
<div id="rename-modal-inner">
|
||||
|
||||
+5
-2
@@ -50,8 +50,8 @@ Route::middleware('guest')->group(function () {
|
||||
Route::post('/reset-password', [PasswordResetController::class, 'update'])->name('password.update');
|
||||
});
|
||||
|
||||
// 处理退出登录
|
||||
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
||||
// 处理退出登录(同时接受 GET 和 POST,避免 POST 后刷新页面导致 404)
|
||||
Route::match(['get', 'post'], '/logout', [AuthController::class, 'logout'])->name('logout');
|
||||
|
||||
// 登录失效后用于收口离场清理的签名地址,不依赖当前会话。
|
||||
Route::get('/room/{id}/leave-expired/{user}', [ChatController::class, 'expiredLeave'])
|
||||
@@ -93,6 +93,7 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
|
||||
// ---- 第十阶段:站内信与留言板系统 ----
|
||||
Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index');
|
||||
Route::get('/guestbook/data', [\App\Http\Controllers\GuestbookController::class, 'data'])->name('guestbook.data');
|
||||
Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store');
|
||||
Route::delete('/guestbook/{id}', [\App\Http\Controllers\GuestbookController::class, 'destroy'])->name('guestbook.destroy');
|
||||
|
||||
@@ -365,6 +366,8 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
// ---- 用户反馈(独立前台页面 /feedback)----
|
||||
// 反馈列表页
|
||||
Route::get('/feedback', [FeedbackController::class, 'index'])->name('feedback.index');
|
||||
// 第一页数据(供聊天室模态弹窗用)
|
||||
Route::get('/feedback/data', [FeedbackController::class, 'data'])->name('feedback.data');
|
||||
// 懒加载接口:scroll 到底追加更多反馈
|
||||
Route::get('/feedback/more', [FeedbackController::class, 'loadMore'])->name('feedback.more');
|
||||
// 提交新反馈
|
||||
|
||||
@@ -568,9 +568,54 @@ class ChatControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试定向消息仅广播到发送方与接收方私有频道。
|
||||
* 测试历史消息会让旁观用户看到普通定向发言,但不会泄露悄悄话。
|
||||
*/
|
||||
public function test_targeted_message_event_uses_private_user_channels(): void
|
||||
public function test_room_history_keeps_non_secret_targeted_messages_visible_to_others(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'histtg']);
|
||||
$sender = User::factory()->create(['username' => 'history-sender']);
|
||||
$receiver = User::factory()->create(['username' => 'history-receiver']);
|
||||
$observer = User::factory()->create(['username' => 'history-observer']);
|
||||
$chatState = app(\App\Services\ChatStateService::class);
|
||||
|
||||
$chatState->pushMessage($room->id, [
|
||||
'id' => 1,
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $sender->username,
|
||||
'to_user' => $receiver->username,
|
||||
'content' => '公开对你说',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
$chatState->pushMessage($room->id, [
|
||||
'id' => 2,
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $sender->username,
|
||||
'to_user' => $receiver->username,
|
||||
'content' => '旁人不可见悄悄话',
|
||||
'is_secret' => true,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($observer)->get(route('chat.room', $room->id));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('historyMessages', function (array $messages): bool {
|
||||
$contents = collect($messages)->pluck('content');
|
||||
|
||||
return $contents->contains('公开对你说')
|
||||
&& ! $contents->contains('旁人不可见悄悄话');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试悄悄话消息仅广播到发送方与接收方私有频道。
|
||||
*/
|
||||
public function test_secret_message_event_uses_private_user_channels(): void
|
||||
{
|
||||
$sender = User::factory()->create(['username' => 'sender-user']);
|
||||
$receiver = User::factory()->create(['username' => 'receiver-user']);
|
||||
@@ -597,7 +642,30 @@ class ChatControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试公共消息仍广播到房间 Presence 频道。
|
||||
* 测试普通定向消息仍广播到房间 Presence 频道。
|
||||
*/
|
||||
public function test_non_secret_targeted_message_event_uses_room_presence_channel(): void
|
||||
{
|
||||
$event = new MessageSent(3, [
|
||||
'room_id' => 3,
|
||||
'from_user' => 'tester',
|
||||
'to_user' => 'receiver-user',
|
||||
'content' => '公开对你说',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$channels = $event->broadcastOn();
|
||||
|
||||
$this->assertCount(1, $channels);
|
||||
$this->assertInstanceOf(PresenceChannel::class, $channels[0]);
|
||||
$this->assertSame('presence-room.3', $channels[0]->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试对大家消息仍广播到房间 Presence 频道。
|
||||
*/
|
||||
public function test_public_message_event_still_uses_room_presence_channel(): void
|
||||
{
|
||||
@@ -721,6 +789,8 @@ class ChatControllerTest extends TestCase
|
||||
$room = Room::create(['room_name' => 'annsafe']);
|
||||
$user = $this->createUserWithPositionPermissions([
|
||||
PositionPermissionRegistry::ROOM_ANNOUNCEMENT,
|
||||
], [
|
||||
'has_received_new_gift' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get(route('chat.room', $room->id));
|
||||
@@ -799,6 +869,39 @@ class ChatControllerTest extends TestCase
|
||||
$this->assertGreaterThanOrEqual(0, $user->exp_num); // Might be 1 depending on sysparam
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试低经验新人心跳后不会被降到 0 级,刷新后仍可进入默认房间。
|
||||
*/
|
||||
public function test_low_exp_newbie_keeps_minimum_level_after_heartbeat(): void
|
||||
{
|
||||
Sysparam::updateOrCreate(['alias' => 'exp_per_heartbeat'], ['body' => '0']);
|
||||
Sysparam::updateOrCreate(['alias' => 'jjb_per_heartbeat'], ['body' => '0']);
|
||||
Sysparam::updateOrCreate(['alias' => 'auto_event_chance'], ['body' => '0']);
|
||||
Cache::flush();
|
||||
|
||||
$room = Room::create([
|
||||
'room_name' => 'newhb',
|
||||
'permit_level' => 1,
|
||||
'door_open' => true,
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'user_level' => 1,
|
||||
'exp_num' => 0,
|
||||
'has_received_new_gift' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get(route('chat.room', $room->id))->assertOk();
|
||||
|
||||
$heartbeatResponse = $this->actingAs($user)->postJson(route('chat.heartbeat', $room->id));
|
||||
|
||||
$heartbeatResponse->assertOk();
|
||||
$heartbeatResponse->assertJsonPath('data.user_level', 1);
|
||||
$this->assertSame(1, $user->fresh()->user_level);
|
||||
|
||||
Redis::del("room:{$room->id}:alive:{$user->username}");
|
||||
$this->actingAs($user)->get(route('chat.room', $room->id))->assertOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试显式退房会清理 Redis 在线状态。
|
||||
*/
|
||||
@@ -992,6 +1095,43 @@ class ChatControllerTest extends TestCase
|
||||
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试新人首次进房时首屏历史包含礼包公告、AI 欢迎和普通进场播报。
|
||||
*/
|
||||
public function test_newbie_first_join_keeps_bonus_ai_and_entry_welcome_messages(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'newbie']);
|
||||
$user = User::factory()->create([
|
||||
'jjb' => 0,
|
||||
'has_received_new_gift' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||
|
||||
$response->assertOk();
|
||||
$history = collect($response->viewData('historyMessages'));
|
||||
$initialWelcomeMessages = collect($response->viewData('initialWelcomeMessages'));
|
||||
|
||||
$newbieBonusMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'newbie_bonus');
|
||||
$aiWelcomeMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ai_newbie_welcome');
|
||||
$entryMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'entry_broadcast');
|
||||
|
||||
$this->assertNotNull($newbieBonusMessage);
|
||||
$this->assertSame('系统公告', $newbieBonusMessage['from_user']);
|
||||
$this->assertStringContainsString('6666 金币新人大礼包', $newbieBonusMessage['content']);
|
||||
$this->assertNotNull($aiWelcomeMessage);
|
||||
$this->assertSame('AI小班长', $aiWelcomeMessage['from_user']);
|
||||
$this->assertSame('大家', $aiWelcomeMessage['to_user']);
|
||||
$this->assertNotNull($entryMessage);
|
||||
$this->assertSame($user->username, $entryMessage['welcome_user']);
|
||||
$this->assertTrue($user->fresh()->has_received_new_gift);
|
||||
$this->assertSame(6666, (int) $user->fresh()->jjb);
|
||||
$this->assertSame(
|
||||
['newbie_bonus', 'ai_newbie_welcome', 'entry_broadcast'],
|
||||
$initialWelcomeMessages->pluck('welcome_kind')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试可以获取所有房间的在线人数状态。
|
||||
*/
|
||||
|
||||
@@ -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/**"],
|
||||
|
||||
Reference in New Issue
Block a user