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

- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
This commit is contained in:
2026-02-28 23:44:38 +08:00
parent a599047cf0
commit 5f30220609
80 changed files with 8579 additions and 473 deletions
+1 -1
View File
@@ -52,6 +52,6 @@ class Autoact extends Model
*/
public function renderText(string $username): string
{
return str_replace('{username}', $username, $this->text_body);
return str_replace('{username}', "{$username}", $this->text_body);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:部门模型
* 对应 departments 表,管理聊天室部门(办公厅 / 迎宾部 / 聊务部 / 宣传部等)
* 一个部门下有多个职务(positions)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Department extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'name',
'rank',
'color',
'sort_order',
'description',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'rank' => 'integer',
'sort_order' => 'integer',
];
}
/**
* 获取该部门下的所有职务(按 rank 降序)
*/
public function positions(): HasMany
{
return $this->hasMany(Position::class)->orderByDesc('rank');
}
/**
* 获取部门当前所有在职用户(通过职务关联)
*/
public function activeMembers(): Collection
{
return UserPosition::query()
->whereHas('position', fn ($q) => $q->where('department_id', $this->id))
->where('is_active', true)
->with(['user', 'position'])
->get();
}
/**
* 按位阶倒序排列的查询范围
*/
public function scopeOrdered($query): void
{
$query->orderBy('sort_order')->orderByDesc('rank');
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:开发日志 Model
* 对应 dev_changelogs 表,管理版本更新记录
* 支持草稿/已发布状态,Markdown 内容渲染
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DevChangelog extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'version',
'title',
'type',
'content',
'is_published',
'notify_chat',
'published_at',
];
/**
* 字段类型自动转换
*/
protected $casts = [
'is_published' => 'boolean',
'notify_chat' => 'boolean',
'published_at' => 'datetime',
];
/**
* 类型标签配置(中文名 + Tailwind 颜色类)
*/
public const TYPE_CONFIG = [
'feature' => ['label' => '🆕 新功能', 'color' => 'emerald'],
'fix' => ['label' => '🐛 修复', 'color' => 'rose'],
'improve' => ['label' => '⚡ 优化', 'color' => 'blue'],
'other' => ['label' => '📌 其他', 'color' => 'slate'],
];
// ═══════════════ 查询作用域 ═══════════════
/**
* 只查询已发布的日志
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true)->orderByDesc('published_at');
}
/**
* 懒加载:查询比指定 ID 更旧的已发布日志(游标分页)
*
* @param int $afterId 已加载的最后一条 ID
*/
public function scopeAfter(Builder $query, int $afterId): Builder
{
return $query->where('id', '<', $afterId);
}
// ═══════════════ 访问器 ═══════════════
/**
* 获取类型对应的中文标签
*/
public function getTypeLabelAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['label'] ?? '📌 其他';
}
/**
* 获取类型对应的 Tailwind 颜色名
*/
public function getTypeColorAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['color'] ?? 'slate';
}
/**
* Markdown 内容渲染为 HTML(使用 Laravel 内置 Str::markdown
*/
public function getContentHtmlAttribute(): string
{
return Str::markdown($this->content, [
'html_input' => 'strip', // 去掉原始 HTML,防止 XSS
'allow_unsafe_links' => false,
]);
}
/**
* 获取内容纯文本摘要(用于列表预览,截取前 150 字)
*/
public function getSummaryAttribute(): string
{
// 去掉 Markdown 标记后截取纯文本
$plain = strip_tags(Str::markdown($this->content));
return Str::limit($plain, 150);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* 文件功能:用户反馈主表 Model
* 对应 feedback_items 表,管理用户提交的 Bug报告和功能建议
* 包含7种处理状态、赞同数/评论数冗余统计
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FeedbackItem extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'user_id',
'username',
'type',
'title',
'content',
'status',
'admin_remark',
'votes_count',
'replies_count',
];
/**
* 处理状态配置(中文名 + 图标 + Tailwind 颜色)
*/
public const STATUS_CONFIG = [
'pending' => ['label' => '待处理', 'icon' => '⏳', 'color' => 'gray'],
'accepted' => ['label' => '已接受', 'icon' => '✅', 'color' => 'green'],
'in_progress' => ['label' => '开发中', 'icon' => '🔧', 'color' => 'blue'],
'fixed' => ['label' => '已修复', 'icon' => '🐛', 'color' => 'emerald'],
'done' => ['label' => '已完成', 'icon' => '🚀', 'color' => 'emerald'],
'rejected' => ['label' => '暂不同意', 'icon' => '❌', 'color' => 'red'],
'shelved' => ['label' => '已搁置', 'icon' => '📦', 'color' => 'orange'],
];
/**
* 类型配置
*/
public const TYPE_CONFIG = [
'bug' => ['label' => '🐛 Bug报告', 'color' => 'rose'],
'suggestion' => ['label' => '💡 功能建议', 'color' => 'blue'],
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联赞同记录
*/
public function votes(): HasMany
{
return $this->hasMany(FeedbackVote::class, 'feedback_id');
}
/**
* 关联补充评论
*/
public function replies(): HasMany
{
return $this->hasMany(FeedbackReply::class, 'feedback_id')->orderBy('created_at');
}
// ═══════════════ 查询作用域 ═══════════════
/**
* 按类型筛选
*
* @param string $type bug|suggestion
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
/**
* 按状态筛选
*
* @param string $status 处理状态
*/
public function scopeOfStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
/**
* 待处理的反馈(用于后台徽标计数)
*/
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
// ═══════════════ 访问器 ═══════════════
/**
* 获取状态对应的配置(标签/图标/颜色)
*/
public function getStatusConfigAttribute(): array
{
return self::STATUS_CONFIG[$this->status] ?? self::STATUS_CONFIG['pending'];
}
/**
* 获取状态中文标签
*/
public function getStatusLabelAttribute(): string
{
return $this->status_config['icon'].' '.$this->status_config['label'];
}
/**
* 获取类型中文标签
*/
public function getTypeLabelAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['label'] ?? '📌 其他';
}
/**
* 判断反馈是否在24小时内(用于普通用户自删权限)
*/
public function getIsWithin24HoursAttribute(): bool
{
return $this->created_at->diffInHours(now()) < 24;
}
/**
* 判断当前状态是否为已处理(已修复/已完成/暂不同意/已搁置)
*/
public function getIsClosedAttribute(): bool
{
return in_array($this->status, ['fixed', 'done', 'rejected', 'shelved']);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:用户反馈补充评论 Model
* 对应 feedback_replies 表,记录用户对反馈的补充说明和管理员官方回复
* is_admin=1 的回复在前台以特殊「开发者回复」样式高亮展示
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeedbackReply extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'feedback_id',
'user_id',
'username',
'content',
'is_admin',
];
/**
* 字段类型转换
*/
protected $casts = [
'is_admin' => 'boolean',
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联所属反馈
*/
public function feedback(): BelongsTo
{
return $this->belongsTo(FeedbackItem::class, 'feedback_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* 文件功能:用户反馈赞同记录 Model
* 对应 feedback_votes 表,记录用户对反馈的赞同行为
* 每个用户每条反馈只能赞同一次(数据库层唯一索引保障)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeedbackVote extends Model
{
/**
* 关闭 updated_at(赞同记录只有创建,无需更新时间)
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'feedback_id',
'user_id',
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联所属反馈
*/
public function feedback(): BelongsTo
{
return $this->belongsTo(FeedbackItem::class, 'feedback_id');
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* 文件功能:职务模型
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Position extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'department_id',
'name',
'icon',
'rank',
'level',
'max_persons',
'max_reward',
'sort_order',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'rank' => 'integer',
'level' => 'integer',
'max_persons' => 'integer',
'max_reward' => 'integer',
'sort_order' => 'integer',
];
}
/**
* 所属部门
*/
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
/**
* 该职务当前在职的用户记录(user_positions
*/
public function activeUserPositions(): HasMany
{
return $this->hasMany(UserPosition::class)->where('is_active', true);
}
/**
* 该职务的所有历史任职记录
*/
public function userPositions(): HasMany
{
return $this->hasMany(UserPosition::class);
}
/**
* 该职务可以任命的目标职务列表(position_appoint_limits 中间表)
*/
public function appointablePositions(): BelongsToMany
{
return $this->belongsToMany(
Position::class,
'position_appoint_limits',
'appointer_position_id',
'appointable_position_id'
);
}
/**
* 哪些职务的持有者可以将用户任命到本职务
*/
public function appointedByPositions(): BelongsToMany
{
return $this->belongsToMany(
Position::class,
'position_appoint_limits',
'appointable_position_id',
'appointer_position_id'
);
}
/**
* 获取当前在职人数
*/
public function currentCount(): int
{
return $this->activeUserPositions()->count();
}
/**
* 是否已满员
*/
public function isFull(): bool
{
if ($this->max_persons === null) {
return false;
}
return $this->currentCount() >= $this->max_persons;
}
/**
* 查询范围:按位阶降序
*/
public function scopeOrdered($query): void
{
$query->orderBy('sort_order')->orderByDesc('rank');
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* 文件功能:职务权限使用记录模型
* 对应 position_authority_logs 表,记录职务持有者每次行使职权的操作
* 包含任命、撤销、奖励金币、警告、踢出、禁言、封IP等操作类型
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PositionAuthorityLog extends Model
{
/**
* 禁用 updated_at(只有 created_at
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'user_position_id',
'action_type',
'target_user_id',
'target_position_id',
'amount',
'remark',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'amount' => 'integer',
'created_at' => 'datetime',
];
}
/**
* 操作类型中文标签
*/
public static array $actionLabels = [
'appoint' => '任命',
'revoke' => '撤销职务',
'reward' => '奖励金币',
'warn' => '警告',
'kick' => '踢出',
'mute' => '禁言',
'banip' => '封锁IP',
'other' => '其他',
];
/**
* 操作人
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 操作时使用的在职记录
*/
public function userPosition(): BelongsTo
{
return $this->belongsTo(UserPosition::class);
}
/**
* 操作对象用户
*/
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
/**
* 任命/撤销时的目标职务
*/
public function targetPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'target_position_id');
}
/**
* 获取操作类型的中文标签
*/
public function getActionLabelAttribute(): string
{
return self::$actionLabels[$this->action_type] ?? $this->action_type;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:在职登录记录模型
* 对应 position_duty_logs 表,记录职务持有者每次进房的登录时间、在线时长和退出时间
* 用于勤务台四榜统计和个人履历出勤数据展示
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PositionDutyLog extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'user_position_id',
'login_at',
'logout_at',
'duration_seconds',
'ip_address',
'room_id',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'login_at' => 'datetime',
'logout_at' => 'datetime',
'duration_seconds' => 'integer',
];
}
/**
* 对应的用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 对应的在职记录
*/
public function userPosition(): BelongsTo
{
return $this->belongsTo(UserPosition::class);
}
/**
* 格式化在线时长为"Xh Ym"字符串(如 128h 30m
*/
public function getFormattedDurationAttribute(): string
{
$seconds = $this->duration_seconds ?? 0;
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
return "{$hours}h {$minutes}m";
}
}
+36
View File
@@ -15,6 +15,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -144,4 +146,38 @@ class User extends Authenticatable
return $this->vipLevel?->icon ?? '';
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
* 全部猎务履历(包括历史记录)
*/
public function positions(): HasMany
{
return $this->hasMany(UserPosition::class)->with(['position.department'])->orderByDesc('appointed_at');
}
/**
* 当前在职职务记录(HasOne,最多一条)
*/
public function activePosition(): HasOne
{
return $this->hasOne(UserPosition::class)->where('is_active', true)->with(['position.department']);
}
/**
* 该用户在职期间的权限操作日志
*/
public function authorityLogs(): HasMany
{
return $this->hasMany(PositionAuthorityLog::class)->orderByDesc('created_at');
}
/**
* 判断用户是否有当前在职职务
*/
public function hasActivePosition(): bool
{
return $this->activePosition()->exists();
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/**
* 文件功能:用户职务关联模型(职务履历核心表)
* 对应 user_positions 表,记录用户当前在职职务及全部历史任职记录
* is_active=true 表示当前在职,false 为历史存档(永久保留)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class UserPosition extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'position_id',
'appointed_by_user_id',
'appointed_at',
'remark',
'revoked_at',
'revoked_by_user_id',
'is_active',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'appointed_at' => 'datetime',
'revoked_at' => 'datetime',
'is_active' => 'boolean',
];
}
/**
* 在职用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 所任职务
*/
public function position(): BelongsTo
{
return $this->belongsTo(Position::class);
}
/**
* 任命人
*/
public function appointedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'appointed_by_user_id');
}
/**
* 撤销人
*/
public function revokedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'revoked_by_user_id');
}
/**
* 该任职期间的登录记录
*/
public function dutyLogs(): HasMany
{
return $this->hasMany(PositionDutyLog::class);
}
/**
* 该任职期间的权限使用记录
*/
public function authorityLogs(): HasMany
{
return $this->hasMany(PositionAuthorityLog::class);
}
/**
* 查询范围:仅当前在职
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 获取任职时长(天数);在职则计算至今
*/
public function getDurationDaysAttribute(): int
{
$end = $this->revoked_at ?? now();
return (int) $this->appointed_at->diffInDays($end);
}
/**
* 任职期间累计在线时长(秒)
*/
public function getTotalOnlineSecondsAttribute(): int
{
return (int) $this->dutyLogs()->sum('duration_seconds');
}
/**
* 任职期间累计发放金币总量
*/
public function getTotalRewardedCoinsAttribute(): int
{
return (int) $this->authorityLogs()
->where('action_type', 'reward')
->sum('amount');
}
}