feat: 完成独立的邀请与达人榜系统架构

This commit is contained in:
2026-03-12 09:33:38 +08:00
parent af1d1c5ace
commit 0ab0483603
9 changed files with 300 additions and 4 deletions

View File

@@ -105,6 +105,16 @@ class AuthController extends Controller
return response()->json(['status' => 'error', 'message' => '该用户名已被系统禁止注册,请更换其他名称。'], 422);
}
// --- 提取邀请人 Cookie ---
$inviterIdCookie = $request->cookie('inviter_id');
$inviterId = null;
if ($inviterIdCookie && is_numeric($inviterIdCookie)) {
// 简单校验邀请人是否存在,防止脏数据
if (User::where('id', $inviterIdCookie)->exists()) {
$inviterId = (int) $inviterIdCookie;
}
}
$newUser = User::create([
'username' => $username,
'password' => Hash::make($password),
@@ -113,10 +123,16 @@ class AuthController extends Controller
'user_level' => 1, // 默认普通用户等级
'sex' => $sex,
'usersf' => '1.gif', // 默认头像
'inviter_id' => $inviterId, // 记录邀请人
]);
$this->performLogin($newUser, $ip);
// 如果是通过邀请注册的,响应成功后建议清除 Cookie防止污染后续注册
if ($inviterId) {
\Illuminate\Support\Facades\Cookie::queue(\Illuminate\Support\Facades\Cookie::forget('inviter_id'));
}
return response()->json(['status' => 'success', 'message' => '注册并登录成功!']);
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
class InviteController extends Controller
{
/**
* 处理邀请链接跳转
*
* @param int $inviter_id 邀请人ID
*/
public function handle(Request $request, int $inviter_id)
{
// 查找邀请人是否存在
$inviter = User::find($inviter_id);
if ($inviter) {
// 将邀请人ID记录到 Cookie 中有效期7天7 * 24 * 60 = 10080 分钟)
// 确保Cookie仅通过 HTTP 访问且作用于全站
Cookie::queue('inviter_id', $inviter->id, 10080);
}
// 重定向回聊天室首页进行注册/登录
return redirect()->route('home');
}
/**
* 独立展示邀请全站排行榜页面
*/
public function leaderboard()
{
// 邀请达人榜 (Top 50)
$topInviters = User::withCount('invitees')
->with(['activePosition.position.department'])
->having('invitees_count', '>', 0)
->orderByDesc('invitees_count')
->limit(50)
->get();
return view('invite.leaderboard', compact('topInviters'));
}
}

View File

@@ -66,6 +66,12 @@ class UserController extends Controller
$data['meili'] = $targetUser->meili ?? 0;
}
// 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接
if ($operator && $operator->id === $targetUser->id) {
$data['id'] = $targetUser->id;
$data['invitees_count'] = $targetUser->invitees()->count();
}
// 职务履历所有任职记录按任命时间倒序positions() 关系已含 with
$data['position_history'] = $targetUser->positions
->map(fn ($up) => [

View File

@@ -36,6 +36,7 @@ class User extends Authenticatable
'sex',
'sign',
'user_level',
'inviter_id',
'room_id',
'first_ip',
'previous_ip',
@@ -183,4 +184,20 @@ class User extends Authenticatable
{
return $this->activePosition()->exists();
}
/**
* 关联:邀请当前用户的人
*/
public function inviter(): BelongsTo
{
return $this->belongsTo(self::class, 'inviter_id');
}
/**
* 关联:当前用户邀请的所有人
*/
public function invitees(): HasMany
{
return $this->hasMany(self::class, 'inviter_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('inviter_id')->nullable()->after('id')->comment('邀请人ID');
$table->foreign('inviter_id')->references('id')->on('users')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['inviter_id']);
$table->dropColumn('inviter_id');
});
}
};

View File

@@ -648,6 +648,30 @@
</div>
</div>
{{-- 邀请贡献卡片 (仅查看自己资料时显示) --}}
<div x-show="userInfo.username === window.chatContext.username && userInfo.id !== undefined"
style="margin-top: 12px; border-radius: 8px; overflow: hidden; border: 1px dashed #c7d2fe;">
<div
style="background: #e0e7ff; padding: 6px 10px; font-size: 11px; font-weight: bold; color: #4338ca; display: flex; align-items: center; justify-content: space-between;">
<span>🤝 我的邀请推广</span>
<span style="font-size: 10px;">累计邀请:<span x-text="userInfo.invitees_count"
style="color: #ef4444; font-size: 12px;"></span> </span>
</div>
<div
style="padding: 8px 10px; background: #f8fafc; font-size: 11px; display: flex; flex-direction: column; gap: 6px;">
<span style="color: #64748b;">复制下方专属链接,邀请好友注册聊天室:</span>
<div style="display: flex; gap: 6px; align-items: center;">
<input type="text" readonly :value="window.location.origin + '/' + userInfo.id"
style="flex: 1; border: 1px solid #cbd5e1; border-radius: 4px; padding: 4px 6px; font-size: 11px; background: #fff; color: #334155;">
<button
x-on:click="navigator.clipboard.writeText(window.location.origin + '/' + userInfo.id); $alert('邀请链接已复制到剪贴板', '复制成功', '#22c55e');"
style="background: #4f46e5; color: white; border: none; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; font-weight: bold;">
复制
</button>
</div>
</div>
</div>
{{-- 管理员可见区域 (IP 归属地) --}}
<div x-show="userInfo.last_ip !== undefined"
style="margin-top: 8px; border-radius: 8px; overflow: hidden;">

