feat: 任命/撤销通知系统 + 用户名片UI优化

- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
This commit is contained in:
2026-02-28 23:44:38 +08:00
parent a599047cf0
commit 5f30220609
80 changed files with 8579 additions and 473 deletions

View File

@@ -0,0 +1,129 @@
---
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

20
.gemini/settings.json Normal file
View File

@@ -0,0 +1,20 @@
{
"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"
}
}
}
}

View File

@@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

256
.junie/guidelines.md Normal file
View File

@@ -0,0 +1,256 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>

20
.junie/mcp/mcp.json Normal file
View File

@@ -0,0 +1,20 @@
{
"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"
}
}
}
}

View File

@@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

256
AGENTS.md Normal file
View File

@@ -0,0 +1,256 @@
<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>

256
GEMINI.md Normal file
View File

@@ -0,0 +1,256 @@
<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>

View File

@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:任命公告广播事件
* 任命操作成功后向对应聊天室 PresenceChannel 推送任命消息,
* 前端接收后展示全屏礼花动画和隆重公告弹窗。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AppointmentAnnounced implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构建任命公告事件
*
* @param int $roomId 广播目标房间 ID
* @param string $targetUsername 被任命用户名
* @param string $positionIcon 职务图标
* @param string $positionName 职务名称
* @param string $departmentName 所属部门名称
* @param string $operatorName 任命人用户名
*/
public function __construct(
public readonly int $roomId,
public readonly string $targetUsername,
public readonly string $positionIcon,
public readonly string $positionName,
public readonly string $departmentName,
public readonly string $operatorName,
public readonly string $type = 'appoint', // appoint | revoke
) {}
/**
* 广播至目标房间的 PresenceChannel
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'type' => $this->type,
'target_username' => $this->targetUsername,
'position_icon' => $this->positionIcon,
'position_name' => $this->positionName,
'department_name' => $this->departmentName,
'operator_name' => $this->operatorName,
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:开发日志发布广播事件
* 当管理员发布新的开发日志并勾选"通知大厅"时触发
* 广播至 Room ID=1(星光大厅)的 presence 频道
* 前端监听此事件并在聊天消息区显示系统通知(含可点击的查看详情链接)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\DevChangelog;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChangelogPublished implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:传入触发通知的日志对象
*
* @param DevChangelog $changelog 刚发布的开发日志
*/
public function __construct(
public readonly DevChangelog $changelog,
) {}
/**
* 广播频道:仅向 Room 1(星光大厅)的 presence 频道广播
* 复用现有聊天室频道机制,无需额外配置
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
// 固定广播至 Room ID = 1 的大厅频道
new PresenceChannel('room.1'),
];
}
/**
* 广播事件名称(前端 .listen('ChangelogPublished', ...) 监听此名称)
*/
public function broadcastAs(): string
{
return 'ChangelogPublished';
}
/**
* 广播携带的数据(前端可直接访问)
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'version' => $this->changelog->version,
'title' => $this->changelog->title,
'type' => $this->changelog->type,
'type_label' => $this->changelog->type_label,
// 前端点击后跳转的目标 URL自动锚定至对应版本
'url' => url('/changelog').'#v'.$this->changelog->version,
];
}
}

View File

@@ -0,0 +1,202 @@
<?php
/**
* 文件功能:后台任命管理控制器
* 管理员可以在此查看所有在职人员、进行新增任命和撤销职务
* 任命/撤销通过 AppointmentService 执行,权限日志自动记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\AppointmentAnnounced;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\User;
use App\Models\UserPosition;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 任命管理主列表(当前全部在职人员)
*/
public function index(): View
{
// 所有在职记录(按部门 rank 倒序、职务 rank 倒序)
$activePositions = UserPosition::query()
->where('is_active', true)
->with([
'user',
'position.department',
'appointedBy',
])
->join('positions', 'user_positions.position_id', '=', 'positions.id')
->join('departments', 'positions.department_id', '=', 'departments.id')
->orderByDesc('departments.rank')
->orderByDesc('positions.rank')
->select('user_positions.*')
->get();
// 部门+职务(供新增任命弹窗下拉选择,带在职人数统计)
$departments = Department::with([
'positions' => fn ($q) => $q->withCount('activeUserPositions')->ordered(),
])->ordered()->get();
return view('admin.appointments.index', compact('activePositions', 'departments'));
}
/**
* 执行新增任命
* 管理员在后台直接任命用户,操作人为当前登录管理员
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:255',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$targetPosition = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $targetPosition, $request->remark);
if ($result['ok']) {
// 向所有当前有人在线的聊天室广播礼花公告(后台操作人不在聊天室内)
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $targetPosition->icon ?? '🎖️',
positionName: $targetPosition->name,
departmentName: $targetPosition->department?->name ?? '',
operatorName: $operator->username,
));
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 撤销职务
*/
public function revoke(Request $request, UserPosition $userPosition): RedirectResponse
{
$operator = Auth::user();
$target = $userPosition->user;
// 撤销前先记录职务信息(撤销后关联就断了)
$userPosition->load('position.department');
$posIcon = $userPosition->position?->icon ?? '🎖️';
$posName = $userPosition->position?->name ?? '';
$deptName = $userPosition->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
if ($result['ok']) {
// 向所有活跃房间广播撤销公告
if ($posName) {
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 查看某任职记录的在职登录日志
*/
public function dutyLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department', 'appointedBy']);
$logs = $userPosition->dutyLogs()
->orderByDesc('login_at')
->paginate(30);
return view('admin.appointments.duty-logs', compact('userPosition', 'logs'));
}
/**
* 查看某任职记录的权限操作日志
*/
public function authorityLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department']);
$logs = $userPosition->authorityLogs()
->with(['targetUser', 'targetPosition'])
->orderByDesc('created_at')
->paginate(30);
return view('admin.appointments.authority-logs', compact('userPosition', 'logs'));
}
/**
* 历史任职记录(全部 is_active=false 的记录)
*/
public function history(): View
{
$history = UserPosition::query()
->where('is_active', false)
->with(['user', 'position.department', 'appointedBy', 'revokedBy'])
->orderByDesc('revoked_at')
->paginate(30);
return view('admin.appointments.history', compact('history'));
}
/**
* 搜索用户(供任命弹窗 Ajax 快速查找)
*/
public function searchUsers(Request $request): JsonResponse
{
$keyword = $request->input('q', '');
$users = User::query()
->where('id', '!=', 1) // 排除超级管理员
->where('username', 'like', "%{$keyword}%")
->whereDoesntHave('activePosition') // 排除已有职务的用户
->select('id', 'username', 'user_level')
->limit(10)
->get();
return response()->json($users);
}
}

View File

@@ -52,10 +52,12 @@ class AutoactController extends Controller
/**
* 更新事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Autoact $autoact): RedirectResponse
{
$event = Autoact::findOrFail($id);
$event = $autoact;
$data = $request->validate([
'text_body' => 'required|string|max:500',
@@ -71,26 +73,29 @@ class AutoactController extends Controller
/**
* 切换事件启用/禁用状态
*
* @param Autoact $autoact 路由模型自动注入
*/
public function toggle(int $id): JsonResponse
public function toggle(Autoact $autoact): JsonResponse
{
$event = Autoact::findOrFail($id);
$event->enabled = ! $event->enabled;
$event->save();
$autoact->enabled = ! $autoact->enabled;
$autoact->save();
return response()->json([
'status' => 'success',
'enabled' => $event->enabled,
'message' => $event->enabled ? '已启用' : '已禁用',
'enabled' => $autoact->enabled,
'message' => $autoact->enabled ? '已启用' : '已禁用',
]);
}
/**
* 删除事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Autoact $autoact): RedirectResponse
{
Autoact::findOrFail($id)->delete();
$autoact->delete();
return redirect()->route('admin.autoact.index')->with('success', '事件已删除!');
}

View File

@@ -0,0 +1,191 @@
<?php
/**
* 文件功能:后台开发日志管理控制器(仅 id=1 超级管理员可访问)
* 提供开发日志的 CRUD 功能,发布时可选择向 Room 1 大厅广播通知
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\ChangelogPublished;
use App\Http\Controllers\Controller;
use App\Jobs\SaveMessageJob;
use App\Models\DevChangelog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/**
* 后台日志列表(含草稿)
*/
public function index(): View
{
$logs = DevChangelog::orderByDesc('created_at')->paginate(20);
return view('admin.changelog.index', compact('logs'));
}
/**
* 新增日志表单页(预填今日日期)
*/
public function create(): View
{
// 预填今日日期为版本号
$todayVersion = now()->format('Y-m-d');
return view('admin.changelog.form', [
'log' => null,
'todayVersion' => $todayVersion,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => true,
]);
}
/**
* 保存新日志
* 若勾选"立即发布",则记录 published_at 并可选向大厅广播通知
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
$notifyChat = (bool) ($data['notify_chat'] ?? false);
/** @var DevChangelog $log */
$log = DevChangelog::create([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
// 只有同时勾选"通知"才记录 notify_chat否则置 false
'notify_chat' => $isPublished && $notifyChat,
// 首次发布时记录发布时间
'published_at' => $isPublished ? Carbon::now() : null,
]);
// 如果发布且勾选了"通知大厅用户",则触发 WebSocket 广播 + 持久化到消息库
if ($isPublished && $notifyChat) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志创建成功!'.($isPublished ? '(已发布)' : '(草稿已保存)'));
}
/**
* 编辑日志表单页
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function edit(DevChangelog $changelog): View
{
return view('admin.changelog.form', [
'log' => $changelog,
'todayVersion' => $changelog->version,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => false,
]);
}
/**
* 更新日志内容(编辑操作不更新 published_at不触发通知
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function update(Request $request, DevChangelog $changelog): RedirectResponse
{
$log = $changelog;
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
// 如果从草稿切换为发布,记录首次发布时间
$publishedAt = $log->published_at;
if ($isPublished && ! $log->is_published) {
$publishedAt = Carbon::now();
} elseif (! $isPublished) {
// 从发布退回草稿,清除发布时间
$publishedAt = null;
}
$log->update([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
'published_at' => $publishedAt,
]);
// 若勾选了「通知大厅用户」且当前已发布,则广播通知 + 持久化到消息库
$notifyChat = (bool) ($data['notify_chat'] ?? false);
if ($notifyChat && $isPublished) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志已更新!');
}
/**
* 删除日志
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function destroy(DevChangelog $changelog): RedirectResponse
{
$changelog->delete();
return redirect()->route('admin.changelogs.index')
->with('success', '日志已删除。');
}
/**
* 将版本更新通知持久化为 Room 1 系统消息
* 确保用户重进聊天室时仍能在历史消息中看到该通知
*
* @param DevChangelog $log 已发布的日志
*/
private function saveChangelogNotification(DevChangelog $log): void
{
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
$url = url('/changelog').'#v'.$log->version;
SaveMessageJob::dispatch([
'room_id' => 1,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'sent_at' => now()->toIso8601String(),
]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* 文件功能:后台部门管理控制器
* 提供部门的 CRUD 功能(增删改查)
* 部门是职务的上级分类,包含位阶、颜色、描述等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
/**
* 部门列表页
*/
public function index(): View
{
$departments = Department::withCount(['positions'])
->orderBy('sort_order')
->orderByDesc('rank')
->get();
return view('admin.departments.index', compact('departments'));
}
/**
* 创建部门
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name',
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
Department::create($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】创建成功!");
}
/**
* 更新部门
*/
public function update(Request $request, Department $department): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name,'.$department->id,
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
$department->update($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】更新成功!");
}
/**
* 删除部门(级联删除职务)
*/
public function destroy(Department $department): RedirectResponse
{
// 检查是否有在职人员
$activeMemberCount = $department->positions()
->whereHas('activeUserPositions')
->count();
if ($activeMemberCount > 0) {
return redirect()->route('admin.departments.index')
->with('error', "部门【{$department->name}】下尚有在职人员,请先撤销所有职务后再删除。");
}
$department->delete();
return redirect()->route('admin.departments.index')->with('success', "部门【{$department->name}】已删除!");
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:后台用户反馈管理控制器(仅 id=1 超级管理员可访问)
* 提供反馈列表查看、处理状态修改、官方回复功能
* 侧边栏显示待处理数量徽标
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackManagerController extends Controller
{
/**
* 后台反馈列表页(支持类型+状态筛选)
*
* @param Request $request type/status 筛选参数
*/
public function index(Request $request): View
{
$type = $request->input('type');
$status = $request->input('status');
$query = FeedbackItem::with(['replies'])
->orderByDesc('created_at');
// 按类型筛选
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
// 按状态筛选
if ($status && array_key_exists($status, FeedbackItem::STATUS_CONFIG)) {
$query->ofStatus($status);
}
$feedbacks = $query->paginate(20)->withQueryString();
// 待处理数量(用于侧边栏徽标)
$pendingCount = FeedbackItem::pending()->count();
return view('admin.feedback.index', [
'feedbacks' => $feedbacks,
'pendingCount' => $pendingCount,
'statusConfig' => FeedbackItem::STATUS_CONFIG,
'typeConfig' => FeedbackItem::TYPE_CONFIG,
'currentType' => $type,
'currentStatus' => $status,
]);
}
/**
* 更新反馈处理状态和官方回复Ajax + 表单双模式)
* Ajax 返回 JSON普通表单提交返回重定向
*
* @param Request $request status/admin_remark 字段
* @param int $id 反馈 ID
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'status' => 'required|in:'.implode(',', array_keys(FeedbackItem::STATUS_CONFIG)),
'admin_remark' => 'nullable|string|max:2000',
]);
$feedback->update([
'status' => $data['status'],
'admin_remark' => $data['admin_remark'] ?? $feedback->admin_remark,
]);
// 如果有新的官方回复内容,同时写入 feedback_replies带 is_admin 标记)
if (! empty($data['admin_remark']) && $data['admin_remark'] !== $feedback->getOriginal('admin_remark')) {
DB::transaction(function () use ($feedback, $data): void {
FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => 1,
'username' => '🛡️ 开发者',
'content' => $data['admin_remark'],
'is_admin' => true,
]);
$feedback->increment('replies_count');
});
}
// Ajax 请求返回 JSON
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'new_status' => $data['status'],
'status_label' => FeedbackItem::STATUS_CONFIG[$data['status']]['icon'].' '.FeedbackItem::STATUS_CONFIG[$data['status']]['label'],
'status_color' => FeedbackItem::STATUS_CONFIG[$data['status']]['color'],
]);
}
return redirect()->route('admin.feedback.index')
->with('success', '反馈状态已更新!');
}
}

View File

@@ -0,0 +1,110 @@
<?php
/**
* 文件功能:后台职务管理控制器
* 提供职务的 CRUD 功能,包含任命权限白名单(多选 position_appoint_limits的同步
* 职务属于部门,包含等级、图标、人数上限、奖励上限等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PositionController extends Controller
{
/**
* 职务列表页
*/
public function index(): View
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
return view('admin.positions.index', compact('departments', 'allPositions'));
}
/**
* 创建职务(同时同步任命白名单)
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position = Position::create($data);
// 同步任命白名单(有选则写,没选则清空=无任命权)
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
}
/**
* 更新职务(含任命白名单同步)
*/
public function update(Request $request, Position $position): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】更新成功!");
}
/**
* 删除职务(有在职人员时拒绝)
*/
public function destroy(Position $position): RedirectResponse
{
if ($position->isFull() || $position->activeUserPositions()->exists()) {
return redirect()->route('admin.positions.index')
->with('error', "职务【{$position->name}】尚有在职人员,请先撤销后再删除。");
}
$position->delete();
return redirect()->route('admin.positions.index')->with('success', "职务【{$position->name}】已删除!");
}
}

View File

@@ -31,11 +31,11 @@ class RoomManagerController extends Controller
/**
* 更新房间信息
*
* @param Room $room 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
$data = $request->validate([
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
@@ -52,11 +52,11 @@ class RoomManagerController extends Controller
/**
* 删除房间(非系统房间)
*
* @param Room $room 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
if ($room->room_keep) {
return redirect()->route('admin.rooms.index')->with('error', '系统房间不允许删除!');
}

View File

@@ -11,8 +11,8 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -30,6 +30,7 @@ class UserManagerController extends Controller
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 显示用户列表及搜索(支持按等级/经验/金币/魅力排序)
*/
@@ -42,11 +43,15 @@ class UserManagerController extends Controller
}
// 排序:允许的字段白名单,防止 SQL 注入
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
$users = $query->orderBy($sortBy, $sortDir)->paginate(20)->withQueryString();
$users = $query
->with(['activePosition.position.department', 'vipLevel'])
->orderBy($sortBy, $sortDir)
->paginate(20)
->withQueryString();
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
@@ -56,10 +61,12 @@ class UserManagerController extends Controller
/**
* 修改用户资料、等级或密码 (AJAX 或表单)
*
* @param User $user 路由模型自动注入
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
public function update(Request $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
@@ -67,12 +74,8 @@ class UserManagerController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
// 管理员级别 = 最高等级 + 1后台编辑最高可设到管理员级别
$adminLevel = (int) \App\Models\Sysparam::getValue('maxlevel', '15') + 1;
$validated = $request->validate([
'sex' => 'sometimes|integer|in:0,1,2',
'user_level' => "sometimes|integer|min:0|max:{$adminLevel}",
'exp_num' => 'sometimes|integer|min:0',
'jjb' => 'sometimes|integer|min:0',
'meili' => 'sometimes|integer|min:0',
@@ -83,17 +86,6 @@ class UserManagerController extends Controller
'hy_time' => 'sometimes|nullable|date',
]);
// 如果传了且没超权,直接赋予
if (isset($validated['user_level'])) {
if ($currentUser->id !== $targetUser->id) {
// 修改别人:只有真正的创始人 (ID=1) 才能修改别人的等级
if ($currentUser->id !== 1) {
return response()->json(['status' => 'error', 'message' => '权限越界:只有星系创始人(站长)才能调整其他用户的行政等级!'], 403);
}
}
$targetUser->user_level = $validated['user_level'];
}
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
}
@@ -158,10 +150,12 @@ class UserManagerController extends Controller
/**
* 物理删除杀封用户
*
* @param User $user 路由模型自动注入
*/
public function destroy(Request $request, int $id): RedirectResponse
public function destroy(Request $request, User $user): RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 越权防护:不允许删除同级或更高等级的账号

View File

@@ -60,11 +60,11 @@ class VipController extends Controller
/**
* 更新会员等级
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level = $vip;
$data = $request->validate([
'name' => 'required|string|max:50',
@@ -90,12 +90,11 @@ class VipController extends Controller
/**
* 删除会员等级(关联用户的 vip_level_id 会自动置 null
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level->delete();
$vip->delete();
return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!');
}

View File

@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:开发日志前台控制器
* 对应独立页面 /changelog展示已发布的版本更新日志
* 支持懒加载IntersectionObserver + 游标分页)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\DevChangelog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/** 每次加载的条数 */
private const PAGE_SIZE = 10;
/**
* 更新日志列表页SSR首屏
* 预加载最新 10 条已发布日志
*/
public function index(): View
{
$changelogs = DevChangelog::published()
->limit(self::PAGE_SIZE)
->get();
return view('changelog.index', compact('changelogs'));
}
/**
* 懒加载更多日志JSON API
* 游标分页:传入已加载的最后一条 ID返回更旧的 10
*
* @param Request $request after_id 参数
*/
public function loadMoreChangelogs(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$items = DevChangelog::published()
->after($afterId)
->limit(self::PAGE_SIZE)
->get();
$data = $items->map(fn (DevChangelog $log) => [
'id' => $log->id,
'version' => $log->version,
'title' => $log->title,
'type_label' => $log->type_label,
'type_color' => $log->type_color,
'content_html' => $log->content_html,
'summary' => $log->summary,
'published_at' => $log->published_at?->format('Y-m-d'),
]);
return response()->json([
'items' => $data,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:聊天室内快速任命/撤销控制器
* 供有职务的管理员在聊天室用户名片弹窗中快速任命或撤销目标用户的职务。
* 权限校验委托给 AppointmentService本控制器只做请求解析和返回 JSON。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatAppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 获取可用职务列表(供名片弹窗下拉选择)
* 返回操作人有权限任命的职务
*/
public function positions(): JsonResponse
{
$operator = Auth::user();
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
$operatorPosition = $operator->activePosition?->position;
$query = Position::query()
->with('department')
->orderByDesc('rank');
// 仅有具体职务(非 superlevel 直通)的操作人才限制 rank 范围
if ($operatorPosition && $operator->user_level < $superLevel) {
$query->where('rank', '<', $operatorPosition->rank);
}
$positions = $query->get()->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'icon' => $p->icon,
'rank' => $p->rank,
'department' => $p->department?->name,
]);
return response()->json(['status' => 'success', 'positions' => $positions]);
}
/**
* 快速任命:将目标用户任命为指定职务
* 成功后向操作人所在聊天室广播任命公告
*/
public function appoint(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$position = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $position, $request->remark);
// 任命成功后广播礼花通知:优先用前端传来的 room_id否则从 Redis 查操作人所在房间
if ($result['ok']) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $position->icon ?? '🎖️',
positionName: $position->name,
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
/**
* 快速撤销:撤销目标用户当前的职务
*/
public function revoke(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
// 撤销前先取目标当前职务信息(撤销后就查不到了)
$activeUp = $target->activePosition?->load('position.department');
$posIcon = $activeUp?->position?->icon ?? '🎖️';
$posName = $activeUp?->position?->name ?? '';
$deptName = $activeUp?->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
// 撤销成功后广播通知到聊天室
if ($result['ok'] && $posName) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
}

View File

@@ -11,38 +11,43 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Enums\CurrencySource;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\Gift;
use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\View\View;
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
) {}
/**
* 进入房间初始化 (等同于原版 INIT.ASP)
*
@@ -56,12 +61,14 @@ class ChatController extends Controller
// 房间人气 +1每次访问递增复刻原版人气计数
$room->increment('visit_num');
// 用户进房时间刷新
$user->update(['in_time' => now()]);
// 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 获取当前在职职务信息(用于内容显示)
$activePosition = $user->activePosition;
$userData = [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -70,6 +77,8 @@ class ChatController extends Controller
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
];
$this->chatState->userJoin($id, $user->username, $userData);
@@ -77,88 +86,123 @@ class ChatController extends Controller
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 3. 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 <b>{$user->username}</b> 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 {$user->username} 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 4. 管理员superlevel进入时触发全房间烟花特效 + 公屏欢迎公告
if ($user->user_level >= $superLevel) {
// 广播烟花特效给所有在线用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
// 发送欢迎公告消息(使用系统公告样式)
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 <b>{$user->username}</b> 驾临本聊天室!请各位文明聊天!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
}
// 5. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则公众发言to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
$allHistory = $this->chatState->getNewMessages($id, 0);
$username = $user->username;
$historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
$toUser = $msg['to_user'] ?? '';
$fromUser = $msg['from_user'] ?? '';
$isSecret = !empty($msg['is_secret']);
// 公众发言(对大家说):所有人都可以看到
if ($toUser === '大家' || $toUser === '') {
return true;
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
// 4. superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
if ($user->user_level >= $superLevel) {
// 管理员专属:全房间烟花
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
} else {
// 5. 非站长:生成通用播报(有职务 > 有VIP > 普通随机词)
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $generalWelcomeMsg);
broadcast(new MessageSent($id, $generalWelcomeMsg))->toOthers();
}
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则公众发言to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
$allHistory = $this->chatState->getNewMessages($id, 0);
$username = $user->username;
$historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
$toUser = $msg['to_user'] ?? '';
$fromUser = $msg['from_user'] ?? '';
$isSecret = ! empty($msg['is_secret']);
// 公众发言(对大家说):所有人都可以看到
if ($toUser === '大家' || $toUser === '') {
return true;
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'historyMessages' => $historyMessages,
]);
// 最后:如果用户有在职职务,开始记录这次入场的在职登录
// 此时用户局部变量已初始化,可以安全读取 in_time
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => now(),
'ip_address' => request()->ip(),
'room_id' => $id,
]);
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user), // 周卡特效(登录自动播放)
'newbieEffect' => $newbieEffect, // 新人入场专属特效
'historyMessages' => $historyMessages, // 把历史消息附带给前端
]);
}
/**
@@ -183,6 +227,20 @@ class ChatController extends Controller
], 403);
}
// 0.5 检查接收方是否在线(防幽灵消息)
$toUser = $data['to_user'] ?? '大家';
if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
// Redis 保存的在线列表
$isOnline = Redis::hexists("room:{$id}:users", $toUser);
if (! $isOnline) {
// 使用 200 状态码,避免 Nginx 拦截非 2xx 响应后触发重定向导致 405 Method Not Allowed
return response()->json([
'status' => 'error',
'message' => "{$toUser}】目前已离开聊天室或不在线,消息未发出。",
], 200);
}
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
@@ -284,6 +342,7 @@ class ChatController extends Controller
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -292,6 +351,8 @@ class ChatController extends Controller
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
]);
// 4. 如果突破境界,向全房系统喊话广播!
@@ -405,11 +466,49 @@ class ChatController extends Controller
// 记录退出时间和退出信息
$user->update([
'out_time' => now(),
'out_info' => "正常退出了房间",
'out_info' => '正常退出了房间',
]);
// 2. 广播通知他人
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
$this->closeDutyLog($user->id);
// 2. 发送离场播报
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level >= $superLevel) {
// 管理员离场:系统公告
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "👋 管理员 【{$user->username}】 已离开聊天室。",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
} else {
[$leaveText, $color] = $this->broadcast->buildLeaveBroadcast($user);
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
}
$this->chatState->pushMessage($id, $leaveMsg);
// 3. 广播通知他人 (UserLeft 更新用户名单列表MessageSent 更新消息记录)
broadcast(new UserLeft($id, $user->username))->toOthers();
broadcast(new MessageSent($id, $leaveMsg))->toOthers();
return response()->json(['status' => 'success']);
}
@@ -601,7 +700,7 @@ class ChatController extends Controller
'room_id' => $roomId,
'from_user' => '送花播报',
'to_user' => $toUsername,
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'is_secret' => false,
'font_color' => '#e91e8f',
'action' => '',
@@ -707,4 +806,21 @@ class ChatController extends Controller
return max(0, (int) $value);
}
/**
* 关闭该用户尚未结束的在职登录记录(结算在线时长)
* 在用户退出房间或心跳超时时调用
*
* @param int $userId 用户 ID
*/
private function closeDutyLog(int $userId): void
{
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* 文件功能:勤务台页面控制器
* 左侧五个子菜单:任职列表、日榜、周榜、月榜、总榜
* 路由GET /duty-hall?tab=roster|day|week|month|all
*
* @author ChatRoom Laravel
*
* @version 1.1.0
*/
namespace App\Http\Controllers;
use App\Models\Department;
use App\Models\PositionDutyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DutyHallController extends Controller
{
/**
* 勤务台主页(根据 tab 切换内容)
*/
public function index(Request $request): View
{
$tab = $request->input('tab', 'roster');
// ── 任职列表:按部门→职务展示全部(含空缺) ────────────────────
$currentStaff = null;
if ($tab === 'roster') {
$currentStaff = Department::query()
->with([
'positions' => fn ($q) => $q->orderByDesc('rank'),
'positions.activeUserPositions.user',
])
->orderByDesc('rank')
->get();
}
// ── 日/周/月/总榜:勤务时长排行 ──────────────────────────────────
$leaderboard = null;
if (in_array($tab, ['day', 'week', 'month', 'all'])) {
$query = PositionDutyLog::query()
->selectRaw('user_id, SUM(duration_seconds) as total_seconds, COUNT(*) as checkin_count');
// 按时间段过滤
match ($tab) {
'day' => $query->whereDate('login_at', today()),
'week' => $query->whereBetween('login_at', [now()->startOfWeek(), now()->endOfWeek()]),
'month' => $query->whereYear('login_at', now()->year)->whereMonth('login_at', now()->month),
'all' => null, // 不加时间限制
};
$leaderboard = $query
->groupBy('user_id')
->orderByDesc('total_seconds')
->limit(20)
->with('user')
->get();
}
// 各榜标签配置
$tabs = [
'roster' => ['label' => '任职列表', 'icon' => '🏛️'],
'day' => ['label' => '日榜', 'icon' => '☀️'],
'week' => ['label' => '周榜', 'icon' => '📆'],
'month' => ['label' => '月榜', 'icon' => '🗓️'],
'all' => ['label' => '总榜', 'icon' => '🏆'],
];
return view('duty-hall.index', compact(
'tab',
'tabs',
'currentStaff',
'leaderboard',
));
}
}

View File

@@ -0,0 +1,298 @@
<?php
/**
* 文件功能:用户反馈前台控制器
* 对应独立页面 /feedback处理用户提交 Bug报告/功能建议、
* 赞同Toggle、补充评论、删除等操作
* 所有写操作均需登录chat.auth 中间件保护)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use App\Models\FeedbackVote;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackController extends Controller
{
/** 每次懒加载的条数 */
private const PAGE_SIZE = 10;
/**
* 用户反馈列表页SSR首屏
* 预加载按赞同数倒序的 10 条反馈
*/
public function index(): View
{
$feedbacks = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at')
->limit(self::PAGE_SIZE)
->get();
// 当前用户已赞同的反馈 ID 集合(前端切换按钮状态用)
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $feedbacks->pluck('id'))
->pluck('feedback_id')
->toArray();
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
}
/**
* 懒加载更多反馈JSON API
* 支持按类型筛选bug / suggestion
*
* @param Request $request after_id / type 筛选参数
*/
public function loadMore(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->where('id', '<', $afterId)
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
// 当前用户已赞同的 ID用于切换按钮状态
$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),
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/**
* 提交新反馈Bug报告或功能建议
*
* @param Request $request type/title/content 字段
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'type' => 'required|in:bug,suggestion',
'title' => 'required|string|max:200',
'content' => 'required|string|max:2000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
$item = FeedbackItem::create([
'user_id' => $user->id,
'username' => $user->username,
'type' => $data['type'],
'title' => $data['title'],
'content' => $data['content'],
'status' => 'pending',
]);
return response()->json([
'status' => 'success',
'message' => '反馈已提交,感谢您的贡献!',
'item' => $this->formatItem($item, false),
]);
}
/**
* 赞同/取消赞同反馈Toggle 操作)
* 每人每条只能赞同一次,再次点击则取消
* 使用数据库事务保证 votes_count 冗余字段与记录一致
*
* @param int $id 反馈 ID
*/
public function vote(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$userId = Auth::id();
// 不能赞同自己提交的反馈
if ($feedback->user_id === $userId) {
return response()->json([
'status' => 'error',
'message' => '不能赞同自己的反馈',
], 422);
}
$voted = false;
DB::transaction(function () use ($feedback, $userId, &$voted): void {
$existing = FeedbackVote::where('feedback_id', $feedback->id)
->where('user_id', $userId)
->first();
if ($existing) {
// 已赞同 → 取消赞同
$existing->delete();
$feedback->decrement('votes_count');
$voted = false;
} else {
// 未赞同 → 新增赞同
FeedbackVote::create([
'feedback_id' => $feedback->id,
'user_id' => $userId,
]);
$feedback->increment('votes_count');
$voted = true;
}
});
return response()->json([
'status' => 'success',
'voted' => $voted,
'votes_count' => $feedback->fresh()->votes_count,
]);
}
/**
* 提交补充评论
* id=1 管理员的回复自动标记 is_admin=true(前台特殊展示)
*
* @param Request $request content 字段
* @param int $id 反馈 ID
*/
public function reply(Request $request, int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'content' => 'required|string|max:1000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
/** @var FeedbackReply $reply */
$reply = null;
DB::transaction(function () use ($feedback, $data, $user, &$reply): void {
$reply = FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => $user->id,
'username' => $user->username,
'content' => $data['content'],
'is_admin' => $user->id === 1,
]);
$feedback->increment('replies_count');
});
return response()->json([
'status' => 'success',
'message' => '评论已提交',
'reply' => [
'id' => $reply->id,
'username' => $reply->username,
'content' => $reply->content,
'is_admin' => $reply->is_admin,
'created_at' => $reply->created_at->diffForHumans(),
],
]);
}
/**
* 删除反馈
* 普通用户仅24小时内可删除自己的反馈
* 管理员id=1):任意时间可删除任意反馈
*
* @param int $id 反馈 ID
*/
public function destroy(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $feedback->user_id === $user->id;
$isAdmin = $user->id === 1;
if (! $isOwner && ! $isAdmin) {
return response()->json(['status' => 'error', 'message' => '无权删除'], 403);
}
if ($isOwner && ! $isAdmin && ! $feedback->is_within_24_hours) {
return response()->json([
'status' => 'error',
'message' => '超过 24 小时的反馈无法删除',
], 422);
}
// 级联删除关联的赞同记录和评论记录
DB::transaction(function () use ($feedback): void {
FeedbackVote::where('feedback_id', $feedback->id)->delete();
FeedbackReply::where('feedback_id', $feedback->id)->delete();
$feedback->delete();
});
return response()->json(['status' => 'success', 'message' => '已删除']);
}
// ═══════════════ 私有辅助方法 ═══════════════
/**
* 格式化单条反馈数据(供 JSON 返回给前端)
*
* @param FeedbackItem $item 反馈实例
* @param bool $voted 当前用户是否已赞同
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
return [
'id' => $item->id,
'type' => $item->type,
'type_label' => $item->type_label,
'title' => $item->title,
'content' => $item->content,
'status' => $item->status,
'status_label' => $item->status_label,
'status_color' => $item->status_config['color'],
'admin_remark' => $item->admin_remark,
'votes_count' => $item->votes_count,
'replies_count' => $item->replies_count,
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id,
'username' => $r->username,
'content' => $r->content,
'is_admin' => $r->is_admin,
'created_at' => $r->created_at->diffForHumans(),
])->values()->toArray(),
];
}
/**
* 批量格式化反馈数据集合
*
* @param \Illuminate\Support\Collection<int, FeedbackItem> $items
* @param array<int> $myVotedIds 当前用户已赞同的 ID 列表
*/
private function formatItems(\Illuminate\Support\Collection $items, array $myVotedIds): array
{
return $items->map(fn (FeedbackItem $item) => $this->formatItem(
$item,
in_array($item->id, $myVotedIds)
))->values()->toArray();
}
}

