Compare commits
22 Commits
e8b4dcc968
..
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 |
@@ -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>
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ class ShopController extends Controller
|
||||
'total_price' => $result['total_price'] ?? $item->price,
|
||||
];
|
||||
|
||||
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
|
||||
if ($item->type === 'auto_fishing') {
|
||||
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
}
|
||||
|
||||
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||
if (! empty($result['slot'])) {
|
||||
$response['slot'] = $result['slot'];
|
||||
|
||||
@@ -52,12 +52,14 @@ class DecorationService
|
||||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||
*
|
||||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||
* 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param ShopItem $item 装扮商品
|
||||
* @param int $quantity 购买份数
|
||||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item): array
|
||||
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
|
||||
{
|
||||
// 根据商品类型映射到对应槽位
|
||||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||
@@ -65,14 +67,29 @@ class DecorationService
|
||||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||
}
|
||||
|
||||
$totalPrice = $item->price * $quantity;
|
||||
|
||||
// 校验金币余额
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
if ($user->jjb < $totalPrice) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
// 计算过期时间(至少 1 天)
|
||||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||
$expiresAt = Carbon::now()->addDays($days);
|
||||
$totalDays = $days * $quantity;
|
||||
|
||||
// 检查同一槽位是否已激活相同样式 → 叠加天数
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$isSameActive = ! empty($decorations[$slot])
|
||||
&& ($decorations[$slot]['style'] ?? '') === $item->slug;
|
||||
|
||||
if ($isSameActive) {
|
||||
// 在现有到期时间上追加天数
|
||||
$existingExpires = Carbon::parse($decorations[$slot]['expires_at']);
|
||||
$expiresAt = $existingExpires->copy()->addDays($totalDays);
|
||||
} else {
|
||||
$expiresAt = Carbon::now()->addDays($totalDays);
|
||||
}
|
||||
|
||||
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
|
||||
$source = match ($item->type) {
|
||||
@@ -84,11 +101,11 @@ class DecorationService
|
||||
};
|
||||
|
||||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
|
||||
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
|
||||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$item->price, $source,
|
||||
"购买装扮:{$item->name}({$days}天)"
|
||||
$user, 'gold', -$totalPrice, $source,
|
||||
"购买装扮:{$item->name}({$totalDays}天)"
|
||||
);
|
||||
|
||||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||
@@ -96,11 +113,11 @@ class DecorationService
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'price_paid' => $totalPrice,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并)
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加)
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$decorations[$slot] = [
|
||||
'style' => $item->slug,
|
||||
@@ -113,13 +130,19 @@ class DecorationService
|
||||
// 重新读取最新余额,避免缓存脏数据
|
||||
$balanceAfter = (int) $user->fresh()->jjb;
|
||||
|
||||
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
|
||||
$displayDays = $isSameActive
|
||||
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
|
||||
: $totalDays;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
|
||||
'balance_after' => $balanceAfter,
|
||||
'slot' => $slot,
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +49,10 @@ class ShopService
|
||||
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
|
||||
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
|
||||
// ── 个人装扮购买(委托给 DecorationService)───────────────
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item),
|
||||
'msg_text_color' => $this->decorationService->purchase($user, $item),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item),
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'msg_text_color' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
default => ['ok' => false, 'message' => '未知商品类型'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
/**
|
||||
* 文件功能:聊天室主界面样式表
|
||||
* 复刻原版海军蓝聊天室色系 (CHAT.CSS)
|
||||
* 包含布局、消息窗格、输入工具栏、用户列表、弹窗等所有聊天界面样式
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
原版海军蓝聊天室色系 (CHAT.CSS 复刻)
|
||||
═══════════════════════════════════════════════════ */
|
||||
:root {
|
||||
--bg-main: #EAF2FF;
|
||||
--bg-bar: #b0d8ff;
|
||||
--bg-header: #4488aa;
|
||||
--bg-toolbar: #5599aa;
|
||||
--text-link: #336699;
|
||||
--text-navy: #112233;
|
||||
--text-white: #ffffff;
|
||||
--border-blue: #336699;
|
||||
--scrollbar-base: #b0d8ff;
|
||||
--scrollbar-arrow: #336699;
|
||||
--scrollbar-track: #EAF2FF;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif;
|
||||
font-size: 10pt;
|
||||
background: var(--bg-main);
|
||||
color: var(--text-navy);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局链接样式 */
|
||||
a {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #009900;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 - 模拟原版 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-base);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--scrollbar-arrow);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: var(--scrollbar-base);
|
||||
}
|
||||
|
||||
/* ── 主布局 ─────────────────────────────────────── */
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
/* 防止整体滚动 */
|
||||
}
|
||||
|
||||
/* 左侧主区域 */
|
||||
.chat-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
/* 约束 Flex 子项高度,防止长历史记录撑破 100vh 导致输入栏被挤出屏幕 */
|
||||
}
|
||||
|
||||
/* 竖向工具条 */
|
||||
.chat-toolbar {
|
||||
width: 28px;
|
||||
background: var(--bg-toolbar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 58px;
|
||||
/* 与标题栏+公告栏底部对齐 */
|
||||
padding-bottom: 75px;
|
||||
/* 与输入栏顶部对齐 */
|
||||
border-left: 1px solid var(--border-blue);
|
||||
border-right: 1px solid var(--border-blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-toolbar .tool-btn {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
font-size: 11px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.chat-toolbar .tool-btn:hover {
|
||||
color: #fff;
|
||||
background: #5599cc;
|
||||
}
|
||||
|
||||
/* 右侧用户列表面板 */
|
||||
.chat-right {
|
||||
width: 175px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-blue);
|
||||
background: var(--bg-main);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 标题栏 ─────────────────────────────────────── */
|
||||
.room-title-bar {
|
||||
background: var(--bg-header);
|
||||
color: var(--text-white);
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-blue);
|
||||
}
|
||||
|
||||
.room-title-bar .room-name {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.room-title-bar .room-desc {
|
||||
color: #cee8ff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 公告/祝福语滚动条(复刻原版 marquee) */
|
||||
.room-announcement-bar {
|
||||
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid var(--border-blue);
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 消息窗格 ───────────────────────────────────── */
|
||||
.message-panes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 5px 8px;
|
||||
background: var(--bg-main);
|
||||
font-size: 10pt;
|
||||
line-height: 170%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-pane.say1 {
|
||||
border-bottom: 2px solid var(--border-blue);
|
||||
}
|
||||
|
||||
.message-pane.say2 {
|
||||
display: block;
|
||||
flex: 0.4;
|
||||
border-top: 2px solid var(--border-blue);
|
||||
}
|
||||
|
||||
.message-pane.say1 {
|
||||
flex: 0.6;
|
||||
}
|
||||
|
||||
|
||||
/* 消息行样式 */
|
||||
.msg-line {
|
||||
margin: 2px 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.msg-line .msg-time {
|
||||
font-size: 9px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.msg-line .msg-user {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.msg-line .msg-user:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.msg-line .msg-action {
|
||||
color: #009900;
|
||||
}
|
||||
|
||||
.msg-line .msg-secret {
|
||||
color: #cc00cc;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg-line .msg-content {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.msg-line.sys-msg {
|
||||
color: #cc0000;
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
/* ── 底部输入工具栏 ─────────────────────────────── */
|
||||
.input-bar {
|
||||
height: auto;
|
||||
min-height: 75px;
|
||||
background: var(--bg-bar);
|
||||
border-top: 2px solid var(--border-blue);
|
||||
padding: 3px 6px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* 欢迎语下拉浮层 */
|
||||
.welcome-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 9000;
|
||||
background: #fff;
|
||||
border: 1px solid #b0c8e0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.welcome-menu-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #224466;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid #eef4fb;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.welcome-menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.welcome-menu-item:hover {
|
||||
background: #ddeeff;
|
||||
color: #003366;
|
||||
}
|
||||
|
||||
|
||||
.input-bar select,
|
||||
.input-bar input[type="text"],
|
||||
.input-bar input[type="checkbox"] {
|
||||
font-size: 12px;
|
||||
border: 1px solid navy;
|
||||
color: #224466;
|
||||
padding: 1px 2px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-bar select {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.input-bar .input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-bar .say-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
font-size: 12px;
|
||||
border: 1px solid navy;
|
||||
color: #224466;
|
||||
padding: 3px 5px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar .say-input:focus {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 3px rgba(0, 102, 204, 0.3);
|
||||
}
|
||||
|
||||
.input-bar .send-btn {
|
||||
background: var(--bg-header);
|
||||
color: white;
|
||||
border: 1px solid var(--border-blue);
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.input-bar .send-btn:hover {
|
||||
background: #336699;
|
||||
}
|
||||
|
||||
.input-bar .send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-bar label {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ── 右侧用户列表 Tab 栏 ──────────────────────── */
|
||||
.right-tabs {
|
||||
display: flex;
|
||||
background: var(--bg-bar);
|
||||
border-bottom: 2px solid navy;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right-tabs .tab-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
color: navy;
|
||||
background: var(--bg-bar);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.right-tabs .tab-btn.active {
|
||||
background: navy;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.right-tabs .tab-btn:hover:not(.active) {
|
||||
background: #99c8ee;
|
||||
}
|
||||
|
||||
/* 用户列表内容 */
|
||||
.user-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px dotted #cde;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.user-item:hover {
|
||||
background: #d0e8ff;
|
||||
}
|
||||
|
||||
.user-item .user-head {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
background: transparent;
|
||||
mix-blend-mode: multiply;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-item .user-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-link);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-item .user-badge-slot {
|
||||
flex-shrink: 0;
|
||||
max-width: 78px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-item .user-sex {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-item .user-level {
|
||||
font-size: 9px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.user-badge-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-hover-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10030;
|
||||
max-width: min(220px, calc(100vw - 20px));
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #244d77;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(to bottom, #fffef2, #fff6c9);
|
||||
color: #243b53;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 6px 18px rgba(17, 34, 51, 0.24);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-hover-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #fff9d8;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.chat-hover-tooltip[data-side="right"]::after {
|
||||
left: -4px;
|
||||
border-left: 1px solid #244d77;
|
||||
border-bottom: 1px solid #244d77;
|
||||
}
|
||||
|
||||
.chat-hover-tooltip[data-side="left"]::after {
|
||||
right: -4px;
|
||||
border-top: 1px solid #244d77;
|
||||
border-right: 1px solid #244d77;
|
||||
}
|
||||
|
||||
/* 在线人数统计 */
|
||||
.online-stats {
|
||||
background: var(--bg-bar);
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
padding: 3px;
|
||||
border-top: 1px solid var(--border-blue);
|
||||
color: navy;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 用户名片弹窗 ───────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: white;
|
||||
border: 2px solid var(--border-blue);
|
||||
border-radius: 8px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, var(--bg-header), var(--border-blue));
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-body .profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-body .profile-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--bg-bar);
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.modal-body .profile-info h4 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--text-navy);
|
||||
}
|
||||
|
||||
.modal-body .profile-info .level-badge {
|
||||
display: inline-block;
|
||||
background: var(--bg-bar);
|
||||
color: var(--border-blue);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.modal-body .profile-info .sex-badge {
|
||||
font-size: 11px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.modal-body .profile-detail {
|
||||
background: #f5f9ff;
|
||||
border: 1px solid #e0ecff;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-actions button,
|
||||
.modal-actions a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-kick {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border-color: #fcc;
|
||||
}
|
||||
|
||||
.btn-kick:hover {
|
||||
background: #fcc;
|
||||
}
|
||||
|
||||
.btn-mute {
|
||||
background: #fff8e6;
|
||||
color: #b86e00;
|
||||
border-color: #ffe0a0;
|
||||
}
|
||||
|
||||
.btn-mute:hover {
|
||||
background: #ffe0a0;
|
||||
}
|
||||
|
||||
.btn-mail {
|
||||
background: #f0e8ff;
|
||||
color: #6633cc;
|
||||
border-color: #d8c8ff;
|
||||
}
|
||||
|
||||
.btn-mail:hover {
|
||||
background: #d8c8ff;
|
||||
}
|
||||
|
||||
.btn-whisper {
|
||||
background: #e8f5ff;
|
||||
color: var(--text-link);
|
||||
border-color: var(--bg-bar);
|
||||
}
|
||||
|
||||
.btn-whisper:hover {
|
||||
background: var(--bg-bar);
|
||||
}
|
||||
|
||||
/* 禁言输入行 */
|
||||
.mute-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.mute-form input {
|
||||
width: 60px;
|
||||
border: 1px solid #ffe0a0;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mute-form button {
|
||||
background: #b86e00;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 头像选择器 ─────────────────────────────────── */
|
||||
.avatar-option {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 3px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.avatar-option:hover {
|
||||
border-color: #88bbdd;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.avatar-option.selected {
|
||||
border-color: #336699;
|
||||
box-shadow: 0 0 6px rgba(51, 102, 153, 0.5);
|
||||
}
|
||||
|
||||
/* 送花礼物弹跳动画 */
|
||||
@keyframes giftBounce {
|
||||
0% {
|
||||
transform: scale(0.3) rotate(-15deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.3) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(0.9) rotate(-3deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
手机端响应式布局(≤ 640px)
|
||||
═══════════════════════════════════════════════════ */
|
||||
@media (max-width: 640px) {
|
||||
|
||||
/* 隐藏中间工具条和右侧面板,让消息区占满全宽 */
|
||||
.chat-toolbar,
|
||||
.chat-right {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 输入框最小宽度适配手机屏 */
|
||||
.input-bar .say-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── 右下角浮动按钮组 ── */
|
||||
#mobile-fabs {
|
||||
position: fixed;
|
||||
top: 35%;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.mobile-fab {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(51, 102, 153, 0.4);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: #336699;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-fab:active {
|
||||
transform: scale(0.92);
|
||||
background: rgba(51, 102, 153, 0.15);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ── 抽屉遮罩 ── */
|
||||
#mobile-drawer-mask {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 600;
|
||||
}
|
||||
|
||||
#mobile-drawer-mask.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── 抽屉通用容器(顶部向下滑入) ── */
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 700;
|
||||
background: #fff;
|
||||
border-bottom: 2px solid var(--border-blue);
|
||||
border-radius: 0 0 14px 14px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-drawer.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 抽屉顶部标题栏 */
|
||||
.mobile-drawer-header {
|
||||
background: var(--bg-header);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-drawer-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ── 工具条抽屉:横向网格排列按钮 ── */
|
||||
#mobile-drawer-toolbar .mobile-drawer-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border-blue);
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#mobile-drawer-toolbar .mobile-tool-btn {
|
||||
background: var(--bg-toolbar);
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
padding: 14px 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
writing-mode: horizontal-tb;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
#mobile-drawer-toolbar .mobile-tool-btn:active {
|
||||
background: #5599cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── 名单/房间抽屉:限定高度,内部 flex 子项才能正确贡献高度 ── */
|
||||
#mobile-drawer-users {
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
#mobile-drawer-users .mobile-right-clone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 抽屉内的 tabs 复用样式 */
|
||||
#mobile-drawer-users .right-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#mobile-drawer-users .user-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#mobile-drawer-users .online-stats {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
|
||||
#mob-online-users-list .user-item {
|
||||
padding: 7px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 现有弹窗(settings / avatar / shop)手机端自适应 ── */
|
||||
/* 防止固定像素宽度的 modal 内容区超出手机屏宽 */
|
||||
#settings-modal > div,
|
||||
#avatar-picker-modal > div {
|
||||
width: 92vw !important;
|
||||
max-width: 420px !important;
|
||||
max-height: 85vh !important;
|
||||
}
|
||||
|
||||
#shop-modal {
|
||||
padding: 12px 6px !important;
|
||||
}
|
||||
|
||||
#shop-modal-inner {
|
||||
width: 92vw !important;
|
||||
max-width: 420px !important;
|
||||
max-height: 85vh !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* ── 手机端隐藏房间介绍 ── */
|
||||
.room-desc {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── 手机端隐藏输入栏中不常用的控件(动作/字色/字号/禁音/分屏) ── */
|
||||
/* 用 :has() 选择器精准定位含对应 input/select 的 label */
|
||||
.input-row label:has(#action),
|
||||
.input-row label:has(#font_color),
|
||||
.input-row label:has(#font_size_select),
|
||||
.input-row label:has(#sound_muted) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端(> 640px)不显示浮动按钮和抽屉组件 */
|
||||
@media (min-width: 641px) {
|
||||
#mobile-fabs,
|
||||
#mobile-drawer-mask,
|
||||
#mobile-drawer-toolbar,
|
||||
#mobile-drawer-users {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
+472
-684
File diff suppressed because it is too large
Load Diff
@@ -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,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;
|
||||
};
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function buildChatMessageContent(msg, fontColor, textColorClass) {
|
||||
<span style="display:inline-flex; align-items:flex-start; gap:6px; vertical-align:middle;">
|
||||
<a href="${fullUrl}" data-full="${fullUrl}" data-alt="${imageName}" data-chat-image-lightbox-open
|
||||
style="display:inline-block; border:1px solid rgba(15,23,42,.14); border-radius:10px; overflow:hidden; background:#f8fafc; box-shadow:0 2px 10px rgba(15,23,42,.10);">
|
||||
<img src="${thumbUrl}" alt="${imageName}"
|
||||
<img src="${thumbUrl}" alt="${imageName}" loading="lazy" decoding="async"
|
||||
style="display:block; max-width:96px; max-height:96px; object-fit:cover; cursor:zoom-in;">
|
||||
</a>
|
||||
${captionHtml}
|
||||
@@ -202,8 +202,25 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
} else if (msg.action === "欢迎") {
|
||||
div.style.cssText =
|
||||
"background: linear-gradient(135deg, #eff6ff, #f0f9ff); border: 1.5px solid #3b82f6; border-radius: 5px; padding: 5px 10px; margin: 3px 0; box-shadow: 0 1px 3px rgba(59,130,246,0.12);";
|
||||
const parsedContent = parseBracketUsers(msg.content, "#1d4ed8");
|
||||
html = `<div style="color: #1e40af;">💬 ${parsedContent} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
|
||||
const userName = msg.from_user;
|
||||
const rawContent = msg.content || "";
|
||||
const colonIndex = rawContent.indexOf(":");
|
||||
let clickablePrefix = "";
|
||||
let bodyPart = rawContent;
|
||||
if (colonIndex !== -1) {
|
||||
const prefixStr = rawContent.substring(0, colonIndex);
|
||||
bodyPart = rawContent.substring(colonIndex);
|
||||
const lastIdx = prefixStr.lastIndexOf(userName);
|
||||
if (lastIdx !== -1) {
|
||||
clickablePrefix =
|
||||
prefixStr.substring(0, lastIdx) +
|
||||
clickableUser(userName, "#1d4ed8", nameClass);
|
||||
} else {
|
||||
clickablePrefix = prefixStr;
|
||||
}
|
||||
}
|
||||
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
|
||||
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
|
||||
timeStrOverride = true;
|
||||
} else if (SYSTEM_USERS.includes(msg.from_user)) {
|
||||
if (msg.from_user === "系统公告") {
|
||||
@@ -440,6 +457,23 @@ export function enqueueChatMessage(msg) {
|
||||
state.chatMessageFlushTimer = scheduleFlush(flushQueuedChatMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为普通用户聊天消息(非系统/游戏通知)。
|
||||
*/
|
||||
function isUserChatMessage(msg) {
|
||||
if (!msg || !msg.from_user) return false;
|
||||
const u = msg.from_user;
|
||||
if (SYSTEM_USERS.includes(u)) return false;
|
||||
if (u.endsWith("播报")) return false;
|
||||
if (u === "百家乐" || u === "跑马") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 后台恢复时系统通知最多保留条数 */
|
||||
const MAX_SYSTEM_BURST = 20;
|
||||
/** 后台恢复时超过该时间的系统通知直接丢弃(分钟) */
|
||||
const MAX_SYSTEM_AGE_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* 分批渲染待处理消息,给动画、输入和滚动留出主线程时间。
|
||||
*/
|
||||
@@ -449,6 +483,55 @@ export function flushQueuedChatMessages() {
|
||||
|
||||
state.chatMessageFlushTimer = null;
|
||||
|
||||
// 大批量消息堆积(后台标签页恢复)时,保留所有用户聊天记录,
|
||||
// 但过时的系统/游戏通知只保留最近 MAX_SYSTEM_BURST 条。
|
||||
if (state.pendingChatMessages.length > MAX_SYSTEM_BURST + 30) {
|
||||
const now = Date.now();
|
||||
const maxAge = MAX_SYSTEM_AGE_MINUTES * 60 * 1000;
|
||||
const totalSystem = state.pendingChatMessages.filter((m) => !isUserChatMessage(m)).length;
|
||||
let systemSeen = 0;
|
||||
let dropped = 0;
|
||||
|
||||
const filtered = state.pendingChatMessages.filter((msg) => {
|
||||
if (isUserChatMessage(msg)) return true;
|
||||
|
||||
systemSeen++;
|
||||
|
||||
// 超过10分钟的系统通知直接丢弃
|
||||
let msgTime = 0;
|
||||
if (msg.sent_at) {
|
||||
msgTime = new Date(msg.sent_at.replace(" ", "T")).getTime();
|
||||
}
|
||||
if (msgTime > 0 && now - msgTime > maxAge) {
|
||||
dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从旧到新遍历,只保留最后 MAX_SYSTEM_BURST 条系统通知
|
||||
const remainingAfter = totalSystem - systemSeen;
|
||||
if (remainingAfter >= MAX_SYSTEM_BURST) {
|
||||
dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (dropped > 0) {
|
||||
const container = state.container;
|
||||
if (container) {
|
||||
const notice = document.createElement("div");
|
||||
notice.className = "msg-line msg-burst-notice";
|
||||
notice.style.cssText =
|
||||
"text-align:center;padding:6px 0;margin:4px 0;font-size:12px;color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;";
|
||||
notice.textContent = `⏫ 省略了 ${dropped} 条系统通知`;
|
||||
container.appendChild(notice);
|
||||
}
|
||||
}
|
||||
|
||||
state.pendingChatMessages = filtered;
|
||||
}
|
||||
|
||||
const batch = state.pendingChatMessages.splice(0, CHAT_MESSAGE_FLUSH_BATCH_SIZE);
|
||||
const renderBatch = createChatMessageRenderBatch();
|
||||
batch.forEach((msg) => appendMessage(msg, renderBatch));
|
||||
|
||||
@@ -703,6 +703,11 @@ export function bindProfileControls() {
|
||||
if (event.target.closest("[data-settings-modal-overlay]")) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
|
||||
// ── 头像选择弹窗:点击遮罩层关闭 ──
|
||||
if (event.target.closest("[data-avatar-picker-overlay]") && !event.target.closest("[data-avatar-picker-panel]")) {
|
||||
closeAvatarPicker();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
|
||||
@@ -336,10 +336,10 @@ export function renderDecorations(data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
list.innerHTML = "";
|
||||
|
||||
// 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。
|
||||
// 购买说明:已激活同款支持叠加天数,不同款式替换时旧装扮作废不退款。
|
||||
const note = document.createElement("div");
|
||||
note.className = "decoration-note";
|
||||
note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。";
|
||||
note.innerHTML = "📌 购买说明:同类型只生效一款;已激活的同款续购自动叠加天数,无需一次买满;购买不同款式时旧装扮自动作废且不退款。";
|
||||
list.appendChild(note);
|
||||
|
||||
DECORATION_GROUPS.forEach((group) => {
|
||||
@@ -548,18 +548,52 @@ async function confirmAndBuyItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
// 个性装扮支持多份购买(叠加天数),弹出数量选择
|
||||
if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") {
|
||||
quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1;
|
||||
if (quantity === null || quantity === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const validityText = buildValidityText(item);
|
||||
const replacementText = DECORATION_TYPE_TO_SLOT[item.type]
|
||||
? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。"
|
||||
: "";
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`;
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\n\n确定购买吗?`;
|
||||
const confirmed = await confirmShopPurchase(confirmMessage);
|
||||
|
||||
if (confirmed) {
|
||||
buyItem(item.id, item.name, item.price, "all", "", quantity);
|
||||
buyItem(item.id, item.name, item.price, "all", "", quantity, item.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数量输入弹窗。
|
||||
*
|
||||
* @param {string} hint 提示文案
|
||||
* @param {number} [minVal=1] 最小值
|
||||
* @param {number} [maxVal=99] 最大值
|
||||
* @returns {Promise<number|null>} 返回数量,用户取消返回 null
|
||||
*/
|
||||
window.promptQuantity = async (hint, minVal = 1, maxVal = 99) => {
|
||||
if (window.chatDialog?.prompt) {
|
||||
const result = await window.chatDialog.prompt(hint, "1", "购买数量");
|
||||
if (result === null || result === undefined) return null;
|
||||
const val = parseInt(result, 10);
|
||||
if (isNaN(val) || val < minVal || val > maxVal) {
|
||||
window.chatDialog?.alert?.(`请输入 ${minVal}~${maxVal} 之间的整数`);
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
const result = window.prompt(`${hint}\n数量(${minVal}~${maxVal}):`, "1");
|
||||
if (result === null) return null;
|
||||
const val = parseInt(result, 10);
|
||||
if (isNaN(val) || val < minVal || val > maxVal) {
|
||||
alert(`请输入 ${minVal}~${maxVal} 之间的整数`);
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容全局弹窗组件缺失时的原生确认。
|
||||
*
|
||||
@@ -645,7 +679,7 @@ export function confirmGift() {
|
||||
}
|
||||
|
||||
closeGiftDialog();
|
||||
buyItem(item.id, item.name, item.price, recipient, message);
|
||||
buyItem(item.id, item.name, item.price, recipient, message, 1, item.type);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,7 +693,7 @@ export function confirmGift() {
|
||||
* @param {number} quantity 购买数量
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function buyItem(itemId, name, price, recipient, message, quantity = 1) {
|
||||
export async function buyItem(itemId, name, price, recipient, message, quantity = 1, itemType = '') {
|
||||
try {
|
||||
const response = await fetch(getShopUrls().buy, {
|
||||
method: "POST",
|
||||
@@ -675,7 +709,7 @@ export async function buyItem(itemId, name, price, recipient, message, quantity
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
handleBuySuccess(data, name);
|
||||
handleBuySuccess(data, name, itemType);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -692,7 +726,7 @@ export async function buyItem(itemId, name, price, recipient, message, quantity
|
||||
* @param {string} itemName 商品名称
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleBuySuccess(data, itemName) {
|
||||
function handleBuySuccess(data, itemName, itemType = '') {
|
||||
const balance = document.getElementById("shop-jjb");
|
||||
|
||||
if (data.jjb !== undefined && balance) {
|
||||
@@ -717,8 +751,15 @@ function handleBuySuccess(data, itemName) {
|
||||
shopLoaded = false;
|
||||
setTimeout(() => {
|
||||
fetchShopData();
|
||||
shopLoaded = true;
|
||||
}, 1000);
|
||||
}, 300);
|
||||
|
||||
// 自动钓鱼卡购买后立即开启自动钓鱼
|
||||
if (itemType === "auto_fishing" && typeof window.checkAndAutoStartFishing === "function") {
|
||||
if (!window.chatContext) window.chatContext = {};
|
||||
window.chatContext.autoFishingMinutesLeft = Number(data.auto_fishing_minutes_left || 1);
|
||||
window.chatContext.fishingCooldownSeconds = 0;
|
||||
setTimeout(() => window.checkAndAutoStartFishing(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]?.();
|
||||
|
||||
@@ -138,9 +138,8 @@
|
||||
// 兼容底部存量同步脚本,完整 URL 工厂由 Vite 的 chat-room/context.js 继续补齐。
|
||||
window.chatContext = JSON.parse(document.getElementById('chat-context-data')?.textContent || '{}');
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
@vite(['resources/css/app.css', 'resources/css/chat.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
<script defer src="/js/alpinejs.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}?v={{ filemtime(public_path('css/chat.css')) }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -223,6 +222,10 @@
|
||||
{{-- 节日福利弹窗 --}}
|
||||
@include('chat.partials.holiday-modal')
|
||||
@include('chat.partials.daily-sign-in-modal')
|
||||
{{-- 留言板弹窗 --}}
|
||||
@include('chat.partials.guestbook-modal')
|
||||
{{-- 反馈弹窗 --}}
|
||||
@include('chat.partials.feedback-modal')
|
||||
|
||||
{{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS) ═══════════ --}}
|
||||
{{-- deferChatGameBootstrap 已迁移到 resources/js/chat-room/game-bootstrap.js --}}
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
{{--
|
||||
文件功能:用户反馈模态弹窗(仿留言板弹窗样式)
|
||||
供聊天室工具栏"反馈"按钮使用,替代跳转反馈页面。
|
||||
|
||||
依赖 CSS:与留言板弹窗一致的蓝白风格
|
||||
依赖 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;">
|
||||
{{-- 标题栏 --}}
|
||||
|
||||
@@ -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');
|
||||
// 提交新反馈
|
||||
|
||||
@@ -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