View File

@@ -0,0 +1,139 @@
{{--
文件功能:邀请排行达人榜
展示全站前 50 名邀请人数最多的用户
--}}
@extends('layouts.app')
@section('title', '新星邀请达人榜 - 飘落流星')
@section('nav-icon', '🏆')
@section('nav-title', '邀请风云排行榜')
@section('content')
<div class="max-w-7xl mx-auto py-10 px-4 sm:px-6 lg:px-8">
{{-- 顶部说明区域 --}}
<div
class="mb-8 bg-gradient-to-r from-rose-50 to-indigo-50 rounded-2xl p-6 md:p-10 shadow-sm border border-rose-100 flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<h2
class="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-rose-600 to-indigo-600 mb-2">
新星崛起·邀请达人榜
</h2>
<p class="text-rose-900/80 text-sm md:text-base max-w-2xl">
欢迎来到漂落流星星光榜单!在房间里打开您的个人名片,复制专属推广链接去邀请新朋友吧!
每成功邀请一位并完成注册,即可积累一次战绩,赶快向着全站 Top 50 进发!
</p>
</div>
<div class="hidden md:flex shrink-0">
<span class="text-7xl">🌟</span>
</div>
</div>
{{-- 榜单主体区域 --}}
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[400px]">
@if ($topInviters->isNotEmpty())
{{-- 表头 --}}
<div
class="grid grid-cols-12 gap-4 bg-gray-50/80 px-6 py-4 border-b border-gray-100 text-xs font-bold text-gray-500 uppercase tracking-wider sticky top-0 z-10">
<div class="col-span-2 text-center">排行</div>
<div class="col-span-1 text-center">头像</div>
<div class="col-span-5 md:col-span-4 pl-4">用户昵称</div>
<div class="col-span-4 md:col-span-3 text-left">在职职务</div>
<div class="col-span-12 md:col-span-2 text-right font-black text-rose-500 mt-2 md:mt-0 max-md:hidden">
邀请记录</div>
</div>
{{-- 列表项 --}}
<div class="divide-y divide-gray-50">
@foreach ($topInviters as $index => $inviter)
<div
class="grid grid-cols-12 gap-4 items-center px-6 py-4 hover:bg-rose-50/30 transition duration-150 relative group">
{{-- 移动端显示的收徒数 (紧凑模式) --}}
<div class="absolute right-4 top-1/2 -translate-y-1/2 md:hidden">
<span class="font-bold text-rose-500 text-lg">{{ $inviter->invitees_count }}</span>
<span class="text-[10px] text-rose-300"></span>
</div>
{{-- 1. 排名徽标 --}}
<div class="col-span-2 flex justify-center">
@if ($index === 0)
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-gradient-to-br from-yellow-300 to-yellow-500 text-white font-black text-lg shadow-md shadow-yellow-200 ring-2 ring-yellow-100">
1</div>
@elseif ($index === 1)
<div
class="w-9 h-9 flex items-center justify-center rounded-full bg-gradient-to-br from-gray-300 to-gray-400 text-white font-black text-base shadow-sm ring-2 ring-gray-100">
2</div>
@elseif ($index === 2)
<div
class="w-8 h-8 flex items-center justify-center rounded-full bg-gradient-to-br from-amber-600 to-amber-700 text-white font-bold text-sm shadow-sm ring-2 ring-amber-50">
3</div>
@elseif ($index < 10)
<div
class="w-7 h-7 flex items-center justify-center rounded-full bg-indigo-50 text-indigo-600 font-bold text-sm">
{{ $index + 1 }}</div>
@else
<div class="w-7 h-7 flex items-center justify-center text-gray-400 font-medium text-sm">
{{ $index + 1 }}</div>
@endif
</div>
{{-- 2. 头像 --}}
<div class="col-span-1 flex justify-center">
<div
class="w-10 h-10 rounded-md overflow-hidden bg-white border border-gray-200 shadow-sm shrink-0">
<img src="/images/headface/{{ strtolower($inviter->headface ?: '1.gif') }}"
onerror="this.style.display='none'" class="w-full h-full object-cover">
</div>
</div>
{{-- 3. 用户名 --}}
<div class="col-span-5 md:col-span-4 pl-4 truncate">
<span class="font-bold text-gray-800 {{ $index < 3 ? 'text-lg' : 'text-base' }}"
title="{{ $inviter->username }}">
{{ $inviter->username }}
</span>
</div>
{{-- 4. 职务徽章 --}}
<div class="col-span-4 md:col-span-3 flex items-center text-left">
@if ($inviter->activePosition)
<div class="inline-flex items-center gap-1.5 px-3 py-1 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-full text-xs text-purple-700 border border-purple-100 shadow-sm max-w-full truncate"
title="{{ $inviter->activePosition->position->name }}">
<span>{{ $inviter->activePosition->position->icon }}</span>
<span
class="font-medium truncate">{{ $inviter->activePosition->position->name }}</span>
</div>
@else
<span class="text-xs text-gray-300"></span>
@endif
</div>
{{-- 5. 收徒数 (PC ) --}}
<div class="col-span-12 md:col-span-2 text-right hidden md:block">
<div class="inline-flex items-baseline gap-1">
<span
class="text-2xl font-black text-rose-500 tracking-tight">{{ $inviter->invitees_count }}</span>
<span class="text-xs font-semibold text-rose-300"></span>
</div>
</div>
</div>
@endforeach
</div>
@else
{{-- 空白提示区 --}}
<div class="absolute inset-0 flex flex-col items-center justify-center">
<div class="text-6xl mb-4 grayscale opacity-50">🚀</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">全站排行榜暂无数据</h3>
<p class="text-gray-500 text-sm max-w-md text-center bg-gray-50 p-4 rounded-lg">
目前还没有达人拉取到足够的新用户入驻。成为第一个霸榜的人吧!
<br><br>
<span class="font-semibold text-rose-500">提示:</span> 在聊天室左上角或右侧的在线列表中点击您自己的名字,即可获取并复制专属推广链接。
</p>
</div>
@endif
</div>
</div>
@endsection