View File

@@ -12,8 +12,8 @@
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
@@ -26,8 +26,8 @@ use Illuminate\Support\Facades\Redis;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {}
@@ -148,7 +148,7 @@ class FishingController extends Controller
'room_id' => $id,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'is_secret' => false,
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
'action' => '',

View File

@@ -30,8 +30,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
class UserController extends Controller
{
/**
@@ -43,6 +41,7 @@ class UserController extends Controller
$operator = Auth::user();
// 基础公开信息
$activePosition = $targetUser->activePosition?->load('position.department')->position;
$data = [
'username' => $targetUser->username,
'sex' => $targetUser->sex,
@@ -52,6 +51,10 @@ class UserController extends Controller
'qianming' => $targetUser->qianming,
'sign' => $targetUser->sign ?? '这个人很懒,什么都没留下。',
'created_at' => $targetUser->created_at->format('Y-m-d'),
// 在职职务(供名片弹窗任命/撤销判断)
'position_name' => $activePosition?->name ?? '',
'position_icon' => $activePosition?->icon ?? '',
'department_name' => $activePosition?->department?->name ?? '',
];
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
@@ -61,6 +64,20 @@ class UserController extends Controller
$data['meili'] = $targetUser->meili ?? 0;
}
// 职务履历所有任职记录按任命时间倒序positions() 关系已含 with
$data['position_history'] = $targetUser->positions
->map(fn ($up) => [
'position_icon' => $up->position?->icon ?? '',
'position_name' => $up->position?->name ?? '',
'department_name' => $up->position?->department?->name ?? '',
'appointed_at' => $up->appointed_at?->format('Y-m-d'),
'revoked_at' => $up->revoked_at?->format('Y-m-d'),
'is_active' => $up->is_active,
'duration_days' => $up->duration_days,
])
->values()
->all();
// 拥有封禁IPlevel_banip或踢人以上权限的管理可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
if ($operator && $operator->user_level >= $levelBanIp) {
@@ -72,24 +89,24 @@ class UserController extends Controller
if ($ipToLookup) {
try {
// 不传路径,使用 zoujingli/ip2region 包自带的内置数据库
$ip2r = new \Ip2Region();
$ip2r = new \Ip2Region;
$info = $ip2r->getIpInfo($ipToLookup);
if ($info) {
$country = $info['country'] ?? '';
$country = $info['country'] ?? '';
$province = $info['province'] ?? '';
$city = $info['city'] ?? '';
$city = $info['city'] ?? '';
// 过滤掉占位符 "0"
$province = ($province === '0') ? '' : $province;
$city = ($city === '0') ? '' : $city;
$city = ($city === '0') ? '' : $city;
if ($country === '中国') {
$data['location'] = trim($province . ($province !== $city ? ' ' . $city : ''));
$data['location'] = trim($province.($province !== $city ? ' '.$city : ''));
} else {
$data['location'] = $country ?: '未知区域';
}
if (empty($data['location'])) {
$data['location'] = '未知区域';
}
@@ -117,7 +134,7 @@ class UserController extends Controller
{
$user = Auth::user();
$data = $request->validated();
// 当用户试图更新邮箱,并且新邮箱不等于当前旧邮箱时启动验证码拦截
if (isset($data['email']) && $data['email'] !== $user->email) {
// 首先判断系统开关是否开启,没开启直接禁止修改邮箱
@@ -131,10 +148,10 @@ class UserController extends Controller
}
// 获取缓存的验证码
$codeKey = 'email_verify_code_' . $user->id . '_' . $data['email'];
$codeKey = 'email_verify_code_'.$user->id.'_'.$data['email'];
$cachedCode = \Illuminate\Support\Facades\Cache::get($codeKey);
if (!$cachedCode || $cachedCode != $emailCode) {
if (! $cachedCode || $cachedCode != $emailCode) {
return response()->json(['status' => 'error', 'message' => '验证码不正确或已过期有效期5分钟请重新获取。'], 422);
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* 文件功能:在职职务验证中间件
* 只要用户当前持有在职职务user_positions.is_active=true),即可访问后台。
* id=1 超级管理员无需职务,直接通过。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Middleware;
use App\Models\Sysparam;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class HasActivePosition
{
/**
* 校验用户是否有在职职务(或为超级管理员)。
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return redirect()->route('home');
}
$user = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// id=1 或 superlevel 及以上:无需职务,直通
if ($user->id === 1 || $user->user_level >= $superLevel) {
return $next($request);
}
// 检查是否有在职职务
if (! $user->activePosition()->exists()) {
if ($request->expectsJson()) {
return response()->json(['message' => '权限不足:您尚未持有任何职务', 'status' => 'error'], 403);
}
abort(403, '权限不足:您尚未持有任何职务,无法访问后台。');
}
return $next($request);
}
}

View File

@@ -10,7 +10,9 @@
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class SendMessageRequest extends FormRequest
{
@@ -45,4 +47,16 @@ class SendMessageRequest extends FormRequest
'content.max' => '发言内容不能超过 500 个字符。',
];
}
/**
* 重写验证失败的处理,无论如何(就算未按 ajax 标准提交)都必须抛出 JSON不可以触发网页重定向去走 GET 请求而引发 302 方法错误
*/
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(response()->json([
'status' => 'error',
'message' => $validator->errors()->first(),
'errors' => $validator->errors(),
], 422));
}
}

View File

@@ -52,6 +52,6 @@ class Autoact extends Model
*/
public function renderText(string $username): string
{
return str_replace('{username}', $username, $this->text_body);
return str_replace('{username}', "{$username}", $this->text_body);
}
}

72
app/Models/Department.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:部门模型
* 对应 departments 表,管理聊天室部门(办公厅 / 迎宾部 / 聊务部 / 宣传部等)
* 一个部门下有多个职务positions
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Department extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'name',
'rank',
'color',
'sort_order',
'description',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'rank' => 'integer',
'sort_order' => 'integer',
];
}
/**
* 获取该部门下的所有职务(按 rank 降序)
*/
public function positions(): HasMany
{
return $this->hasMany(Position::class)->orderByDesc('rank');
}
/**
* 获取部门当前所有在职用户(通过职务关联)
*/
public function activeMembers(): Collection
{
return UserPosition::query()
->whereHas('position', fn ($q) => $q->where('department_id', $this->id))
->where('is_active', true)
->with(['user', 'position'])
->get();
}
/**
* 按位阶倒序排列的查询范围
*/
public function scopeOrdered($query): void
{
$query->orderBy('sort_order')->orderByDesc('rank');
}
}

112
app/Models/DevChangelog.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:开发日志 Model
* 对应 dev_changelogs 表,管理版本更新记录
* 支持草稿/已发布状态Markdown 内容渲染
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DevChangelog extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'version',
'title',
'type',
'content',
'is_published',
'notify_chat',
'published_at',
];
/**
* 字段类型自动转换
*/
protected $casts = [
'is_published' => 'boolean',
'notify_chat' => 'boolean',
'published_at' => 'datetime',
];
/**
* 类型标签配置(中文名 + Tailwind 颜色类)
*/
public const TYPE_CONFIG = [
'feature' => ['label' => '🆕 新功能', 'color' => 'emerald'],
'fix' => ['label' => '🐛 修复', 'color' => 'rose'],
'improve' => ['label' => '⚡ 优化', 'color' => 'blue'],
'other' => ['label' => '📌 其他', 'color' => 'slate'],
];
// ═══════════════ 查询作用域 ═══════════════
/**
* 只查询已发布的日志
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true)->orderByDesc('published_at');
}
/**
* 懒加载:查询比指定 ID 更旧的已发布日志(游标分页)
*
* @param int $afterId 已加载的最后一条 ID
*/
public function scopeAfter(Builder $query, int $afterId): Builder
{
return $query->where('id', '<', $afterId);
}
// ═══════════════ 访问器 ═══════════════
/**
* 获取类型对应的中文标签
*/
public function getTypeLabelAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['label'] ?? '📌 其他';
}
/**
* 获取类型对应的 Tailwind 颜色名
*/
public function getTypeColorAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['color'] ?? 'slate';
}
/**
* Markdown 内容渲染为 HTML使用 Laravel 内置 Str::markdown
*/
public function getContentHtmlAttribute(): string
{
return Str::markdown($this->content, [
'html_input' => 'strip', // 去掉原始 HTML防止 XSS
'allow_unsafe_links' => false,
]);
}
/**
* 获取内容纯文本摘要(用于列表预览,截取前 150 字)
*/
public function getSummaryAttribute(): string
{
// 去掉 Markdown 标记后截取纯文本
$plain = strip_tags(Str::markdown($this->content));
return Str::limit($plain, 150);
}
}

146
app/Models/FeedbackItem.php Normal file
View File

@@ -0,0 +1,146 @@
<?php
/**
* 文件功能:用户反馈主表 Model
* 对应 feedback_items 表,管理用户提交的 Bug报告和功能建议
* 包含7种处理状态、赞同数/评论数冗余统计
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FeedbackItem extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'user_id',
'username',
'type',
'title',
'content',
'status',
'admin_remark',
'votes_count',
'replies_count',
];
/**
* 处理状态配置(中文名 + 图标 + Tailwind 颜色)
*/
public const STATUS_CONFIG = [
'pending' => ['label' => '待处理', 'icon' => '⏳', 'color' => 'gray'],
'accepted' => ['label' => '已接受', 'icon' => '✅', 'color' => 'green'],
'in_progress' => ['label' => '开发中', 'icon' => '🔧', 'color' => 'blue'],
'fixed' => ['label' => '已修复', 'icon' => '🐛', 'color' => 'emerald'],
'done' => ['label' => '已完成', 'icon' => '🚀', 'color' => 'emerald'],
'rejected' => ['label' => '暂不同意', 'icon' => '❌', 'color' => 'red'],
'shelved' => ['label' => '已搁置', 'icon' => '📦', 'color' => 'orange'],
];
/**
* 类型配置
*/
public const TYPE_CONFIG = [
'bug' => ['label' => '🐛 Bug报告', 'color' => 'rose'],
'suggestion' => ['label' => '💡 功能建议', 'color' => 'blue'],
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联赞同记录
*/
public function votes(): HasMany
{
return $this->hasMany(FeedbackVote::class, 'feedback_id');
}
/**
* 关联补充评论
*/
public function replies(): HasMany
{
return $this->hasMany(FeedbackReply::class, 'feedback_id')->orderBy('created_at');
}
// ═══════════════ 查询作用域 ═══════════════
/**
* 按类型筛选
*
* @param string $type bug|suggestion
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
/**
* 按状态筛选
*
* @param string $status 处理状态
*/
public function scopeOfStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
/**
* 待处理的反馈(用于后台徽标计数)
*/
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
// ═══════════════ 访问器 ═══════════════
/**
* 获取状态对应的配置(标签/图标/颜色)
*/
public function getStatusConfigAttribute(): array
{
return self::STATUS_CONFIG[$this->status] ?? self::STATUS_CONFIG['pending'];
}
/**
* 获取状态中文标签
*/
public function getStatusLabelAttribute(): string
{
return $this->status_config['icon'].' '.$this->status_config['label'];
}
/**
* 获取类型中文标签
*/
public function getTypeLabelAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['label'] ?? '📌 其他';
}
/**
* 判断反馈是否在24小时内用于普通用户自删权限
*/
public function getIsWithin24HoursAttribute(): bool
{
return $this->created_at->diffInHours(now()) < 24;
}
/**
* 判断当前状态是否为已处理(已修复/已完成/暂不同意/已搁置)
*/
public function getIsClosedAttribute(): bool
{
return in_array($this->status, ['fixed', 'done', 'rejected', 'shelved']);
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:用户反馈补充评论 Model
* 对应 feedback_replies 表,记录用户对反馈的补充说明和管理员官方回复
* is_admin=1 的回复在前台以特殊「开发者回复」样式高亮展示
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeedbackReply extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'feedback_id',
'user_id',
'username',
'content',
'is_admin',
];
/**
* 字段类型转换
*/
protected $casts = [
'is_admin' => 'boolean',
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联所属反馈
*/
public function feedback(): BelongsTo
{
return $this->belongsTo(FeedbackItem::class, 'feedback_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* 文件功能:用户反馈赞同记录 Model
* 对应 feedback_votes 表,记录用户对反馈的赞同行为
* 每个用户每条反馈只能赞同一次(数据库层唯一索引保障)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeedbackVote extends Model
{
/**
* 关闭 updated_at赞同记录只有创建无需更新时间
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'feedback_id',
'user_id',
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联所属反馈
*/
public function feedback(): BelongsTo
{
return $this->belongsTo(FeedbackItem::class, 'feedback_id');
}
}

129
app/Models/Position.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
/**
* 文件功能:职务模型
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Position extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'department_id',
'name',
'icon',
'rank',
'level',
'max_persons',
'max_reward',
'sort_order',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'rank' => 'integer',
'level' => 'integer',
'max_persons' => 'integer',
'max_reward' => 'integer',
'sort_order' => 'integer',
];
}
/**
* 所属部门
*/
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
/**
* 该职务当前在职的用户记录user_positions
*/
public function activeUserPositions(): HasMany
{
return $this->hasMany(UserPosition::class)->where('is_active', true);
}
/**
* 该职务的所有历史任职记录
*/
public function userPositions(): HasMany
{
return $this->hasMany(UserPosition::class);
}
/**
* 该职务可以任命的目标职务列表position_appoint_limits 中间表)
*/
public function appointablePositions(): BelongsToMany
{
return $this->belongsToMany(
Position::class,
'position_appoint_limits',
'appointer_position_id',
'appointable_position_id'
);
}
/**
* 哪些职务的持有者可以将用户任命到本职务
*/
public function appointedByPositions(): BelongsToMany
{
return $this->belongsToMany(
Position::class,
'position_appoint_limits',
'appointable_position_id',
'appointer_position_id'
);
}
/**
* 获取当前在职人数
*/
public function currentCount(): int
{
return $this->activeUserPositions()->count();
}
/**
* 是否已满员
*/
public function isFull(): bool
{
if ($this->max_persons === null) {
return false;
}
return $this->currentCount() >= $this->max_persons;
}
/**
* 查询范围:按位阶降序
*/
public function scopeOrdered($query): void
{
$query->orderBy('sort_order')->orderByDesc('rank');
}
}

View File

@@ -0,0 +1,104 @@
<?php
/**
* 文件功能:职务权限使用记录模型
* 对应 position_authority_logs 表,记录职务持有者每次行使职权的操作
* 包含任命、撤销、奖励金币、警告、踢出、禁言、封IP等操作类型
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PositionAuthorityLog extends Model
{
/**
* 禁用 updated_at只有 created_at
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'user_position_id',
'action_type',
'target_user_id',
'target_position_id',
'amount',
'remark',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'amount' => 'integer',
'created_at' => 'datetime',
];
}
/**
* 操作类型中文标签
*/
public static array $actionLabels = [
'appoint' => '任命',
'revoke' => '撤销职务',
'reward' => '奖励金币',
'warn' => '警告',
'kick' => '踢出',
'mute' => '禁言',
'banip' => '封锁IP',
'other' => '其他',
];
/**
* 操作人
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 操作时使用的在职记录
*/
public function userPosition(): BelongsTo
{
return $this->belongsTo(UserPosition::class);
}
/**
* 操作对象用户
*/
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
/**
* 任命/撤销时的目标职务
*/
public function targetPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'target_position_id');
}
/**
* 获取操作类型的中文标签
*/
public function getActionLabelAttribute(): string
{
return self::$actionLabels[$this->action_type] ?? $this->action_type;
}
}

View File

@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:在职登录记录模型
* 对应 position_duty_logs 表,记录职务持有者每次进房的登录时间、在线时长和退出时间
* 用于勤务台四榜统计和个人履历出勤数据展示
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PositionDutyLog extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'user_position_id',
'login_at',
'logout_at',
'duration_seconds',
'ip_address',
'room_id',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'login_at' => 'datetime',
'logout_at' => 'datetime',
'duration_seconds' => 'integer',
];
}
/**
* 对应的用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 对应的在职记录
*/
public function userPosition(): BelongsTo
{
return $this->belongsTo(UserPosition::class);
}
/**
* 格式化在线时长为"Xh Ym"字符串(如 128h 30m
*/
public function getFormattedDurationAttribute(): string
{
$seconds = $this->duration_seconds ?? 0;
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
return "{$hours}h {$minutes}m";
}
}

View File

@@ -15,6 +15,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -144,4 +146,38 @@ class User extends Authenticatable
return $this->vipLevel?->icon ?? '';
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
* 全部猎务履历(包括历史记录)
*/
public function positions(): HasMany
{
return $this->hasMany(UserPosition::class)->with(['position.department'])->orderByDesc('appointed_at');
}
/**
* 当前在职职务记录HasOne最多一条
*/
public function activePosition(): HasOne
{
return $this->hasOne(UserPosition::class)->where('is_active', true)->with(['position.department']);
}
/**
* 该用户在职期间的权限操作日志
*/
public function authorityLogs(): HasMany
{
return $this->hasMany(PositionAuthorityLog::class)->orderByDesc('created_at');
}
/**
* 判断用户是否有当前在职职务
*/
public function hasActivePosition(): bool
{
return $this->activePosition()->exists();
}
}

133
app/Models/UserPosition.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
/**
* 文件功能:用户职务关联模型(职务履历核心表)
* 对应 user_positions 表,记录用户当前在职职务及全部历史任职记录
* is_active=true 表示当前在职false 为历史存档(永久保留)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class UserPosition extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'position_id',
'appointed_by_user_id',
'appointed_at',
'remark',
'revoked_at',
'revoked_by_user_id',
'is_active',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'appointed_at' => 'datetime',
'revoked_at' => 'datetime',
'is_active' => 'boolean',
];
}
/**
* 在职用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 所任职务
*/
public function position(): BelongsTo
{
return $this->belongsTo(Position::class);
}
/**
* 任命人
*/
public function appointedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'appointed_by_user_id');
}
/**
* 撤销人
*/
public function revokedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'revoked_by_user_id');
}
/**
* 该任职期间的登录记录
*/
public function dutyLogs(): HasMany
{
return $this->hasMany(PositionDutyLog::class);
}
/**
* 该任职期间的权限使用记录
*/
public function authorityLogs(): HasMany
{
return $this->hasMany(PositionAuthorityLog::class);
}
/**
* 查询范围:仅当前在职
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 获取任职时长(天数);在职则计算至今
*/
public function getDurationDaysAttribute(): int
{
$end = $this->revoked_at ?? now();
return (int) $this->appointed_at->diffInDays($end);
}
/**
* 任职期间累计在线时长(秒)
*/
public function getTotalOnlineSecondsAttribute(): int
{
return (int) $this->dutyLogs()->sum('duration_seconds');
}
/**
* 任职期间累计发放金币总量
*/
public function getTotalRewardedCoinsAttribute(): int
{
return (int) $this->authorityLogs()
->where('action_type', 'reward')
->sum('amount');
}
}

View File

@@ -0,0 +1,287 @@
<?php
/**
* 文件功能:职务任命服务
* 处理职务系统的核心业务逻辑:任命、撤销、权限校验
* 所有权限操作均写入 position_authority_logs 留存审计
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\Position;
use App\Models\PositionAuthorityLog;
use App\Models\User;
use App\Models\UserPosition;
use Illuminate\Support\Facades\DB;
class AppointmentService
{
/**
* 获取用户当前在职记录(无则返回 null
*/
public function getActivePosition(User $user): ?UserPosition
{
return UserPosition::query()
->where('user_id', $user->id)
->where('is_active', true)
->with(['position.department', 'position.appointablePositions'])
->first();
}
/**
* 校验操作人是否有权将目标用户任命到指定职务
*
* id=1 超级管理员绕过所有要目校验,可直接任命任意职务。
*
* @return array{ok: bool, message: string}
*/
public function validateAppoint(User $operator, User $target, Position $targetPosition): array
{
// 超级管理员id=1特权跳过职务和白名单校验只检查被任命人是否已有职务
if ($operator->id === 1) {
$existingPosition = $this->getActivePosition($target);
if ($existingPosition) {
$currentName = $existingPosition->position->name;
return ['ok' => false, 'message' => "{$target->username}】当前已担任【{$currentName}】,请先撤销其职务再重新任命。"];
}
return ['ok' => true, 'message' => '超级管理员直接授权'];
}
// 操作人必须有在职职务
$operatorPosition = $this->getActivePosition($operator);
if (! $operatorPosition) {
return ['ok' => false, 'message' => '您当前无在职职务,无法进行任命操作。'];
}
// 校验任命白名单:目标职务是否在操作人职务的可任命列表内
$isAllowed = $operatorPosition->position
->appointablePositions()
->where('positions.id', $targetPosition->id)
->exists();
if (! $isAllowed) {
return ['ok' => false, 'message' => "您的职务无权任命【{$targetPosition->name}】职位。"];
}
// 检查目标职务是否已满员
if ($targetPosition->isFull()) {
return ['ok' => false, 'message' => "{$targetPosition->name}】职位人数已满,无法继续任命。"];
}
// 检查被任命人是否已有在职职务
$existingPosition = $this->getActivePosition($target);
if ($existingPosition) {
$currentName = $existingPosition->position->name;
return ['ok' => false, 'message' => "{$target->username}】当前已担任【{$currentName}】,请先撤销其职务再重新任命。"];
}
return ['ok' => true, 'message' => '校验通过'];
}
/**
* 执行任命操作
* 任命成功后自动同步 user_level 并写入权限日志
*
* @return array{ok: bool, message: string, userPosition?: UserPosition}
*/
public function appoint(User $operator, User $target, Position $targetPosition, ?string $remark = null): array
{
// 权限校验
$validation = $this->validateAppoint($operator, $target, $targetPosition);
if (! $validation['ok']) {
return $validation;
}
// id=1 超级管理员无需在职职务,直接任命
$operatorPosition = $operator->id === 1 ? null : $this->getActivePosition($operator);
DB::transaction(function () use ($operator, $target, $targetPosition, $remark, $operatorPosition, &$userPosition) {
// 创建任职记录
$userPosition = UserPosition::create([
'user_id' => $target->id,
'position_id' => $targetPosition->id,
'appointed_by_user_id' => $operator->id,
'appointed_at' => now(),
'remark' => $remark,
'is_active' => true,
]);
// 同步 user_level
$target->update(['user_level' => $targetPosition->level]);
// 写入权限操作日志
$this->logAuthority(
operator: $operator,
operatorPosition: $operatorPosition,
actionType: 'appoint',
target: $target,
targetPosition: $targetPosition,
remark: $remark
);
});
return [
'ok' => true,
'message' => "已成功将【{$target->username}】任命为【{$targetPosition->name}】。",
'userPosition' => $userPosition,
];
}
/**
* 校验操作人是否有权撤销目标用户的职务
*
* id=1 超级管理员可直接撤销任意职务。
*
* @return array{ok: bool, message: string}
*/
public function validateRevoke(User $operator, User $target): array
{
// 超级管理员id=1特权跳过白名单校验直接撤销任意职务
if ($operator->id === 1) {
$targetPosition = $this->getActivePosition($target);
if (! $targetPosition) {
return ['ok' => false, 'message' => "{$target->username}】当前没有在职职务。"];
}
return ['ok' => true, 'message' => '超级管理员直接授权'];
}
// 操作人必须有在职职务
$operatorPosition = $this->getActivePosition($operator);
if (! $operatorPosition) {
return ['ok' => false, 'message' => '您当前无在职职务,无法进行撤职操作。'];
}
// 被撤销人必须有在职职务
$targetPosition = $this->getActivePosition($target);
if (! $targetPosition) {
return ['ok' => false, 'message' => "{$target->username}】当前没有在职职务。"];
}
// 操作人不能撤销自己
if ($operator->id === $target->id) {
return ['ok' => false, 'message' => '不能撤销自己的职务。'];
}
// 操作人的任命白名单中必须包含目标职务(即有权任命该职务,也就有权撤销)
$isAllowed = $operatorPosition->position
->appointablePositions()
->where('positions.id', $targetPosition->position_id)
->exists();
if (! $isAllowed) {
return ['ok' => false, 'message' => "您的职务无权撤销【{$targetPosition->position->name}】职位的人员。"];
}
return ['ok' => true, 'message' => '校验通过'];
}
/**
* 执行撤销职务操作
* 撤销后 user_level 1,并写入权限日志
*
* @return array{ok: bool, message: string}
*/
public function revoke(User $operator, User $target, ?string $remark = null): array
{
// 权限校验
$validation = $this->validateRevoke($operator, $target);
if (! $validation['ok']) {
return $validation;
}
$operatorPosition = $this->getActivePosition($operator);
$targetUP = $this->getActivePosition($target);
DB::transaction(function () use ($operator, $target, $remark, $operatorPosition, $targetUP) {
// 撤销在职记录
$targetUP->update([
'is_active' => false,
'revoked_at' => now(),
'revoked_by_user_id' => $operator->id,
]);
// 关闭尚未结束的 duty_log
$target->activePosition?->dutyLogs()
->whereNull('logout_at')
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
// user_level 归 1由系统经验值自然升级机制重新成长
$target->update(['user_level' => 1]);
// 写入权限操作日志
$this->logAuthority(
operator: $operator,
operatorPosition: $operatorPosition,
actionType: 'revoke',
target: $target,
targetPosition: $targetUP->position,
remark: $remark
);
});
return [
'ok' => true,
'message' => "已成功撤销【{$target->username}】的【{$targetUP->position->name}】职务,其等级已归 1。",
];
}
/**
* 记录权限操作日志(各类管理操作公共调用)
*
* @param string $actionType 操作类型appoint/revoke/reward/warn/kick/mute/banip/other
*/
public function logAuthority(
User $operator,
?UserPosition $operatorPosition,
string $actionType,
User $target,
?Position $targetPosition = null,
?int $amount = null,
?string $remark = null,
): void {
// 无在职职务的操作不记录(普通管理员通过 user_level 操作不进此表)
if (! $operatorPosition) {
return;
}
PositionAuthorityLog::create([
'user_id' => $operator->id,
'user_position_id' => $operatorPosition->id,
'action_type' => $actionType,
'target_user_id' => $target->id,
'target_position_id' => $targetPosition?->id,
'amount' => $amount,
'remark' => $remark,
]);
}
/**
* 获取视图用:操作人有权任命的职务列表(用于后台/弹窗任命下拉选择)
*
* @return \Illuminate\Database\Eloquent\Collection<int, Position>
*/
public function getAppointablePositions(User $operator)
{
$operatorPosition = $this->getActivePosition($operator);
if (! $operatorPosition) {
return collect();
}
return $operatorPosition->position
->appointablePositions()
->with('department')
->orderByDesc('rank')
->get();
}
}

