From 5f3022060967da5bf125930f5422c7cafea3c6de Mon Sep 17 00:00:00 2001 From: lkddi Date: Sat, 28 Feb 2026 23:44:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=91=BD/=E6=92=A4=E9=94=80?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=B3=BB=E7=BB=9F=20+=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=90=8D=E7=89=87UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任命/撤销事件增加 type 字段区分类型 - 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息 - 撤销:灰色弹窗 + 灰色系统消息,无礼花 - 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏 - 系统消息加随机鼓励语(各5条轮换) - ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds) - 用户名片折叠优化:管理员视野、职务履历均可折叠 - 管理操作 + 职务操作合并为「🔧 管理操作」折叠区 - 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开 --- .../skills/tailwindcss-development/SKILL.md | 129 +++++ .gemini/settings.json | 20 + .../skills/tailwindcss-development/SKILL.md | 129 +++++ .junie/guidelines.md | 256 ++++++++++ .junie/mcp/mcp.json | 20 + .../skills/tailwindcss-development/SKILL.md | 129 +++++ AGENTS.md | 256 ++++++++++ GEMINI.md | 256 ++++++++++ app/Events/AppointmentAnnounced.php | 73 +++ app/Events/ChangelogPublished.php | 74 +++ .../Admin/AppointmentController.php | 202 ++++++++ .../Controllers/Admin/AutoactController.php | 25 +- .../Controllers/Admin/ChangelogController.php | 191 +++++++ .../Admin/DepartmentController.php | 91 ++++ .../Admin/FeedbackManagerController.php | 112 ++++ .../Controllers/Admin/PositionController.php | 110 ++++ .../Admin/RoomManagerController.php | 12 +- .../Admin/UserManagerController.php | 42 +- app/Http/Controllers/Admin/VipController.php | 13 +- app/Http/Controllers/ChangelogController.php | 69 +++ .../Controllers/ChatAppointmentController.php | 147 ++++++ app/Http/Controllers/ChatController.php | 284 ++++++++--- app/Http/Controllers/DutyHallController.php | 79 +++ app/Http/Controllers/FeedbackController.php | 298 +++++++++++ app/Http/Controllers/FishingController.php | 8 +- app/Http/Controllers/UserController.php | 45 +- app/Http/Middleware/HasActivePosition.php | 53 ++ app/Http/Requests/SendMessageRequest.php | 14 + app/Models/Autoact.php | 2 +- app/Models/Department.php | 72 +++ app/Models/DevChangelog.php | 112 ++++ app/Models/FeedbackItem.php | 146 ++++++ app/Models/FeedbackReply.php | 47 ++ app/Models/FeedbackVote.php | 42 ++ app/Models/Position.php | 129 +++++ app/Models/PositionAuthorityLog.php | 104 ++++ app/Models/PositionDutyLog.php | 74 +++ app/Models/User.php | 36 ++ app/Models/UserPosition.php | 133 +++++ app/Services/AppointmentService.php | 287 +++++++++++ app/Services/ChatStateService.php | 81 ++- app/Services/RoomBroadcastService.php | 152 ++++++ boost.json | 16 + bootstrap/app.php | 12 +- composer.json | 1 + composer.lock | 202 +++++++- ..._28_141644_create_dev_changelogs_table.php | 55 ++ ...26_02_28_141645_create_feedback_tables.php | 99 ++++ ..._02_28_201430_create_departments_table.php | 41 ++ ...26_02_28_201430_create_positions_table.php | 44 ++ ...1_create_position_appoint_limits_table.php | 48 ++ ..._28_201431_create_user_positions_table.php | 66 +++ ...2_create_position_authority_logs_table.php | 80 +++ ...201432_create_position_duty_logs_table.php | 56 ++ database/seeders/DepartmentPositionSeeder.php | 240 +++++++++ opencode.json | 25 + resources/js/chat.js | 7 + .../appointments/authority-logs.blade.php | 73 +++ .../admin/appointments/duty-logs.blade.php | 80 +++ .../admin/appointments/history.blade.php | 62 +++ .../views/admin/appointments/index.blade.php | 250 +++++++++ resources/views/admin/autoact/index.blade.php | 2 +- .../views/admin/changelog/form.blade.php | 116 +++++ .../views/admin/changelog/index.blade.php | 102 ++++ .../views/admin/departments/index.blade.php | 185 +++++++ .../views/admin/feedback/index.blade.php | 187 +++++++ resources/views/admin/layouts/app.blade.php | 90 +++- .../views/admin/positions/index.blade.php | 298 +++++++++++ resources/views/admin/rooms/index.blade.php | 2 +- resources/views/admin/users/index.blade.php | 38 +- resources/views/changelog/index.blade.php | 309 +++++++++++ resources/views/chat/frame.blade.php | 6 +- .../views/chat/partials/scripts.blade.php | 346 ++++++++++--- .../views/chat/partials/toolbar.blade.php | 4 +- .../chat/partials/user-actions.blade.php | 463 ++++++++++++----- resources/views/duty-hall/index.blade.php | 239 +++++++++ resources/views/feedback/index.blade.php | 480 ++++++++++++++++++ resources/views/guestbook/index.blade.php | 4 - resources/views/layouts/app.blade.php | 26 +- routes/web.php | 144 ++++-- 80 files changed, 8579 insertions(+), 473 deletions(-) create mode 100644 .agents/skills/tailwindcss-development/SKILL.md create mode 100644 .gemini/settings.json create mode 100644 .github/skills/tailwindcss-development/SKILL.md create mode 100644 .junie/guidelines.md create mode 100644 .junie/mcp/mcp.json create mode 100644 .junie/skills/tailwindcss-development/SKILL.md create mode 100644 AGENTS.md create mode 100644 GEMINI.md create mode 100644 app/Events/AppointmentAnnounced.php create mode 100644 app/Events/ChangelogPublished.php create mode 100644 app/Http/Controllers/Admin/AppointmentController.php create mode 100644 app/Http/Controllers/Admin/ChangelogController.php create mode 100644 app/Http/Controllers/Admin/DepartmentController.php create mode 100644 app/Http/Controllers/Admin/FeedbackManagerController.php create mode 100644 app/Http/Controllers/Admin/PositionController.php create mode 100644 app/Http/Controllers/ChangelogController.php create mode 100644 app/Http/Controllers/ChatAppointmentController.php create mode 100644 app/Http/Controllers/DutyHallController.php create mode 100644 app/Http/Controllers/FeedbackController.php create mode 100644 app/Http/Middleware/HasActivePosition.php create mode 100644 app/Models/Department.php create mode 100644 app/Models/DevChangelog.php create mode 100644 app/Models/FeedbackItem.php create mode 100644 app/Models/FeedbackReply.php create mode 100644 app/Models/FeedbackVote.php create mode 100644 app/Models/Position.php create mode 100644 app/Models/PositionAuthorityLog.php create mode 100644 app/Models/PositionDutyLog.php create mode 100644 app/Models/UserPosition.php create mode 100644 app/Services/AppointmentService.php create mode 100644 app/Services/RoomBroadcastService.php create mode 100644 boost.json create mode 100644 database/migrations/2026_02_28_141644_create_dev_changelogs_table.php create mode 100644 database/migrations/2026_02_28_141645_create_feedback_tables.php create mode 100644 database/migrations/2026_02_28_201430_create_departments_table.php create mode 100644 database/migrations/2026_02_28_201430_create_positions_table.php create mode 100644 database/migrations/2026_02_28_201431_create_position_appoint_limits_table.php create mode 100644 database/migrations/2026_02_28_201431_create_user_positions_table.php create mode 100644 database/migrations/2026_02_28_201432_create_position_authority_logs_table.php create mode 100644 database/migrations/2026_02_28_201432_create_position_duty_logs_table.php create mode 100644 database/seeders/DepartmentPositionSeeder.php create mode 100644 opencode.json create mode 100644 resources/views/admin/appointments/authority-logs.blade.php create mode 100644 resources/views/admin/appointments/duty-logs.blade.php create mode 100644 resources/views/admin/appointments/history.blade.php create mode 100644 resources/views/admin/appointments/index.blade.php create mode 100644 resources/views/admin/changelog/form.blade.php create mode 100644 resources/views/admin/changelog/index.blade.php create mode 100644 resources/views/admin/departments/index.blade.php create mode 100644 resources/views/admin/feedback/index.blade.php create mode 100644 resources/views/admin/positions/index.blade.php create mode 100644 resources/views/changelog/index.blade.php create mode 100644 resources/views/duty-hall/index.blade.php create mode 100644 resources/views/feedback/index.blade.php diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -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 +@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: + + +```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: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## 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: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## 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 \ No newline at end of file diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..a561af2 --- /dev/null +++ b/.gemini/settings.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/.github/skills/tailwindcss-development/SKILL.md b/.github/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.github/skills/tailwindcss-development/SKILL.md @@ -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 +@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: + + +```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: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## 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: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## 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 \ No newline at end of file diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..de20950 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,256 @@ + +=== 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. + + +```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. + + diff --git a/.junie/mcp/mcp.json b/.junie/mcp/mcp.json new file mode 100644 index 0000000..ff55465 --- /dev/null +++ b/.junie/mcp/mcp.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/.junie/skills/tailwindcss-development/SKILL.md b/.junie/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.junie/skills/tailwindcss-development/SKILL.md @@ -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 +@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: + + +```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: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## 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: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## 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 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..de20950 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,256 @@ + +=== 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. + + +```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. + + diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..de20950 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,256 @@ + +=== 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. + + +```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. + + diff --git a/app/Events/AppointmentAnnounced.php b/app/Events/AppointmentAnnounced.php new file mode 100644 index 0000000..20b9f72 --- /dev/null +++ b/app/Events/AppointmentAnnounced.php @@ -0,0 +1,73 @@ + + */ + public function broadcastOn(): array + { + return [ + new PresenceChannel('room.'.$this->roomId), + ]; + } + + /** + * 广播数据 + * + * @return array + */ + 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, + ]; + } +} diff --git a/app/Events/ChangelogPublished.php b/app/Events/ChangelogPublished.php new file mode 100644 index 0000000..aebf9c3 --- /dev/null +++ b/app/Events/ChangelogPublished.php @@ -0,0 +1,74 @@ + + */ + public function broadcastOn(): array + { + return [ + // 固定广播至 Room ID = 1 的大厅频道 + new PresenceChannel('room.1'), + ]; + } + + /** + * 广播事件名称(前端 .listen('ChangelogPublished', ...) 监听此名称) + */ + public function broadcastAs(): string + { + return 'ChangelogPublished'; + } + + /** + * 广播携带的数据(前端可直接访问) + * + * @return array + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/Admin/AppointmentController.php b/app/Http/Controllers/Admin/AppointmentController.php new file mode 100644 index 0000000..ef2eb23 --- /dev/null +++ b/app/Http/Controllers/Admin/AppointmentController.php @@ -0,0 +1,202 @@ +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); + } +} diff --git a/app/Http/Controllers/Admin/AutoactController.php b/app/Http/Controllers/Admin/AutoactController.php index 142e035..4b9a7d0 100644 --- a/app/Http/Controllers/Admin/AutoactController.php +++ b/app/Http/Controllers/Admin/AutoactController.php @@ -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', '事件已删除!'); } diff --git a/app/Http/Controllers/Admin/ChangelogController.php b/app/Http/Controllers/Admin/ChangelogController.php new file mode 100644 index 0000000..71f79c3 --- /dev/null +++ b/app/Http/Controllers/Admin/ChangelogController.php @@ -0,0 +1,191 @@ +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}》— 点击查看详情", + 'is_secret' => false, + 'font_color' => '#7c3aed', + 'action' => '', + 'sent_at' => now()->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Admin/DepartmentController.php b/app/Http/Controllers/Admin/DepartmentController.php new file mode 100644 index 0000000..4934687 --- /dev/null +++ b/app/Http/Controllers/Admin/DepartmentController.php @@ -0,0 +1,91 @@ +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}】已删除!"); + } +} diff --git a/app/Http/Controllers/Admin/FeedbackManagerController.php b/app/Http/Controllers/Admin/FeedbackManagerController.php new file mode 100644 index 0000000..957d047 --- /dev/null +++ b/app/Http/Controllers/Admin/FeedbackManagerController.php @@ -0,0 +1,112 @@ +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', '反馈状态已更新!'); + } +} diff --git a/app/Http/Controllers/Admin/PositionController.php b/app/Http/Controllers/Admin/PositionController.php new file mode 100644 index 0000000..3529691 --- /dev/null +++ b/app/Http/Controllers/Admin/PositionController.php @@ -0,0 +1,110 @@ + 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}】已删除!"); + } +} diff --git a/app/Http/Controllers/Admin/RoomManagerController.php b/app/Http/Controllers/Admin/RoomManagerController.php index 7d22b25..cd9d59b 100644 --- a/app/Http/Controllers/Admin/RoomManagerController.php +++ b/app/Http/Controllers/Admin/RoomManagerController.php @@ -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', '系统房间不允许删除!'); } diff --git a/app/Http/Controllers/Admin/UserManagerController.php b/app/Http/Controllers/Admin/UserManagerController.php index c3b69ac..fccbaa7 100644 --- a/app/Http/Controllers/Admin/UserManagerController.php +++ b/app/Http/Controllers/Admin/UserManagerController.php @@ -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(); // 越权防护:不允许删除同级或更高等级的账号 diff --git a/app/Http/Controllers/Admin/VipController.php b/app/Http/Controllers/Admin/VipController.php index de6cb30..d628d89 100644 --- a/app/Http/Controllers/Admin/VipController.php +++ b/app/Http/Controllers/Admin/VipController.php @@ -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', '会员等级已删除!'); } diff --git a/app/Http/Controllers/ChangelogController.php b/app/Http/Controllers/ChangelogController.php new file mode 100644 index 0000000..3800e1e --- /dev/null +++ b/app/Http/Controllers/ChangelogController.php @@ -0,0 +1,69 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/ChatAppointmentController.php b/app/Http/Controllers/ChatAppointmentController.php new file mode 100644 index 0000000..5ef2481 --- /dev/null +++ b/app/Http/Controllers/ChatAppointmentController.php @@ -0,0 +1,147 @@ +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); + } +} diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 289ab98..50e9f8a 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -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' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 {$user->username} 首次驾临本聊天室!系统已自动赠送 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' => "🎉 欢迎管理员 {$user->username} 驾临本聊天室!请各位文明聊天!", - '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' => "{$text}", + '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' => "{$leaveText}", + '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())'), + ]); + } } diff --git a/app/Http/Controllers/DutyHallController.php b/app/Http/Controllers/DutyHallController.php new file mode 100644 index 0000000..caf6e60 --- /dev/null +++ b/app/Http/Controllers/DutyHallController.php @@ -0,0 +1,79 @@ +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', + )); + } +} diff --git a/app/Http/Controllers/FeedbackController.php b/app/Http/Controllers/FeedbackController.php new file mode 100644 index 0000000..df13818 --- /dev/null +++ b/app/Http/Controllers/FeedbackController.php @@ -0,0 +1,298 @@ +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 $items + * @param array $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(); + } +} diff --git a/app/Http/Controllers/FishingController.php b/app/Http/Controllers/FishingController.php index 727464a..acd4074 100644 --- a/app/Http/Controllers/FishingController.php +++ b/app/Http/Controllers/FishingController.php @@ -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' => '', diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 9f1340f..808a260 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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(); + // 拥有封禁IP(level_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); } diff --git a/app/Http/Middleware/HasActivePosition.php b/app/Http/Middleware/HasActivePosition.php new file mode 100644 index 0000000..c3716f1 --- /dev/null +++ b/app/Http/Middleware/HasActivePosition.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/app/Http/Requests/SendMessageRequest.php b/app/Http/Requests/SendMessageRequest.php index 3e7c357..fe48c4c 100644 --- a/app/Http/Requests/SendMessageRequest.php +++ b/app/Http/Requests/SendMessageRequest.php @@ -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)); + } } diff --git a/app/Models/Autoact.php b/app/Models/Autoact.php index 3467a8f..3462f9e 100644 --- a/app/Models/Autoact.php +++ b/app/Models/Autoact.php @@ -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); } } diff --git a/app/Models/Department.php b/app/Models/Department.php new file mode 100644 index 0000000..06e780e --- /dev/null +++ b/app/Models/Department.php @@ -0,0 +1,72 @@ + + */ + 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'); + } +} diff --git a/app/Models/DevChangelog.php b/app/Models/DevChangelog.php new file mode 100644 index 0000000..96ff6f1 --- /dev/null +++ b/app/Models/DevChangelog.php @@ -0,0 +1,112 @@ + '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); + } +} diff --git a/app/Models/FeedbackItem.php b/app/Models/FeedbackItem.php new file mode 100644 index 0000000..ddc2567 --- /dev/null +++ b/app/Models/FeedbackItem.php @@ -0,0 +1,146 @@ + ['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']); + } +} diff --git a/app/Models/FeedbackReply.php b/app/Models/FeedbackReply.php new file mode 100644 index 0000000..26eb1f9 --- /dev/null +++ b/app/Models/FeedbackReply.php @@ -0,0 +1,47 @@ + 'boolean', + ]; + + // ═══════════════ 关联关系 ═══════════════ + + /** + * 关联所属反馈 + */ + public function feedback(): BelongsTo + { + return $this->belongsTo(FeedbackItem::class, 'feedback_id'); + } +} diff --git a/app/Models/FeedbackVote.php b/app/Models/FeedbackVote.php new file mode 100644 index 0000000..f9667c4 --- /dev/null +++ b/app/Models/FeedbackVote.php @@ -0,0 +1,42 @@ +belongsTo(FeedbackItem::class, 'feedback_id'); + } +} diff --git a/app/Models/Position.php b/app/Models/Position.php new file mode 100644 index 0000000..07a1263 --- /dev/null +++ b/app/Models/Position.php @@ -0,0 +1,129 @@ + + */ + 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'); + } +} diff --git a/app/Models/PositionAuthorityLog.php b/app/Models/PositionAuthorityLog.php new file mode 100644 index 0000000..b6cb7de --- /dev/null +++ b/app/Models/PositionAuthorityLog.php @@ -0,0 +1,104 @@ + + */ + 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; + } +} diff --git a/app/Models/PositionDutyLog.php b/app/Models/PositionDutyLog.php new file mode 100644 index 0000000..9d6f2ec --- /dev/null +++ b/app/Models/PositionDutyLog.php @@ -0,0 +1,74 @@ + + */ + 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"; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 84e2465..f06c55a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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(); + } } diff --git a/app/Models/UserPosition.php b/app/Models/UserPosition.php new file mode 100644 index 0000000..95d7aa2 --- /dev/null +++ b/app/Models/UserPosition.php @@ -0,0 +1,133 @@ + + */ + 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'); + } +} diff --git a/app/Services/AppointmentService.php b/app/Services/AppointmentService.php new file mode 100644 index 0000000..cb1f82c --- /dev/null +++ b/app/Services/AppointmentService.php @@ -0,0 +1,287 @@ +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 + */ + public function getAppointablePositions(User $operator) + { + $operatorPosition = $this->getActivePosition($operator); + if (! $operatorPosition) { + return collect(); + } + + return $operatorPosition->position + ->appointablePositions() + ->with('department') + ->orderByDesc('rank') + ->get(); + } +} diff --git a/app/Services/ChatStateService.php b/app/Services/ChatStateService.php index edb08a1..06c5af6 100644 --- a/app/Services/ChatStateService.php +++ b/app/Services/ChatStateService.php @@ -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 + */ + 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); + } + } + } + /** * 获取指定房间的新发言记录。 * 在高频长轮询或前端断线重连拉取时使用。 diff --git a/app/Services/RoomBroadcastService.php b/app/Services/RoomBroadcastService.php new file mode 100644 index 0000000..24ee2f1 --- /dev/null +++ b/app/Services/RoomBroadcastService.php @@ -0,0 +1,152 @@ + 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)]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..6c1633f --- /dev/null +++ b/boost.json @@ -0,0 +1,16 @@ +{ + "agents": [ + "copilot", + "junie", + "opencode", + "gemini" + ], + "guidelines": true, + "herd_mcp": true, + "mcp": true, + "nightwatch_mcp": false, + "sail": false, + "skills": [ + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index cf26ab9..6a8c1f2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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(); diff --git a/composer.json b/composer.json index f07eeee..8f9ea0e 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 85ca94b..0b8210e 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/migrations/2026_02_28_141644_create_dev_changelogs_table.php b/database/migrations/2026_02_28_141644_create_dev_changelogs_table.php new file mode 100644 index 0000000..9b8e38e --- /dev/null +++ b/database/migrations/2026_02_28_141644_create_dev_changelogs_table.php @@ -0,0 +1,55 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_28_141645_create_feedback_tables.php b/database/migrations/2026_02_28_141645_create_feedback_tables.php new file mode 100644 index 0000000..0b9f3b7 --- /dev/null +++ b/database/migrations/2026_02_28_141645_create_feedback_tables.php @@ -0,0 +1,99 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_28_201430_create_departments_table.php b/database/migrations/2026_02_28_201430_create_departments_table.php new file mode 100644 index 0000000..72b7585 --- /dev/null +++ b/database/migrations/2026_02_28_201430_create_departments_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('name', 50)->comment('部门名称'); + $table->unsignedTinyInteger('rank')->default(0)->comment('部门位阶(0~99,99 最高)'); + $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'); + } +}; diff --git a/database/migrations/2026_02_28_201430_create_positions_table.php b/database/migrations/2026_02_28_201430_create_positions_table.php new file mode 100644 index 0000000..0d98e35 --- /dev/null +++ b/database/migrations/2026_02_28_201430_create_positions_table.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_28_201431_create_position_appoint_limits_table.php b/database/migrations/2026_02_28_201431_create_position_appoint_limits_table.php new file mode 100644 index 0000000..1d3c0bb --- /dev/null +++ b/database/migrations/2026_02_28_201431_create_position_appoint_limits_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_28_201431_create_user_positions_table.php b/database/migrations/2026_02_28_201431_create_user_positions_table.php new file mode 100644 index 0000000..04bb8d6 --- /dev/null +++ b/database/migrations/2026_02_28_201431_create_user_positions_table.php @@ -0,0 +1,66 @@ +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_id(null=系统初始化)'); + $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'); + } +}; diff --git a/database/migrations/2026_02_28_201432_create_position_authority_logs_table.php b/database/migrations/2026_02_28_201432_create_position_authority_logs_table.php new file mode 100644 index 0000000..109a6aa --- /dev/null +++ b/database/migrations/2026_02_28_201432_create_position_authority_logs_table.php @@ -0,0 +1,80 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_28_201432_create_position_duty_logs_table.php b/database/migrations/2026_02_28_201432_create_position_duty_logs_table.php new file mode 100644 index 0000000..a277d24 --- /dev/null +++ b/database/migrations/2026_02_28_201432_create_position_duty_logs_table.php @@ -0,0 +1,56 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete() + ->comment('用户 ID'); + $table->foreignId('user_position_id') + ->constrained('user_positions') + ->cascadeOnDelete() + ->comment('关联的在职记录 ID(user_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'); + } +}; diff --git a/database/seeders/DepartmentPositionSeeder.php b/database/seeders/DepartmentPositionSeeder.php new file mode 100644 index 0000000..595ea7b --- /dev/null +++ b/database/seeders/DepartmentPositionSeeder.php @@ -0,0 +1,240 @@ +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> + */ + 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> + */ + 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 $positions + * @return array}> + */ + 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)], + ]; + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..9338cd1 --- /dev/null +++ b/opencode.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/resources/js/chat.js b/resources/js/chat.js index 0855a9b..91c93b3 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -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 }), + ); }); } diff --git a/resources/views/admin/appointments/authority-logs.blade.php b/resources/views/admin/appointments/authority-logs.blade.php new file mode 100644 index 0000000..5fddd9c --- /dev/null +++ b/resources/views/admin/appointments/authority-logs.blade.php @@ -0,0 +1,73 @@ +{{-- + 文件功能:在职期间权限操作日志子页 + 展示某任职记录期间该用户的所有权限操作(任命/撤销/奖励/警告/踢出/禁言/封IP等) + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', '权限操作日志 · ' . $userPosition->user->username) + +@section('content') +
+ ← 返回任命管理 +