View File

@@ -73,6 +73,10 @@
class="text-indigo-200 hover:text-white font-bold flex items-center transition hidden sm:flex">
大厅
</a>
<a href="{{ route('invite.leaderboard') }}"
class="text-rose-400 hover:text-rose-300 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('invite.leaderboard') ? 'text-rose-200 underline underline-offset-4' : '' }}">
邀请排行
</a>
<a href="{{ route('leaderboard.index') }}"
class="text-yellow-400 hover:text-yellow-300 font-bold flex items-center transition hidden sm:flex">
风云榜

View File

@@ -40,12 +40,18 @@ Route::middleware(['chat.auth'])->group(function () {
Route::delete('/rooms/{id}', [RoomController::class, 'destroy'])->name('rooms.destroy');
Route::post('/rooms/{id}/transfer', [RoomController::class, 'transfer'])->name('rooms.transfer');
// ---- 第九阶段:外围矩阵 - 风云排行榜 ----
// ═══════════════════════════════════════════════════════════════════
// 排行榜系统 (风云榜、土豪榜等)
// ═══════════════════════════════════════════════════════════════════
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
// 今日风云榜(独立页,经验/金币/魅力今日排行)
Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'todayIndex'])->name('leaderboard.today');
Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'today'])->name('leaderboard.today');
// 用户个人积分流水日志(查询自己的经验/金币/魅力历史)
// ═══════════════════════════════════════════════════════════════════
// 邀请排行达人榜
// ═══════════════════════════════════════════════════════════════════
Route::get('/invites/leaderboard', [\App\Http\Controllers\InviteController::class, 'leaderboard'])->name('invite.leaderboard');
// ═══════════════════════════════════════════════════════════════════用户个人积分流水日志(查询自己的经验/金币/魅力历史)
Route::get('/my/currency-logs', [\App\Http\Controllers\LeaderboardController::class, 'myLogs'])->name('currency.my-logs');
// ---- 勤务台(展示四榜)----
@@ -529,3 +535,11 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
Route::delete('/forbidden-usernames/{id}', [\App\Http\Controllers\Admin\ForbiddenUsernameController::class, 'destroy'])->name('forbidden-usernames.destroy');
});
});
// ═══════════════════════════════════════════════════════════════════
// 邀请链接路由 (严格纯数字)
// 必须放在最后以避免与其他如 /admin 路由冲突
// ═══════════════════════════════════════════════════════════════════
Route::get('/{inviter_id}', [\App\Http\Controllers\InviteController::class, 'handle'])
->where('inviter_id', '[0-9]+')
->name('invite.link');