View File

@@ -53,15 +53,18 @@ class ChatStateService
public function getUserRooms(string $username): array
{
$rooms = [];
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
do {
[$cursor, $keys] = Redis::scan($cursor, ['match' => 'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $key) {
if (Redis::hexists($key, $username)) {
// 从 key "room:123:users" 中提取房间 ID
preg_match('/room:(\d+):users/', $key, $matches);
if (isset($matches[1])) {
$rooms[] = (int) $matches[1];
// scan 带前缀通配,返回的 key 也带前缀
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $fullKey) {
// 去掉前缀得到 Laravel Redis Facade 认识的短少 key
$shortKey = $prefix ? ltrim(substr($fullKey, strlen($prefix)), '') : $fullKey;
if (Redis::hexists($shortKey, $username)) {
preg_match('/room:(\d+):users/', $shortKey, $m);
if (isset($m[1])) {
$rooms[] = (int) $m[1];
}
}
}
@@ -88,6 +91,34 @@ class ChatStateService
return $result;
}
/**
* 扫描 Redis返回当前所有有在线用户的房间 ID 数组(用于全局广播)。
*
* @return array<int>
*/
public function getAllActiveRoomIds(): array
{
$roomIds = [];
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
do {
// scan 带前缀通配,返回的 key 也带前缀
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $fullKey) {
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
// 只有 hash 非空(有在线用户)才算活跃房间
if (Redis::hlen($shortKey) > 0) {
preg_match('/room:(\d+):users/', $shortKey, $m);
if (isset($m[1])) {
$roomIds[] = (int) $m[1];
}
}
}
} while ($cursor !== '0');
return array_unique($roomIds);
}
/**
* 将一条新发言推入 Redis 列表,并限制最大保留数量,防止内存泄漏。
*
@@ -115,6 +146,42 @@ class ChatStateService
Redis::del($key);
}
/**
* 清除指定房间内,关于某个用户的旧欢迎消息(支持普通人、管理员、新人)。
* 保证聊天记录里只保留最新的一条,解决频繁进出造成的刷屏问题。
*
* @param int $roomId 房间ID
* @param string $username 用户名
*/
public function removeOldWelcomeMessages(int $roomId, string $username): void
{
$key = "room:{$roomId}:messages";
$messages = Redis::lrange($key, 0, -1);
if (empty($messages)) {
return;
}
$filtered = [];
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
continue;
}
$filtered[] = $msgJson;
}
// 重新写回 Redis如果发生了过滤
if (count($filtered) !== count($messages)) {
Redis::del($key);
if (! empty($filtered)) {
Redis::rpush($key, ...$filtered);
}
}
}
/**
* 获取指定房间的新发言记录。
* 在高频长轮询或前端断线重连拉取时使用。

View File

@@ -0,0 +1,152 @@
<?php
/**
* 文件功能:聊天室入场/离场播报服务
* 负责构建进出播报文本与颜色,按优先级(职务 > VIP > 普通随机词)选择合适的播报样式。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\User;
class RoomBroadcastService
{
/**
* 构造函数注入 VIP 服务(用于获取 VIP 专属入场/离场模板)
*/
public function __construct(
private readonly VipService $vipService,
) {}
/**
* 构建入场播报,返回 [文本, 颜色]
* 优先级:有职务 > VIP专属模板优先> 普通随机词
*
* @return array{string, string}
*/
public function buildEntryBroadcast(User $user): array
{
$position = $user->activePosition?->position;
// 有职务:显示职务图标 + 随机入场词
if ($position) {
$icon = $position->icon ?? '🎖️';
$name = $position->name;
$text = '【'.$icon.' '.$name.'】'.$this->randomWelcomeMsg($user);
return [$text, '#7c3aed']; // 紫色
}
// 有 VIP优先用专属进入模板无模板则随机词加前缀
if ($user->isVip() && $user->vipLevel) {
$color = $user->vipLevel->color ?: '#f59e0b';
$template = $this->vipService->getJoinMessage($user);
if ($template) {
return [$template, $color];
}
$text = '【'.$user->vipIcon().' '.$user->vipName().'】'.$this->randomWelcomeMsg($user);
return [$text, $color];
}
// 普通用户:绿色随机词
return [$this->randomWelcomeMsg($user), '#16a34a'];
}
/**
* 构建离场播报,返回 [文本, 颜色]
* 优先级:有职务 > VIP专属模板优先> 普通随机词
*
* @return array{string, string}
*/
public function buildLeaveBroadcast(User $user): array
{
$position = $user->activePosition?->position;
// 有职务:显示职务图标 + 随机离场词
if ($position) {
$icon = $position->icon ?? '🎖️';
$name = $position->name;
$text = '【'.$icon.' '.$name.'】'.$this->randomLeaveMsg($user);
return [$text, '#7c3aed']; // 紫色
}
// 有 VIP优先用专属离场模板无模板则随机词加前缀
if ($user->isVip() && $user->vipLevel) {
$color = $user->vipLevel->color ?: '#f59e0b';
$template = $this->vipService->getLeaveMessage($user);
if ($template) {
return [$template, $color];
}
$text = '【'.$user->vipIcon().' '.$user->vipName().'】'.$this->randomLeaveMsg($user);
return [$text, $color];
}
// 普通用户:橙色随机词
return [$this->randomLeaveMsg($user), '#cc6600'];
}
/**
* 生成随机趣味入场词
*/
public function randomWelcomeMsg(User $user): string
{
$gender = $user->sex == 2 ? '美女' : '帅哥';
$uname = $user->username;
$templates = [
$gender.'【'.$uname.'】开着刚买不久的车来到了,见到各位大虾,拱手曰:"众位大虾,小生有礼了"',
$gender.'【'.$uname.'】骑着小毛驴哼着小调,悠闲地走了进来,对大家嘿嘿一笑',
$gender.'【'.$uname.'】坐着豪华轿车缓缓驶入,推门而出,拍了拍身上的灰,霸气说道:"我来也!"',
$gender.'【'.$uname.'】踩着七彩祥云从天而降,众人皆惊,抱拳道:"各位久等了!"',
$gender.'【'.$uname.'】划着小船飘然而至,微微一笑,翩然上岸',
$gender.'【'.$uname.'】骑着自行车铃铛叮当响,远远就喊:"我来啦!想我没?"',
$gender.'【'.$uname.'】开着拖拉机突突突地开了进来,下车后拍了拍手说:"交通不便,来迟了!"',
$gender.'【'.$uname.'】坐着火箭嗖的一声到了,吓了大家一跳,嘿嘿笑道:"别怕别怕,是我啊"',
$gender.'【'.$uname.'】骑着白马翩翩而来,英姿飒爽,拱手道:"江湖路远,各位有礼了"',
$gender.'【'.$uname.'】开着宝马一路飞驰到此,推开车门走了下来,向大家挥了挥手',
$gender.'【'.$uname.'】踩着风火轮呼啸而至,在人群中潇洒亮相',
$gender.'【'.$uname.'】乘坐滑翔伞从天空缓缓降落,对大家喊道:"hello我从天上来"',
$gender.'【'.$uname.'】从地下钻了出来,拍了拍土,说:"哎呀,走错路了,不过总算到了"',
$gender.'【'.$uname.'】蹦蹦跳跳地跑了进来,嘻嘻哈哈地跟大家打招呼',
$gender.'【'.$uname.'】悄悄地溜了进来,生怕被人发现,东张西望了一番',
$gender.'【'.$uname.'】迈着六亲不认的步伐走进来,气场两米八',
];
return $templates[array_rand($templates)];
}
/**
* 生成随机趣味离场词
*/
public function randomLeaveMsg(User $user): string
{
$gender = $user->sex == 2 ? '美女' : '帅哥';
$uname = $user->username;
$templates = [
$gender.'【'.$uname.'】潇洒地挥了挥手,骑着小毛驴哼着小调离去了',
$gender.'【'.$uname.'】开着跑车扬长而去,留下一路烟尘',
$gender.'【'.$uname.'】踩着七彩祥云飘然远去,消失在天际',
$gender.'【'.$uname.'】悄无声息地溜走了,连个招呼都不打',
$gender.'【'.$uname.'】跳上直升机螺旋桨呼呼作响,朝大家喊道:"我先走啦!"',
$gender.'【'.$uname.'】拱手告别:"各位大虾,后会有期!"随后翩然离去',
$gender.'【'.$uname.'】骑着自行车铃铛叮当响,远远就喊:"下次再聊!拜拜!"',
$gender.'【'.$uname.'】坐着热气球缓缓升空,朝大家挥手告别',
$gender.'【'.$uname.'】迈着六亲不认的步伐离开了,留下一众人目瞪口呆',
$gender.'【'.$uname.'】化作一缕青烟消散在空气中……',
];
return $templates[array_rand($templates)];
}
}

16
boost.json Normal file
View File

@@ -0,0 +1,16 @@
{
"agents": [
"copilot",
"junie",
"opencode",
"gemini"
],
"guidelines": true,
"herd_mcp": true,
"mcp": true,
"nightwatch_mcp": false,
"sail": false,
"skills": [
"tailwindcss-development"
]
}

View File

@@ -16,11 +16,21 @@ return Application::configure(basePath: dirname(__DIR__))
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
'chat.site_owner' => \App\Http\Middleware\SiteOwnerRequired::class,
'chat.has_position' => \App\Http\Middleware\HasActivePosition::class,
]);
// 这一步是为了防止用户访问需要登录的页面时,默认被跳到原版 Laravel 未定义的 login 路由报错
$middleware->redirectGuestsTo('/');
})
->withExceptions(function (Exceptions $exceptions): void {
//
// 聊天室 AJAX 接口CSRF token 过期419返回 JSON 提示而非重定向
// 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误
$exceptions->render(function (\Illuminate\Session\TokenMismatchException $e, \Illuminate\Http\Request $request) {
if ($request->is('room/*/send', 'room/*/heartbeat', 'room/*/leave', 'room/*/announcement', 'gift/*', 'command/*', 'chatbot/*', 'shop/*')) {
return response()->json([
'status' => 'error',
'message' => '页面已过期,请刷新后重试。',
], 419);
}
});
})->create();

View File

@@ -22,6 +22,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.2",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",

202
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e1aec7cecd4daa266d448c0e106408c6",
"content-hash": "e501ac28571f87b0f192898f912648f5",
"packages": [
{
"name": "brick/math",
@@ -7995,6 +7995,145 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "laravel/boost",
"version": "v2.2.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "e27f1616177377fef95296620530c44a7dda4df9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/e27f1616177377fef95296620530c44a7dda4df9",
"reference": "e27f1616177377fef95296620530c44a7dda4df9",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^11.45.3|^12.41.1",
"illuminate/contracts": "^11.45.3|^12.41.1",
"illuminate/routing": "^11.45.3|^12.41.1",
"illuminate/support": "^11.45.3|^12.41.1",
"laravel/mcp": "^0.5.1",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^0.5.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
"orchestra/testbench": "^9.15.0|^10.6",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Boost\\BoostServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Boost\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2026-02-25T16:07:36+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.5.9",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129",
"reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-02-17T19:05:53+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.6",
@@ -8142,6 +8281,67 @@
},
"time": "2026-02-10T20:00:20+00:00"
},
{
"name": "laravel/roster",
"version": "v0.5.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
"reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/roster/zipball/56904a78f4d7360c1c490ced7deeebf9aecb8c0e",
"reference": "56904a78f4d7360c1c490ced7deeebf9aecb8c0e",
"shasum": ""
},
"require": {
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/routing": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/yaml": "^7.2|^8.0"
},
"require-dev": {
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"pestphp/pest": "^3.0|^4.1",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Roster\\RosterServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Roster\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Detect packages & approaches in use within a Laravel project",
"homepage": "https://github.com/laravel/roster",
"keywords": [
"dev",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
"time": "2026-02-17T17:33:35+00:00"
},
{
"name": "laravel/sail",
"version": "v1.53.0",

View File

@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:开发日志表迁移
* 记录功能开发、Bug修复、优化迭代等版本变更历史
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建开发日志表
*/
public function up(): void
{
Schema::create('dev_changelogs', function (Blueprint $table): void {
$table->id();
// 版本号,按日期格式如 2026-02-28可自由输入
$table->string('version', 30)->comment('版本号,格式: 2026-02-28');
// 日志标题,简洁描述本次更新重点
$table->string('title', 200)->comment('日志标题');
// 类型:新功能/修复/优化/其他
$table->enum('type', ['feature', 'fix', 'improve', 'other'])
->default('feature')
->comment('类型: feature=新功能 fix=修复 improve=优化 other=其他');
// Markdown 格式的详细内容
$table->longText('content')->comment('详细内容Markdown 格式)');
// 是否已发布0=草稿,不在前台显示)
$table->boolean('is_published')->default(false)->comment('是否已发布0=草稿');
// 发布时是否向 Room ID=1 的大厅广播通知
$table->boolean('notify_chat')->default(true)->comment('发布时是否通知 Room 1 大厅');
// 首次发布时间,编辑时不更新
$table->timestamp('published_at')->nullable()->comment('首次发布时间,编辑操作不更新此字段');
$table->timestamps();
// 复合索引:前台查询已发布日志并按时间排序
$table->index(['is_published', 'published_at'], 'idx_published');
});
}
/**
* 回滚:删除开发日志表
*/
public function down(): void
{
Schema::dropIfExists('dev_changelogs');
}
};

View File

@@ -0,0 +1,99 @@
<?php
/**
* 文件功能:用户反馈相关表迁移
* 包含三张关联表:
* - feedback_items反馈主表Bug报告/功能建议)
* - feedback_votes赞同记录每人每条唯一支持取消
* - feedback_replies补充评论支持管理员官方回复标记
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建用户反馈相关三张表
*/
public function up(): void
{
// ——— 表1反馈主表 ———
Schema::create('feedback_items', function (Blueprint $table): void {
$table->id();
// 提交人信息(冗余 username防止用户改名后记录混乱
$table->unsignedBigInteger('user_id')->comment('提交人 ID');
$table->string('username', 50)->comment('提交人用户名(提交时快照)');
// 反馈类型
$table->enum('type', ['bug', 'suggestion'])
->comment('类型: bug=缺陷报告 suggestion=功能建议');
// 反馈内容
$table->string('title', 200)->comment('反馈标题(一句话概括)');
$table->text('content')->comment('详细描述内容');
// 处理状态7种
$table->enum('status', [
'pending', // 待处理(默认)
'accepted', // 已接受(纳入计划)
'in_progress', // 开发中/修复中
'fixed', // Bug已修复
'done', // 建议已实现
'rejected', // 暂不同意
'shelved', // 已搁置
])->default('pending')->comment('处理状态');
// 管理员回复(前台显示为特殊"开发者回复"区块)
$table->text('admin_remark')->nullable()->comment('管理员官方回复(公开显示)');
// 冗余计数字段,配合排序(避免每次 COUNT JOIN
$table->unsignedInteger('votes_count')->default(0)->comment('赞同人数(冗余,方便排序)');
$table->unsignedInteger('replies_count')->default(0)->comment('补充评论数(冗余)');
$table->timestamps();
// 索引:按类型+状态筛选、按赞同数排序
$table->index(['type', 'status'], 'idx_type_status');
$table->index('user_id', 'idx_user_id');
$table->index('votes_count', 'idx_votes');
});
// ——— 表2赞同记录 ———
Schema::create('feedback_votes', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('feedback_id')->comment('对应反馈 ID');
$table->unsignedBigInteger('user_id')->comment('赞同用户 ID');
$table->timestamp('created_at')->nullable();
// 联合唯一索引:每个用户每条反馈只能赞同一次
$table->unique(['feedback_id', 'user_id'], 'uq_vote');
$table->index('feedback_id', 'idx_feedback_id');
});
// ——— 表3补充评论 ———
Schema::create('feedback_replies', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('feedback_id')->comment('对应反馈 ID');
// 回复人信息(冗余 username
$table->unsignedBigInteger('user_id')->comment('回复人 ID');
$table->string('username', 50)->comment('回复人用户名(快照)');
// 评论内容(纯文本,不支持 Markdown防止滥用
$table->text('content')->comment('补充内容(纯文本)');
// 管理员官方回复标记(前台特殊高亮展示)
$table->boolean('is_admin')->default(false)->comment('是否为 id=1 管理员的官方回复');
$table->timestamps();
$table->index('feedback_id', 'idx_feedback_id');
});
}
/**
* 回滚:删除用户反馈相关三张表
*/
public function down(): void
{
Schema::dropIfExists('feedback_replies');
Schema::dropIfExists('feedback_votes');
Schema::dropIfExists('feedback_items');
}
};

View File

@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:创建部门表迁移
* 部门是职务的上级分类(办公厅 / 迎宾部 / 聊务部 / 宣传部等)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建部门表
*/
public function up(): void
{
Schema::create('departments', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->comment('部门名称');
$table->unsignedTinyInteger('rank')->default(0)->comment('部门位阶0~9999 最高)');
$table->string('color', 10)->nullable()->comment('展示颜色 hex如 #8B0000');
$table->tinyInteger('sort_order')->default(0)->comment('后台列表排序');
$table->string('description', 255)->nullable()->comment('部门描述');
$table->timestamps();
});
}
/**
* 回滚:删除部门表
*/
public function down(): void
{
Schema::dropIfExists('departments');
}
};

View File

@@ -0,0 +1,44 @@
<?php
/**
* 文件功能:创建职务表迁移
* 职务属于某个部门,包含 rank 位阶、对应 user_level、人数上限、奖励上限和展示图标
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建职务表
*/
public function up(): void
{
Schema::create('positions', function (Blueprint $table) {
$table->id();
$table->foreignId('department_id')->constrained('departments')->cascadeOnDelete()->comment('所属部门');
$table->string('name', 50)->comment('职务名称');
$table->string('icon', 10)->nullable()->comment('职务图标emoji展示在聊天室用户列表');
$table->unsignedTinyInteger('rank')->default(0)->comment('职务位阶0~99跨全局排序99 最高)');
$table->tinyInteger('level')->default(1)->comment('对应 user_level任命后同步写入 users.user_level');
$table->unsignedTinyInteger('max_persons')->nullable()->comment('人数上限null=不限)');
$table->unsignedInteger('max_reward')->nullable()->comment('单次奖励上限金币null=不限)');
$table->tinyInteger('sort_order')->default(0)->comment('后台列表排序');
$table->timestamps();
});
}
/**
* 回滚:删除职务表
*/
public function down(): void
{
Schema::dropIfExists('positions');
}
};

View File

@@ -0,0 +1,48 @@
<?php
/**
* 文件功能:创建职务任命权限中间表迁移
* 记录"职务 A 的持有者可以将用户任命到职务 B"的多对多关系
* 某职务若无记录则视为无任命权
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建职务任命权限表
*/
public function up(): void
{
Schema::create('position_appoint_limits', function (Blueprint $table) {
// 任命人的职务(该职务的持有者有权任命)
$table->foreignId('appointer_position_id')
->constrained('positions')
->cascadeOnDelete()
->comment('任命人职务 ID');
// 可被任命到的目标职务
$table->foreignId('appointable_position_id')
->constrained('positions')
->cascadeOnDelete()
->comment('可任命的目标职务 ID');
$table->primary(['appointer_position_id', 'appointable_position_id']);
});
}
/**
* 回滚:删除职务任命权限表
*/
public function down(): void
{
Schema::dropIfExists('position_appoint_limits');
}
};

View File

@@ -0,0 +1,66 @@
<?php
/**
* 文件功能:创建用户职务关联表迁移
* 记录每个用户的当前在职职务及全部历史任职记录(职务履历核心表)
* is_active=true 表示当前在职false 表示历史记录(永久保留)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建用户职务关联表
*/
public function up(): void
{
Schema::create('user_positions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete()
->comment('用户 ID');
$table->foreignId('position_id')
->constrained('positions')
->cascadeOnDelete()
->comment('职务 ID');
// 任命信息
$table->unsignedBigInteger('appointed_by_user_id')->nullable()->comment('任命人 user_idnull=系统初始化)');
$table->timestamp('appointed_at')->comment('任命时间');
$table->string('remark', 255)->nullable()->comment('任命备注');
// 撤销信息
$table->timestamp('revoked_at')->nullable()->comment('撤销时间null=仍在职)');
$table->unsignedBigInteger('revoked_by_user_id')->nullable()->comment('撤销人 user_id');
// 状态true=当前在职false=历史记录
$table->boolean('is_active')->default(true)->index()->comment('是否当前在职');
$table->timestamps();
// 同一用户同一时刻只能有一条在职记录(通过业务层保证,索引辅助查询)
$table->index(['user_id', 'is_active']);
$table->index(['position_id', 'is_active']);
// 外键约束(任命人/撤销人)
$table->foreign('appointed_by_user_id')->references('id')->on('users')->nullOnDelete();
$table->foreign('revoked_by_user_id')->references('id')->on('users')->nullOnDelete();
});
}
/**
* 回滚:删除用户职务关联表
*/
public function down(): void
{
Schema::dropIfExists('user_positions');
}
};

View File

@@ -0,0 +1,80 @@
<?php
/**
* 文件功能:创建职务权限使用记录表迁移
* 记录职务持有者每一次行使职权的操作任命、撤销、奖励、警告、踢出、禁言、封IP等
* 操作人必须有在职职务才会写入此表
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建职务权限使用记录表
*/
public function up(): void
{
Schema::create('position_authority_logs', function (Blueprint $table) {
$table->id();
// 操作人信息
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete()
->comment('操作人 user_id');
$table->foreignId('user_position_id')
->constrained('user_positions')
->cascadeOnDelete()
->comment('操作时使用的职务记录 ID');
// 操作类型
$table->enum('action_type', [
'appoint', // 任命他人到某职务
'revoke', // 撤销他人职务
'reward', // 奖励金币
'warn', // 警告用户
'kick', // 踢出用户
'mute', // 禁言用户
'banip', // 封锁 IP
'other', // 其他操作
])->comment('操作类型');
// 操作对象
$table->foreignId('target_user_id')
->constrained('users')
->cascadeOnDelete()
->comment('操作对象 user_id');
$table->foreignId('target_position_id')
->nullable()
->constrained('positions')
->nullOnDelete()
->comment('任命/撤销时的目标职务 ID');
// 附加数据
$table->unsignedInteger('amount')->nullable()->comment('奖励金额reward 操作时填写)');
$table->string('remark', 255)->nullable()->comment('操作备注/理由');
$table->timestamp('created_at')->comment('操作时间');
// 查询索引(手动指定短名称,避免超过 MySQL 64 字符限制)
$table->index(['user_position_id', 'action_type', 'created_at'], 'pal_up_id_action_created_idx');
$table->index(['user_id', 'created_at'], 'pal_user_created_idx');
$table->index(['target_user_id', 'created_at'], 'pal_target_created_idx');
});
}
/**
* 回滚:删除职务权限使用记录表
*/
public function down(): void
{
Schema::dropIfExists('position_authority_logs');
}
};

View File

@@ -0,0 +1,56 @@
<?php
/**
* 文件功能:创建在职登录记录表迁移
* 记录职务持有者每次进入聊天室的登录时间、在线时长和登出时间
* 用于统计在职期间的出勤数据(勤务台四榜 + 个人履历)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建在职登录记录表
*/
public function up(): void
{
Schema::create('position_duty_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete()
->comment('用户 ID');
$table->foreignId('user_position_id')
->constrained('user_positions')
->cascadeOnDelete()
->comment('关联的在职记录 IDuser_positions.id');
$table->timestamp('login_at')->comment('登录/进房时间');
$table->timestamp('logout_at')->nullable()->comment('退出时间null=尚未退出)');
$table->unsignedInteger('duration_seconds')->nullable()->comment('在线时长(秒)');
$table->string('ip_address', 45)->nullable()->comment('登录 IP');
$table->unsignedInteger('room_id')->nullable()->comment('进入的房间 ID');
$table->timestamps();
// 常用查询索引
$table->index(['user_position_id', 'login_at']);
$table->index(['user_id', 'login_at']);
});
}
/**
* 回滚:删除在职登录记录表
*/
public function down(): void
{
Schema::dropIfExists('position_duty_logs');
}
};

View File