+ {{ $userPosition->position->icon }} {{ $userPosition->user->username }} · {{ $userPosition->position->name }} +

+

权限操作记录(共 {{ $logs->total() }} 条)

+
+ + @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 + +
+ + + + + + + + + + + + + @forelse ($logs as $log) + @php $colorClass = $actionColors[$log->action_type] ?? 'bg-gray-100 text-gray-600'; @endphp + + + + + + + + + @empty + + + + @endforelse + +
操作时间操作类型操作对象目标职务奖励金额备注
{{ $log->created_at->format('m-d H:i:s') }} + + {{ $log->action_label }} + + {{ $log->targetUser->username ?? '—' }}{{ $log->targetPosition?->name ?? '—' }} + {{ $log->amount ? number_format($log->amount) . ' 金币' : '—' }} + {{ $log->remark ?? '—' }}
暂无权限操作记录
+
+
{{ $logs->links() }}
+@endsection diff --git a/resources/views/admin/appointments/duty-logs.blade.php b/resources/views/admin/appointments/duty-logs.blade.php new file mode 100644 index 0000000..3bf6b21 --- /dev/null +++ b/resources/views/admin/appointments/duty-logs.blade.php @@ -0,0 +1,80 @@ +{{-- + 文件功能:任职期间登录记录子页 + 展示某个用户在职期间的每次登录时间、在线时长、IP 和房间 + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', '在职登录日志 · ' . $userPosition->user->username) + +@section('content') +
+ ← 返回任命管理 +

+ {{ $userPosition->position->icon }} {{ $userPosition->user->username }} · {{ $userPosition->position->name }} +

+

+ 任命于 {{ $userPosition->appointed_at->format('Y-m-d') }}, + 任命人:{{ $userPosition->appointedBy?->username ?? '系统' }} +

+
+ + {{-- 统计摘要 --}} +
+
+
{{ $logs->total() }}
+
总登录次数
+
+
+
+ {{ gmdate('H', $logs->sum('duration_seconds')) }}h {{ gmdate('i', $logs->sum('duration_seconds')) }}m +
+
累计在线时长(当前页)
+
+
+
{{ $userPosition->total_rewarded_coins }}
+
在职期间发放金币
+
+
+ +
+ + + + + + + + + + + + @forelse ($logs as $log) + + + + + + + + @empty + + + + @endforelse + +
登录时间退出时间在线时长房间IP 地址
{{ $log->login_at->format('m-d H:i:s') }}{{ $log->logout_at?->format('m-d H:i:s') ?? '在线中...' }} + @if ($log->duration_seconds) + {{ $log->formatted_duration }} + @else + + @endif + {{ $log->room_id ? "房间#{$log->room_id}" : '—' }} + {{ $log->ip_address }}
暂无登录记录
+
+
{{ $logs->links() }}
+@endsection diff --git a/resources/views/admin/appointments/history.blade.php b/resources/views/admin/appointments/history.blade.php new file mode 100644 index 0000000..e48deac --- /dev/null +++ b/resources/views/admin/appointments/history.blade.php @@ -0,0 +1,62 @@ +{{-- + 文件功能:历史任职记录页面 + 展示所有已撤销的职务记录(is_active=false),含任命人和撤销人信息 + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', '历史任职记录') + +@section('content') +
+ ← 返回任命管理 +

历史任职记录

+

已撤销的职务记录(共 {{ $history->total() }} 条)

+
+ +
+ + + + + + + + + + + + + + @forelse ($history as $up) + + + + + + + + + + @empty + + + + @endforelse + +
用户职务任命人任命时间撤销人撤销时间在职天数
{{ $up->user->username }} + {{ $up->position->icon }} + {{ $up->position->name }} + {{ $up->position->department->name }} + {{ $up->appointedBy?->username ?? '系统' }}{{ $up->appointed_at->format('Y-m-d') }} + {{ $up->revokedBy?->username ?? '系统' }} + {{ $up->revoked_at?->format('Y-m-d') ?? '—' }} + {{ $up->duration_days }} + 天 +
暂无历史记录
+
+
{{ $history->links() }}
+@endsection diff --git a/resources/views/admin/appointments/index.blade.php b/resources/views/admin/appointments/index.blade.php new file mode 100644 index 0000000..e50d7ac --- /dev/null +++ b/resources/views/admin/appointments/index.blade.php @@ -0,0 +1,250 @@ +{{-- + 文件功能:后台任命管理页面 + 展示当前所有在职人员,支持新增任命和撤销职务 + 任命时可搜索用户并选择目标职务 + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', '任命管理') + +@section('content') +
+ + {{-- 头部 --}} +
+
+

任命管理

+

管理当前所有在职职位人员,执行任命或撤销操作

+
+
+ + 历史记录 + + +
+
+ + @if (session('success')) +
+ {{ session('success') }}
+ @endif + @if (session('error')) +
+ {{ session('error') }}
+ @endif + + {{-- 在职人员列表 --}} +
+ + + + + + + + + + + + + + @forelse ($activePositions as $up) + + + + + + + + + + @empty + + + + @endforelse + +
用户部门·职务等级任命人任命时间在职天数操作
+
{{ $up->user->username }}
+
Lv.{{ $up->user->user_level }}
+
+
+ {{ $up->position->icon }} +
+
{{ $up->position->department->name }}
+
+ {{ $up->position->name }} +
+
+
+
+ + Lv.{{ $up->position->level }} + + {{ $up->appointedBy?->username ?? '系统' }} + {{ $up->appointed_at->format('Y-m-d') }} + + + {{ $up->duration_days }} 天 + + + + 登录日志 + + + 操作日志 + +
+ @csrf @method('DELETE') + +
+
暂无在职人员
+
+ + {{-- 新增任命弹窗 --}} + +
+@endsection diff --git a/resources/views/admin/autoact/index.blade.php b/resources/views/admin/autoact/index.blade.php index 1d3c776..6f69c15 100644 --- a/resources/views/admin/autoact/index.blade.php +++ b/resources/views/admin/autoact/index.blade.php @@ -20,7 +20,7 @@
-
diff --git a/resources/views/admin/changelog/form.blade.php b/resources/views/admin/changelog/form.blade.php new file mode 100644 index 0000000..931e084 --- /dev/null +++ b/resources/views/admin/changelog/form.blade.php @@ -0,0 +1,116 @@ +{{-- + 文件功能:后台开发日志新增/编辑表单(仅 id=1 超级管理员可访问) + 新增时:可选立即发布 + 通知大厅用户(触发 WebSocket 广播) + 编辑时:不显示"通知大厅"选项,不更新 published_at + + @extends admin.layouts.app +--}} +@extends('admin.layouts.app') + +@section('title', $isCreate ? '新增开发日志' : '编辑开发日志') + +@section('content') +
+ ← 返回列表 +

{{ $isCreate ? '📝 新增开发日志' : '✏️ 编辑开发日志' }}

+
+ +
+
+ @csrf + @if (!$isCreate) + @method('PUT') + @endif + + {{-- 版本号 + 类型 --}} +
+
+ + +
+
+ + +
+
+ + {{-- 标题 --}} +
+ + +
+ + {{-- Markdown 内容 --}} +
+ + +

支持 ## 标题、- 列表、`代码` 等 Markdown 格式

+
+ + {{-- 发布选项 --}} +
+ + + @if ($isCreate) + {{-- 新增时显示"通知大厅"选项 --}} + + @else + {{-- 编辑时也可手动触发通知 --}} + + @endif +
+ + {{-- 操作按钮 --}} +
+ + 取消 + + +
+
+
+@endsection diff --git a/resources/views/admin/changelog/index.blade.php b/resources/views/admin/changelog/index.blade.php new file mode 100644 index 0000000..02204be --- /dev/null +++ b/resources/views/admin/changelog/index.blade.php @@ -0,0 +1,102 @@ +{{-- + 文件功能:后台开发日志列表页(仅 id=1 超级管理员可访问) + 展示所有日志(含草稿),支持发布/编辑/删除操作 + + @extends admin.layouts.app +--}} +@extends('admin.layouts.app') + +@section('title', '开发日志管理') + +@section('content') +
+
+

📋 开发日志管理

+

管理版本更新记录,发布后可在大厅消息区通知用户

+
+ + + 新增日志 + +
+ + {{-- 日志列表 --}} +
+ + + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + + @empty + + + + @endforelse + +
版本号标题类型状态发布时间操作
v{{ $log->version }}{{ $log->title }} + @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 + + {{ $typeConfig['label'] ?? '其他' }} + + + @if ($log->is_published) + ✅ + 已发布 + @else + 🔒 + 草稿 + @endif + + {{ $log->published_at?->format('Y-m-d H:i') ?? '—' }} + +
+ + 编辑 + +
+ @csrf @method('DELETE') + +
+
+
+

📭

+

还没有任何日志,点击右上角「新增日志」开始吧

+
+ + @if ($logs->hasPages()) +
+ {{ $logs->links() }} +
+ @endif +
+@endsection diff --git a/resources/views/admin/departments/index.blade.php b/resources/views/admin/departments/index.blade.php new file mode 100644 index 0000000..510fbb7 --- /dev/null +++ b/resources/views/admin/departments/index.blade.php @@ -0,0 +1,185 @@ +{{-- + 文件功能:后台部门管理页面 + 提供部门的新增、编辑、删除功能,展示每个部门的职务数量 + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', '部门管理') + +@section('content') +
+ + {{-- 头部 --}} +
+
+

部门管理

+

管理聊天室部门架构,设置位阶、颜色与描述

+
+
+ + 📋 职务管理 → + + @if (Auth::id() === 1) + + @endif +
+
+ + @if (session('success')) +
+ {{ session('success') }} +
+ @endif + @if (session('error')) +
+ {{ session('error') }} +
+ @endif + + {{-- 部门卡片列表 --}} +
+ @foreach ($departments as $dept) +
+
+
+
+ {{ $dept->name }} + 位阶 + {{ $dept->rank }} +
+
+
+ 职务数量 + {{ $dept->positions_count }} 个 +
+
+ 排序 + {{ $dept->sort_order }} +
+ @if ($dept->description) +

{{ $dept->description }}

+ @endif +
+
+ @php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp + @if (Auth::user()->user_level >= $superLvl) +
+ + @if (Auth::id() === 1) +
+ @csrf @method('DELETE') + +
+ @endif +
+ @endif +
+ @endforeach +
+ + @if ($departments->isEmpty()) +
+

暂无部门,点击右上角「新增部门」创建

+
+ @endif + + {{-- 新增/编辑弹窗 --}} + +
+@endsection diff --git a/resources/views/admin/feedback/index.blade.php b/resources/views/admin/feedback/index.blade.php new file mode 100644 index 0000000..6141656 --- /dev/null +++ b/resources/views/admin/feedback/index.blade.php @@ -0,0 +1,187 @@ +{{-- + 文件功能:后台用户反馈管理页面(仅 id=1 超级管理员可访问) + 列表展示所有用户提交的 Bug 报告和功能建议 + 支持按类型+状态筛选,可直接修改状态(Ajax)和填写官方回复 + + @extends admin.layouts.app +--}} +@extends('admin.layouts.app') + +@section('title', '用户反馈管理') + +@section('content') +
+
+

+ 💬 用户反馈管理 + @if ($pendingCount > 0) + + {{ $pendingCount }} 条待处理 + + @endif +

+

管理用户提交的 Bug 报告和功能建议,修改状态后前台实时更新

+
+
+ + {{-- 筛选栏 --}} +
+ + + @if ($currentType || $currentStatus) + + ✕ 清除筛选 + + @endif +
+ + {{-- 反馈列表 --}} +
+ @forelse($feedbacks as $feedback) +
+ {{-- 卡片头部 --}} +
+ {{-- 赞同数 --}} +
+
{{ $feedback->votes_count }}
+
赞同
+
+ {{-- 类型+状态+标题 --}} +
+
+ @php + $typeConfig = \App\Models\FeedbackItem::TYPE_CONFIG[$feedback->type] ?? null; + @endphp + + {{ $typeConfig['label'] ?? '' }} + + {{-- 状态下拉(Ajax 即时保存) --}} + + 💬 {{ $feedback->replies_count }} + 保存中... +
+

{{ $feedback->title }}

+

+ by {{ $feedback->username }} · {{ $feedback->created_at->diffForHumans() }} +

+
+ {{-- 展开按钮 --}} + +
+ + {{-- 展开详情 --}} +
+ {{-- 原始描述 --}} +
+

用户描述

+

{{ $feedback->content }}

+
+ + {{-- 所有补充评论 --}} + @if ($feedback->replies->count() > 0) +
+

用户补充 ({{ $feedback->replies->count() }} 条)

+ @foreach ($feedback->replies as $reply) +
+
+ {{ $reply->username }} + @if ($reply->is_admin) + 开发者 + @endif + {{ $reply->created_at->diffForHumans() }} +
+

{{ $reply->content }}

+
+ @endforeach +
+ @endif + + {{-- 官方回复+保存区 --}} +
+

🛡️ 官方回复(公开显示给所有用户)

+ +
+ +
+
+
+
+ @empty +
+

💬

+

暂无用户反馈

+

等待用户从前台提交问题和建议

+
+ @endforelse +
+ + {{-- 分页 --}} + @if ($feedbacks->hasPages()) +
+ {{ $feedbacks->links() }} +
+ @endif +@endsection diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 7d7c9c8..7205f34 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -18,6 +18,8 @@

飘落流星 控制台

diff --git a/resources/views/admin/positions/index.blade.php b/resources/views/admin/positions/index.blade.php new file mode 100644 index 0000000..4a1b4ea --- /dev/null +++ b/resources/views/admin/positions/index.blade.php @@ -0,0 +1,298 @@ +{{-- + 文件功能:后台职务管理页面 + 按部门分组展示所有职务,支持新增/编辑/删除职务 + 编辑时可通过多选框配置该职务可任命的目标职务列表(任命白名单) + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', '职务管理') + +@section('content') +
+ + {{-- 头部 --}} +
+
+

职务管理

+

管理各部门职务,配置等级、图标、人数上限和任命权限

+
+
+ + ← 部门管理 + + + 🎖️ 任命管理 → + + @if (Auth::id() === 1) + + @endif +
+
+ + @if (session('success')) +
+ {{ session('success') }}
+ @endif + @if (session('error')) +
+ {{ session('error') }}
+ @endif + + {{-- 按部门分组展示职务 --}} + @foreach ($departments as $dept) +
+
+
+

{{ $dept->name }}

+ 位阶 {{ $dept->rank }} +
+ +
+ + + + + + + + + + + + @php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp + @if (Auth::user()->user_level >= $superLvl) + + @endif + + + + @forelse ($dept->positions as $pos) + @php $appointableIds = $pos->appointablePositions->pluck('id')->toArray(); @endphp + + + + + + + + + + + + @empty + + + + @endforelse + +
图标职务名位阶等级人数上限当前在职奖励上限任命权操作
{{ $pos->icon }}{{ $pos->name }} + {{ $pos->rank }} + + Lv.{{ $pos->level }} + {{ $pos->max_persons ?? '不限' }} + + {{ $pos->active_user_positions_count }} 人 + + + {{ $pos->max_reward ? number_format($pos->max_reward) . '金币' : '不限' }} + + @if (count($appointableIds) > 0) + {{ count($appointableIds) }} + 个职务 + @else + + @endif + + @php $superLvl = (int) \App\Models\Sysparam::getValue('superlevel', '100'); @endphp + @if (Auth::user()->user_level >= $superLvl) + + @endif + @if (Auth::id() === 1) +
+ @csrf @method('DELETE') + +
+ @endif +
该部门暂无职务
+
+
+ @endforeach + + {{-- 新增/编辑弹窗 --}} + +
+@endsection diff --git a/resources/views/admin/rooms/index.blade.php b/resources/views/admin/rooms/index.blade.php index efff848..904eb49 100644 --- a/resources/views/admin/rooms/index.blade.php +++ b/resources/views/admin/rooms/index.blade.php @@ -97,7 +97,7 @@ @unless ($room->room_keep)
+ onsubmit="return confirm('确定要删除房间「{{ $room->room_name }}」吗?此操作不可撑销!')"> @csrf @method('DELETE') - - 写私信 - -
- - {{-- 送花/礼物互动区 --}} -
- - {{-- 初始状态:只显示一个主操作按钮 --}} - - - {{-- 展开状态:显示礼物面板 --}} -
-
-
- 🎁 - 选择礼物 -
- + {{-- 职务履历时间轴(有任职记录才显示,可折叠) --}} +
+ {{-- 可点击标题 --}} +
+ 🎖️ 职务履历 +
- - {{-- 礼物选择列表 --}} -
-