@@ -0,0 +1,240 @@
<?php
/**
* 文件功能:部门与职务数据填充器
* 预填所有部门(办公厅 / 迎宾部 / 聊务部 / 宣传部)
* 及各部门下的完整职务数据含图标、rank、level、人数/奖励上限)
* 并写入默认的任命权限白名单position_appoint_limits
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Database\Seeders;
use App\Models\Department;
use App\Models\Position;
use Illuminate\Database\Seeder;
class DepartmentPositionSeeder extends Seeder
{
/**
* 执行数据填充
*/
public function run(): void
{
// ── 1. 创建部门 ────────────────────────────────────────────────────
$depts = [];
foreach ($this->departmentsData() as $data) {
$depts[$data['name']] = Department::firstOrCreate(
['name' => $data['name']],
$data
);
}
// ── 2. 创建职务 ────────────────────────────────────────────────────
$positions = []; // key = "部门名::职务名"
foreach ($this->positionsData() as $row) {
$dept = $depts[$row['department']];
$position = Position::firstOrCreate(
['department_id' => $dept->id, 'name' => $row['name']],
[
'department_id' => $dept->id,
'name' => $row['name'],
'icon' => $row['icon'],
'rank' => $row['rank'],
'level' => $row['level'],
'max_persons' => $row['max_persons'],
'max_reward' => $row['max_reward'],
'sort_order' => $row['sort_order'],
]
);
$positions["{$row['department']}::{$row['name']}"] = $position;
}
// ── 3. 设置任命权限白名单 ────────────────────────────────────────────
foreach ($this->appointLimitsData($positions) as [$appointer, $appointableList]) {
/** @var Position $appointerPosition */
$appointerPosition = $positions[$appointer] ?? null;
if (! $appointerPosition) {
continue;
}
// 同步任命白名单(不附加旧数据,完整覆盖)
$appointableIds = collect($appointableList)
->map(fn ($key) => $positions[$key] ?? null)
->filter()
->pluck('id')
->toArray();
$appointerPosition->appointablePositions()->sync($appointableIds);
}
$this->command->info('部门职务 Seeder 完成:'.count($positions).' 个职务已写入。');
}
/**
* 部门基础数据
*
* @return array<int, array<string, mixed>>
*/
private function departmentsData(): array
{
return [
['name' => '办公厅', 'rank' => 99, 'color' => '#8B0000', 'sort_order' => 1, 'description' => '站级最高指挥机构'],
['name' => '迎宾部', 'rank' => 80, 'color' => '#1a5276', 'sort_order' => 2, 'description' => '负责新用户迎接与接待工作'],
['name' => '聊务部', 'rank' => 75, 'color' => '#196F3D', 'sort_order' => 3, 'description' => '负责聊天室日常管理与氛围维护'],
['name' => '宣传部', 'rank' => 70, 'color' => '#7D6608', 'sort_order' => 4, 'description' => '负责聊天室宣传推广与活动策划'],
];
}
/**
* 职务完整数据
*
* @return array<int, array<string, mixed>>
*/
private function positionsData(): array
{
// [部门, 职务名, icon, rank, level, max_persons, max_reward, sort_order]
$rows = [
// ── 办公厅 ──────────────────────────────────────────────────────
['办公厅', '站长', '👑', 99, 100, 1, null, 1],
['办公厅', '执行站长', '⭐', 97, 99, 1, null, 2],
['办公厅', '常务副站长', '📜', 95, 98, 1, 2000, 3],
['办公厅', '副站长', '🔴', 93, 97, 1, 1500, 4],
['办公厅', '秘书长', '✏️', 91, 96, 2, 1200, 5],
['办公厅', '三部总长', '🪖', 89, 95, 1, 1000, 6],
// ── 迎宾部 ──────────────────────────────────────────────────────
['迎宾部', '迎宾部长', '🏆', 87, 94, 1, 800, 1],
['迎宾部', '迎宾政委', '👤', 87, 94, 1, 800, 2],
['迎宾部', '迎宾常务副部长', '📋', 85, 93, 1, 500, 3],
['迎宾部', '迎宾副部长', '🔵', 83, 92, 1, 300, 4],
['迎宾部', '迎宾副政委', '🔵', 81, 91, 1, 300, 5],
['迎宾部', '金牌迎宾员', '🥇', 70, 90, 2, null, 6],
['迎宾部', '银牌迎宾员', '🥈', 50, 80, 3, null, 7],
['迎宾部', '铜牌迎宾员', '🥉', 30, 70, 4, null, 8],
['迎宾部', '实习迎宾员', '🌱', 10, 60, 4, null, 9],
// ── 聊务部 ──────────────────────────────────────────────────────
['聊务部', '聊务部部长', '🏆', 87, 94, 1, 800, 1],
['聊务部', '聊务部政委', '👤', 87, 94, 1, 800, 2],
['聊务部', '聊务部常务副部长', '📋', 85, 93, 1, 500, 3],
['聊务部', '聊务部副部长', '🔵', 83, 92, 1, 300, 4],
['聊务部', '聊务部副政委', '🔵', 81, 91, 1, 300, 5],
['聊务部', '金牌聊务员', '🥇', 70, 90, 2, null, 6],
['聊务部', '银牌聊务员', '🥈', 50, 80, 3, null, 7],
['聊务部', '铜牌聊务员', '🥉', 30, 70, 4, null, 8],
['聊务部', '实习聊务员', '🌱', 10, 60, 4, null, 9],
// ── 宣传部 ──────────────────────────────────────────────────────
['宣传部', '宣传部部长', '🏆', 87, 94, 1, 800, 1],
['宣传部', '宣传部政委', '👤', 87, 94, 1, 800, 2],
['宣传部', '宣传部常务副部长', '📋', 85, 93, 1, 500, 3],
['宣传部', '宣传部副部长', '🔵', 83, 92, 1, 300, 4],
['宣传部', '宣传部副政委', '🔵', 81, 91, 1, 300, 5],
['宣传部', '金牌宣传员', '🥇', 70, 90, 2, null, 6],
['宣传部', '银牌宣传员', '🥈', 50, 80, 3, null, 7],
['宣传部', '铜牌宣传员', '🥉', 30, 70, 4, null, 8],
['宣传部', '实习宣传员', '🌱', 10, 60, 4, null, 9],
];
return array_map(fn ($r) => [
'department' => $r[0],
'name' => $r[1],
'icon' => $r[2],
'rank' => $r[3],
'level' => $r[4],
'max_persons' => $r[5],
'max_reward' => $r[6],
'sort_order' => $r[7],
], $rows);
}
/**
* 默认任命权限白名单
* key = "任命人职务对应的 positions key"
* value = [可被任命的职务 key 列表]
*
* @param array<string, Position> $positions
* @return array<int, array{0: string, 1: list<string>}>
*/
private function appointLimitsData(array $positions): array
{
// 全三部门的所有职务 key 列表(不含办公厅)
$allThreeDepts = array_keys(array_filter(
$positions,
fn ($k) => ! str_starts_with($k, '办公厅::'),
ARRAY_FILTER_USE_KEY
));
// 三部门所有部长/政委及以下(全部)
$allThreeAll = $allThreeDepts;
// 三部门常务副部长及以下level <= 93
$allThreeBelowZhangzhang = array_keys(array_filter(
$positions,
fn ($p, $k) => ! str_starts_with($k, '办公厅::') && $p->level <= 93,
ARRAY_FILTER_USE_BOTH
));
// 三部门副部长/副政委及以下level <= 92
$allThreeBelowFubu = array_keys(array_filter(
$positions,
fn ($p, $k) => ! str_starts_with($k, '办公厅::') && $p->level <= 92,
ARRAY_FILTER_USE_BOTH
));
// 三部门金牌及以下level <= 90
$allThreeBelowGold = array_keys(array_filter(
$positions,
fn ($p, $k) => ! str_starts_with($k, '办公厅::') && $p->level <= 90,
ARRAY_FILTER_USE_BOTH
));
// 三部门银牌及以下level <= 80
$allThreeBelowSilver = array_keys(array_filter(
$positions,
fn ($p, $k) => ! str_starts_with($k, '办公厅::') && $p->level <= 80,
ARRAY_FILTER_USE_BOTH
));
/**
* 便捷闭包:获取指定部门、指定最大 level 的职务 key 列表
*/
$deptBelow = fn (string $dept, int $maxLevel) => array_keys(array_filter(
$positions,
fn ($p, $k) => str_starts_with($k, "{$dept}::") && $p->level <= $maxLevel,
ARRAY_FILTER_USE_BOTH
));
return [
// 办公厅
['办公厅::站长', array_merge(
['办公厅::执行站长', '办公厅::常务副站长', '办公厅::副站长', '办公厅::秘书长', '办公厅::三部总长'],
$allThreeAll
)],
['办公厅::执行站长', $allThreeAll], // 三部所有职务
['办公厅::常务副站长', $allThreeBelowZhangzhang], // 三部常务副部长及以下
['办公厅::副站长', $allThreeBelowFubu], // 三部副部长级及以下
['办公厅::秘书长', $allThreeBelowGold], // 三部金牌及以下
['办公厅::三部总长', $allThreeBelowSilver], // 三部银牌及以下
// 迎宾部
['迎宾部::迎宾部长', $deptBelow('迎宾部', 90)],
['迎宾部::迎宾政委', $deptBelow('迎宾部', 90)],
['迎宾部::迎宾常务副部长', $deptBelow('迎宾部', 80)],
['迎宾部::迎宾副部长', $deptBelow('迎宾部', 70)],
['迎宾部::迎宾副政委', $deptBelow('迎宾部', 70)],
// 聊务部
['聊务部::聊务部部长', $deptBelow('聊务部', 90)],
['聊务部::聊务部政委', $deptBelow('聊务部', 90)],
['聊务部::聊务部常务副部长', $deptBelow('聊务部', 80)],
['聊务部::聊务部副部长', $deptBelow('聊务部', 70)],
['聊务部::聊务部副政委', $deptBelow('聊务部', 70)],
// 宣传部
['宣传部::宣传部部长', $deptBelow('宣传部', 90)],
['宣传部::宣传部政委', $deptBelow('宣传部', 90)],
['宣传部::宣传部常务副部长', $deptBelow('宣传部', 80)],
['宣传部::宣传部副部长', $deptBelow('宣传部', 70)],
['宣传部::宣传部副政委', $deptBelow('宣传部', 70)],
];
}
}

25
opencode.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"laravel-boost": {
"type": "local",
"enabled": true,
"command": [
"php",
"artisan",
"boost:mcp"
]
},
"herd": {
"type": "local",
"enabled": true,
"command": [
"php",
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"environment": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}

View File

@@ -68,6 +68,13 @@ export function initChat(roomId) {
.listen("EffectBroadcast", (e) => {
console.log("特效播放:", e);
window.dispatchEvent(new CustomEvent("chat:effect", { detail: e }));
})
// 监听任命公告(礼花 + 隆重弹窗)
.listen("AppointmentAnnounced", (e) => {
console.log("任命公告:", e);
window.dispatchEvent(
new CustomEvent("chat:appointment-announced", { detail: e }),
);
});
}

View File

@@ -0,0 +1,73 @@
{{--
文件功能:在职期间权限操作日志子页
展示某任职记录期间该用户的所有权限操作(任命/撤销/奖励/警告/踢出/禁言/封IP等
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '权限操作日志 · ' . $userPosition->user->username)
@section('content')
<div class="mb-6">
<a href="{{ route('admin.appointments.index') }}" class="text-sm text-indigo-600 hover:underline"> 返回任命管理</a>
<h2 class="text-lg font-bold text-gray-800 mt-2">
{{ $userPosition->position->icon }} {{ $userPosition->user->username }} · {{ $userPosition->position->name }}
</h2>
<p class="text-sm text-gray-500 mt-1">权限操作记录(共 {{ $logs->total() }} 条)</p>
</div>
@php
$actionColors = [
'appoint' => 'bg-green-100 text-green-700',
'revoke' => 'bg-red-100 text-red-700',
'reward' => 'bg-yellow-100 text-yellow-700',
'warn' => 'bg-orange-100 text-orange-700',
'kick' => 'bg-red-100 text-red-700',
'mute' => 'bg-purple-100 text-purple-700',
'banip' => 'bg-gray-200 text-gray-700',
'other' => 'bg-gray-100 text-gray-600',
];
@endphp
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs">
<tr>
<th class="px-4 py-3 text-left">操作时间</th>
<th class="px-4 py-3 text-center">操作类型</th>
<th class="px-4 py-3 text-left">操作对象</th>
<th class="px-4 py-3 text-left">目标职务</th>
<th class="px-4 py-3 text-center">奖励金额</th>
<th class="px-4 py-3 text-left">备注</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($logs as $log)
@php $colorClass = $actionColors[$log->action_type] ?? 'bg-gray-100 text-gray-600'; @endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-500 text-xs">{{ $log->created_at->format('m-d H:i:s') }}</td>
<td class="px-4 py-3 text-center">
<span class="text-xs px-2 py-0.5 rounded font-bold {{ $colorClass }}">
{{ $log->action_label }}
</span>
</td>
<td class="px-4 py-3 font-bold text-gray-700">{{ $log->targetUser->username ?? '—' }}</td>
<td class="px-4 py-3 text-gray-500">{{ $log->targetPosition?->name ?? '—' }}</td>
<td class="px-4 py-3 text-center text-yellow-600 font-bold">
{{ $log->amount ? number_format($log->amount) . ' 金币' : '—' }}
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{{ $log->remark ?? '—' }}</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-12 text-center text-gray-400">暂无权限操作记录</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $logs->links() }}</div>
@endsection

View File

@@ -0,0 +1,80 @@
{{--
文件功能:任职期间登录记录子页
展示某个用户在职期间的每次登录时间、在线时长、IP 和房间
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '在职登录日志 · ' . $userPosition->user->username)
@section('content')
<div class="mb-6">
<a href="{{ route('admin.appointments.index') }}" class="text-sm text-indigo-600 hover:underline"> 返回任命管理</a>
<h2 class="text-lg font-bold text-gray-800 mt-2">
{{ $userPosition->position->icon }} {{ $userPosition->user->username }} · {{ $userPosition->position->name }}
</h2>
<p class="text-sm text-gray-500 mt-1">
任命于 {{ $userPosition->appointed_at->format('Y-m-d') }}
任命人:{{ $userPosition->appointedBy?->username ?? '系统' }}
</p>
</div>
{{-- 统计摘要 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl p-5 border shadow-sm text-center">
<div class="text-2xl font-bold text-indigo-600">{{ $logs->total() }}</div>
<div class="text-xs text-gray-500 mt-1">总登录次数</div>
</div>
<div class="bg-white rounded-xl p-5 border shadow-sm text-center">
<div class="text-2xl font-bold text-green-600">
{{ gmdate('H', $logs->sum('duration_seconds')) }}h {{ gmdate('i', $logs->sum('duration_seconds')) }}m
</div>
<div class="text-xs text-gray-500 mt-1">累计在线时长(当前页)</div>
</div>
<div class="bg-white rounded-xl p-5 border shadow-sm text-center">
<div class="text-2xl font-bold text-orange-600">{{ $userPosition->total_rewarded_coins }}</div>
<div class="text-xs text-gray-500 mt-1">在职期间发放金币</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs">
<tr>
<th class="px-4 py-3 text-left">登录时间</th>
<th class="px-4 py-3 text-left">退出时间</th>
<th class="px-4 py-3 text-center">在线时长</th>
<th class="px-4 py-3 text-center">房间</th>
<th class="px-4 py-3 text-left">IP 地址</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($logs as $log)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-700">{{ $log->login_at->format('m-d H:i:s') }}</td>
<td class="px-4 py-3 text-gray-500">{{ $log->logout_at?->format('m-d H:i:s') ?? '在线中...' }}</td>
<td class="px-4 py-3 text-center">
@if ($log->duration_seconds)
<span
class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">{{ $log->formatted_duration }}</span>
@else
<span class="text-xs text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-center text-gray-500">{{ $log->room_id ? "房间#{$log->room_id}" : '—' }}
</td>
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ $log->ip_address }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-12 text-center text-gray-400">暂无登录记录</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $logs->links() }}</div>
@endsection

View File

@@ -0,0 +1,62 @@
{{--
文件功能:历史任职记录页面
展示所有已撤销的职务记录is_active=false),含任命人和撤销人信息
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '历史任职记录')
@section('content')
<div class="mb-6">
<a href="{{ route('admin.appointments.index') }}" class="text-sm text-indigo-600 hover:underline"> 返回任命管理</a>
<h2 class="text-lg font-bold text-gray-800 mt-2">历史任职记录</h2>
<p class="text-sm text-gray-500 mt-1">已撤销的职务记录(共 {{ $history->total() }} 条)</p>
</div>
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs">
<tr>
<th class="px-4 py-3 text-left">用户</th>
<th class="px-4 py-3 text-left">职务</th>
<th class="px-4 py-3 text-left">任命人</th>
<th class="px-4 py-3 text-center">任命时间</th>
<th class="px-4 py-3 text-left">撤销人</th>
<th class="px-4 py-3 text-center">撤销时间</th>
<th class="px-4 py-3 text-center">在职天数</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($history as $up)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-bold text-gray-800">{{ $up->user->username }}</td>
<td class="px-4 py-3">
<span class="mr-1">{{ $up->position->icon }}</span>
<span style="color: {{ $up->position->department->color }}">{{ $up->position->name }}</span>
<span class="text-xs text-gray-400 ml-1">{{ $up->position->department->name }}</span>
</td>
<td class="px-4 py-3 text-gray-500">{{ $up->appointedBy?->username ?? '系统' }}</td>
<td class="px-4 py-3 text-center text-gray-500 text-xs">{{ $up->appointed_at->format('Y-m-d') }}
</td>
<td class="px-4 py-3 text-gray-500">{{ $up->revokedBy?->username ?? '系统' }}</td>
<td class="px-4 py-3 text-center text-gray-500 text-xs">
{{ $up->revoked_at?->format('Y-m-d') ?? '—' }}</td>
<td class="px-4 py-3 text-center">
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{{ $up->duration_days }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-400">暂无历史记录</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $history->links() }}</div>
@endsection

View File

@@ -0,0 +1,250 @@
{{--
文件功能:后台任命管理页面
展示当前所有在职人员,支持新增任命和撤销职务
任命时可搜索用户并选择目标职务
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '任命管理')
@section('content')
<div x-data="{
showForm: false,
username: '',
position_id: '',
remark: '',
searchResults: [],
showDropdown: false,
searching: false,
searchTimer: null,
openAppoint() {
this.username = '';
this.position_id = '';
this.remark = '';
this.searchResults = [];
this.showDropdown = false;
this.showForm = true;
},
async doSearch(val) {
this.username = val;
clearTimeout(this.searchTimer);
if (val.length < 1) {
this.searchResults = [];
this.showDropdown = false;
return;
}
this.searching = true;
this.searchTimer = setTimeout(async () => {
const res = await fetch(`{{ route('admin.appointments.search-users') }}?q=${encodeURIComponent(val)}`);
this.searchResults = await res.json();
this.showDropdown = this.searchResults.length > 0;
this.searching = false;
}, 250);
},
selectUser(u) {
this.username = u.username;
this.showDropdown = false;
this.searchResults = [];
}
}">
{{-- 头部 --}}
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-lg font-bold text-gray-800">任命管理</h2>
<p class="text-sm text-gray-500">管理当前所有在职职位人员,执行任命或撤销操作</p>
</div>
<div class="flex space-x-2">
<a href="{{ route('admin.appointments.history') }}"
style="background-color:#e5e7eb;color:#374151;padding:0.5rem 1rem;border-radius:0.5rem;font-weight:700;font-size:0.875rem;display:inline-flex;align-items:center;text-decoration:none;"
onmouseover="this.style.backgroundColor='#d1d5db'" onmouseout="this.style.backgroundColor='#e5e7eb'">
历史记录
</a>
<button @click="openAppoint()"
style="background-color:#f97316;color:#fff;padding:0.5rem 1.25rem;border-radius:0.5rem;font-weight:700;border:none;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#ea580c'" onmouseout="this.style.backgroundColor='#f97316'">
+ 新增任命
</button>
</div>
</div>
@if (session('success'))
<div class="mb-4 px-4 py-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ session('error') }}</div>
@endif
{{-- 在职人员列表 --}}
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs">
<tr>
<th class="px-4 py-3 text-left">用户</th>
<th class="px-4 py-3 text-left">部门·职务</th>
<th class="px-4 py-3 text-center">等级</th>
<th class="px-4 py-3 text-left">任命人</th>
<th class="px-4 py-3 text-center">任命时间</th>
<th class="px-4 py-3 text-center">在职天数</th>
<th class="px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($activePositions as $up)
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3">
<div class="font-bold text-gray-800">{{ $up->user->username }}</div>
<div class="text-xs text-gray-400">Lv.{{ $up->user->user_level }}</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-1">
<span class="text-lg">{{ $up->position->icon }}</span>
<div>
<div class="text-xs text-gray-400">{{ $up->position->department->name }}</div>
<div class="font-bold" style="color: {{ $up->position->department->color }}">
{{ $up->position->name }}
</div>
</div>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded font-mono">
Lv.{{ $up->position->level }}
</span>
</td>
<td class="px-4 py-3 text-gray-600">{{ $up->appointedBy?->username ?? '系统' }}</td>
<td class="px-4 py-3 text-center text-gray-500">
{{ $up->appointed_at->format('Y-m-d') }}
</td>
<td class="px-4 py-3 text-center">
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">
{{ $up->duration_days }}
</span>
</td>
<td class="px-4 py-3 text-right space-x-1">
<a href="{{ route('admin.appointments.duty-logs', $up->id) }}"
class="text-xs bg-blue-50 text-blue-600 font-bold px-2 py-1 rounded hover:bg-blue-600 hover:text-white transition">
登录日志
</a>
<a href="{{ route('admin.appointments.authority-logs', $up->id) }}"
class="text-xs bg-purple-50 text-purple-600 font-bold px-2 py-1 rounded hover:bg-purple-600 hover:text-white transition">
操作日志
</a>
<form action="{{ route('admin.appointments.revoke', $up->id) }}" method="POST"
class="inline"
onsubmit="return confirm('确定撤销【{{ $up->user->username }}】的【{{ $up->position->name }}】职务?撤销后其等级将归 1。')">
@csrf @method('DELETE')
<button type="submit"
class="text-xs bg-red-50 text-red-600 font-bold px-2 py-1 rounded hover:bg-red-600 hover:text-white transition">
撤销职务
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-400">暂无在职人员</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- 新增任命弹窗 --}}
<div x-show="showForm" style="display: none;"
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div @click.away="showForm = false" class="bg-white rounded-xl shadow-2xl w-full max-w-md" x-transition>
<div
style="background-color:#c2410c;padding:1rem 1.5rem;display:flex;justify-content:space-between;align-items:center;border-radius:0.75rem 0.75rem 0 0;">
<h3 style="font-weight:700;font-size:1.125rem;color:#fff;margin:0;">新增任命</h3>
<button @click="showForm = false"
style="color:#fed7aa;background:none;border:none;font-size:1.5rem;cursor:pointer;line-height:1;"
onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#fed7aa'">&times;</button>
</div>
<div class="p-6">
<form action="{{ route('admin.appointments.store') }}" method="POST">
@csrf
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">用户名</label>
<div class="relative">
<input type="text" name="username" x-model="username" required
placeholder="输入关键字搜索用户..." @input="doSearch($event.target.value)"
@blur="setTimeout(() => showDropdown = false, 200)"
@focus="if(username.length > 0) doSearch(username)"
class="w-full border rounded-md p-2 text-sm" autocomplete="off">
{{-- 搜索中指示 --}}
<span x-show="searching"
class="absolute right-2 top-2.5 text-gray-400 text-xs">搜索中…</span>
{{-- 下拉结果 --}}
<div x-show="showDropdown" style="display:none;"
class="absolute z-50 w-full bg-white border rounded-md shadow-lg mt-1 max-h-48 overflow-y-auto">
<template x-for="u in searchResults" :key="u.id">
<div @mousedown="selectUser(u)"
class="px-3 py-2 hover:bg-orange-50 cursor-pointer flex justify-between items-center">
<span class="font-bold text-sm" x-text="u.username"></span>
<span class="text-xs text-gray-400" x-text="'Lv.' + u.user_level"></span>
</div>
</template>
<div x-show="searchResults.length === 0 && !searching"
class="px-3 py-2 text-xs text-gray-400">无匹配用户(或已有职务)</div>
</div>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">目标职务</label>
<select name="position_id" x-model="position_id" required
class="w-full border rounded-md p-2 text-sm">
<option value="">-- 请选择职务 --</option>
@foreach ($departments as $dept)
<optgroup label="{{ $dept->name }}">
@foreach ($dept->positions as $pos)
@php
$current = $pos->active_user_positions_count ?? 0;
$max = $pos->max_persons;
$isFull = $max && $current >= $max;
$cap = $max
? "在职 {$current}/{$max}" . ($isFull ? ' ⚠️满' : '')
: "在职 {$current} 人·不限额";
@endphp
<option value="{{ $pos->id }}"
{{ $isFull ? 'style=color:#dc2626' : '' }}>
{{ $pos->icon }}
{{ $pos->name }}Lv.{{ $pos->level }}{{ $cap }}
</option>
@endforeach
</optgroup>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">任命备注(可选)</label>
<input type="text" name="remark" x-model="remark" maxlength="255"
placeholder="例:表现优秀,特此提拔" class="w-full border rounded-md p-2 text-sm">
</div>
</div>
<div
style="display:flex;justify-content:flex-end;gap:0.75rem;padding-top:1rem;margin-top:1rem;border-top:1px solid #e5e7eb;">
<button type="button" @click="showForm = false"
style="padding:0.5rem 1rem;border:1px solid #d1d5db;border-radius:0.375rem;font-weight:500;color:#4b5563;background:#fff;cursor:pointer;"
onmouseover="this.style.background='#f9fafb'"
onmouseout="this.style.background='#fff'">取消</button>
<button type="submit"
style="padding:0.5rem 1rem;background-color:#f97316;color:#fff;border-radius:0.375rem;font-weight:700;border:none;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#ea580c'"
onmouseout="this.style.backgroundColor='#f97316'">
确认任命
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -20,7 +20,7 @@
<div class="md:col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">事件文本
<span class="text-gray-400 font-normal">{username} 将被替换为触发者用户名)</span></label>
<input type="text" name="text_body" required placeholder="例:🎉 恭喜【{username}】获得 100 经验"
<input type="text" name="text_body" required placeholder="例:🎉 恭喜【{username}】获得 100 经验"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2 bg-white border">
</div>
<div>

View File

@@ -0,0 +1,116 @@
{{--
文件功能:后台开发日志新增/编辑表单(仅 id=1 超级管理员可访问)
新增时:可选立即发布 + 通知大厅用户(触发 WebSocket 广播)
编辑时:不显示"通知大厅"选项,不更新 published_at
@extends admin.layouts.app
--}}
@extends('admin.layouts.app')
@section('title', $isCreate ? '新增开发日志' : '编辑开发日志')
@section('content')
<div class="flex items-center gap-3 mb-6">
<a href="{{ route('admin.changelogs.index') }}" class="text-gray-400 hover:text-gray-600 transition"> 返回列表</a>
<h2 class="text-xl font-bold text-gray-800">{{ $isCreate ? '📝 新增开发日志' : '✏️ 编辑开发日志' }}</h2>
</div>
<div class="max-w-3xl bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<form action="{{ $isCreate ? route('admin.changelogs.store') : route('admin.changelogs.update', $log->id) }}"
method="POST">
@csrf
@if (!$isCreate)
@method('PUT')
@endif
{{-- 版本号 + 类型 --}}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
版本号 <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1">(推荐使用日期格式,如 2026-02-28)</span>
</label>
<input type="text" name="version" value="{{ old('version', $log?->version ?? $todayVersion) }}"
required maxlength="30" placeholder="例: 2026-02-28"
class="w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-indigo-400 outline-none">
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">类型 <span class="text-red-500">*</span></label>
<select name="type"
class="w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-indigo-400 outline-none">
@foreach ($typeOptions as $value => $config)
<option value="{{ $value }}" {{ old('type', $log?->type) === $value ? 'selected' : '' }}>
{{ $config['label'] }}
</option>
@endforeach
</select>
</div>
</div>
{{-- 标题 --}}
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">标题 <span class="text-red-500">*</span></label>
<input type="text" name="title" value="{{ old('title', $log?->title) }}" required maxlength="200"
placeholder="简洁描述本次更新的重点..."
class="w-full border border-gray-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-indigo-400 outline-none">
</div>
{{-- Markdown 内容 --}}
<div class="mb-5">
<label class="block text-sm font-bold text-gray-700 mb-2">
详细内容 <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1">(支持 Markdown 格式)</span>
</label>
<textarea name="content" required rows="16"
placeholder="## 新增功能&#10;- 新增了 AI 聊天机器人功能&#10;- 支持多种 AI 服务商OpenAI / DeepSeek&#10;&#10;## Bug 修复&#10;- 修复了钓鱼游戏积分计算错误&#10;&#10;## 优化&#10;- 改进了消息加载速度"
class="w-full border border-gray-200 rounded-lg p-3 text-sm font-mono resize-y focus:ring-2 focus:ring-indigo-400 outline-none leading-relaxed">{{ old('content', $log?->content) }}</textarea>
<p class="text-xs text-gray-400 mt-1">支持 ## 标题、- 列表、`代码` 等 Markdown 格式</p>
</div>
{{-- 发布选项 --}}
<div class="bg-gray-50 rounded-xl p-4 mb-5 border border-gray-200">
<label class="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" name="is_published" value="1"
{{ old('is_published', $log?->is_published) ? 'checked' : '' }}
class="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-400" id="is_published">
<span class="font-bold text-gray-800">立即发布</span>
<span class="text-gray-500 text-sm font-normal">(取消勾选则保存为草稿)</span>
</label>
@if ($isCreate)
{{-- 新增时显示"通知大厅"选项 --}}
<label class="flex items-center gap-3 cursor-pointer mt-3" id="notify-row">
<input type="checkbox" name="notify_chat" value="1" checked
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-400" id="notify_chat">
<span class="font-bold text-gray-800">通知大厅用户</span>
<span class="text-gray-500 text-sm font-normal">
(发布时在「星光大厅 Room 1」的聊天区广播通知,含可点击链接)
</span>
</label>
@else
{{-- 编辑时也可手动触发通知 --}}
<label class="flex items-center gap-3 cursor-pointer mt-3" id="notify-row">
<input type="checkbox" name="notify_chat" value="1"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-400" id="notify_chat">
<span class="font-bold text-gray-800">重新通知大厅用户</span>
<span class="text-gray-500 text-sm font-normal">
(勾选后保存,将再次向「星光大厅」广播此日志通知)
</span>
</label>
@endif
</div>
{{-- 操作按钮 --}}
<div class="flex justify-end gap-3">
<a href="{{ route('admin.changelogs.index') }}"
class="px-5 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 font-medium text-sm transition">
取消
</a>
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 text-sm shadow-sm transition">
{{ $isCreate ? '保存日志' : '更新日志' }}
</button>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,102 @@
{{--
文件功能:后台开发日志列表页(仅 id=1 超级管理员可访问)
展示所有日志(含草稿),支持发布/编辑/删除操作
@extends admin.layouts.app
--}}
@extends('admin.layouts.app')
@section('title', '开发日志管理')
@section('content')
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-xl font-bold text-gray-800">📋 开发日志管理</h2>
<p class="text-sm text-gray-500 mt-1">管理版本更新记录,发布后可在大厅消息区通知用户</p>
</div>
<a href="{{ route('admin.changelogs.create') }}"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-bold text-sm shadow transition">
+ 新增日志
</a>
</div>
{{-- 日志列表 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr class="text-left text-gray-500 text-xs font-bold uppercase tracking-wider">
<th class="px-5 py-3">版本号</th>
<th class="px-5 py-3">标题</th>
<th class="px-5 py-3">类型</th>
<th class="px-5 py-3">状态</th>
<th class="px-5 py-3">发布时间</th>
<th class="px-5 py-3 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@forelse($logs as $log)
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-5 py-3 font-mono text-indigo-700 font-bold">v{{ $log->version }}</td>
<td class="px-5 py-3 text-gray-800 font-medium">{{ $log->title }}</td>
<td class="px-5 py-3">
@php
$typeConfig = \App\Models\DevChangelog::TYPE_CONFIG[$log->type] ?? null;
$colorMap = [
'emerald' => 'bg-emerald-100 text-emerald-700',
'rose' => 'bg-rose-100 text-rose-700',
'blue' => 'bg-blue-100 text-blue-700',
'slate' => 'bg-slate-100 text-slate-700',
];
@endphp
<span
class="px-2 py-0.5 rounded-full text-xs font-bold {{ $colorMap[$typeConfig['color'] ?? 'slate'] ?? 'bg-slate-100 text-slate-700' }}">
{{ $typeConfig['label'] ?? '其他' }}
</span>
</td>
<td class="px-5 py-3">
@if ($log->is_published)
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs font-bold">
已发布</span>
@else
<span class="px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full text-xs font-bold">🔒
草稿</span>
@endif
</td>
<td class="px-5 py-3 text-gray-400 text-xs">
{{ $log->published_at?->format('Y-m-d H:i') ?? '—' }}
</td>
<td class="px-5 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('admin.changelogs.edit', $log->id) }}"
class="text-blue-600 hover:text-blue-800 font-semibold text-xs px-2 py-1 rounded hover:bg-blue-50 transition">
编辑
</a>
<form action="{{ route('admin.changelogs.destroy', $log->id) }}" method="POST"
onsubmit="return confirm('确定要删除这条日志吗?');">
@csrf @method('DELETE')
<button type="submit"
class="text-red-600 hover:text-red-800 font-semibold text-xs px-2 py-1 rounded hover:bg-red-50 transition">
删除
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-5 py-12 text-center text-gray-400">
<p class="text-3xl mb-2">📭</p>
<p>还没有任何日志,点击右上角「新增日志」开始吧</p>
</td>
</tr>
@endforelse
</tbody>
</table>
@if ($logs->hasPages())
<div class="px-5 py-4 border-t border-gray-100">
{{ $logs->links() }}
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,185 @@
{{--
文件功能:后台部门管理页面
提供部门的新增、编辑、删除功能,展示每个部门的职务数量
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '部门管理')
@section('content')
<div x-data="{
showForm: false,
editing: null,
form: { name: '', rank: 80, color: '#1a5276', sort_order: 0, description: '' },
openCreate() {
this.editing = null;
this.form = { name: '', rank: 80, color: '#1a5276', sort_order: 0, description: '' };
this.showForm = true;
},
openEdit(dept) {
this.editing = dept;
this.form = { name: dept.name, rank: dept.rank, color: dept.color, sort_order: dept.sort_order, description: dept.description };
this.showForm = true;
}
}">
{{-- 头部 --}}
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-lg font-bold text-gray-800">部门管理</h2>
<p class="text-sm text-gray-500">管理聊天室部门架构,设置位阶、颜色与描述</p>
</div>
<div class="flex space-x-2">
<a href="{{ route('admin.positions.index') }}"
style="background-color:#16a34a;color:#fff;padding:0.5rem 1rem;border-radius:0.5rem;font-weight:700;font-size:0.875rem;display:inline-flex;align-items:center;text-decoration:none;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#15803d'" onmouseout="this.style.backgroundColor='#16a34a'">
📋 职务管理
</a>
@if (Auth::id() === 1)
<button @click="openCreate()"
style="background-color:#4f46e5;color:#fff;padding:0.5rem 1.25rem;border-radius:0.5rem;font-weight:700;border:none;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#4338ca'"
onmouseout="this.style.backgroundColor='#4f46e5'">
+ 新增部门
</button>
@endif
</div>
</div>
@if (session('success'))
<div class="mb-4 px-4 py-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ session('error') }}
</div>
@endif
{{-- 部门卡片列表 --}}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
@foreach ($departments as $dept)
<div class="bg-white rounded-xl shadow-sm border overflow-hidden hover:shadow-md transition">
<div class="h-2" style="background-color: {{ $dept->color }}"></div>
<div class="p-5">
<div class="flex items-center justify-between mb-3">
<span class="font-bold text-lg" style="color: {{ $dept->color }}">{{ $dept->name }}</span>
<span class="text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-500">位阶
{{ $dept->rank }}</span>
</div>
<div class="space-y-1.5 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">职务数量</span>
<span class="font-bold text-indigo-600">{{ $dept->positions_count }} </span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">排序</span>
<span class="font-bold">{{ $dept->sort_order }}</span>
</div>
@if ($dept->description)
<p class="text-gray-400 text-xs pt-1">{{ $dept->description }}</p>
@endif
</div>
</div>
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
<div class="border-t bg-gray-50 px-5 py-3 flex justify-end space-x-2">
<button
@click="openEdit({
id: {{ $dept->id }},
name: '{{ addslashes($dept->name) }}',
rank: {{ $dept->rank }},
color: '{{ $dept->color }}',
sort_order: {{ $dept->sort_order }},
description: '{{ addslashes($dept->description ?? '') }}',
requestUrl: '{{ route('admin.departments.update', $dept->id) }}'
})"
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-3 py-1.5 rounded hover:bg-indigo-600 hover:text-white transition">
编辑
</button>
@if (Auth::id() === 1)
<form action="{{ route('admin.departments.destroy', $dept->id) }}" method="POST"
onsubmit="return confirm('确定删除部门【{{ $dept->name }}】?该操作会同时删除该部门所有职务(有在职人员时拒绝删除)。')">
@csrf @method('DELETE')
<button type="submit"
class="text-xs bg-red-50 text-red-600 font-bold px-3 py-1.5 rounded hover:bg-red-600 hover:text-white transition">
删除
</button>
</form>
@endif
</div>
@endif
</div>
@endforeach
</div>
@if ($departments->isEmpty())
<div class="text-center py-16 text-gray-400">
<p class="text-lg">暂无部门,点击右上角「新增部门」创建</p>
</div>
@endif
{{-- 新增/编辑弹窗 --}}
<div x-show="showForm" style="display: none;"
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div @click.away="showForm = false" class="bg-white rounded-xl shadow-2xl w-full max-w-md" x-transition>
<div class="bg-indigo-900 px-6 py-4 flex justify-between items-center rounded-t-xl text-white">
<h3 class="font-bold text-lg" x-text="editing ? '编辑部门:' + editing.name : '新增部门'"></h3>
<button @click="showForm = false" class="text-gray-400 hover:text-white text-xl">&times;</button>
</div>
<div class="p-6">
<form :action="editing ? editing.requestUrl : '{{ route('admin.departments.store') }}'" method="POST">
@csrf
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">部门名称</label>
<input type="text" name="name" x-model="form.name" required maxlength="50"
class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">位阶0~99,越大越高)</label>
<input type="number" name="rank" x-model="form.rank" required min="0"
max="99" class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">排序</label>
<input type="number" name="sort_order" x-model="form.sort_order" required min="0"
class="w-full border rounded-md p-2 text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">展示颜色</label>
<div class="flex items-center space-x-2">
<input type="color" name="color" x-model="form.color"
class="w-10 h-8 border rounded cursor-pointer">
<input type="text" x-model="form.color" maxlength="10"
class="flex-1 border rounded-md p-2 text-sm font-mono">
</div>
</div>
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">部门描述(可选)</label>
<input type="text" name="description" x-model="form.description" maxlength="255"
class="w-full border rounded-md p-2 text-sm">
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 mt-4 border-t">
<button type="button" @click="showForm = false"
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded font-bold hover:bg-indigo-700 shadow-sm"
x-text="editing ? '保存修改' : '创建部门'"></button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,187 @@
{{--
文件功能:后台用户反馈管理页面(仅 id=1 超级管理员可访问)
列表展示所有用户提交的 Bug 报告和功能建议
支持按类型+状态筛选可直接修改状态Ajax和填写官方回复
@extends admin.layouts.app
--}}
@extends('admin.layouts.app')
@section('title', '用户反馈管理')
@section('content')
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-xl font-bold text-gray-800">
💬 用户反馈管理
@if ($pendingCount > 0)
<span class="ml-2 text-sm bg-orange-100 text-orange-700 font-bold px-2 py-0.5 rounded-full">
{{ $pendingCount }} 条待处理
</span>
@endif
</h2>
<p class="text-sm text-gray-500 mt-1">管理用户提交的 Bug 报告和功能建议,修改状态后前台实时更新</p>
</div>
</div>
{{-- 筛选栏 --}}
<div class="flex flex-wrap gap-3 mb-5" x-data="{
type: '{{ $currentType ?? '' }}',
status: '{{ $currentStatus ?? '' }}',
go() {
const params = new URLSearchParams();
if (this.type) params.set('type', this.type);
if (this.status) params.set('status', this.status);
window.location.href = '/admin/feedback?' + params.toString();
}
}">
<select x-model="type" @change="go()"
class="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 outline-none bg-white">
<option value="">所有类型</option>
<option value="bug">🐛 Bug报告</option>
<option value="suggestion">💡 功能建议</option>
</select>
<select x-model="status" @change="go()"
class="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400 outline-none bg-white">
<option value="">所有状态</option>
@foreach ($statusConfig as $key => $config)
<option value="{{ $key }}">{{ $config['icon'] }} {{ $config['label'] }}</option>
@endforeach
</select>
@if ($currentType || $currentStatus)
<a href="{{ route('admin.feedback.index') }}"
class="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg bg-white hover:bg-gray-50 transition">
清除筛选
</a>
@endif
</div>
{{-- 反馈列表 --}}
<div class="space-y-3">
@forelse($feedbacks as $feedback)
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden" x-data="{
expanded: false,
status: '{{ $feedback->status }}',
remark: @js($feedback->admin_remark ?? ''),
saving: false,
async updateStatus() {
this.saving = true;
const res = await fetch('/admin/feedback/{{ $feedback->id }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-HTTP-Method-Override': 'PUT',
},
body: JSON.stringify({ status: this.status, admin_remark: this.remark, _method: 'PUT' }),
});
const data = await res.json();
this.saving = false;
if (data.status !== 'success') alert('保存失败');
}
}">
{{-- 卡片头部 --}}
<div class="px-5 py-4 flex items-start gap-4">
{{-- 赞同数 --}}
<div class="shrink-0 text-center">
<div class="text-2xl font-black text-indigo-600">{{ $feedback->votes_count }}</div>
<div class="text-xs text-gray-400">赞同</div>
</div>
{{-- 类型+状态+标题 --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-1">
@php
$typeConfig = \App\Models\FeedbackItem::TYPE_CONFIG[$feedback->type] ?? null;
@endphp
<span
class="px-2 py-0.5 rounded text-xs font-bold {{ $feedback->type === 'bug' ? 'bg-rose-100 text-rose-700' : 'bg-blue-100 text-blue-700' }}">
{{ $typeConfig['label'] ?? '' }}
</span>
{{-- 状态下拉Ajax 即时保存) --}}
<select x-model="status" @change="updateStatus()"
class="border border-gray-200 rounded px-2 py-0.5 text-xs font-bold focus:ring-1 focus:ring-indigo-400 outline-none cursor-pointer"
:disabled="saving">
@foreach ($statusConfig as $key => $config)
<option value="{{ $key }}"
{{ $feedback->status === $key ? 'selected' : '' }}>
{{ $config['icon'] }} {{ $config['label'] }}
</option>
@endforeach
</select>
<span class="text-gray-400 text-xs">💬 {{ $feedback->replies_count }}</span>
<span x-show="saving" class="text-xs text-indigo-500 animate-pulse">保存中...</span>
</div>
<h4 class="font-bold text-gray-800 text-sm">{{ $feedback->title }}</h4>
<p class="text-gray-400 text-xs mt-1">
by {{ $feedback->username }} · {{ $feedback->created_at->diffForHumans() }}
</p>
</div>
{{-- 展开按钮 --}}
<button @click="expanded = !expanded"
class="shrink-0 text-indigo-600 hover:text-indigo-800 text-sm font-bold border border-indigo-200 hover:border-indigo-400 px-3 py-1.5 rounded-lg transition">
<span x-text="expanded ? '收起 ▲' : '展开 ▽'"></span>
</button>
</div>
{{-- 展开详情 --}}
<div x-show="expanded" x-transition.opacity class="border-t border-gray-100">
{{-- 原始描述 --}}
<div class="px-5 py-4 bg-gray-50 text-sm text-gray-700 leading-relaxed">
<p class="font-bold text-gray-500 text-xs mb-2">用户描述</p>
<p class="whitespace-pre-wrap">{{ $feedback->content }}</p>
</div>
{{-- 所有补充评论 --}}
@if ($feedback->replies->count() > 0)
<div class="px-5 py-3 border-t border-gray-100 space-y-2">
<p class="text-xs font-bold text-gray-500 mb-2">用户补充 ({{ $feedback->replies->count() }} )</p>
@foreach ($feedback->replies as $reply)
<div
class="rounded-lg px-3 py-2 text-sm {{ $reply->is_admin ? 'bg-indigo-50 border border-indigo-200' : 'bg-gray-50' }}">
<div class="flex items-center gap-2 mb-1">
<span
class="font-bold {{ $reply->is_admin ? 'text-indigo-700' : 'text-gray-700' }}">{{ $reply->username }}</span>
@if ($reply->is_admin)
<span
class="text-xs bg-indigo-200 text-indigo-800 px-1.5 rounded font-bold">开发者</span>
@endif
<span
class="text-gray-400 text-xs">{{ $reply->created_at->diffForHumans() }}</span>
</div>
<p class="text-gray-700 whitespace-pre-wrap">{{ $reply->content }}</p>
</div>
@endforeach
</div>
@endif
{{-- 官方回复+保存区 --}}
<div class="px-5 py-4 border-t border-gray-100 bg-indigo-50/40">
<p class="text-xs font-bold text-indigo-800 mb-2">🛡️ 官方回复(公开显示给所有用户)</p>
<textarea x-model="remark" rows="3" placeholder="填写官方处理说明、修复进度或拒绝原因..."
class="w-full border border-indigo-200 rounded-lg p-2.5 text-sm resize-none focus:ring-2 focus:ring-indigo-400 outline-none bg-white"></textarea>
<div class="flex justify-end mt-2">
<button @click="updateStatus()" :disabled="saving"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-xs font-bold disabled:opacity-50 transition">
<span x-text="saving ? '保存中...' : '保存状态+回复'"></span>
</button>
</div>
</div>
</div>
</div>
@empty
<div class="bg-white rounded-xl border border-gray-100 py-16 text-center text-gray-400">
<p class="text-4xl mb-3">💬</p>
<p class="font-bold text-lg">暂无用户反馈</p>
<p class="text-sm mt-1">等待用户从前台提交问题和建议</p>
</div>
@endforelse
</div>
{{-- 分页 --}}
@if ($feedbacks->hasPages())
<div class="mt-6">
{{ $feedbacks->links() }}
</div>
@endif
@endsection

View File

@@ -18,6 +18,8 @@
<p class="text-xs text-slate-400 mt-2">飘落流星 控制台</p>
</div>
<nav class="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{{-- ──────── 所有有职务的人都可见 ──────── --}}
<a href="{{ route('admin.dashboard') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.dashboard') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📊 仪表盘
@@ -26,38 +28,76 @@
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-stats.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📈 积分流水统计
</a>
@if (Auth::id() === 1)
<a href="{{ route('admin.system.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.system.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
⚙️ 聊天室参数设置
</a>
<a href="{{ route('admin.smtp.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.smtp.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📧 邮件 SMTP 配置
</a>
@endif
<a href="{{ route('admin.users.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.users.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
👥 用户管理
</a>
<a href="{{ route('admin.rooms.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🏠 房间管理
</a>
<a href="{{ route('admin.autoact.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.autoact.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🎲 随机事件
</a>
<a href="{{ route('admin.vip.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
👑 VIP 会员等级
{{-- ──────── 部门职务任命系统 ──────── --}}
<div class="border-t border-white/10 my-2"></div>
<a href="{{ route('admin.appointments.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.appointments.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🎖️ 任命管理
</a>
@if (Auth::id() === 1)
<a href="{{ route('admin.ai-providers.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.ai-providers.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🤖 AI 厂商配置
{{-- superlevel 及以上可查看只读标注以下模块id=1 可编辑 --}}
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
@php $ro = Auth::id() !== 1 ? ' <span style="font-size:10px;opacity:.45;font-weight:normal;">(只读)</span>' : ''; @endphp
<div class="border-t border-white/10 my-2"></div>
<p class="px-4 text-xs text-slate-500 uppercase tracking-widest mb-1">
{{ Auth::id() === 1 ? '站长功能' : '查看' }}</p>
<a href="{{ route('admin.system.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.system.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '⚙️ 聊天室参数' . $ro !!}
</a>
<a href="{{ route('admin.rooms.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🏠 房间管理' . $ro !!}
</a>
<a href="{{ route('admin.autoact.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.autoact.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🎲 随机事件' . $ro !!}
</a>
<a href="{{ route('admin.vip.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '👑 VIP 会员等级' . $ro !!}
</a>
<a href="{{ route('admin.departments.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.departments.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🏛️ 部门管理' . $ro !!}
</a>
<a href="{{ route('admin.positions.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.positions.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '📋 职务管理' . $ro !!}
</a>
{{-- 以下纯写操作:仅 id=1 可见 --}}
@if (Auth::id() === 1)
<div class="border-t border-white/10 my-2"></div>
<p class="px-4 text-xs text-slate-500 uppercase tracking-widest mb-1">系统配置</p>
<a href="{{ route('admin.smtp.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.smtp.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📧 邮件 SMTP 配置
</a>
<a href="{{ route('admin.ai-providers.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.ai-providers.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🤖 AI 厂商配置
</a>
<a href="{{ route('admin.changelogs.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.changelogs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📋 开发日志
</a>
<a href="{{ route('admin.feedback.index') }}"
class="flex items-center justify-between px-4 py-3 rounded-md transition {{ request()->routeIs('admin.feedback.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
<span>💬 用户反馈</span>
@php $pendingFeedback = \App\Models\FeedbackItem::pending()->count(); @endphp
@if ($pendingFeedback > 0)
<span
class="bg-orange-500 text-white text-xs px-1.5 py-0.5 rounded-full font-bold">{{ $pendingFeedback }}</span>
@endif
</a>
@endif
@endif
</nav>
<div class="p-4 border-t border-white/10">

View File

@@ -0,0 +1,298 @@
{{--
文件功能:后台职务管理页面
按部门分组展示所有职务,支持新增/编辑/删除职务
编辑时可通过多选框配置该职务可任命的目标职务列表(任命白名单)
@author ChatRoom Laravel
@version 1.0.0
--}}
@extends('admin.layouts.app')
@section('title', '职务管理')
@section('content')
<div x-data="{
showForm: false,
editing: null,
selectedIds: [],
form: {
department_id: '',
name: '',
icon: '🎖️',
rank: 50,
level: 60,
max_persons: 1,
max_reward: '',
sort_order: 0
},
openCreate() {
this.editing = null;
this.selectedIds = [];
this.form = { department_id: '', name: '', icon: '🎖️', rank: 50, level: 60, max_persons: 1, max_reward: '', sort_order: 0 };
this.showForm = true;
},
openEdit(pos, appointableIds) {
this.editing = pos;
this.selectedIds = appointableIds;
this.form = {
department_id: pos.department_id,
name: pos.name,
icon: pos.icon || '',
rank: pos.rank,
level: pos.level,
max_persons: pos.max_persons || '',
max_reward: pos.max_reward || '',
sort_order: pos.sort_order,
};
this.showForm = true;
},
toggleId(id) {
if (this.selectedIds.includes(id)) {
this.selectedIds = this.selectedIds.filter(i => i !== id);
} else {
this.selectedIds.push(id);
}
},
isSelected(id) {
return this.selectedIds.includes(id);
}
}">
{{-- 头部 --}}
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-lg font-bold text-gray-800">职务管理</h2>
<p class="text-sm text-gray-500">管理各部门职务,配置等级、图标、人数上限和任命权限</p>
</div>
<div class="flex space-x-2">
<a href="{{ route('admin.departments.index') }}"
style="background-color:#e5e7eb;color:#374151;padding:0.5rem 1rem;border-radius:0.5rem;font-weight:700;font-size:0.875rem;display:inline-flex;align-items:center;text-decoration:none;"
onmouseover="this.style.backgroundColor='#d1d5db'" onmouseout="this.style.backgroundColor='#e5e7eb'">
部门管理
</a>
<a href="{{ route('admin.appointments.index') }}"
style="background-color:#f97316;color:#fff;padding:0.5rem 1rem;border-radius:0.5rem;font-weight:700;font-size:0.875rem;display:inline-flex;align-items:center;text-decoration:none;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#ea580c'" onmouseout="this.style.backgroundColor='#f97316'">
🎖️ 任命管理
</a>
@if (Auth::id() === 1)
<button @click="openCreate()"
style="background-color:#4f46e5;color:#fff;padding:0.5rem 1.25rem;border-radius:0.5rem;font-weight:700;border:none;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.1);"
onmouseover="this.style.backgroundColor='#4338ca'"
onmouseout="this.style.backgroundColor='#4f46e5'">
+ 新增职务
</button>
@endif
</div>
</div>
@if (session('success'))
<div class="mb-4 px-4 py-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ session('error') }}</div>
@endif
{{-- 按部门分组展示职务 --}}
@foreach ($departments as $dept)
<div class="mb-8">
<div class="flex items-center space-x-3 mb-3">
<div class="w-3 h-3 rounded-full" style="background-color: {{ $dept->color }}"></div>
<h3 class="font-bold text-base" style="color: {{ $dept->color }}">{{ $dept->name }}</h3>
<span class="text-xs text-gray-400">位阶 {{ $dept->rank }}</span>
</div>
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs">
<tr>
<th class="px-4 py-3 text-left">图标</th>
<th class="px-4 py-3 text-left">职务名</th>
<th class="px-4 py-3 text-center">位阶</th>
<th class="px-4 py-3 text-center">等级</th>
<th class="px-4 py-3 text-center">人数上限</th>
<th class="px-4 py-3 text-center">当前在职</th>
<th class="px-4 py-3 text-center">奖励上限</th>
<th class="px-4 py-3 text-center">任命权</th>
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
<th class="px-4 py-3 text-right">操作</th>
@endif
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($dept->positions as $pos)
@php $appointableIds = $pos->appointablePositions->pluck('id')->toArray(); @endphp
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 text-xl">{{ $pos->icon }}</td>
<td class="px-4 py-3 font-bold">{{ $pos->name }}</td>
<td class="px-4 py-3 text-center">
<span
class="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded font-mono">{{ $pos->rank }}</span>
</td>
<td class="px-4 py-3 text-center">
<span
class="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded font-mono">Lv.{{ $pos->level }}</span>
</td>
<td class="px-4 py-3 text-center text-gray-600">{{ $pos->max_persons ?? '不限' }}</td>
<td class="px-4 py-3 text-center">
<span
class="{{ $pos->active_user_positions_count >= ($pos->max_persons ?? 999) ? 'text-red-600 font-bold' : 'text-indigo-600' }}">
{{ $pos->active_user_positions_count }}&nbsp;
</span>
</td>
<td class="px-4 py-3 text-center text-gray-600">
{{ $pos->max_reward ? number_format($pos->max_reward) . '金币' : '不限' }}
</td>
<td class="px-4 py-3 text-center">
@if (count($appointableIds) > 0)
<span
class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">{{ count($appointableIds) }}
个职务</span>
@else
<span class="text-xs text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-right space-x-1">
@php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp
@if (Auth::user()->user_level >= $superLvl)
<button
@click="openEdit({
id: {{ $pos->id }},
department_id: {{ $pos->department_id }},
name: '{{ addslashes($pos->name) }}',
icon: '{{ $pos->icon }}',
rank: {{ $pos->rank }},
level: {{ $pos->level }},
max_persons: {{ $pos->max_persons ?? 'null' }},
max_reward: {{ $pos->max_reward ?? 'null' }},
sort_order: {{ $pos->sort_order }},
requestUrl: '{{ route('admin.positions.update', $pos->id) }}'
}, {{ json_encode($appointableIds) }})"
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-2 py-1 rounded hover:bg-indigo-600 hover:text-white transition">
编辑
</button>
@endif
@if (Auth::id() === 1)
<form action="{{ route('admin.positions.destroy', $pos->id) }}" method="POST"
class="inline" onsubmit="return confirm('确定删除职务【{{ $pos->name }}】?')">
@csrf @method('DELETE')
<button type="submit"
class="text-xs bg-red-50 text-red-600 font-bold px-2 py-1 rounded hover:bg-red-600 hover:text-white transition">
删除
</button>
</form>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="9" class="px-4 py-6 text-center text-gray-400">该部门暂无职务</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endforeach
{{-- 新增/编辑弹窗 --}}
<div x-show="showForm" style="display: none;"
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div @click.away="showForm = false"
class="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[92vh] overflow-y-auto" x-transition>
<div class="bg-indigo-900 px-6 py-4 flex justify-between items-center rounded-t-xl text-white sticky top-0">
<h3 class="font-bold text-lg" x-text="editing ? '编辑职务:' + editing.name : '新增职务'"></h3>
<button @click="showForm = false" class="text-gray-400 hover:text-white text-xl">&times;</button>
</div>
<div class="p-6">
<form :action="editing ? editing.requestUrl : '{{ route('admin.positions.store') }}'" method="POST">
@csrf
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">所属部门</label>
<select name="department_id" x-model="form.department_id" required
class="w-full border rounded-md p-2 text-sm">
<option value="">-- 请选择部门 --</option>
@foreach ($departments as $dept)
<option value="{{ $dept->id }}">{{ $dept->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">职务名称</label>
<input type="text" name="name" x-model="form.name" required maxlength="50"
class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">图标Emoji</label>
<input type="text" name="icon" x-model="form.icon" maxlength="10"
class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">位阶0~99,跨全局排序)</label>
<input type="number" name="rank" x-model="form.rank" required min="0"
max="99" class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">等级user_level1~100</label>
<input type="number" name="level" x-model="form.level" required min="1"
max="100" class="w-full border rounded-md p-2 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">人数上限(空=不限)</label>
<input type="number" name="max_persons" x-model="form.max_persons" min="1"
class="w-full border rounded-md p-2 text-sm" placeholder="留空不限">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">单次奖励上限金币(空=不限)</label>
<input type="number" name="max_reward" x-model="form.max_reward" min="0"
class="w-full border rounded-md p-2 text-sm" placeholder="留空不限">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">排序</label>
<input type="number" name="sort_order" x-model="form.sort_order" required
min="0" class="w-full border rounded-md p-2 text-sm">
</div>
</div>
{{-- 任命白名单多选 --}}
<div class="border rounded-lg p-4 bg-gray-50">
<h4 class="text-xs font-bold text-gray-700 mb-2">
任命权限白名单
<span class="font-normal text-gray-400 ml-1">(勾选后此职务持有者可将用户任命到以下职务;不勾选则该职务无任命权)</span>
</h4>
<div class="grid grid-cols-2 gap-1 max-h-52 overflow-y-auto">
@foreach ($allPositions as $ap)
<label
class="flex items-center space-x-2 cursor-pointer hover:bg-white rounded p-1.5 text-sm"
:class="isSelected({{ $ap->id }}) ? 'bg-indigo-50 text-indigo-700 font-bold' :
'text-gray-700'">
<input type="checkbox" name="appointable_ids[]" value="{{ $ap->id }}"
:checked="isSelected({{ $ap->id }})"
@change="toggleId({{ $ap->id }})" class="rounded text-indigo-600">
<span>{{ $ap->department->name }}·{{ $ap->name }}</span>
</label>
@endforeach
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 mt-4 border-t">
<button type="button" @click="showForm = false"
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded font-bold hover:bg-indigo-700 shadow-sm"
x-text="editing ? '保存修改' : '创建职务'"></button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -97,7 +97,7 @@
@unless ($room->room_keep)
<form action="{{ route('admin.rooms.destroy', $room->id) }}" method="POST"
class="inline"
onsubmit="return confirm('确定要删除房间「{{ $room->room_name }}」吗?此操作不可销!')">
onsubmit="return confirm('确定要删除房间「{{ $room->room_name }}」吗?此操作不可销!')">
@csrf
@method('DELETE')
<button type="submit"

View File

@@ -4,10 +4,6 @@
@section('content')
@php
// 管理员级别 = 最高等级 + 1后台编辑最高可设到管理员级别
$adminLevel = (int) \App\Models\Sysparam::getValue('maxlevel', '15') + 1;
@endphp
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-6" x-data="userEditor()">
<div class="p-6 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<form action="{{ route('admin.users.index') }}" method="GET" class="flex gap-2">
@@ -54,6 +50,7 @@
等级<span class="text-indigo-500">{{ $arrow('user_level') }}</span>
</a>
</th>
<th class="p-4">职务</th>
<th class="p-4">
<a href="{{ $sortLink('exp_num') }}" class="hover:text-indigo-600 flex items-center gap-1">
经验<span class="text-indigo-500">{{ $arrow('exp_num') }}</span>
@@ -95,6 +92,17 @@
LV.{{ $user->user_level }}
</span>
</td>
<td class="p-4">
@if ($user->activePosition)
@php $pos = $user->activePosition->position; @endphp
<div class="text-xs text-gray-400">{{ $pos->department->name }}</div>
<div class="font-bold text-sm" style="color: {{ $pos->department->color }}">
{{ $pos->icon }} {{ $pos->name }}
</div>
@else
<span class="text-gray-300 text-xs"></span>
@endif
</td>
<td class="p-4 text-sm font-mono text-gray-600">
{{ number_format($user->exp_num ?? 0) }}
</td>
@@ -111,7 +119,6 @@
@click="editingUser = {
id: {{ $user->id }},
username: '{{ addslashes($user->username) }}',
user_level: {{ $user->user_level }},
exp_num: {{ $user->exp_num ?? 0 }},
jjb: {{ $user->jjb ?? 0 }},
meili: {{ $user->meili ?? 0 }},
@@ -172,26 +179,6 @@
@csrf @method('PUT')
<div class="grid grid-cols-2 gap-4">
{{-- 等级 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">
等级
<span class="text-gray-400 font-normal">
(最高 <span x-text="adminLevel"></span> )
</span>
</label>
<input type="number" name="user_level" x-model="editingUser.user_level" required
min="0" :max="adminLevel"
:readonly="{{ Auth::id() }} !== 1 && editingUser.id !== {{ Auth::id() }}"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm"
:class="{
'bg-gray-100 cursor-not-allowed': {{ Auth::id() }} !== 1 && editingUser
.id !==
{{ Auth::id() }}
}"
:title="{{ Auth::id() }} !== 1 && editingUser.id !== {{ Auth::id() }} ?
'仅系统创始人可修改他人等级' : ''">
</div>
{{-- 经验 --}}
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">经验值</label>
@@ -285,7 +272,6 @@
Alpine.data('userEditor', () => ({
showEditModal: false,
editingUser: {},
adminLevel: {{ $adminLevel }},
editToast: false,
editToastOk: true,
editToastMsg: '',

View File

@@ -0,0 +1,309 @@
{{--
文件功能:开发日志前台独立页面(/changelog
时间轴样式懒加载IntersectionObserver倒序显示
仅展示已发布的日志is_published=1
支持 URL #vYYYY-MM-DD 锚点直跳指定版本
@extends layouts.app
--}}
@extends('layouts.app')
@section('title', '更新日志 - 飘落流星')
@section('nav-icon', '📋')
@section('nav-title', '更新日志')
@section('head')
<style>
/* 时间轴主线 */
.tl-line {
position: absolute;
left: 9px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, #818cf8 0%, #c4b5fd 70%, transparent 100%);
}
/* 时间轴节点圆点 */
.tl-dot {
position: absolute;
left: 0;
top: 22px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #818cf8;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: transform 0.2s, border-color 0.2s;
z-index: 1;
}
.tl-item:hover .tl-dot {
transform: scale(1.25);
border-color: #4f46e5;
}
/* Markdown 内容样式 */
.prose-log h1,
.prose-log h2,
.prose-log h3 {
font-weight: 700;
color: #1e1b4b;
margin: 1rem 0 0.5rem;
}
.prose-log h2 {
font-size: 1rem;
border-bottom: 1px solid #e0e7ff;
padding-bottom: 4px;
}
.prose-log ul {
list-style: disc;
padding-left: 1.5rem;
margin: 0.4rem 0;
}
.prose-log ol {
list-style: decimal;
padding-left: 1.5rem;
margin: 0.4rem 0;
}
.prose-log li {
margin: 0.2rem 0;
line-height: 1.6;
}
.prose-log p {
margin: 0.4rem 0;
line-height: 1.7;
}
.prose-log code {
background: #f1f5f9;
padding: 1px 5px;
border-radius: 4px;
font-size: 0.85em;
color: #7c3aed;
font-family: monospace;
}
.prose-log a {
color: #4f46e5;
text-decoration: underline;
}
.prose-log strong {
font-weight: 600;
}
.prose-log blockquote {
border-left: 3px solid #818cf8;
padding-left: 10px;
color: #64748b;
font-style: italic;
margin: 0.5rem 0;
}
</style>
@endsection
@section('content')
<div class="max-w-3xl mx-auto py-8 px-4 sm:px-6" x-data="{
items: [],
lastId: null,
hasMore: true,
loading: false,
init() {
// SSR 首屏数据注入
this.items = {{ json_encode(
$changelogs->map(
fn($l) => [
'id' => $l->id,
'version' => $l->version,
'title' => $l->title,
'type_label' => $l->type_label,
'type_color' => $l->type_color,
'content_html' => $l->content_html,
'summary' => $l->summary,
'published_at' => $l->published_at?->format('Y-m-d'),
'expanded' => false,
],
),
) }};
if (this.items.length > 0) {
this.lastId = this.items[this.items.length - 1].id;
this.hasMore = this.items.length >= 10;
} else {
this.hasMore = false;
}
// 处理 URL #v2026-02-28 锚点跳转
if (window.location.hash && window.location.hash.startsWith('#v')) {
const version = window.location.hash.slice(2);
this.$nextTick(() => {
const el = document.getElementById('v' + version);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
const item = this.items.find(i => i.version === version);
if (item) item.expanded = true;
}
});
}
},
// 懒加载更多
async loadMore() {
if (this.loading || !this.hasMore) return;
this.loading = true;
try {
const res = await fetch(`/changelog/more?after_id=${this.lastId}`);
const data = await res.json();
this.items.push(...data.items.map(i => ({ ...i, expanded: false })));
if (data.items.length > 0) this.lastId = data.items[data.items.length - 1].id;
this.hasMore = data.has_more;
} catch (e) { console.error(e); } finally { this.loading = false; }
},
}">
{{-- 页面标题区 --}}
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-2xl font-extrabold text-gray-800 flex items-center gap-2">
📋 <span>开发日志</span>
</h2>
<p class="text-sm text-gray-500 mt-1">记录每次版本的功能新增、Bug 修复与优化改进</p>
</div>
<div class="text-right text-xs text-gray-400">
<p>按更新时间排序</p>
<p class="text-indigo-500 font-medium mt-0.5"> {{ $changelogs->count() }}+ 条日志</p>
</div>
</div>
{{-- 空状态 --}}
<template x-if="items.length === 0">
<div class="text-center py-24 text-gray-400">
<p class="text-6xl mb-4">📭</p>
<p class="text-lg font-bold text-gray-500">暂无更新日志</p>
<p class="text-sm mt-2">开发团队还没有发布任何日志,请稍后再来查看</p>
</div>
</template>
{{-- 时间轴 --}}
<div class="relative pl-10">
{{-- 时间轴主线 --}}
<div class="tl-line"></div>
<template x-for="(log, index) in items" :key="log.id">
<div class="tl-item relative mb-8" :id="'v' + log.version">
{{-- 圆点节点 --}}
<div class="tl-dot"
:class="{
'border-emerald-400': log.type_color === 'emerald',
'border-rose-400': log.type_color === 'rose',
'border-blue-400': log.type_color === 'blue',
'border-slate-400': log.type_color === 'slate',
}">
<span
x-text="log.type_color === 'emerald' ? '🆕' : log.type_color === 'rose' ? '🐛' : log.type_color === 'blue' ? '⚡' : '📌'"
class="text-xs"></span>
</div>
{{-- 日志卡片 --}}
<div
class="bg-white rounded-2xl shadow-sm border border-gray-100 hover:shadow-md transition-shadow duration-200 overflow-hidden">
{{-- 卡片头部 --}}
<div class="px-5 py-4 border-b border-gray-50">
<div class="flex items-center gap-3 flex-wrap">
{{-- 版本号 --}}
<span class="font-mono text-lg font-black text-indigo-700 tracking-wide"
x-text="'v' + log.version"></span>
{{-- 类型标签 --}}
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold"
:class="{
'bg-emerald-100 text-emerald-700': log.type_color === 'emerald',
'bg-rose-100 text-rose-700': log.type_color === 'rose',
'bg-blue-100 text-blue-700': log.type_color === 'blue',
'bg-slate-100 text-slate-600': log.type_color === 'slate',
}"
x-text="log.type_label"></span>
{{-- 发布日期 --}}
<span class="text-gray-400 text-xs ml-auto" x-text="log.published_at"></span>
</div>
{{-- 标题 --}}
<h3 class="text-base font-bold text-gray-800 mt-2 leading-snug" x-text="log.title"></h3>
</div>
{{-- 内容区 --}}
<div class="px-5 py-4">
{{-- 默认折叠:摘要 --}}
<div x-show="!log.expanded">
<p class="text-gray-600 text-sm leading-relaxed" x-text="log.summary"></p>
<button @click="log.expanded = true"
class="mt-3 text-indigo-600 hover:text-indigo-800 text-xs font-semibold hover:underline flex items-center gap-1">
展开完整内容 <span class="text-base leading-none"></span>
</button>
</div>
{{-- 展开:完整 Markdown 内容 --}}
<div x-show="log.expanded" x-transition.opacity.duration.150ms>
<div class="prose-log text-sm text-gray-700" x-html="log.content_html"></div>
<button @click="log.expanded = false"
class="mt-3 text-gray-400 hover:text-gray-600 text-xs font-semibold hover:underline flex items-center gap-1">
收起 <span class="text-base leading-none"></span>
</button>
</div>
</div>
{{-- 底部:版本锚点复制链接 --}}
<div class="px-5 py-2 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
<span class="text-gray-400 text-xs">
#<span x-text="log.version"></span>
</span>
<button
@click="navigator.clipboard?.writeText(window.location.origin + '/changelog#v' + log.version).then(() => { $el.textContent = '✅ 已复制'; setTimeout(() => $el.textContent = '🔗 复制链接', 1500) })"
class="text-xs text-gray-400 hover:text-indigo-600 transition">
🔗 复制链接
</button>
</div>
</div>
</div>
</template>
{{-- 懒加载触发哨兵 --}}
<div x-show="hasMore" x-intersect.threshold.10="loadMore()" class="py-6 text-center">
<template x-if="loading">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z">
</path>
</svg>
加载更多...
</div>
</template>
</div>
{{-- 全部加载完成提示 --}}
<div x-show="!hasMore && items.length > 0" class="text-center py-6 text-gray-400 text-sm">
<div class="inline-flex items-center gap-2">
<div class="h-px w-16 bg-gray-200"></div>
<span>以上是全部更新日志</span>
<div class="h-px w-16 bg-gray-200"></div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -43,7 +43,11 @@
fishReelUrl: "{{ route('fishing.reel', $room->id) }}",
chatBotUrl: "{{ route('chatbot.chat') }}",
chatBotClearUrl: "{{ route('chatbot.clear') }}",
chatBotEnabled: {{ \App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1' ? 'true' : 'false' }}
chatBotEnabled: {{ \App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1' ? 'true' : 'false' }},
hasPosition: {{ Auth::user()->activePosition || Auth::user()->user_level >= $superLevel ? 'true' : 'false' }},
appointPositionsUrl: "{{ route('chat.appoint.positions') }}",
appointUrl: "{{ route('chat.appoint.appoint') }}",
revokeUrl: "{{ route('chat.appoint.revoke') }}"
};
</script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])

View File

@@ -145,10 +145,15 @@
item.dataset.username = username;
const headface = (user.headface || '1.gif').toLowerCase();
// VIP 图标和管理员标识 (互斥显示:管理员优先)
// 徽章优先级:职务图标 > 管理员 > VIP
let badges = '';
if (user.is_admin) {
// 军人专属风格:由于宽度受限,采用代表最高荣誉与权威的“将官军功章”单字符
if (user.position_icon) {
// 有职务显示职务图标hover 显示职务名称
const posTitle = (user.position_name || '在职') + ' · ' + username;
badges +=
`<span style="font-size:13px; margin-left:2px;" title="${posTitle}">${user.position_icon}</span>`;
} else if (user.is_admin) {
badges += `<span style="font-size:12px; margin-left:2px;" title="最高统帅">🎖️</span>`;
} else if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
@@ -315,14 +320,33 @@
let html = '';
// 系统用户消息以醒目公告样式显示
if (systemUsers.includes(msg.from_user)) {
// 第一个判断分支:如果是纯 HTML 系统的进出播报
if (msg.action === 'system_welcome') {
div.style.cssText = 'margin: 3px 0;';
const iconImg =
`<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
html = `${iconImg} ${parsedContent}`;
}
// 接下来再判断各类发话人
else if (systemUsers.includes(msg.from_user)) {
if (msg.from_user === '系统公告') {
// 管理员公告:大字醒目红框样式
div.style.cssText =
'background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 6px; padding: 8px 12px; margin: 4px 0; box-shadow: 0 2px 4px rgba(239,68,68,0.15);';
let parsedContent = msg.content;
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#dc2626') + '】';
});
html =
`<div style="font-weight: bold; color: #dc2626;">${msg.content} <span style="color: #999; font-weight: normal;">(${timeStr})</span></div>`;
`<div style="font-weight: bold; color: #dc2626;">${parsedContent} <span style="color: #999; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (msg.from_user === '系统传音') {
// 自动升级播报 / 赠礼通知:金色左边框,轻量提示样式,不喧宾夺主
@@ -343,8 +367,16 @@
giftHtml =
`<img src="${msg.gift_image}" alt="${msg.gift_name || ''}" style="display:inline-block;width:40px;height:40px;vertical-align:middle;margin-left:6px;animation:giftBounce 0.6s ease-in-out;">`;
}
// 让带有【用户名】的系统通知变成可点击和双击的蓝色用户标
let parsedContent = msg.content;
// 利用正则匹配【用户名】结构,捕获组 $1 即是里面真正的用户名
parsedContent = parsedContent.replace(/([^]+)/g, function(match, uName) {
return '【' + clickableUser(uName, '#000099') + '】';
});
html =
`${headImg}<span class="msg-user" style="color: ${fontColor}; font-weight: bold;">${msg.from_user}</span><span class="msg-content" style="color: ${fontColor}">${msg.content}</span>${giftHtml}`;
`${headImg}<span class="msg-user" style="color: ${fontColor}; font-weight: bold;">${msg.from_user}</span><span class="msg-content" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
}
} else if (msg.is_secret) {
if (msg.from_user === '系统') {
@@ -384,6 +416,14 @@
}
div.innerHTML = html;
// 后端下发的带有 welcome_user 的也是系统欢迎/离开消息,加上属性标记
if (msg.welcome_user) {
div.setAttribute('data-system-user', msg.welcome_user);
// 收到后端来的新欢迎消息时,把界面上该用户旧的都删掉
const oldWelcomes = container.querySelectorAll(`[data-system-user="${msg.welcome_user}"]`);
oldWelcomes.forEach(el => el.remove());
}
// 路由规则(复刻原版):
// 公众窗口(say1):别人的公聊消息
// 包厢窗口(say2):自己发的消息 + 悄悄话 + 对自己说的消息
@@ -439,89 +479,12 @@
const user = e.detail;
onlineUsers[user.username] = user;
renderUserList();
// 原版风格:完整句式的随机趣味欢迎语
const gender = user.sex == 2 ? '美女' : '帅哥';
const uname = user.username;
const welcomeTemplates = [
`${gender}<b>${uname}</b>开着刚买不久的车,来到了,见到各位大虾,拱手曰:"众位大虾,小生有礼了"`,
`${gender}<b>${uname}</b>骑着小毛驴哼着小调,悠闲地走了进来,对大家嘿嘿一笑`,
`${gender}<b>${uname}</b>坐着豪华轿车缓缓驶入,推门而出,拍了拍身上的灰,霸气说道:"我来也!"`,
`${gender}<b>${uname}</b>踩着七彩祥云从天而降,众人皆惊,抱拳道:"各位久等了!"`,
`${gender}<b>${uname}</b>划着小船飘然而至,微微一笑,翩然上岸`,
`${gender}<b>${uname}</b>骑着自行车铃铛叮当响,远远就喊:"我来啦!想我没?"`,
`${gender}<b>${uname}</b>开着拖拉机突突突地开了进来,下车后拍了拍手说:"交通不便,来迟了!"`,
`${gender}<b>${uname}</b>坐着火箭嗖的一声到了,吓了大家一跳,嘿嘿笑道:"别怕别怕,是我啊"`,
`${gender}<b>${uname}</b>骑着白马翩翩而来,英姿飒爽,拱手道:"江湖路远,各位有礼了"`,
`${gender}<b>${uname}</b>开着宝马一路飞驰到此,推开车门走了下来,向大家挥了挥手`,
`${gender}<b>${uname}</b>踩着风火轮呼啸而至,在人群中潇洒亮相`,
`${gender}<b>${uname}</b>乘坐滑翔伞从天空缓缓降落,对大家喊道:"hello我从天上来"`,
`${gender}<b>${uname}</b>从地下钻了出来,拍了拍土,说:"哎呀,走错路了,不过总算到了"`,
`${gender}<b>${uname}</b>蹦蹦跳跳地跑了进来,嘻嘻哈哈地跟大家打招呼`,
`${gender}<b>${uname}</b>悄悄地溜了进来,生怕被人发现,东张西望了一番`,
`${gender}<b>${uname}</b>迈着六亲不认的步伐走进来,气场两米八`,
];
const msg = welcomeTemplates[Math.floor(Math.random() * welcomeTemplates.length)];
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
// VIP 用户使用专属颜色和图标
if (user.vip_icon && user.vip_name) {
const vipColor = user.vip_color || '#f59e0b';
sysDiv.innerHTML =
`<span style="color: ${vipColor}; font-weight: bold;">【${user.vip_icon} ${user.vip_name}】${msg}</span><span class="msg-time">(${timeStr})</span>`;
} else {
sysDiv.innerHTML =
`<span style="color: green">【欢迎】${msg}</span><span class="msg-time">(${timeStr})</span>`;
}
container.appendChild(sysDiv);
scrollToBottom();
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
renderUserList();
// 原版风格:趣味离开语(与进入一致的风格)
const gender = user.sex == 2 ? '美女' : '帅哥';
const uname = user.username;
const leaveTemplates = [
`${gender}<b>${uname}</b>潇洒地挥了挥手,骑着小毛驴哼着小调离去了`,
`${gender}<b>${uname}</b>开着跑车扬长而去,留下一路烟尘`,
`${gender}<b>${uname}</b>踩着七彩祥云飘然远去,消失在天际`,
`${gender}<b>${uname}</b>悄无声息地溜走了,连个招呼都不打`,
`${gender}<b>${uname}</b>跳上直升机螺旋桨呼呼作响,朝大家喊道:"我先走啦!"`,
`${gender}<b>${uname}</b>拱手告别:"各位大虾,后会有期!"随后翩然离去`,
`${gender}<b>${uname}</b>骑着自行车铃铛叮当响,远远就喊:"下次再聊!拜拜!"`,
`${gender}<b>${uname}</b>坐着热气球缓缓升空,朝大家挥手告别`,
`${gender}<b>${uname}</b>迈着六亲不认的步伐离开了,留下一众人目瞪口呆`,
`${gender}<b>${uname}</b>化作一缕青烟消散在空气中……`,
];
const msg = leaveTemplates[Math.floor(Math.random() * leaveTemplates.length)];
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
// VIP 用户离开也带专属颜色和图标
if (user.vip_icon && user.vip_name) {
const vipColor = user.vip_color || '#f59e0b';
sysDiv.innerHTML =
`<span style="color: ${vipColor}; font-weight: bold;">【${user.vip_icon} ${user.vip_name}】${msg}</span><span class="msg-time">(${timeStr})</span>`;
} else {
sysDiv.innerHTML =
`<span style="color: #cc6600">【离开】${msg}</span><span class="msg-time">(${timeStr})</span>`;
}
container.appendChild(sysDiv);
scrollToBottom();
});
window.addEventListener('chat:message', (e) => {
@@ -642,6 +605,51 @@
// DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载
document.addEventListener('DOMContentLoaded', setupScreenClearedListener);
// ── 开发日志发布通知(仅 Room 1 大厅可见)────────────
/**
* 监听 ChangelogPublished 事件,在大厅聊天区展示系统通知
* 通知包含版本号、标题和可点击的查看链接
* 复用「系统传音」样式(金色左边框,不喧宾夺主)
*/
function setupChangelogPublishedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupChangelogPublishedListener, 500);
return;
}
// 仅在 Room 1星光大厅时监听
if (window.chatContext.roomId !== 1) {
return;
}
window.Echo.join('room.1')
.listen('.ChangelogPublished', (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
// 金色左边框通知样式(复用「系统传音」风格)
sysDiv.style.cssText =
'background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;';
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
📋 【版本更新】v${e.version} · ${e.title}
<a href="${e.url}" target="_blank" rel="noopener"
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
查看详情
</a>
</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById('chat-messages-container');
if (say1) {
say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight;
}
});
console.log('ChangelogPublished 监听器已注册Room 1 专属)');
}
document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener);
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
window.addEventListener('chat:effect', (e) => {
const type = e.detail?.type;
@@ -1397,4 +1405,174 @@
div.textContent = text;
return div.innerHTML;
}
// ══════════════════════════════════════════
// 任命公告:复用现有礼花特效 + 隆重弹窗
// ══════════════════════════════════════════
/**
* 显示任命公告弹窗居中5 秒后淡出)
*/
function showAppointmentBanner(data) {
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
const isRevoke = data.type === 'revoke';
const banner = document.createElement('div');
banner.id = 'appointment-banner';
banner.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 99999; text-align: center; pointer-events: none;
animation: appoint-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
`;
if (isRevoke) {
banner.innerHTML = `
<div style="background: linear-gradient(135deg, #374151, #4b5563, #6b7280);
border-radius: 20px; padding: 24px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.15); backdrop-filter: blur(8px);">
<div style="font-size: 36px; margin-bottom: 8px;">📋</div>
<div style="color: #d1d5db; font-size: 12px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
── 职务撤销 ──
</div>
<div style="color: white; font-size: 20px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}
</div>
<div style="color: #d1d5db; font-size: 13px; margin-top: 8px;">
<strong style="color: #f3f4f6;">${dept}${escapeHtml(data.position_name)}</strong> 职务已被撤销
</div>
<div style="color: rgba(255,255,255,0.4); font-size: 11px; margin-top: 10px;">
${escapeHtml(data.operator_name)} 执行 · ${new Date().toLocaleTimeString('zh-CN')}
</div>
</div>
`;
} else {
banner.innerHTML = `
<div style="background: linear-gradient(135deg, #4f46e5, #7c3aed, #db2777);
border-radius: 20px; padding: 28px 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px);">
<div style="font-size: 40px; margin-bottom: 8px;">🎊🎖️🎊</div>
<div style="color: #fde68a; font-size: 13px; font-weight: bold; letter-spacing: 3px; margin-bottom: 12px;">
══ 任命公告 ══
</div>
<div style="color: white; font-size: 22px; font-weight: 900; text-shadow: 0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(data.position_icon)} ${escapeHtml(data.target_username)}
</div>
<div style="color: #e0e7ff; font-size: 14px; margin-top: 8px;">
荣任 <strong style="color: #fde68a;">${dept}${escapeHtml(data.position_name)}</strong>
</div>
<div style="color: rgba(255,255,255,0.5); font-size: 11px; margin-top: 10px;">
${escapeHtml(data.operator_name)} 任命 · ${new Date().toLocaleTimeString('zh-CN')}
</div>
</div>
`;
}
// 弹窗动画关键帧(动态注入一次)
if (!document.getElementById('appoint-keyframes')) {
const style = document.createElement('style');
style.id = 'appoint-keyframes';
style.textContent = `
@keyframes appoint-pop {
0% { opacity: 0; transform: translate(-50%,-50%) scale(0.5); }
70% { transform: translate(-50%,-50%) scale(1.05); }
100% { opacity: 1; transform: translate(-50%,-50%) scale(1); }
}
@keyframes appoint-fade-out {
from { opacity: 1; }
to { opacity: 0; transform: translate(-50%,-50%) scale(0.9); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(banner);
// 4.5 秒后淡出移除
setTimeout(() => {
banner.style.animation = 'appoint-fade-out 0.5s ease forwards';
setTimeout(() => banner.remove(), 500);
}, 4500);
}
/**
* 监听任命公告事件:根据 type 区分任命(礼花+紫色弹窗)和撤销(灰色弹窗)
*/
window.addEventListener('chat:appointment-announced', (e) => {
const data = e.detail;
const isRevoke = data.type === 'revoke';
const dept = data.department_name ? escapeHtml(data.department_name) + ' · ' : '';
// ── 任命才有礼花 ──
if (!isRevoke && typeof EffectManager !== 'undefined') {
EffectManager.play('fireworks');
}
showAppointmentBanner(data);
// ── 聊天区系统消息:操作者/被操作者 → 私聊面板;其余人 → 公屏 ──
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0') + ':' +
now.getSeconds().toString().padStart(2, '0');
const myName = window.chatContext?.username ?? '';
const isInvolved = myName === data.operator_name || myName === data.target_username;
// 随机鼓励语库
const appointPhrases = [
'望再接再厉,大展宏图,为大家服务!',
'期待在任期间带领大家更上一层楼!',
'众望所归,任重道远,加油!',
'新官上任,一展风采,前程似锦!',
'相信你能胜任,期待你的精彩表现!',
];
const revokePhrases = [
'感谢在任期间的辛勤付出,辛苦了!',
'江湖路长,愿前程似锦,未来可期!',
'感谢您为大家的奉献,一路顺风!',
'在任一场,情谊长存,感谢付出!',
'相信以后还有更多精彩,继续加油!',
];
const randomPhrase = isRevoke ?
revokePhrases[Math.floor(Math.random() * revokePhrases.length)] :
appointPhrases[Math.floor(Math.random() * appointPhrases.length)];
// 构建消息 DOM内容相同分配到不同面板
function buildSysMsg() {
const sysDiv = document.createElement('div');
sysDiv.className = 'msg-line';
if (isRevoke) {
sysDiv.style.cssText =
'background:#f3f4f6; border-left:3px solid #9ca3af; border-radius:4px; padding:4px 10px; margin:2px 0;';
sysDiv.innerHTML =
`<span style="color:#6b7280;">📋 </span>` +
`<span style="color:#374151;"><b>${escapeHtml(data.target_username)}</b> 的 ${escapeHtml(data.position_icon)} ${dept}${escapeHtml(data.position_name)} 职务已被 <b>${escapeHtml(data.operator_name)}</b> 撤销。${randomPhrase}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
} else {
sysDiv.style.cssText =
'background:#f5f3ff; border-left:3px solid #7c3aed; border-radius:4px; padding:4px 10px; margin:2px 0;';
sysDiv.innerHTML =
`<span style="color:#7c3aed;">🎖️ </span>` +
`<span style="color:#3730a3;">恭喜 <b>${escapeHtml(data.target_username)}</b> 荣任 ${escapeHtml(data.position_icon)} ${dept}<b>${escapeHtml(data.position_name)}</b>,由 <b>${escapeHtml(data.operator_name)}</b> 任命。${randomPhrase}</span>` +
`<span class="msg-time">(${timeStr})</span>`;
}
return sysDiv;
}
const say1 = document.getElementById('chat-messages-container');
const say2 = document.getElementById('chat-messages-container2');
if (isInvolved) {
// 操作者 / 被操作者:消息进私聊面板(包厢窗口)
if (say2) {
say2.appendChild(buildSysMsg());
say2.scrollTop = say2.scrollHeight;
}
} else {
// 其他人:消息进公屏
if (say1) {
say1.appendChild(buildSysMsg());
say1.scrollTop = say1.scrollHeight;
}
}
});
</script>

View File

@@ -27,8 +27,10 @@
<div class="tool-btn" onclick="window.open('{{ route('guestbook.index') }}', '_blank')" title="留言板/私信">留言</div>
<div class="tool-btn" onclick="window.open('{{ route('guide') }}', '_blank')" title="规则/帮助">规则</div>
@if ($user->user_level >= $superLevel)
@if ($user->id === 1 || $user->activePosition()->exists())
<div class="tool-btn" style="color: #ffcc00;" onclick="window.open('/admin', '_blank')" title="管理后台">管理</div>
<div class="tool-btn" onclick="window.open('{{ route('leaderboard.index') }}', '_blank')" title="排行榜">排行
</div>
@else
<div class="tool-btn" onclick="window.open('{{ route('leaderboard.index') }}', '_blank')" title="排行榜">排行
</div>

View File

@@ -89,6 +89,18 @@
giftCount: 1,
sendingGift: false,
// 任命相关
showAppointPanel: false,
appointPositions: [],
selectedPositionId: null,
appointRemark: '',
appointLoading: false,
// 折叠状态
showAdminView: false, // 管理员视野
showPositionHistory: false, // 职务履历
showAdminPanel: false, // 管理操作(管理操作+职务操作合并)
/** 获取用户资料 */
async fetchUser(username) {
try {
@@ -102,7 +114,6 @@
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error('Failed to fetch user:', errorData.message || res.statusText);
// 如果是 404 或者 500 等错误,直接静默退出或提示
return;
}
@@ -113,12 +124,90 @@
this.isMuting = false;
this.showWhispers = false;
this.whisperList = [];
this.showAppointPanel = false;
this.selectedPositionId = null;
this.appointRemark = '';
// 有职务的操作人预加载可用职务列表
if (window.chatContext?.hasPosition) {
this._loadPositions();
}
}
} catch (e) {
console.error('Error fetching user:', e);
}
},
/** 加载可任命职务列表 */
async _loadPositions() {
if (!window.chatContext?.appointPositionsUrl) return;
try {
const res = await fetch(window.chatContext.appointPositionsUrl, {
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.status === 'success') {
this.appointPositions = data.positions;
if (this.appointPositions.length > 0) {
this.selectedPositionId = this.appointPositions[0].id;
}
}
} catch (e) {
/* 静默失败 */
}
},
/** 快速任命 */
async doAppoint() {
if (this.appointLoading || !this.selectedPositionId) return;
this.appointLoading = true;
try {
const res = await fetch(window.chatContext.appointUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
position_id: this.selectedPositionId,
remark: this.appointRemark.trim() || null,
room_id: window.chatContext.roomId ?? null,
})
});
const data = await res.json();
alert(data.message);
if (data.status === 'success') {
this.showUserModal = false;
}
} catch (e) {
alert('网络异常');
}
this.appointLoading = false;
},
/** 快速撤销 */
async doRevoke() {
if (!confirm('确定要撤销 ' + this.userInfo.username + ' 的职务吗?')) return;
this.appointLoading = true;
try {
const res = await fetch(window.chatContext.revokeUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
remark: '聊天室快速撤销',
room_id: window.chatContext.roomId ?? null,
})
});
const data = await res.json();
alert(data.message);
if (data.status === 'success') {
this.showUserModal = false;
}
} catch (e) {
alert('网络异常');
}
this.appointLoading = false;
},
/** 踢出用户 */
async kickUser() {
const reason = prompt('踢出原因(可留空):', '违反聊天室规则');
@@ -324,7 +413,14 @@
:style="userInfo.sex === '男' ? 'color: blue' : (userInfo.sex === '女' ?
'color: deeppink' : '')"></span>
</h4>
<div style="font-size: 11px; color: #999; margin-top: 2px;">
{{-- 在职职务标签(有职务时才显示) --}}
<div x-show="userInfo.position_name"
style="display: inline-flex; align-items: center; gap: 3px; margin-top: 3px; padding: 2px 8px; background: #f3e8ff; border: 1px solid #d8b4fe; border-radius: 20px; font-size: 11px; color: #7c3aed; font-weight: bold;">
<span x-text="userInfo.position_icon" style="font-size: 13px;"></span>
<span
x-text="(userInfo.department_name ? userInfo.department_name + ' · ' : '') + userInfo.position_name"></span>
</div>
<div style="font-size: 11px; color: #999; margin-top: 4px;">
加入: <span x-text="userInfo.created_at"></span>
</div>
</div>
@@ -359,161 +455,260 @@
{{-- 管理员可见区域 (IP 归属地) --}}
<div x-show="userInfo.last_ip !== undefined"
style="margin-top: 8px; padding: 8px 10px; background: #fee2e2; border: 1px dashed #fca5a5; border-radius: 8px; font-size: 11px; color: #991b1b;">
<div style="font-weight: bold; margin-bottom: 4px; display: flex; align-items: center; gap: 4px;">
<span>🛡️</span> 管理员视野
style="margin-top: 8px; border-radius: 8px; overflow: hidden;">
{{-- 可点击标题 --}}
<div x-on:click="showAdminView = !showAdminView"
style="display: flex; align-items: center; justify-content: space-between; padding: 6px 10px;
background: #fee2e2; border: 1px dashed #fca5a5; border-radius: 8px;
cursor: pointer; font-size: 11px; color: #991b1b; font-weight: bold; user-select: none;">
<span>🛡️ 管理员视野</span>
<span x-text="showAdminView ? '▲' : '▼'" style="font-size: 10px; opacity: 0.6;"></span>
</div>
<div style="display: flex; flex-direction: column; gap: 3px;">
<div><span style="opacity: 0.8;">主要IP</span><span x-text="userInfo.last_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">本次IP</span><span x-text="userInfo.login_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">归属地</span><span x-text="userInfo.location || '未知'"></span>
{{-- 折叠内容 --}}
<div x-show="showAdminView" x-transition
style="display: none; padding: 8px 10px; background: #fff5f5; border: 1px dashed #fca5a5;
border-top: none; border-radius: 0 0 8px 8px; font-size: 11px; color: #991b1b;">
<div style="display: flex; flex-direction: column; gap: 3px;">
<div><span style="opacity: 0.8;">主要IP</span><span x-text="userInfo.last_ip || ''"></span>
</div>
<div><span style="opacity: 0.8;">本次IP</span><span x-text="userInfo.login_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">归属地:</span><span x-text="userInfo.location || '未知'"></span>
</div>
</div>
</div>
</div>
<div class="profile-detail" x-text="userInfo.sign || '这家伙很懒,什么也没留下'" style="margin-top: 12px;"></div>
</div>
{{-- 普通操作按钮 --}}
<div class="modal-actions" x-show="userInfo.username !== window.chatContext.username">
<button class="btn-whisper"
x-on:click="document.getElementById('to_user').value = userInfo.username; document.getElementById('content').focus(); showUserModal = false;">
悄悄话
</button>
<a class="btn-mail"
:href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank">
写私信
</a>
</div>
{{-- 送花/礼物互动区 --}}
<div style="padding: 16px; margin: 0 16px 16px; background: #fff; border-radius: 12px; border: 1px solid #f1f5f9; box-shadow: 0 2px 8px rgba(0,0,0,0.02);"
x-show="userInfo.username !== window.chatContext.username" x-data="{ showGiftPanel: false }">
{{-- 初始状态:只显示一个主操作按钮 --}}
<button x-show="!showGiftPanel" x-on:click="showGiftPanel = true"
style="width: 100%; height: 44px; display: flex; align-items: center; justify-content: center; gap: 8px; background: linear-gradient(135deg, #fce4ec 0%, #fbcfe8 100%); color: #db2777; border: 1px dashed #f9a8d4; border-radius: 10px; font-size: 15px; font-weight: bold; cursor: pointer; transition: all 0.2s; box-shadow: 0 2px 4px rgba(219, 39, 119, 0.1);"
x-on:mousedown="$el.style.transform='scale(0.98)'" x-on:mouseup="$el.style.transform='scale(1)'"
x-on:mouseleave="$el.style.transform='scale(1)'">
<span style="font-size: 18px;">🎁</span>
<span>赠送礼物给 TA</span>
</button>
{{-- 展开状态:显示礼物面板 --}}
<div x-show="showGiftPanel" style="display: none;" x-transition>
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 16px;">🎁</span>
<span style="font-size: 14px; color: #334155; font-weight: bold;">选择礼物</span>
</div>
<button x-on:click="showGiftPanel = false"
style="background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 20px; line-height: 1; padding: 0 4px;">&times;</button>
{{-- 职务履历时间轴(有任职记录才显示,可折叠) --}}
<div x-show="userInfo.position_history && userInfo.position_history.length > 0"
style="display: none; margin-top: 10px; border-top: 1px solid #f0f0f0; padding-top: 8px;">
{{-- 可点击标题 --}}
<div x-on:click="showPositionHistory = !showPositionHistory"
style="display: flex; align-items: center; justify-content: space-between;
cursor: pointer; font-size: 11px; font-weight: bold; color: #7c3aed;
margin-bottom: 4px; user-select: none;">
<span>🎖️ 职务履历 <span style="font-weight: normal; font-size: 10px; color: #9ca3af;"
x-text="'' + userInfo.position_history.length + ' 条)'"></span></span>
<span x-text="showPositionHistory ? '▲' : '▼'" style="font-size: 10px; opacity:0.5;"></span>
</div>
{{-- 礼物选择列表 --}}
<div
style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; max-height: 200px; overflow-y: auto; padding-right: 4px;">
<template x-for="g in gifts" :key="g.id">
<div x-on:click="selectedGiftId = g.id"
:style="selectedGiftId === g.id ?
'border-color: #f43f5e; background: #fff1f2; box-shadow: 0 4px 12px rgba(244, 63, 94, 0.15); transform: translateY(-1px);' :
'border-color: #e2e8f0; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.03); transform: translateY(0);'"
style="border: 2px solid; padding: 10px 4px; border-radius: 10px; text-align: center; cursor: pointer; transition: all 0.2s ease; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<img :src="'/images/gifts/' + g.image"
style="width: 44px; height: 44px; object-fit: contain; margin-bottom: 6px; transition: transform 0.2s ease;"
:style="selectedGiftId === g.id ? 'transform: scale(1.1);' : ''"
:alt="g.name">
<div style="font-size: 12px; color: #1e293b; font-weight: 600; margin-bottom: 4px; width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
x-text="g.name"></div>
<div style="font-size: 10px; color: #e11d48; font-weight: 500; line-height: 1.3;">
<div x-text="g.cost + ' 💰'"></div>
<div x-text="'+' + g.charm + ' ✨'"></div>
{{-- 折叠内容 --}}
<div x-show="showPositionHistory" x-transition style="display: none;">
<template x-for="(h, idx) in userInfo.position_history" :key="idx">
<div style="display: flex; gap: 10px; margin-bottom: 8px; position: relative;">
{{-- 线 --}}
<div
style="display: flex; flex-direction: column; align-items: center; width: 18px; flex-shrink: 0;">
<div style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 2px;"
:style="h.is_active ? 'background: #7c3aed; box-shadow: 0 0 0 3px #ede9fe;' :
'background: #d1d5db;'">
</div>
<template x-if="idx < userInfo.position_history.length - 1">
<div style="width: 1px; flex: 1; background: #e5e7eb; margin-top: 2px;"></div>
</template>
</div>
{{-- 内容 --}}
<div style="flex: 1; font-size: 11px; padding-bottom: 4px;">
<div style="font-weight: bold; color: #374151;">
<span x-text="h.position_icon" style="margin-right: 2px;"></span>
<span
x-text="(h.department_name ? h.department_name + ' · ' : '') + h.position_name"></span>
<span x-show="h.is_active"
style="display: inline-block; margin-left: 4px; padding: 0 5px; background: #ede9fe; color: #7c3aed; border-radius: 10px; font-size: 10px;">在职中</span>
</div>
<div style="color: #9ca3af; font-size: 10px; margin-top: 2px;">
<span x-text="h.appointed_at"></span>
<span> </span>
<span x-text="h.is_active ? '至今' : (h.revoked_at || '')"></span>
<span style="margin-left: 4px;" x-text="'(' + h.duration_days + ' 天)'"></span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
{{-- 数量 + 送出按钮 --}}
<div style="display: flex; gap: 12px; align-items: center;">
<div style="position: relative;">
<select x-model.number="giftCount"
style="appearance: none; width: 76px; height: 42px; padding: 0 24px 0 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px; font-weight: 500; color: #334155; background-color: #fff; cursor: pointer; outline: none; box-shadow: 0 1px 2px rgba(0,0,0,0.05); transition: border-color 0.2s ease;"
onfocus="this.style.borderColor='#f43f5e'" onblur="this.style.borderColor='#cbd5e1'">
<option value="1">1 </option>
<option value="5">5 </option>
<option value="10">10 </option>
<option value="66">66 </option>
<option value="99">99 </option>
<option value="520">520 </option>
</select>
<div
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; color: #94a3b8; font-size: 10px;">
{{-- 普通操作按鈕:写私信 + 送花 + 内联礼物面板 --}}
<div x-data="{ showGiftPanel: false }" x-show="userInfo.username !== window.chatContext.username">
<div class="modal-actions" style="margin-bottom: 0;">
{{-- 写私信 --}}
<a class="btn-mail"
:href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank">
写私信
</a>
{{-- 送花按鈕(与写私信并列) --}}
<button class="btn-whisper" x-on:click="showGiftPanel = !showGiftPanel">
🎁 送礼物
</button>
</div>
{{-- 内联礼物面板 --}}
<div x-show="showGiftPanel" x-transition
style="display: none;
padding: 12px 16px; background: #fff; border-top: 1px solid #f1f5f9;">
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-size: 13px; color: #334155; font-weight: bold;">🎁 选择礼物</span>
<button x-on:click="showGiftPanel = false"
style="background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 18px; line-height: 1;">×</button>
</div>
{{-- 礼物选择列表 --}}
<div
style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 12px; max-height: 180px; overflow-y: auto;">
<template x-for="g in gifts" :key="g.id">
<div x-on:click="selectedGiftId = g.id"
:style="selectedGiftId === g.id ?
'border-color: #f43f5e; background: #fff1f2; box-shadow: 0 4px 12px rgba(244,63,94,0.15);' :
'border-color: #e2e8f0; background: #fff;'"
style="border: 2px solid; padding: 8px 4px; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s;">
<img :src="'/images/gifts/' + g.image"
style="width: 36px; height: 36px; object-fit: contain; margin-bottom: 4px;"
:alt="g.name">
<div style="font-size: 11px; color: #1e293b; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
x-text="g.name"></div>
<div style="font-size: 10px; color: #e11d48;" x-text="g.cost + ' 💰'"></div>
</div>
</div>
</template>
</div>
{{-- 数量 + 送出 --}}
<div style="display: flex; gap: 8px; align-items: center;">
<select x-model.number="giftCount"
style="width: 70px; height: 36px; padding: 0 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; color: #334155;">
<option value="1">1 </option>
<option value="5">5 </option>
<option value="10">10 </option>
<option value="66">66 </option>
<option value="99">99 </option>
<option value="520">520 </option>
</select>
<button x-on:click="sendGift(); showGiftPanel = false;" :disabled="sendingGift"
style="flex: 1; height: 42px; display: flex; align-items: center; justify-content: center; gap: 8px; background: linear-gradient(135deg, #f43f5e 0%, #be123c 100%); color: #fff; border: 1px solid #9f1239; border-radius: 8px; font-size: 16px; font-weight: 800; letter-spacing: 1px; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 12px rgba(225, 29, 72, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.3);"
x-on:mousedown="if(!sendingGift) { $el.style.transform='scale(0.96)'; $el.style.boxShadow='0 2px 6px rgba(225, 29, 72, 0.3)'; }"
x-on:mouseup="if(!sendingGift) { $el.style.transform='scale(1)'; $el.style.boxShadow='0 4px 12px rgba(225, 29, 72, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.3)'; }"
x-on:mouseleave="if(!sendingGift) { $el.style.transform='scale(1)'; $el.style.boxShadow='0 4px 12px rgba(225, 29, 72, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.3)'; }"
:style="sendingGift ?
'opacity: 0.7; cursor: not-allowed; transform: scale(1) !important; box-shadow: none;' :
''">
<span x-text="sendingGift ? '正在送出...' : '💝 确认赠送'"
style="text-shadow: 0 1px 2px rgba(0,0,0,0.3);"></span>
style="flex:1; height: 36px; background: linear-gradient(135deg,#f43f5e,#be123c); color:#fff;
border: none; border-radius: 6px; font-size: 14px; font-weight: bold; cursor: pointer;"
:style="sendingGift ? 'opacity:0.7; cursor:not-allowed;' : ''">
<span x-text="sendingGift ? '正在送出...' : '💝 确认赠送'"></span>
</button>
</div>
</div>
</div>
{{-- 特权操作(各按钮按等级独立显示) --}}
@if ($myLevel >= $levelWarn || $room->master == Auth::user()->username)
<div style="padding: 0 16px 12px;"
x-show="userInfo.username !== window.chatContext.username && userInfo.user_level < {{ $myLevel }}">
<div style="font-size: 11px; color: #c00; margin-bottom: 6px; font-weight: bold;">管理操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
@if ($myLevel >= $levelWarn)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="warnUser()">⚠️ 警告</button>
@endif
@if ($myLevel >= $levelKick)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
x-on:click="kickUser()">🚫 踢出</button>
@endif
@if ($myLevel >= $levelMute)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
x-on:click="isMuting = !isMuting">🔇 禁言</button>
@endif
@if ($myLevel >= $levelFreeze)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #dbeafe; border: 1px solid #3b82f6; cursor: pointer;"
x-on:click="freezeUser()">🧊 冻结</button>
@endif
@if ($myLevel >= $superLevel)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;"
x-on:click="loadWhispers()">🔍 私信</button>
@endif
{{-- 管理操作 + 职务操作 合并折叠区 --}}
@if (
$myLevel >= $levelWarn ||
$room->master == Auth::user()->username ||
Auth::user()->activePosition ||
$myLevel >= $superLevel)
<div style="padding: 0 16px 12px;" x-show="userInfo.username !== window.chatContext.username">
{{-- 折叠标题 --}}
<div x-on:click="showAdminPanel = !showAdminPanel"
style="display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; background: #fef2f2; border: 1px solid #fecaca;
border-radius: 6px; cursor: pointer; user-select: none;">
<span style="font-size: 11px; color: #c00; font-weight: bold;">🔧 管理操作</span>
<span x-text="showAdminPanel ? '▲' : '▼'"
style="font-size: 10px; color: #c00; opacity: 0.6;"></span>
</div>
</div>
{{-- 禁言表单 --}}
<div x-show="isMuting" style="display: none; padding: 0 16px 12px;">
<div style="display: flex; gap: 6px; align-items: center;">
<input type="number" x-model="muteDuration" min="1" max="1440" placeholder="分钟"
style="width: 60px; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 11px;">
<span style="font-size: 11px; color: #b86e00;">分钟</span>
<button x-on:click="muteUser()"
style="padding: 4px 12px; background: #6366f1; color: #fff; border: none; border-radius: 3px; font-size: 11px; cursor: pointer;">执行</button>
{{-- 折叠内容 --}}
<div x-show="showAdminPanel" x-transition style="display: none; margin-top: 6px;">
@if ($myLevel >= $levelWarn || $room->master == Auth::user()->username)
<div x-show="userInfo.user_level < {{ $myLevel }}">
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
管理员操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;">
@if ($myLevel >= $levelWarn)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="warnUser()">⚠️ 警告</button>
@endif
@if ($myLevel >= $levelKick)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
x-on:click="kickUser()">🚫 踢出</button>
@endif
@if ($myLevel >= $levelMute)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
x-on:click="isMuting = !isMuting">🔇 禁言</button>
@endif
@if ($myLevel >= $levelFreeze)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #dbeafe; border: 1px solid #3b82f6; cursor: pointer;"
x-on:click="freezeUser()">🧊 冻结</button>
@endif
@if ($myLevel >= $superLevel)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;"
x-on:click="loadWhispers()">🔍 私信</button>
@endif
</div>
</div>
@endif
@if (Auth::user()->activePosition || $myLevel >= $superLevel)
<div>
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
职务操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<template x-if="!userInfo.position_name">
<button x-on:click="showAppointPanel = !showAppointPanel"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;">
任命职务</button>
</template>
<template x-if="userInfo.position_name">
<button x-on:click="doRevoke()" :disabled="appointLoading"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef9c3; border: 1px solid #eab308; cursor: pointer;">🔧
撤销职务</button>
</template>
</div>
<div x-show="showAppointPanel" x-transition
style="display:none; margin-top:8px; padding:10px; background:#faf5ff; border:1px solid #d8b4fe; border-radius:6px;">
<div style="font-size:11px; color:#7c3aed; margin-bottom:6px;">选择职务:</div>
<select x-model.number="selectedPositionId"
style="width:100%; padding:4px; border:1px solid #c4b5fd; border-radius:4px; font-size:11px; margin-bottom:6px;">
<template x-for="p in appointPositions" :key="p.id">
<option :value="p.id"
x-text="(p.icon?p.icon+' ':'')+p.department+' · '+p.name"></option>
</template>
</select>
<input type="text" x-model="appointRemark" placeholder="备注(如任命原因)"
style="width:100%; padding:4px; border:1px solid #c4b5fd; border-radius:4px; font-size:11px; box-sizing:border-box; margin-bottom:6px;">
<div style="display:flex; gap:6px;">
<button x-on:click="doAppoint()"
:disabled="appointLoading || !selectedPositionId"
style="flex:1; padding:5px; background:#7c3aed; color:#fff; border:none; border-radius:4px; font-size:11px; cursor:pointer;">
<span x-text="appointLoading?'处理中...':'✅ 确认任命'"></span>
</button>
<button x-on:click="showAppointPanel=false"
style="padding:5px 10px; background:#fff; border:1px solid #ccc; border-radius:4px; font-size:11px; cursor:pointer;">取消</button>
</div>
</div>
</div>
@endif
</div>{{-- /折叠内容 --}}
{{-- 禁言输入表单 --}}
<div x-show="isMuting" style="display:none; margin-top:6px;">
<div style="display:flex; gap:6px; align-items:center;">
<input type="number" x-model="muteDuration" min="1" max="1440"
placeholder="分钟"
style="width:60px; padding:4px; border:1px solid #ccc; border-radius:3px; font-size:11px;">
<span style="font-size:11px; color:#b86e00;">分钟</span>
<button x-on:click="muteUser()"
style="padding:4px 12px; background:#6366f1; color:#fff; border:none; border-radius:3px; font-size:11px; cursor:pointer;">执行</button>
</div>
</div>
</div>
@endif

View File

@@ -0,0 +1,239 @@
{{--
文件功能:勤务台页面(职务管理荣誉展示台)
左侧子菜单:任职列表、日榜、周榜、月榜、总榜
URL/duty-hall?tab=roster|day|week|month|all
@extends layouts.app
--}}
@extends('layouts.app')
@section('title', '勤务台 · ' . ($tabs[$tab]['label'] ?? '任职列表') . ' - 飘落流星')
@section('nav-icon', '🏛️')
@section('nav-title', '勤务台')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex gap-6 items-start">
{{-- ═══ 左侧子菜单 ═══ --}}
<aside
class="w-52 shrink-0 bg-white border border-gray-100 rounded-2xl shadow-sm overflow-hidden sticky top-20 self-start hidden md:block">
<div class="px-4 pt-4 pb-2">
<p class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">勤务台</p>
<nav class="space-y-1">
@foreach ($tabs as $key => $meta)
<a href="{{ route('duty-hall.index', ['tab' => $key]) }}"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors
{{ $tab === $key
? 'bg-purple-50 text-purple-700 font-bold'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-800' }}">
<span class="text-base">{{ $meta['icon'] }}</span>
<span>{{ $meta['label'] }}</span>
@if ($tab === $key)
<span class="ml-auto w-1.5 h-1.5 rounded-full bg-purple-600"></span>
@endif
</a>
@endforeach
</nav>
</div>
<div class="px-4 py-3 mt-2 border-t border-gray-50 text-xs text-gray-400 text-center">
光荣服务,公示透明
</div>
</aside>
{{-- ═══ 主内容区 ═══ --}}
<main class="flex-1 min-w-0">
{{-- ─── Tab任职列表 ─── --}}
@if ($tab === 'roster')
<div class="mb-4">
<h2 class="text-lg font-bold text-gray-800">🏛️ 任职列表</h2>
<p class="text-xs text-gray-400 mt-0.5">按部门 · 职务展示当前全部在职人员</p>
</div>
@if ($currentStaff->isEmpty())
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm py-20 text-center text-gray-400">
<div class="text-5xl mb-4">📭</div>
<p>暂未设置任何部门</p>
</div>
@else
<div class="space-y-5">
@foreach ($currentStaff as $dept)
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
{{-- 部门标题 --}}
<div
class="px-5 py-3 bg-gradient-to-r from-purple-50 to-indigo-50 border-b border-gray-100 flex items-center gap-2">
<span class="text-base">{{ $dept->icon ?? '🏢' }}</span>
<span class="font-bold text-purple-700 text-sm">{{ $dept->name }}</span>
<span class="ml-auto text-xs text-gray-400">{{ $dept->positions->count() }} 个职务</span>
</div>
@if ($dept->positions->isEmpty())
<div class="px-5 py-4 text-xs text-gray-400 italic">该部门暂无职务设置</div>
@else
<div class="divide-y divide-gray-50">
@foreach ($dept->positions as $position)
<div class="px-5 py-3">
{{-- 职务名 --}}
<div class="flex items-center gap-1.5 mb-2">
<span class="text-sm">{{ $position->icon }}</span>
<span
class="text-sm font-semibold text-gray-700">{{ $position->name }}</span>
@php
$current = $position->activeUserPositions->count();
$maxSlots = $position->max_persons;
$isFull = $maxSlots !== null && $current >= $maxSlots;
@endphp
{{-- 名额计数标签 --}}
<span
class="ml-1 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[10px] font-bold
{{ $current === 0
? 'bg-gray-100 text-gray-400'
: ($isFull
? 'bg-red-100 text-red-600'
: 'bg-purple-100 text-purple-600') }}">
{{ $current }}
@if ($maxSlots !== null)
<span class="opacity-60">/{{ $maxSlots }}</span>
@endif
</span>
@if ($isFull)
<span class="text-[10px] text-red-400">已满</span>
@elseif ($current === 0)
<span class="text-[10px] text-gray-300">(暂缺)</span>
@endif
</div>
{{-- 在职人员列表 --}}
@if ($position->activeUserPositions->isNotEmpty())
<div class="flex flex-wrap gap-2">
@foreach ($position->activeUserPositions as $up)
<div
class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-100 rounded-xl">
<img src="/images/headface/{{ strtolower($up->user?->headface ?? '1.gif') }}"
class="w-7 h-7 rounded-full border border-purple-100 object-cover bg-white"
onerror="this.src='/images/headface/1.gif'">
<div>
<p class="text-xs font-bold text-gray-800">
{{ $up->user?->username ?? '未知' }}</p>
<p class="text-[10px] text-gray-400">
{{ $up->appointed_at?->format('Y-m-d') }} ·
{{ $up->duration_days }} </p>
</div>
</div>
@endforeach
</div>
@else
{{-- 空缺占位 --}}
<div
class="flex items-center gap-2 px-3 py-2 border border-dashed border-gray-200 rounded-xl text-xs text-gray-300 w-fit">
<span>👤</span>
<span>暂无任职人员</span>
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
{{-- ─── Tab///总榜 ─── --}}
@else
@php
$tabMeta = $tabs[$tab];
$periodLabel = match ($tab) {
'day' => today()->format('Y年m月d日'),
'week' => now()->startOfWeek()->format('m月d日') . ' ' . now()->endOfWeek()->format('m月d日'),
'month' => now()->format('Y年m月'),
'all' => '历史累计',
};
@endphp
<div class="mb-4">
<h2 class="text-lg font-bold text-gray-800">{{ $tabMeta['icon'] }} 勤务{{ $tabMeta['label'] }}</h2>
<p class="text-xs text-gray-400 mt-0.5">{{ $periodLabel }} · 按在职期间登录时长排名</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
@if ($leaderboard->isEmpty())
<div class="py-20 text-center text-gray-400">
<div class="text-5xl mb-4">📊</div>
<p>该时段暂无勤务记录</p>
</div>
@else
{{-- 表头 --}}
<div
class="grid grid-cols-12 gap-4 px-5 py-2.5 bg-gray-50 border-b border-gray-100 text-xs font-bold text-gray-400 uppercase">
<div class="col-span-1 text-center">名次</div>
<div class="col-span-5">成员</div>
<div class="col-span-3 text-right">在线时长</div>
<div class="col-span-3 text-right">签到次数</div>
</div>
@foreach ($leaderboard as $i => $row)
@php
$h = intdiv($row->total_seconds, 3600);
$m = intdiv($row->total_seconds % 3600, 60);
$medal = match ($i) {
0 => '🥇',
1 => '🥈',
2 => '🥉',
default => null,
};
@endphp
<div
class="grid grid-cols-12 gap-4 items-center px-5 py-3 border-b border-gray-50 last:border-0
{{ $i === 0 ? 'bg-yellow-50/40' : ($i === 1 ? 'bg-gray-50/60' : ($i === 2 ? 'bg-amber-50/40' : '')) }}
hover:bg-purple-50/30 transition-colors">
{{-- 名次 --}}
<div class="col-span-1 text-center">
@if ($medal)
<span class="text-lg">{{ $medal }}</span>
@else
<span class="text-sm font-bold text-gray-300">{{ $i + 1 }}</span>
@endif
</div>
{{-- 成员 --}}
<div class="col-span-5 flex items-center gap-3">
<img src="/images/headface/{{ strtolower($row->user?->headface ?? '1.gif') }}"
class="w-9 h-9 rounded-full border-2 border-purple-100 object-cover bg-white"
onerror="this.src='/images/headface/1.gif'">
<div>
<p class="font-bold text-sm text-gray-800">{{ $row->user?->username ?? '未知' }}</p>
<p class="text-xs text-gray-400">LV.{{ $row->user?->user_level ?? '' }}</p>
</div>
</div>
{{-- 时长 --}}
<div class="col-span-3 text-right">
<span class="text-sm font-bold text-purple-600 tabular-nums">{{ $h }}h
{{ $m }}m</span>
</div>
{{-- 次数 --}}
<div class="col-span-3 text-right">
<span class="text-sm font-bold text-indigo-500 tabular-nums">{{ $row->checkin_count }}
</span>
</div>
</div>
@endforeach
@endif
</div>
@endif
</main>
</div>
{{-- 移动端底部 Tab 导航 --}}
<div class="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex z-30">
@foreach ($tabs as $key => $meta)
<a href="{{ route('duty-hall.index', ['tab' => $key]) }}"
class="flex-1 flex flex-col items-center py-2 text-xs
{{ $tab === $key ? 'text-purple-600 font-bold' : 'text-gray-500' }}">
<span class="text-lg">{{ $meta['icon'] }}</span>
<span class="mt-0.5">{{ $meta['label'] }}</span>
</a>
@endforeach
</div>
@endsection

View File

@@ -0,0 +1,480 @@
{{--
文件功能:用户反馈前台独立页面(/feedback
用户可提交 Bug 报告或功能建议可赞同Toggle、补充评论
支持按类型筛选(全部/Bug/建议),懒加载列表
右上角按钮打开提交 Modal
@extends layouts.app
--}}
@extends('layouts.app')
@section('title', '用户反馈 - 飘落流星')
@section('nav-icon', '💬')
@section('nav-title', '用户反馈')
@section('content')
<div class="max-w-3xl mx-auto py-8 px-4 sm:px-6" x-data="{
// 列表状态
items: [],
lastId: null,
hasMore: true,
loading: false,
filterType: 'all',
// 展开/评论状态
expandedId: null,
replyContent: {},
submittingReply: {},
// 提交 Modal 状态
showModal: false,
submitting: false,
form: { type: 'bug', title: '', content: '' },
// 当前用户已赞同的反馈 ID set
myVotedIds: new Set({{ json_encode($myVotedIds) }}),
init() {
// SSR 首屏数据
const raw = {{ json_encode(
$feedbacks->map(
fn($f) => [
'id' => $f->id,
'type' => $f->type,
'type_label' => $f->type_label,
'title' => $f->title,
'content' => $f->content,
'status' => $f->status,
'status_label' => $f->status_label,
'status_color' => $f->status_config['color'],
'admin_remark' => $f->admin_remark,
'votes_count' => $f->votes_count,
'replies_count' => $f->replies_count,
'username' => $f->username,
'created_at' => $f->created_at->diffForHumans(),
'replies' => $f->replies->map(
fn($r) => [
'id' => $r->id,
'username' => $r->username,
'content' => $r->content,
'is_admin' => $r->is_admin,
'created_at' => $r->created_at->diffForHumans(),
],
)->values()->toArray(),
],
),
) }};
this.items = raw.map(f => ({ ...f, voted: this.myVotedIds.has(f.id) }));
if (this.items.length > 0) {
this.lastId = this.items[this.items.length - 1].id;
this.hasMore = this.items.length >= 10;
} else {
this.hasMore = false;
}
},
// 懒加载更多
async loadMore() {
if (this.loading || !this.hasMore) return;
this.loading = true;
try {
const typeParam = this.filterType !== 'all' ? `&type=${this.filterType}` : '';
const res = await fetch(`/feedback/more?after_id=${this.lastId}${typeParam}`);
const data = await res.json();
const newItems = data.items.map(f => ({ ...f, voted: this.myVotedIds.has(f.id) }));
this.items.push(...newItems);
if (data.items.length > 0) this.lastId = data.items[data.items.length - 1].id;
this.hasMore = data.has_more;
} catch (e) { console.error(e); } finally { this.loading = false; }
},
// 切换类型筛选(重置并重新加载)
async switchType(type) {
this.filterType = type;
this.items = [];
this.lastId = 999999999;
this.hasMore = true;
this.expandedId = null;
await this.loadMore();
},
// 提交新反馈
async submitFeedback() {
if (this.submitting) return;
if (!this.form.title.trim() || !this.form.content.trim()) {
alert('请填写标题和详细描述!');
return;
}
this.submitting = true;
try {
const res = await fetch('/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
this.items.unshift(data.item);
this.showModal = false;
this.form = { type: 'bug', title: '', content: '' };
} else {
alert(data.message || '提交失败,请重试');
}
} catch (e) { alert('网络异常,请重试'); } finally { this.submitting = false; }
},
// 赞同/取消赞同(乐观更新)
async toggleVote(feedbackId) {
const item = this.items.find(f => f.id === feedbackId);
if (!item) return;
const prev = { voted: item.voted, count: item.votes_count };
item.voted = !item.voted;
item.votes_count += item.voted ? 1 : -1;
try {
const res = await fetch(`/feedback/${feedbackId}/vote`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
});
const data = await res.json();
if (data.status !== 'success') {
// 回滚
item.voted = prev.voted;
item.votes_count = prev.count;
alert(data.message || '操作失败');
} else {
item.votes_count = data.votes_count;
if (data.voted) {
this.myVotedIds.add(feedbackId);
} else {
this.myVotedIds.delete(feedbackId);
}
}
} catch (e) {
item.voted = prev.voted;
item.votes_count = prev.count;
}
},
// 提交补充评论
async submitReply(feedbackId) {
const content = (this.replyContent[feedbackId] || '').trim();
if (!content) return;
this.submittingReply[feedbackId] = true;
try {
const res = await fetch(`/feedback/${feedbackId}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ content }),
});
const data = await res.json();
if (data.status === 'success') {
const item = this.items.find(f => f.id === feedbackId);
if (item) {
item.replies.push(data.reply);
item.replies_count++;
}
this.replyContent[feedbackId] = '';
} else {
alert(data.message || '评论失败');
}
} catch (e) { alert('网络异常'); } finally { this.submittingReply[feedbackId] = false; }
},
// 辅助:状态徽标 CSS 类
statusClass(color) {
const map = {
gray: 'bg-gray-100 text-gray-600',
green: 'bg-green-100 text-green-700',
blue: 'bg-blue-100 text-blue-700',
emerald: 'bg-emerald-100 text-emerald-700',
red: 'bg-red-100 text-red-700',
orange: 'bg-orange-100 text-orange-700',
};
return map[color] || 'bg-gray-100 text-gray-600';
},
}">
{{-- ═══ 页面标题 + 提交按钮 ═══ --}}
<div class="flex items-start justify-between mb-6">
<div>
<h2 class="text-2xl font-extrabold text-gray-800">💬 用户反馈</h2>
<p class="text-sm text-gray-500 mt-1">提交 Bug 报告或功能建议,开发者会跟进处理</p>
</div>
<button @click="showModal = true"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2.5 rounded-xl font-bold text-sm shadow-md hover:shadow-lg transition-all flex items-center gap-2 shrink-0">
<span class="text-lg leading-none"></span> 提交反馈
</button>
</div>
{{-- ═══ 类型筛选 Tab ═══ --}}
<div class="flex gap-2 mb-5">
<button @click="switchType('all')"
:class="filterType === 'all' ? 'bg-indigo-600 text-white shadow-sm' :
'bg-white text-gray-600 border border-gray-200 hover:border-indigo-300 hover:text-indigo-600'"
class="px-4 py-2 rounded-full text-xs font-bold transition-all">全部</button>
<button @click="switchType('bug')"
:class="filterType === 'bug' ? 'bg-rose-600 text-white shadow-sm' :
'bg-white text-gray-600 border border-gray-200 hover:border-rose-300 hover:text-rose-600'"
class="px-4 py-2 rounded-full text-xs font-bold transition-all">🐛 Bug 报告</button>
<button @click="switchType('suggestion')"
:class="filterType === 'suggestion' ? 'bg-blue-600 text-white shadow-sm' :
'bg-white text-gray-600 border border-gray-200 hover:border-blue-300 hover:text-blue-600'"
class="px-4 py-2 rounded-full text-xs font-bold transition-all">💡 功能建议</button>
</div>
{{-- ═══ 空状态 ═══ --}}
<template x-if="items.length === 0 && !loading">
<div class="text-center py-24 text-gray-400">
<p class="text-6xl mb-4">💬</p>
<p class="text-lg font-bold text-gray-500">还没有任何反馈</p>
<p class="text-sm mt-2">点击「提交反馈」成为第一个贡献者!</p>
</div>
</template>
{{-- ═══ 反馈列表 ═══ --}}
<div class="space-y-3">
<template x-for="item in items" :key="item.id">
<div class="bg-white border border-gray-100 rounded-2xl shadow-sm overflow-hidden
hover:shadow-md transition-shadow duration-200"
:class="{ 'ring-2 ring-indigo-200': expandedId === item.id }">
{{-- 卡片主区(点击展开/收起) --}}
<div class="flex items-center gap-3 p-4 cursor-pointer"
@click="expandedId = expandedId === item.id ? null : item.id">
{{-- 赞同按钮 --}}
<div class="shrink-0" @click.stop>
<button @click="toggleVote(item.id)"
class="flex flex-col items-center justify-center w-12 h-14 rounded-xl border-2 font-bold transition-all"
:class="item.voted ?
'bg-indigo-600 border-indigo-600 text-white shadow-md' :
'border-gray-200 text-gray-500 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50'">
<span class="text-base leading-none" x-text="item.voted ? '👍' : '👆'"></span>
<span class="text-xs mt-1 font-bold" x-text="item.votes_count"></span>
</button>
</div>
{{-- 内容摘要 --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-1.5">
{{-- 类型标签 --}}
<span class="px-2 py-0.5 rounded text-xs font-bold shrink-0"
:class="item.type === 'bug' ? 'bg-rose-100 text-rose-700' : 'bg-blue-100 text-blue-700'"
x-text="item.type_label"></span>
{{-- 状态标签 --}}
<span class="px-2 py-0.5 rounded text-xs font-bold shrink-0"
:class="statusClass(item.status_color)" x-text="item.status_label"></span>
{{-- 评论数 --}}
<span class="text-gray-400 text-xs" x-text="'💬 ' + item.replies_count + ' 条'"></span>
</div>
<h4 class="font-bold text-gray-800 text-sm leading-snug truncate" x-text="item.title"></h4>
<p class="text-gray-400 text-xs mt-1" x-text="'by ' + item.username + ' · ' + item.created_at">
</p>
</div>
{{-- 展开指示箭头 --}}
<div class="shrink-0 text-gray-300 transition-transform duration-200"
:class="{ 'rotate-180 text-indigo-400': expandedId === item.id }">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{{-- 展开详情区 --}}
<div x-show="expandedId === item.id" x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0" class="border-t border-gray-100">
{{-- 详细描述 --}}
<div class="px-5 py-4 bg-gray-50/60">
<p class="text-xs font-bold text-gray-400 mb-2">📝 详细描述</p>
<p class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap" x-text="item.content"></p>
</div>
{{-- 管理员官方回复 --}}
<template x-if="item.admin_remark">
<div class="mx-4 my-3 p-4 bg-indigo-50 border-l-4 border-indigo-400 rounded-r-xl">
<p class="text-xs font-bold text-indigo-700 mb-1.5">🛡️ 开发者官方回复</p>
<p class="text-sm text-indigo-800 whitespace-pre-wrap leading-relaxed"
x-text="item.admin_remark"></p>
</div>
</template>
{{-- 补充评论列表 --}}
<template x-if="item.replies && item.replies.length > 0">
<div class="px-4 py-3 border-t border-gray-100 space-y-2">
<p class="text-xs font-bold text-gray-400 mb-2">💬 补充评论 (<span
x-text="item.replies_count"></span>)</p>
<template x-for="reply in item.replies" :key="reply.id">
<div class="rounded-xl px-3 py-2.5"
:class="reply.is_admin ?
'bg-indigo-50 border border-indigo-200' :
'bg-gray-100'">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-xs"
:class="reply.is_admin ? 'text-indigo-700' : 'text-gray-700'"
x-text="reply.username"></span>
<template x-if="reply.is_admin">
<span
class="text-xs bg-indigo-200 text-indigo-800 px-1.5 rounded font-bold">开发者</span>
</template>
<span class="text-gray-400 text-xs ml-auto" x-text="reply.created_at"></span>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed"
x-text="reply.content"></p>
</div>
</template>
</div>
</template>
{{-- 评论输入框 --}}
<div class="px-4 pb-4 pt-2 border-t border-gray-100">
<div class="flex gap-2">
<textarea x-model="replyContent[item.id]" placeholder="补充说明、复现步骤或相关信息..." rows="2" maxlength="1000"
class="flex-1 border border-gray-200 rounded-xl text-sm px-3 py-2 resize-none
focus:ring-2 focus:ring-indigo-400 focus:border-transparent outline-none
placeholder:text-gray-400"></textarea>
<button @click="submitReply(item.id)"
:disabled="submittingReply[item.id] || !replyContent[item.id]?.trim()"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl
text-xs font-bold disabled:opacity-40 transition-all self-end shrink-0">
发送
</button>
</div>
</div>
</div>
</div>
</template>
{{-- 懒加载哨兵 --}}
<div x-show="hasMore && items.length > 0" x-intersect.threshold.10="loadMore()" class="py-4 text-center">
<template x-if="loading">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z">
</path>
</svg>
加载更多...
</div>
</template>
</div>
<div x-show="!hasMore && items.length > 0" class="text-center py-6 text-gray-400 text-sm">
<div class="inline-flex items-center gap-2">
<div class="h-px w-16 bg-gray-200"></div>
<span>以上是全部反馈</span>
<div class="h-px w-16 bg-gray-200"></div>
</div>
</div>
</div>
{{-- ═══════════ 提交反馈 Modal必须在 x-data 容器内)═══════════ --}}
<div x-show="showModal" style="display:none;" class="fixed inset-0 z-[200] flex items-center justify-center p-4"
x-transition.opacity>
{{-- 蒙板 --}}
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="showModal = false"></div>
{{-- 弹窗 --}}
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100">
{{-- 头部 --}}
<div class="bg-gradient-to-r from-indigo-600 to-violet-600 px-6 py-5 flex items-center justify-between">
<div>
<h3 class="text-white font-bold text-lg">📝 提交反馈</h3>
<p class="text-indigo-200 text-xs mt-0.5">您的反馈将帮助我们改进产品</p>
</div>
<button @click="showModal = false"
class="text-white/60 hover:text-white text-2xl font-light transition leading-none">&times;</button>
</div>
{{-- 表单 --}}
<div class="p-6 space-y-4">
{{-- 类型选择 --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">反馈类型 <span
class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-2">
<label class="relative cursor-pointer">
<input type="radio" x-model="form.type" value="bug" class="peer sr-only">
<div
class="border-2 border-gray-200 peer-checked:border-rose-500 peer-checked:bg-rose-50 rounded-xl p-3 text-center transition">
<span class="text-2xl block mb-1">🐛</span>
<span class="text-xs font-bold text-gray-700">Bug 报告</span>
<p class="text-xs text-gray-400 mt-0.5">发现了问题</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" x-model="form.type" value="suggestion" class="peer sr-only">
<div
class="border-2 border-gray-200 peer-checked:border-blue-500 peer-checked:bg-blue-50 rounded-xl p-3 text-center transition">
<span class="text-2xl block mb-1">💡</span>
<span class="text-xs font-bold text-gray-700">功能建议</span>
<p class="text-xs text-gray-400 mt-0.5">希望改进或新增</p>
</div>
</label>
</div>
</div>
{{-- 标题 --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
标题 <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1 text-xs">(一句话描述)</span>
</label>
<input x-model="form.title" type="text" maxlength="200" placeholder="例:点击发送按钮后页面空白..."
class="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm
focus:ring-2 focus:ring-indigo-400 focus:border-transparent outline-none">
<p class="text-xs text-gray-400 mt-1" x-text="form.title.length + '/200'"></p>
</div>
{{-- 详细描述 --}}
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
详细描述 <span class="text-red-500">*</span>
</label>
<textarea x-model="form.content" rows="5" maxlength="2000"
:placeholder="form.type === 'bug' ?
'请描述:\n1. 触发 Bug 的操作步骤\n2. 实际看到的现象\n3. 期望的正确行为' :
'请描述:\n1. 您希望实现什么功能\n2. 这个功能对您有什么帮助'"
class="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm resize-none
focus:ring-2 focus:ring-indigo-400 focus:border-transparent outline-none leading-relaxed"></textarea>
<p class="text-xs text-gray-400 mt-1" x-text="form.content.length + '/2000'"></p>
</div>
{{-- 操作按钮 --}}
<div class="flex justify-end gap-3 pt-1">
<button @click="showModal = false"
class="px-5 py-2.5 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 text-sm font-medium transition">
取消
</button>
<button @click="submitFeedback()"
:disabled="submitting || !form.title.trim() || !form.content.trim()"
class="px-6 py-2.5 bg-indigo-600 text-white rounded-xl font-bold
hover:bg-indigo-700 disabled:opacity-40 text-sm shadow-sm transition">
<span x-text="submitting ? '提交中...' : '确认提交'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -12,10 +12,6 @@
@section('nav-title', '星光留言板')
@section('content')
<div x-data="{ showWriteForm: false, towho: '{{ $defaultTo }}' }" class="w-full">

View File

@@ -26,6 +26,8 @@
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', '飘落流星聊天室')</title>
<script src="https://cdn.tailwindcss.com"></script>
{{-- Alpine.js Intersect 插件(懒加载 x-intersect 需要,必须在主包之前加载) --}}
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
/* 通用滚动条美化 */
@@ -69,31 +71,43 @@
{{-- 公共导航链接 --}}
<a href="{{ route('rooms.index') }}"
class="text-indigo-200 hover:text-white font-bold flex items-center transition hidden sm:flex">
<span class="mr-1">🏠</span> 大厅
大厅
</a>
<a href="{{ route('leaderboard.index') }}"
class="text-yellow-400 hover:text-yellow-300 font-bold flex items-center transition hidden sm:flex">
<span class="mr-1">🏆</span> 风云榜
风云榜
</a>
<a href="{{ route('leaderboard.today') }}"
class="text-green-400 hover:text-green-300 font-bold flex items-center transition hidden sm:flex">
<span class="mr-1">📅</span> 今日榜
今日榜
</a>
<a href="{{ route('duty-hall.index') }}"
class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('duty-hall.*') ? 'text-purple-100 underline underline-offset-4' : '' }}">
勤务台
</a>
<a href="{{ route('guestbook.index') }}"
class="text-indigo-200 hover:text-white font-bold flex items-center transition hidden sm:flex">
<span class="mr-1">✉️</span> 留言板
留言板
</a>
<a href="{{ route('changelog.index') }}"
class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('changelog.*') ? 'text-purple-100 underline underline-offset-4' : '' }}">
更新日志
</a>
<a href="{{ route('feedback.index') }}"
class="text-sky-300 hover:text-sky-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('feedback.*') ? 'text-sky-100 underline underline-offset-4' : '' }}">
用户反馈
</a>
{{-- 通用快捷操作区 --}}
@auth
<a href="{{ route('guide') }}"
class="text-indigo-200 hover:text-white font-bold transition hidden sm:flex items-center">
<span class="mr-1">📖</span> 说明
说明
</a>
@if (Auth::user()->user_level >= 15)
<a href="{{ route('admin.dashboard') }}"
class="text-indigo-200 hover:text-white font-bold transition hidden sm:flex items-center">
<span class="mr-1">⚙️</span> 后台
后台
</a>
@endif

View File

@@ -2,8 +2,10 @@
use App\Http\Controllers\AdminCommandController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ChangelogController;
use App\Http\Controllers\ChatBotController;
use App\Http\Controllers\ChatController;
use App\Http\Controllers\FeedbackController;
use App\Http\Controllers\FishingController;
use App\Http\Controllers\RoomController;
use App\Http\Controllers\UserController;
@@ -46,6 +48,9 @@ Route::middleware(['chat.auth'])->group(function () {
// 用户个人积分流水日志(查询自己的经验/金币/魅力历史)
Route::get('/my/currency-logs', [\App\Http\Controllers\LeaderboardController::class, 'myLogs'])->name('currency.my-logs');
// ---- 勤务台(展示四榜)----
Route::get('/duty-hall', [\App\Http\Controllers\DutyHallController::class, 'index'])->name('duty-hall.index');
// ---- 第十阶段:站内信与留言板系统 ----
Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index');
Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store');
@@ -110,52 +115,122 @@ Route::middleware(['chat.auth'])->group(function () {
Route::get('/shop/items', [\App\Http\Controllers\ShopController::class, 'items'])->name('shop.items');
Route::post('/shop/buy', [\App\Http\Controllers\ShopController::class, 'buy'])->name('shop.buy');
Route::post('/shop/rename', [\App\Http\Controllers\ShopController::class, 'rename'])->name('shop.rename');
// ---- 开发日志(独立前台页面 /changelog----
Route::get('/changelog', [ChangelogController::class, 'index'])->name('changelog.index');
// 懒加载接口scroll 到底追加更多日志
Route::get('/changelog/more', [ChangelogController::class, 'loadMoreChangelogs'])->name('changelog.more');
// ---- 用户反馈(独立前台页面 /feedback----
// 反馈列表页
Route::get('/feedback', [FeedbackController::class, 'index'])->name('feedback.index');
// 懒加载接口scroll 到底追加更多反馈
Route::get('/feedback/more', [FeedbackController::class, 'loadMore'])->name('feedback.more');
// 提交新反馈
Route::post('/feedback', [FeedbackController::class, 'store'])->middleware('throttle:5,1')->name('feedback.store');
// 赞同/取消赞同Toggle
Route::post('/feedback/{id}/vote', [FeedbackController::class, 'vote'])->name('feedback.vote');
// 提交补充评论
Route::post('/feedback/{id}/reply', [FeedbackController::class, 'reply'])->middleware('throttle:10,1')->name('feedback.reply');
// 删除反馈本人24小时内或管理员
Route::delete('/feedback/{id}', [FeedbackController::class, 'destroy'])->name('feedback.destroy');
});
// 强力特权层中间件:同时验证 chat.auth 登录态 和 chat.level:super 特权superlevel 由 sysparam 配置)
Route::middleware(['chat.auth', 'chat.level:super'])->prefix('admin')->name('admin.')->group(function () {
// ═══════════════════════════════════════════════════════════════════
// 后台管理路由(三层权限)
// 层级 1chat.has_position ── 有在职职务即可id=1 始终通过)
// 层级 2chat.site_owner ── 仅站长id=1
// 聊天室内快速任命/撤销(需登录 + 有在职职务)
Route::middleware(['chat.auth', 'chat.has_position'])->prefix('chat-appoint')->name('chat.appoint.')->group(function () {
Route::get('/positions', [\App\Http\Controllers\ChatAppointmentController::class, 'positions'])->name('positions');
Route::post('/appoint', [\App\Http\Controllers\ChatAppointmentController::class, 'appoint'])->name('appoint');
Route::post('/revoke', [\App\Http\Controllers\ChatAppointmentController::class, 'revoke'])->name('revoke');
});
// ═══════════════════════════════════════════════════════════════════
Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('admin.')->group(function () {
// ──────────────────────────────────────────────────────────────
// 层级 1有在职职务即可访问
// ──────────────────────────────────────────────────────────────
// 后台首页概览
Route::get('/', [\App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard');
// 系统参数配置 (替代 VIEWSYS.ASP / SetSYS.ASP)
Route::middleware(['chat.site_owner'])->group(function () {
// 积分流水统计
Route::get('/currency-stats', [\App\Http\Controllers\Admin\CurrencyStatsController::class, 'index'])->name('currency-stats.index');
// 用户管理有职务的人只能查看indexupdate/destroy 仅站长
Route::get('/users', [\App\Http\Controllers\Admin\UserManagerController::class, 'index'])->name('users.index');
// 秘语查看(耳语记录)
Route::get('/whispers', function () {
return view('admin.whispers');
})->name('whispers.index');
// 任命管理(任命权限由 AppointmentService 内部校验)
Route::get('/appointments', [\App\Http\Controllers\Admin\AppointmentController::class, 'index'])->name('appointments.index');
Route::post('/appointments', [\App\Http\Controllers\Admin\AppointmentController::class, 'store'])->name('appointments.store');
Route::delete('/appointments/{userPosition}/revoke', [\App\Http\Controllers\Admin\AppointmentController::class, 'revoke'])->name('appointments.revoke');
Route::get('/appointments/{userPosition}/duty-logs', [\App\Http\Controllers\Admin\AppointmentController::class, 'dutyLogs'])->name('appointments.duty-logs');
Route::get('/appointments/{userPosition}/authority-logs', [\App\Http\Controllers\Admin\AppointmentController::class, 'authorityLogs'])->name('appointments.authority-logs');
Route::get('/appointments/history', [\App\Http\Controllers\Admin\AppointmentController::class, 'history'])->name('appointments.history');
Route::get('/appointments/search-users', [\App\Http\Controllers\Admin\AppointmentController::class, 'searchUsers'])->name('appointments.search-users');
// ── 层级 1.5superlevel 及以上可完整操作以下模块 ──
Route::middleware(['chat.level:super'])->group(function () {
// 部门 / 职务列表 + 编辑(删除仍限 id=1
Route::get('/departments', [\App\Http\Controllers\Admin\DepartmentController::class, 'index'])->name('departments.index');
Route::get('/positions', [\App\Http\Controllers\Admin\PositionController::class, 'index'])->name('positions.index');
Route::put('/departments/{department}', [\App\Http\Controllers\Admin\DepartmentController::class, 'update'])->name('departments.update');
Route::put('/positions/{position}', [\App\Http\Controllers\Admin\PositionController::class, 'update'])->name('positions.update');
// 聊天室参数(含保存)
Route::get('/system', [\App\Http\Controllers\Admin\SystemController::class, 'edit'])->name('system.edit');
Route::put('/system', [\App\Http\Controllers\Admin\SystemController::class, 'update'])->name('system.update');
// 房间管理(含编辑/删除)
Route::get('/rooms', [\App\Http\Controllers\Admin\RoomManagerController::class, 'index'])->name('rooms.index');
Route::put('/rooms/{room}', [\App\Http\Controllers\Admin\RoomManagerController::class, 'update'])->name('rooms.update');
Route::delete('/rooms/{room}', [\App\Http\Controllers\Admin\RoomManagerController::class, 'destroy'])->name('rooms.destroy');
// 随机事件(含新增/编辑/删除/切换)
Route::get('/autoact', [\App\Http\Controllers\Admin\AutoactController::class, 'index'])->name('autoact.index');
Route::post('/autoact', [\App\Http\Controllers\Admin\AutoactController::class, 'store'])->name('autoact.store');
Route::put('/autoact/{autoact}', [\App\Http\Controllers\Admin\AutoactController::class, 'update'])->name('autoact.update');
Route::delete('/autoact/{autoact}', [\App\Http\Controllers\Admin\AutoactController::class, 'destroy'])->name('autoact.destroy');
Route::post('/autoact/{autoact}/toggle', [\App\Http\Controllers\Admin\AutoactController::class, 'toggle'])->name('autoact.toggle');
// VIP 会员等级(含新增/编辑/删除)
Route::get('/vip', [\App\Http\Controllers\Admin\VipController::class, 'index'])->name('vip.index');
Route::post('/vip', [\App\Http\Controllers\Admin\VipController::class, 'store'])->name('vip.store');
Route::put('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update');
Route::delete('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy');
});
// ──────────────────────────────────────────────────────────────
// 层级 2仅站长id=1可进行以下操作
// ──────────────────────────────────────────────────────────────
Route::middleware(['chat.site_owner'])->group(function () {
// 用户编辑 & 删除
Route::put('/users/{user}', [\App\Http\Controllers\Admin\UserManagerController::class, 'update'])->name('users.update');
Route::delete('/users/{user}', [\App\Http\Controllers\Admin\UserManagerController::class, 'destroy'])->name('users.destroy');
// 发信配置管理
Route::get('/smtp', [\App\Http\Controllers\Admin\SmtpController::class, 'edit'])->name('smtp.edit');
Route::put('/smtp', [\App\Http\Controllers\Admin\SmtpController::class, 'update'])->name('smtp.update');
Route::post('/smtp/test', [\App\Http\Controllers\Admin\SmtpController::class, 'test'])->name('smtp.test');
});
// 用户大盘管理 (替代 gl/ 目录下的各种用户管理功能)
Route::get('/users', [\App\Http\Controllers\Admin\UserManagerController::class, 'index'])->name('users.index');
Route::put('/users/{id}', [\App\Http\Controllers\Admin\UserManagerController::class, 'update'])->name('users.update');
Route::delete('/users/{id}', [\App\Http\Controllers\Admin\UserManagerController::class, 'destroy'])->name('users.destroy'); // 物理封杀
// 部门新增/删除(编辑已在 superlevel 层)
Route::post('/departments', [\App\Http\Controllers\Admin\DepartmentController::class, 'store'])->name('departments.store');
Route::delete('/departments/{department}', [\App\Http\Controllers\Admin\DepartmentController::class, 'destroy'])->name('departments.destroy');
// 房间管理
Route::get('/rooms', [\App\Http\Controllers\Admin\RoomManagerController::class, 'index'])->name('rooms.index');
Route::put('/rooms/{id}', [\App\Http\Controllers\Admin\RoomManagerController::class, 'update'])->name('rooms.update');
Route::delete('/rooms/{id}', [\App\Http\Controllers\Admin\RoomManagerController::class, 'destroy'])->name('rooms.destroy');
// 职务新增/删除(编辑已在 superlevel 层)
Route::post('/positions', [\App\Http\Controllers\Admin\PositionController::class, 'store'])->name('positions.store');
Route::delete('/positions/{position}', [\App\Http\Controllers\Admin\PositionController::class, 'destroy'])->name('positions.destroy');
// 随机事件管理(复刻原版 autoact 系统)
Route::get('/autoact', [\App\Http\Controllers\Admin\AutoactController::class, 'index'])->name('autoact.index');
Route::post('/autoact', [\App\Http\Controllers\Admin\AutoactController::class, 'store'])->name('autoact.store');
Route::put('/autoact/{id}', [\App\Http\Controllers\Admin\AutoactController::class, 'update'])->name('autoact.update');
Route::post('/autoact/{id}/toggle', [\App\Http\Controllers\Admin\AutoactController::class, 'toggle'])->name('autoact.toggle');
Route::delete('/autoact/{id}', [\App\Http\Controllers\Admin\AutoactController::class, 'destroy'])->name('autoact.destroy');
// VIP 会员等级管理
Route::get('/vip', [\App\Http\Controllers\Admin\VipController::class, 'index'])->name('vip.index');
Route::post('/vip', [\App\Http\Controllers\Admin\VipController::class, 'store'])->name('vip.store');
Route::put('/vip/{id}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update');
Route::delete('/vip/{id}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy');
// 积分流水活动统计(管理员查看每日经验/金币产出概况)
Route::get('/currency-stats', [\App\Http\Controllers\Admin\CurrencyStatsController::class, 'index'])->name('currency-stats.index');
// AI 厂商配置管理
Route::middleware(['chat.site_owner'])->group(function () {
// AI 厂商配置管理
Route::get('/ai-providers', [\App\Http\Controllers\Admin\AiProviderController::class, 'index'])->name('ai-providers.index');
Route::post('/ai-providers', [\App\Http\Controllers\Admin\AiProviderController::class, 'store'])->name('ai-providers.store');
Route::put('/ai-providers/{id}', [\App\Http\Controllers\Admin\AiProviderController::class, 'update'])->name('ai-providers.update');
@@ -163,5 +238,12 @@ Route::middleware(['chat.auth', 'chat.level:super'])->prefix('admin')->name('adm
Route::post('/ai-providers/{id}/default', [\App\Http\Controllers\Admin\AiProviderController::class, 'setDefault'])->name('ai-providers.default');
Route::post('/ai-providers/toggle-chatbot', [\App\Http\Controllers\Admin\AiProviderController::class, 'toggleChatBot'])->name('ai-providers.toggle-chatbot');
Route::delete('/ai-providers/{id}', [\App\Http\Controllers\Admin\AiProviderController::class, 'destroy'])->name('ai-providers.destroy');
// 开发日志管理
Route::resource('changelogs', \App\Http\Controllers\Admin\ChangelogController::class)->except(['show']);
// 用户反馈管理
Route::get('/feedback', [\App\Http\Controllers\Admin\FeedbackManagerController::class, 'index'])->name('feedback.index');
Route::put('/feedback/{id}', [\App\Http\Controllers\Admin\FeedbackManagerController::class, 'update'])->name('feedback.update');
});
});