From fd3214eaff2cd185909eb793c90eb3159db9405d Mon Sep 17 00:00:00 2001 From: lkddi Date: Thu, 26 Feb 2026 21:30:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9AVIP=20=E8=B5=9E?= =?UTF-8?q?=E5=8A=A9=E4=BC=9A=E5=91=98=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 vip_levels 表(名称、图标、颜色、经验/金币倍率、专属进入/离开模板) - 默认4个等级种子:白银🥈(×1.5)、黄金🥇(×2.0)、钻石💎(×3.0)、至尊👑(×5.0) - 后台 VIP 等级 CRUD 管理(新增/编辑/删除,配置模板和倍率) - 后台用户编辑弹窗支持设置 VIP 等级和到期时间 - ChatController 心跳经验按 VIP 倍率加成 - FishingController 正向奖励按 VIP 倍率加成(负面惩罚不变) - 在线名单显示 VIP 图标和管理员🛡️标识 - VIP 用户进入/离开使用专属颜色和标题 - 后台侧栏新增「👑 VIP 会员等级」入口 --- .../Admin/AiProviderController.php | 218 +++++++++++ .../Admin/UserManagerController.php | 15 +- app/Http/Controllers/Admin/VipController.php | 123 ++++++ app/Http/Controllers/ChatBotController.php | 96 +++++ app/Http/Controllers/ChatController.php | 29 +- app/Http/Controllers/FishingController.php | 12 +- app/Models/AiProviderConfig.php | 126 +++++++ app/Models/AiUsageLog.php | 71 ++++ app/Models/User.php | 52 +++ app/Models/VipLevel.php | 98 +++++ app/Services/AiChatService.php | 273 ++++++++++++++ app/Services/VipService.php | 108 ++++++ ...6_02_26_132014_create_vip_levels_table.php | 129 +++++++ ...132015_add_vip_level_id_to_users_table.php | 38 ++ .../2026_02_26_132600_create_ai_tables.php | 70 ++++ .../views/admin/ai-providers/index.blade.php | 357 ++++++++++++++++++ resources/views/admin/layouts/app.blade.php | 8 + resources/views/admin/users/index.blade.php | 28 ++ resources/views/admin/vip/index.blade.php | 244 ++++++++++++ resources/views/chat/frame.blade.php | 5 +- .../views/chat/partials/scripts.blade.php | 192 +++++++++- routes/web.php | 20 + 22 files changed, 2293 insertions(+), 19 deletions(-) create mode 100644 app/Http/Controllers/Admin/AiProviderController.php create mode 100644 app/Http/Controllers/Admin/VipController.php create mode 100644 app/Http/Controllers/ChatBotController.php create mode 100644 app/Models/AiProviderConfig.php create mode 100644 app/Models/AiUsageLog.php create mode 100644 app/Models/VipLevel.php create mode 100644 app/Services/AiChatService.php create mode 100644 app/Services/VipService.php create mode 100644 database/migrations/2026_02_26_132014_create_vip_levels_table.php create mode 100644 database/migrations/2026_02_26_132015_add_vip_level_id_to_users_table.php create mode 100644 database/migrations/2026_02_26_132600_create_ai_tables.php create mode 100644 resources/views/admin/ai-providers/index.blade.php create mode 100644 resources/views/admin/vip/index.blade.php diff --git a/app/Http/Controllers/Admin/AiProviderController.php b/app/Http/Controllers/Admin/AiProviderController.php new file mode 100644 index 0000000..82e16f3 --- /dev/null +++ b/app/Http/Controllers/Admin/AiProviderController.php @@ -0,0 +1,218 @@ +get(); + $chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1'; + + return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled')); + } + + /** + * 新增 AI 厂商配置 + * + * @param Request $request 请求对象 + * @return RedirectResponse 重定向到列表页 + */ + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'provider' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'api_key' => 'required|string', + 'api_endpoint' => 'required|url|max:255', + 'model' => 'required|string|max:100', + 'temperature' => 'nullable|numeric|min:0|max:2', + 'max_tokens' => 'nullable|integer|min:100|max:32000', + 'sort_order' => 'nullable|integer|min:0', + ]); + + // 加密 API Key + $data['api_key'] = Crypt::encryptString($data['api_key']); + $data['temperature'] = $data['temperature'] ?? 0.3; + $data['max_tokens'] = $data['max_tokens'] ?? 2048; + $data['sort_order'] = $data['sort_order'] ?? 0; + $data['is_enabled'] = true; + $data['is_default'] = false; + + AiProviderConfig::create($data); + + return redirect()->route('admin.ai-providers.index') + ->with('success', '已成功添加 AI 厂商配置!'); + } + + /** + * 更新 AI 厂商配置 + * + * @param Request $request 请求对象 + * @param int $id 厂商配置 ID + * @return RedirectResponse 重定向到列表页 + */ + public function update(Request $request, int $id): RedirectResponse + { + $provider = AiProviderConfig::findOrFail($id); + + $data = $request->validate([ + 'provider' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'api_key' => 'nullable|string', + 'api_endpoint' => 'required|url|max:255', + 'model' => 'required|string|max:100', + 'temperature' => 'nullable|numeric|min:0|max:2', + 'max_tokens' => 'nullable|integer|min:100|max:32000', + 'sort_order' => 'nullable|integer|min:0', + ]); + + // 只在用户提供了新 API Key 时才更新(空值表示不修改) + if (! empty($data['api_key'])) { + $data['api_key'] = Crypt::encryptString($data['api_key']); + } else { + unset($data['api_key']); + } + + $provider->update($data); + + return redirect()->route('admin.ai-providers.index') + ->with('success', "已更新 {$provider->name} 的配置!"); + } + + /** + * 切换 AI 厂商的启用/禁用状态 + * + * @param int $id 厂商配置 ID + * @return JsonResponse 操作结果 + */ + public function toggleEnabled(int $id): JsonResponse + { + $provider = AiProviderConfig::findOrFail($id); + $provider->is_enabled = ! $provider->is_enabled; + $provider->save(); + + $status = $provider->is_enabled ? '启用' : '禁用'; + + return response()->json([ + 'status' => 'success', + 'message' => "{$provider->name} 已{$status}", + 'is_enabled' => $provider->is_enabled, + ]); + } + + /** + * 设置指定厂商为默认使用 + * + * 互斥操作:将其他厂商的 is_default 全部置为 false。 + * + * @param int $id 厂商配置 ID + * @return JsonResponse 操作结果 + */ + public function setDefault(int $id): JsonResponse + { + $provider = AiProviderConfig::findOrFail($id); + + // 先将所有厂商的默认标记清除 + AiProviderConfig::where('is_default', true)->update(['is_default' => false]); + + // 设置当前厂商为默认 + $provider->is_default = true; + $provider->is_enabled = true; // 默认的必须是启用状态 + $provider->save(); + + return response()->json([ + 'status' => 'success', + 'message' => "{$provider->name} 已设为默认 AI 厂商", + ]); + } + + /** + * 切换聊天机器人全局开关 + * + * 通过 sysparam 的 chatbot_enabled 参数控制是否在聊天室中显示 AI 机器人。 + * + * @param Request $request 请求对象 + * @return JsonResponse 操作结果 + */ + public function toggleChatBot(Request $request): JsonResponse + { + $current = Sysparam::getValue('chatbot_enabled', '0'); + $newValue = $current === '1' ? '0' : '1'; + + // 更新 sysparam + Sysparam::updateOrCreate( + ['alias' => 'chatbot_enabled'], + [ + 'body' => $newValue, + 'guidetxt' => 'AI聊天机器人开关(1=开启,0=关闭)', + ] + ); + + // 刷新缓存 + $this->chatState->setSysParam('chatbot_enabled', $newValue); + Sysparam::clearCache('chatbot_enabled'); + + $status = $newValue === '1' ? '开启' : '关闭'; + + return response()->json([ + 'status' => 'success', + 'message' => "聊天机器人已{$status}", + 'enabled' => $newValue === '1', + ]); + } + + /** + * 删除 AI 厂商配置 + * + * @param int $id 厂商配置 ID + * @return RedirectResponse 重定向到列表页 + */ + public function destroy(int $id): RedirectResponse + { + $provider = AiProviderConfig::findOrFail($id); + $name = $provider->name; + $provider->delete(); + + return redirect()->route('admin.ai-providers.index') + ->with('success', "已删除 {$name}!"); + } +} diff --git a/app/Http/Controllers/Admin/UserManagerController.php b/app/Http/Controllers/Admin/UserManagerController.php index 4e247e9..cc6680c 100644 --- a/app/Http/Controllers/Admin/UserManagerController.php +++ b/app/Http/Controllers/Admin/UserManagerController.php @@ -36,7 +36,10 @@ class UserManagerController extends Controller // 分页获取用户 $users = $query->orderBy('id', 'desc')->paginate(20); - return view('admin.users.index', compact('users')); + // VIP 等级选项列表(供编辑弹窗使用) + $vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get(); + + return view('admin.users.index', compact('users', 'vipLevels')); } /** @@ -64,6 +67,8 @@ class UserManagerController extends Controller 'qianming' => 'sometimes|nullable|string|max:255', 'headface' => 'sometimes|string|max:50', 'password' => 'nullable|string|min:6', + 'vip_level_id' => 'sometimes|nullable|integer|exists:vip_levels,id', + 'hy_time' => 'sometimes|nullable|date', ]); // 如果传了且没超权,直接赋予 @@ -94,6 +99,14 @@ class UserManagerController extends Controller $targetUser->headface = $validated['headface']; } + // VIP 会员等级设置 + if (array_key_exists('vip_level_id', $validated)) { + $targetUser->vip_level_id = $validated['vip_level_id'] ?: null; + } + if (array_key_exists('hy_time', $validated)) { + $targetUser->hy_time = $validated['hy_time'] ?: null; + } + if (! empty($validated['password'])) { $targetUser->password = Hash::make($validated['password']); } diff --git a/app/Http/Controllers/Admin/VipController.php b/app/Http/Controllers/Admin/VipController.php new file mode 100644 index 0000000..de6cb30 --- /dev/null +++ b/app/Http/Controllers/Admin/VipController.php @@ -0,0 +1,123 @@ +get(); + + return view('admin.vip.index', compact('levels')); + } + + /** + * 新增会员等级 + */ + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:50', + 'icon' => 'required|string|max:20', + 'color' => 'required|string|max:10', + 'exp_multiplier' => 'required|numeric|min:1|max:99', + 'jjb_multiplier' => 'required|numeric|min:1|max:99', + 'sort_order' => 'required|integer|min:0', + 'price' => 'required|integer|min:0', + 'duration_days' => 'required|integer|min:0', + 'join_templates' => 'nullable|string', + 'leave_templates' => 'nullable|string', + ]); + + // 将文本框的多行模板转为 JSON 数组 + $data['join_templates'] = $this->textToJson($data['join_templates'] ?? ''); + $data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? ''); + + VipLevel::create($data); + + return redirect()->route('admin.vip.index')->with('success', '会员等级创建成功!'); + } + + /** + * 更新会员等级 + * + * @param int $id 等级ID + */ + public function update(Request $request, int $id): RedirectResponse + { + $level = VipLevel::findOrFail($id); + + $data = $request->validate([ + 'name' => 'required|string|max:50', + 'icon' => 'required|string|max:20', + 'color' => 'required|string|max:10', + 'exp_multiplier' => 'required|numeric|min:1|max:99', + 'jjb_multiplier' => 'required|numeric|min:1|max:99', + 'sort_order' => 'required|integer|min:0', + 'price' => 'required|integer|min:0', + 'duration_days' => 'required|integer|min:0', + 'join_templates' => 'nullable|string', + 'leave_templates' => 'nullable|string', + ]); + + $data['join_templates'] = $this->textToJson($data['join_templates'] ?? ''); + $data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? ''); + + $level->update($data); + + return redirect()->route('admin.vip.index')->with('success', '会员等级更新成功!'); + } + + /** + * 删除会员等级(关联用户的 vip_level_id 会自动置 null) + * + * @param int $id 等级ID + */ + public function destroy(int $id): RedirectResponse + { + $level = VipLevel::findOrFail($id); + $level->delete(); + + return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!'); + } + + /** + * 将多行文本转为 JSON 数组字符串 + * 每行一个模板,空行忽略 + * + * @param string $text 多行文本 + * @return string|null JSON 字符串 + */ + private function textToJson(string $text): ?string + { + $lines = array_filter( + array_map('trim', explode("\n", $text)), + fn ($line) => $line !== '' + ); + + if (empty($lines)) { + return null; + } + + return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE); + } +} diff --git a/app/Http/Controllers/ChatBotController.php b/app/Http/Controllers/ChatBotController.php new file mode 100644 index 0000000..7e66fcd --- /dev/null +++ b/app/Http/Controllers/ChatBotController.php @@ -0,0 +1,96 @@ +validate([ + 'message' => 'required|string|max:2000', + 'room_id' => 'required|integer', + ]); + + // 检查全局开关 + $enabled = Sysparam::getValue('chatbot_enabled', '0'); + if ($enabled !== '1') { + return response()->json([ + 'status' => 'error', + 'message' => 'AI 机器人功能已关闭,请联系管理员开启。', + ], 403); + } + + $user = Auth::user(); + $message = $request->input('message'); + $roomId = $request->input('room_id'); + + try { + $result = $this->aiChat->chat($user->id, $message, $roomId); + + return response()->json([ + 'status' => 'success', + 'reply' => $result['reply'], + 'provider' => $result['provider'], + 'model' => $result['model'], + ]); + } catch (\Exception $e) { + return response()->json([ + 'status' => 'error', + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * 清除当前用户的 AI 对话上下文 + * + * 用于用户想要重新开始对话时使用。 + * + * @param Request $request 请求对象 + * @return JsonResponse 操作结果 + */ + public function clearContext(Request $request): JsonResponse + { + $user = Auth::user(); + $this->aiChat->clearContext($user->id); + + return response()->json([ + 'status' => 'success', + 'message' => '对话上下文已清除,可以开始新的对话了。', + ]); + } +} diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index cdacda0..16c06a9 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -21,6 +21,7 @@ use App\Models\Room; use App\Models\Sysparam; use App\Services\ChatStateService; use App\Services\MessageFilterService; +use App\Services\VipService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -32,6 +33,7 @@ class ChatController extends Controller public function __construct( private readonly ChatStateService $chatState, private readonly MessageFilterService $filter, + private readonly VipService $vipService, ) {} /** @@ -48,19 +50,21 @@ class ChatController extends Controller // 房间人气 +1(每次访问递增,复刻原版人气计数) $room->increment('visit_num'); - // 1. 将当前用户加入到 Redis 房间在线列表 - $this->chatState->userJoin($id, $user->username, [ + // 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息) + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + $userData = [ 'level' => $user->user_level, 'sex' => $user->sex, 'headface' => $user->headface, - ]); + 'vip_icon' => $user->vipIcon(), + 'vip_name' => $user->vipName(), + 'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '', + 'is_admin' => $user->user_level >= $superLevel, + ]; + $this->chatState->userJoin($id, $user->username, $userData); // 2. 广播 UserJoined 事件,通知房间内的其他人 - broadcast(new UserJoined($id, $user->username, [ - 'level' => $user->user_level, - 'sex' => $user->sex, - 'headface' => $user->headface, - ]))->toOthers(); + broadcast(new UserJoined($id, $user->username, $userData))->toOthers(); // 3. 获取历史消息用于初次渲染 // TODO: 可在前端通过请求另外的接口拉取历史记录,或者直接在这里 attach @@ -145,9 +149,10 @@ class ChatController extends Controller return response()->json(['status' => 'error'], 401); } - // 1. 每次心跳增加经验(可在 sysparam 后台配置) + // 1. 每次心跳增加经验(可在 sysparam 后台配置),VIP 倍率加成 $expGain = (int) Sysparam::getValue('exp_per_heartbeat', '1'); - $user->exp_num += $expGain; + $expMultiplier = $this->vipService->getExpMultiplier($user); + $user->exp_num += (int) round($expGain * $expMultiplier); // 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级 // 管理员(superlevel 及以上)不参与自动升降级,等级由后台手动设置 @@ -171,6 +176,10 @@ class ChatController extends Controller 'level' => $user->user_level, 'sex' => $user->sex, 'headface' => $user->headface, + 'vip_icon' => $user->vipIcon(), + 'vip_name' => $user->vipName(), + 'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '', + 'is_admin' => $user->user_level >= $superLevel, ]); // 4. 如果突破境界,向全房系统喊话广播! diff --git a/app/Http/Controllers/FishingController.php b/app/Http/Controllers/FishingController.php index d605387..4313cd7 100644 --- a/app/Http/Controllers/FishingController.php +++ b/app/Http/Controllers/FishingController.php @@ -15,6 +15,7 @@ namespace App\Http\Controllers; use App\Events\MessageSent; use App\Models\Sysparam; use App\Services\ChatStateService; +use App\Services\VipService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -24,6 +25,7 @@ class FishingController extends Controller { public function __construct( private readonly ChatStateService $chatState, + private readonly VipService $vipService, ) {} /** @@ -111,12 +113,16 @@ class FishingController extends Controller // 3. 随机决定钓鱼结果 $result = $this->randomFishResult(); - // 4. 更新用户经验和金币(不低于 0) + // 4. 更新用户经验和金币(正向奖励按 VIP 倍率加成,负面惩罚不变) + $expMul = $this->vipService->getExpMultiplier($user); + $jjbMul = $this->vipService->getJjbMultiplier($user); if ($result['exp'] !== 0) { - $user->exp_num = max(0, ($user->exp_num ?? 0) + $result['exp']); + $finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp']; + $user->exp_num = max(0, ($user->exp_num ?? 0) + $finalExp); } if ($result['jjb'] !== 0) { - $user->jjb = max(0, ($user->jjb ?? 0) + $result['jjb']); + $finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb']; + $user->jjb = max(0, ($user->jjb ?? 0) + $finalJjb); } $user->save(); diff --git a/app/Models/AiProviderConfig.php b/app/Models/AiProviderConfig.php new file mode 100644 index 0000000..a1aca45 --- /dev/null +++ b/app/Models/AiProviderConfig.php @@ -0,0 +1,126 @@ + + */ + protected $fillable = [ + 'provider', + 'name', + 'api_key', + 'api_endpoint', + 'model', + 'temperature', + 'max_tokens', + 'is_enabled', + 'is_default', + 'sort_order', + ]; + + /** + * 属性类型转换 + * + * @return array + */ + protected function casts(): array + { + return [ + 'temperature' => 'float', + 'max_tokens' => 'integer', + 'is_enabled' => 'boolean', + 'is_default' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + /** + * 获取解密后的 API Key + * + * @return string|null 解密后的 API Key + */ + public function getDecryptedApiKey(): ?string + { + if (empty($this->api_key)) { + return null; + } + + try { + return Crypt::decryptString($this->api_key); + } catch (\Exception) { + // 如果解密失败(可能是明文存储的旧数据),直接返回原值 + return $this->api_key; + } + } + + /** + * 设置加密存储的 API Key + * + * @param string $value 原始 API Key + */ + public function setApiKeyEncrypted(string $value): void + { + $this->api_key = Crypt::encryptString($value); + } + + /** + * 获取当前默认的 AI 配置 + * + * 返回 is_default=1 且 is_enabled=1 的第一条配置。 + * 如果没有设置默认,则返回第一条已启用的配置。 + */ + public static function getDefault(): ?self + { + // 优先获取标记为默认且已启用的 + $default = static::where('is_default', true) + ->where('is_enabled', true) + ->first(); + + if ($default) { + return $default; + } + + // 退而求其次,取第一个已启用的 + return static::where('is_enabled', true) + ->orderBy('sort_order') + ->first(); + } + + /** + * 获取所有已启用的 AI 厂商配置(按 sort_order 排序) + * + * 用于故障转移时依次尝试备用厂商。 + */ + public static function getEnabledProviders(): \Illuminate\Database\Eloquent\Collection + { + return static::where('is_enabled', true) + ->orderBy('is_default', 'desc') // 默认的排最前面 + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/AiUsageLog.php b/app/Models/AiUsageLog.php new file mode 100644 index 0000000..1d7e3f2 --- /dev/null +++ b/app/Models/AiUsageLog.php @@ -0,0 +1,71 @@ + + */ + protected $fillable = [ + 'company_id', + 'user_id', + 'provider', + 'model', + 'action', + 'prompt_tokens', + 'completion_tokens', + 'total_tokens', + 'cost', + 'response_time_ms', + 'success', + 'error_message', + ]; + + /** + * 属性类型转换 + * + * @return array + */ + protected function casts(): array + { + return [ + 'prompt_tokens' => 'integer', + 'completion_tokens' => 'integer', + 'total_tokens' => 'integer', + 'cost' => 'float', + 'response_time_ms' => 'integer', + 'success' => 'boolean', + ]; + } + + /** + * 关联用户 + */ + public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 405530a..3a8871b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -36,6 +37,8 @@ class User extends Authenticatable 'first_ip', 'last_ip', 'usersf', + 'vip_level_id', + 'hy_time', ]; /** @@ -85,4 +88,53 @@ class User extends Authenticatable get: fn () => $this->usersf ?: '1.GIF', ); } + + /** + * 关联:用户所属的 VIP 会员等级 + */ + public function vipLevel(): BelongsTo + { + return $this->belongsTo(VipLevel::class, 'vip_level_id'); + } + + /** + * 判断用户是否为有效 VIP(有等级且未过期) + */ + public function isVip(): bool + { + if (! $this->vip_level_id) { + return false; + } + + // hy_time 为 null 表示永久会员 + if (! $this->hy_time) { + return true; + } + + return $this->hy_time->isFuture(); + } + + /** + * 获取 VIP 会员名称(无效则返回空字符串) + */ + public function vipName(): string + { + if (! $this->isVip()) { + return ''; + } + + return $this->vipLevel?->name ?? ''; + } + + /** + * 获取 VIP 会员图标(无效则返回空字符串) + */ + public function vipIcon(): string + { + if (! $this->isVip()) { + return ''; + } + + return $this->vipLevel?->icon ?? ''; + } } diff --git a/app/Models/VipLevel.php b/app/Models/VipLevel.php new file mode 100644 index 0000000..c9e9bf9 --- /dev/null +++ b/app/Models/VipLevel.php @@ -0,0 +1,98 @@ + 'float', + 'jjb_multiplier' => 'float', + 'sort_order' => 'integer', + 'price' => 'integer', + 'duration_days' => 'integer', + ]; + + /** + * 关联:该等级下的所有用户 + */ + public function users(): HasMany + { + return $this->hasMany(User::class, 'vip_level_id'); + } + + /** + * 获取进入聊天室的专属欢迎语模板数组 + */ + public function getJoinTemplatesArrayAttribute(): array + { + if (empty($this->join_templates)) { + return []; + } + + $decoded = json_decode($this->join_templates, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * 获取离开聊天室的专属提示语模板数组 + */ + public function getLeaveTemplatesArrayAttribute(): array + { + if (empty($this->leave_templates)) { + return []; + } + + $decoded = json_decode($this->leave_templates, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * 从模板数组中随机选一条,替换 {username} 占位符 + * + * @param array $templates 模板数组 + * @param string $username 用户名 + */ + public static function renderTemplate(array $templates, string $username): ?string + { + if (empty($templates)) { + return null; + } + + $template = $templates[array_rand($templates)]; + + return str_replace('{username}', $username, $template); + } +} diff --git a/app/Services/AiChatService.php b/app/Services/AiChatService.php new file mode 100644 index 0000000..59909b1 --- /dev/null +++ b/app/Services/AiChatService.php @@ -0,0 +1,273 @@ +isEmpty()) { + throw new \Exception('没有可用的 AI 厂商配置,请联系管理员。'); + } + + // 构建对话上下文 + $contextKey = self::CONTEXT_PREFIX.$userId; + $context = $this->getContext($contextKey); + + // 将用户消息加入上下文 + $context[] = ['role' => 'user', 'content' => $message]; + + // 构建完整的 messages 数组(系统提示 + 对话上下文) + $messages = array_merge( + [['role' => 'system', 'content' => self::SYSTEM_PROMPT]], + $context + ); + + // 依次尝试每个厂商 + $lastError = null; + foreach ($providers as $provider) { + try { + $result = $this->callProvider($provider, $messages, $userId); + + // 调用成功,更新上下文 + $context[] = ['role' => 'assistant', 'content' => $result['reply']]; + $this->saveContext($contextKey, $context); + + return $result; + } catch (\Exception $e) { + $lastError = $e; + Log::warning("AI 厂商 [{$provider->name}] 调用失败,尝试下一个", [ + 'provider' => $provider->provider, + 'error' => $e->getMessage(), + ]); + + continue; + } + } + + // 所有厂商都失败了 + throw new \Exception('AI 服务暂时不可用,请稍后再试。('.($lastError?->getMessage() ?? '未知错误').')'); + } + + /** + * 调用单个 AI 厂商 API + * + * 使用 OpenAI 兼容协议发送请求到 /v1/chat/completions 端点。 + * + * @param AiProviderConfig $config AI 厂商配置 + * @param array $messages 包含系统提示和对话上下文的消息数组 + * @param int $userId 用户 ID(用于日志记录) + * @return array{reply: string, provider: string, model: string} + * + * @throws \Exception 调用失败时抛出异常 + */ + private function callProvider(AiProviderConfig $config, array $messages, int $userId): array + { + $startTime = microtime(true); + $apiKey = $config->getDecryptedApiKey(); + $endpoint = rtrim($config->api_endpoint, '/').'/v1/chat/completions'; + + try { + $response = Http::withToken($apiKey) + ->timeout(self::REQUEST_TIMEOUT) + ->post($endpoint, [ + 'model' => $config->model, + 'messages' => $messages, + 'temperature' => $config->temperature, + 'max_tokens' => $config->max_tokens, + ]); + + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + + if (! $response->successful()) { + // 记录失败日志 + $this->logUsage($userId, $config, 'chatbot', 0, 0, $responseTimeMs, false, $response->body()); + + throw new \Exception("HTTP {$response->status()}: {$response->body()}"); + } + + $data = $response->json(); + $reply = $data['choices'][0]['message']['content'] ?? ''; + $promptTokens = $data['usage']['prompt_tokens'] ?? 0; + $completionTokens = $data['usage']['completion_tokens'] ?? 0; + + // 记录成功日志 + $this->logUsage( + $userId, + $config, + 'chatbot', + $promptTokens, + $completionTokens, + $responseTimeMs, + true + ); + + return [ + 'reply' => trim($reply), + 'provider' => $config->name, + 'model' => $config->model, + ]; + } catch (\Illuminate\Http\Client\ConnectionException $e) { + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + $this->logUsage($userId, $config, 'chatbot', 0, 0, $responseTimeMs, false, $e->getMessage()); + + throw new \Exception("连接超时: {$e->getMessage()}"); + } + } + + /** + * 记录 AI 调用日志到 ai_usage_logs 表 + * + * @param int $userId 用户 ID + * @param AiProviderConfig $config AI 厂商配置 + * @param string $action 操作类型 + * @param int $promptTokens 输入 token 数 + * @param int $completionTokens 输出 token 数 + * @param int $responseTimeMs 响应时间(毫秒) + * @param bool $success 是否成功 + * @param string|null $errorMessage 错误信息 + */ + private function logUsage( + int $userId, + AiProviderConfig $config, + string $action, + int $promptTokens, + int $completionTokens, + int $responseTimeMs, + bool $success, + ?string $errorMessage = null + ): void { + try { + AiUsageLog::create([ + 'user_id' => $userId, + 'provider' => $config->provider, + 'model' => $config->model, + 'action' => $action, + 'prompt_tokens' => $promptTokens, + 'completion_tokens' => $completionTokens, + 'total_tokens' => $promptTokens + $completionTokens, + 'response_time_ms' => $responseTimeMs, + 'success' => $success, + 'error_message' => $errorMessage ? mb_substr($errorMessage, 0, 500) : null, + ]); + } catch (\Exception $e) { + // 日志记录失败不应影响主流程 + Log::error('AI 调用日志记录失败', ['error' => $e->getMessage()]); + } + } + + /** + * 从 Redis 获取用户的对话上下文 + * + * @param string $key Redis key + * @return array 对话历史数组 + */ + private function getContext(string $key): array + { + $raw = Redis::get($key); + if (! $raw) { + return []; + } + + $context = json_decode($raw, true); + + return is_array($context) ? $context : []; + } + + /** + * 保存用户的对话上下文到 Redis + * + * 只保留最近 MAX_CONTEXT_ROUNDS 轮对话(每轮 = 1 条 user + 1 条 assistant)。 + * + * @param string $key Redis key + * @param array $context 完整的对话历史 + */ + private function saveContext(string $key, array $context): void + { + // 限制上下文长度,保留最近 N 轮(N*2 条消息) + $maxMessages = self::MAX_CONTEXT_ROUNDS * 2; + if (count($context) > $maxMessages) { + $context = array_slice($context, -$maxMessages); + } + + Redis::setex($key, self::CONTEXT_TTL, json_encode($context, JSON_UNESCAPED_UNICODE)); + } + + /** + * 清除指定用户的对话上下文 + * + * @param int $userId 用户 ID + */ + public function clearContext(int $userId): void + { + Redis::del(self::CONTEXT_PREFIX.$userId); + } +} diff --git a/app/Services/VipService.php b/app/Services/VipService.php new file mode 100644 index 0000000..6d425a6 --- /dev/null +++ b/app/Services/VipService.php @@ -0,0 +1,108 @@ +isVip()) { + return 1.0; + } + + return $user->vipLevel?->exp_multiplier ?? 1.0; + } + + /** + * 获取用户的金币倍率(非 VIP 返回 1.0) + */ + public function getJjbMultiplier(User $user): float + { + if (! $user->isVip()) { + return 1.0; + } + + return $user->vipLevel?->jjb_multiplier ?? 1.0; + } + + /** + * 授予用户 VIP 等级 + * + * @param User $user 目标用户 + * @param int $vipLevelId VIP 等级 ID + * @param int $days 天数(0=永久) + */ + public function grantVip(User $user, int $vipLevelId, int $days = 30): void + { + $user->vip_level_id = $vipLevelId; + + if ($days > 0) { + // 如果用户已有未过期的会员,在现有到期时间上延长 + $baseTime = ($user->hy_time && $user->hy_time->isFuture()) + ? $user->hy_time + : now(); + $user->hy_time = $baseTime->addDays($days); + } else { + // 永久会员 + $user->hy_time = null; + } + + $user->save(); + } + + /** + * 撤销用户 VIP 等级 + */ + public function revokeVip(User $user): void + { + $user->vip_level_id = null; + $user->hy_time = null; + $user->save(); + } + + /** + * 获取用户专属进入聊天室的欢迎语(非 VIP 返回 null) + * + * @param User $user 用户 + * @return string|null 渲染后的欢迎语 + */ + public function getJoinMessage(User $user): ?string + { + if (! $user->isVip() || ! $user->vipLevel) { + return null; + } + + $templates = $user->vipLevel->join_templates_array; + + return VipLevel::renderTemplate($templates, $user->username); + } + + /** + * 获取用户专属离开聊天室的提示语(非 VIP 返回 null) + */ + public function getLeaveMessage(User $user): ?string + { + if (! $user->isVip() || ! $user->vipLevel) { + return null; + } + + $templates = $user->vipLevel->leave_templates_array; + + return VipLevel::renderTemplate($templates, $user->username); + } +} diff --git a/database/migrations/2026_02_26_132014_create_vip_levels_table.php b/database/migrations/2026_02_26_132014_create_vip_levels_table.php new file mode 100644 index 0000000..f9f64f4 --- /dev/null +++ b/database/migrations/2026_02_26_132014_create_vip_levels_table.php @@ -0,0 +1,129 @@ +id(); + $table->string('name', 50)->comment('会员名称(如:白银会员)'); + $table->string('icon', 20)->default('⭐')->comment('等级图标/emoji'); + $table->string('color', 10)->default('#f59e0b')->comment('等级颜色(hex)'); + $table->decimal('exp_multiplier', 3, 1)->default(1.0)->comment('经验倍率'); + $table->decimal('jjb_multiplier', 3, 1)->default(1.0)->comment('金币倍率'); + $table->text('join_templates')->nullable()->comment('进入聊天室专属欢迎语 JSON 数组'); + $table->text('leave_templates')->nullable()->comment('离开聊天室专属提示语 JSON 数组'); + $table->tinyInteger('sort_order')->default(0)->comment('排序(越大越高级)'); + $table->integer('price')->default(0)->comment('赞助金额(元,供展示参考)'); + $table->integer('duration_days')->default(30)->comment('有效天数(0=永久)'); + $table->timestamps(); + }); + + // 默认种子数据(后台可随时修改/删除/新增) + $now = now(); + DB::table('vip_levels')->insert([ + [ + 'name' => '白银会员', + 'icon' => '🥈', + 'color' => '#94a3b8', + 'exp_multiplier' => 1.5, + 'jjb_multiplier' => 1.2, + 'join_templates' => json_encode([ + '白银贵宾{username}乘坐银色马车缓缓驶入,气质不凡!', + '白银贵宾{username}手持银色令牌,大步流星走了进来!', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '白银贵宾{username}挥了挥手,潇洒离去', + '白银贵宾{username}骑着银色骏马飘然而去', + ], JSON_UNESCAPED_UNICODE), + 'sort_order' => 1, + 'price' => 10, + 'duration_days' => 30, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'name' => '黄金会员', + 'icon' => '🥇', + 'color' => '#f59e0b', + 'exp_multiplier' => 2.0, + 'jjb_multiplier' => 1.5, + 'join_templates' => json_encode([ + '黄金贵宾{username}踩着金光闪闪的地毯,王者归来!', + '黄金贵宾{username}开着金色豪车呼啸而至,全场瞩目!', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '黄金贵宾{username}乘金色祥云腾空而去,霸气侧漏!', + '黄金贵宾{username}微微拱手,金光一闪消失不见', + ], JSON_UNESCAPED_UNICODE), + 'sort_order' => 2, + 'price' => 30, + 'duration_days' => 30, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'name' => '钻石会员', + 'icon' => '💎', + 'color' => '#3b82f6', + 'exp_multiplier' => 3.0, + 'jjb_multiplier' => 2.0, + 'join_templates' => json_encode([ + '钻石贵宾{username}踏着星辰大海从天而降,万众敬仰!', + '钻石贵宾{username}驾驭钻石巨龙破空而来,威震四方!', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '钻石贵宾{username}化作一道星光冲天而去,令人叹服!', + '钻石贵宾{username}乘坐钻石飞船消失在银河尽头', + ], JSON_UNESCAPED_UNICODE), + 'sort_order' => 3, + 'price' => 50, + 'duration_days' => 30, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'name' => '至尊会员', + 'icon' => '👑', + 'color' => '#a855f7', + 'exp_multiplier' => 5.0, + 'jjb_multiplier' => 3.0, + 'join_templates' => json_encode([ + '👑至尊{username}御驾亲临!九天雷鸣,万物俯首!众卿接驾!', + '👑至尊{username}脚踏七彩祥云,身披紫金龙袍,驾临此地!天地为之变色!', + ], JSON_UNESCAPED_UNICODE), + 'leave_templates' => json_encode([ + '👑至尊{username}龙袍一甩,紫气东来,瞬间消失在九天之上!', + '👑至尊{username}御驾回宫,百鸟齐鸣相送!', + ], JSON_UNESCAPED_UNICODE), + 'sort_order' => 4, + 'price' => 100, + 'duration_days' => 30, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + } + + /** + * 回滚:删除 vip_levels 表 + */ + public function down(): void + { + Schema::dropIfExists('vip_levels'); + } +}; diff --git a/database/migrations/2026_02_26_132015_add_vip_level_id_to_users_table.php b/database/migrations/2026_02_26_132015_add_vip_level_id_to_users_table.php new file mode 100644 index 0000000..383d69f --- /dev/null +++ b/database/migrations/2026_02_26_132015_add_vip_level_id_to_users_table.php @@ -0,0 +1,38 @@ +unsignedBigInteger('vip_level_id')->nullable()->after('huiyuan')->comment('会员等级ID'); + } + $table->foreign('vip_level_id')->references('id')->on('vip_levels')->nullOnDelete(); + }); + } + + /** + * 回滚:删除 vip_level_id 字段 + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['vip_level_id']); + $table->dropColumn('vip_level_id'); + }); + } +}; diff --git a/database/migrations/2026_02_26_132600_create_ai_tables.php b/database/migrations/2026_02_26_132600_create_ai_tables.php new file mode 100644 index 0000000..b917c85 --- /dev/null +++ b/database/migrations/2026_02_26_132600_create_ai_tables.php @@ -0,0 +1,70 @@ +id(); + $table->string('provider', 50)->index()->comment('厂商标识(如 deepseek, qwen, openai)'); + $table->string('name', 100)->comment('厂商显示名称'); + $table->text('api_key')->comment('API Key(加密存储)'); + $table->string('api_endpoint', 255)->comment('API 端点地址'); + $table->string('model', 100)->comment('使用的模型名称'); + $table->decimal('temperature', 3, 2)->default(0.30)->comment('温度参数'); + $table->integer('max_tokens')->default(2048)->comment('最大 Token 数'); + $table->boolean('is_enabled')->default(true)->index()->comment('是否启用'); + $table->boolean('is_default')->default(false)->comment('是否为默认厂商'); + $table->integer('sort_order')->default(0)->comment('排序(故障转移时按此顺序)'); + $table->timestamps(); + }); + + // AI 使用日志表 + Schema::create('ai_usage_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete()->comment('使用者'); + $table->string('provider', 50)->comment('AI 厂商标识'); + $table->string('model', 100)->comment('使用的模型'); + $table->string('action', 50)->default('chatbot')->comment('操作类型'); + $table->integer('prompt_tokens')->default(0)->comment('输入 Token 数'); + $table->integer('completion_tokens')->default(0)->comment('输出 Token 数'); + $table->integer('total_tokens')->default(0)->comment('总 Token 数'); + $table->decimal('cost', 10, 6)->default(0)->comment('费用'); + $table->integer('response_time_ms')->default(0)->comment('响应时间(毫秒)'); + $table->boolean('success')->default(true)->comment('是否成功'); + $table->text('error_message')->nullable()->comment('错误信息'); + $table->timestamps(); + + // 索引 + $table->index(['provider', 'created_at']); + }); + } + + /** + * 回滚迁移:删除 AI 相关的两张数据表 + */ + public function down(): void + { + Schema::dropIfExists('ai_usage_logs'); + Schema::dropIfExists('ai_provider_configs'); + } +}; diff --git a/resources/views/admin/ai-providers/index.blade.php b/resources/views/admin/ai-providers/index.blade.php new file mode 100644 index 0000000..285410d --- /dev/null +++ b/resources/views/admin/ai-providers/index.blade.php @@ -0,0 +1,357 @@ +{{-- + 文件功能:AI 厂商配置管理页面 + + 提供 AI 厂商的完整 CRUD 管理: + - 列表展示所有配置(名称、模型、状态等) + - 新增/编辑厂商配置弹窗 + - 启用/禁用切换、设为默认、删除 + - 全局开关控制聊天机器人是否启用 + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +@extends('admin.layouts.app') + +@section('title', 'AI 厂商配置') + +@section('content') +
+ + {{-- 全局开关 + 操作栏 --}} +
+
+
+

🤖 AI 聊天机器人

+
+ 全局开关: + + + {{ $chatbotEnabled ? '已开启' : '已关闭' }} + +
+
+ +
+
+ + {{-- 厂商列表 --}} +
+ + + + + + + + + + + + + + + @forelse ($providers as $provider) + + + + + + + + + + + @empty + + + + @endforelse + +
厂商模型API 端点参数排序状态默认操作
+
{{ $provider->name }}
+
{{ $provider->provider }}
+
+ {{ $provider->model }} + + + {{ $provider->api_endpoint }} + + + T={{ $provider->temperature }} / {{ $provider->max_tokens }} + + {{ $provider->sort_order }} + + + + @if ($provider->is_default) + ★ + 默认 + @else + + @endif + +
+ +
+ @csrf + @method('DELETE') + +
+
+
+ 暂无 AI 厂商配置,请点击上方"添加 AI 厂商"按钮。 +
+
+ + {{-- 新增/编辑弹窗 --}} + +
+ + +@endsection diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 4796d88..04b07bd 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -37,6 +37,14 @@ class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.autoact.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> 🎲 随机事件 + + 👑 VIP 会员等级 + + + 🤖 AI 厂商配置 + {{ [0 => '保密', 1 => '男', 2 => '女'][$user->sex] ?? '保密' }} @@ -70,6 +74,8 @@ sex: '{{ $user->sex }}', qianming: '{{ addslashes($user->qianming ?? '') }}', visit_num: {{ $user->visit_num ?? 0 }}, + vip_level_id: '{{ $user->vip_level_id ?? '' }}', + hy_time: '{{ $user->hy_time ? $user->hy_time->format('Y-m-d') : '' }}', requestUrl: '{{ route('admin.users.update', $user->id) }}' }; showEditModal = true" class="text-xs bg-indigo-50 text-indigo-600 font-bold px-3 py-1.5 rounded hover:bg-indigo-600 hover:text-white transition cursor-pointer"> @@ -168,6 +174,28 @@ class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm"> + {{-- VIP 会员设置 --}} +
+
+ + +
+
+ + +
+
+ {{-- 密码 --}}
+@endsection diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 9ea6fc2..0f21477 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -37,7 +37,10 @@ leaveUrl: "{{ route('chat.leave', $room->id) }}", heartbeatUrl: "{{ route('chat.heartbeat', $room->id) }}", fishCastUrl: "{{ route('fishing.cast', $room->id) }}", - fishReelUrl: "{{ route('fishing.reel', $room->id) }}" + fishReelUrl: "{{ route('fishing.reel', $room->id) }}", + chatBotUrl: "{{ route('chatbot.chat') }}", + chatBotClearUrl: "{{ route('chatbot.clear') }}", + chatBotEnabled: {{ \App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1' ? 'true' : 'false' }} }; @vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js']) diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 598a801..1ddf064 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -84,6 +84,27 @@ }; userList.appendChild(allDiv); + // ── AI 小助手(仅当全局开关开启时显示)── + if (window.chatContext.chatBotEnabled) { + let botDiv = document.createElement('div'); + botDiv.className = 'user-item'; + botDiv.style.background = 'linear-gradient(135deg, #e0f2fe, #f0fdf4)'; + botDiv.style.borderLeft = '3px solid #22c55e'; + botDiv.innerHTML = '🤖' + + 'AI小助手'; + botDiv.onclick = () => { + toUserSelect.value = 'AI小助手'; + document.getElementById('content').focus(); + }; + userList.appendChild(botDiv); + + // 在发言对象下拉框中也添加 AI 小助手 + let botOption = document.createElement('option'); + botOption.value = 'AI小助手'; + botOption.textContent = '🤖 AI小助手'; + toUserSelect.appendChild(botOption); + } + // 获取排序方式 const sortSelect = document.getElementById('user-sort-select'); const sortBy = sortSelect ? sortSelect.value : 'default'; @@ -112,10 +133,20 @@ item.dataset.username = username; const headface = user.headface || '1.GIF'; + // VIP 图标和管理员标识 + let badges = ''; + if (user.vip_icon) { + const vipColor = user.vip_color || '#f59e0b'; + badges += + `${user.vip_icon}`; + } + if (user.is_admin) { + badges += `🛡️`; + } item.innerHTML = ` - ${username} + ${username}${badges} `; item.onclick = () => { @@ -330,8 +361,16 @@ const sysDiv = document.createElement('div'); sysDiv.className = 'msg-line'; - sysDiv.innerHTML = - `【欢迎】${msg}(${timeStr})`; + + // VIP 用户使用专属颜色和图标 + if (user.vip_icon && user.vip_name) { + const vipColor = user.vip_color || '#f59e0b'; + sysDiv.innerHTML = + `【${user.vip_icon} ${user.vip_name}】${msg}(${timeStr})`; + } else { + sysDiv.innerHTML = + `【欢迎】${msg}(${timeStr})`; + } container.appendChild(sysDiv); scrollToBottom(); }); @@ -343,7 +382,14 @@ const sysDiv = document.createElement('div'); sysDiv.className = 'msg-line sys-msg'; - sysDiv.innerHTML = `☆ ${user.username} 离开了聊天室 ☆`; + // VIP 用户离开也带专属颜色 + if (user.vip_icon && user.vip_name) { + const vipColor = user.vip_color || '#f59e0b'; + sysDiv.innerHTML = + `☆ ${user.vip_icon}${user.vip_name} ${user.username} 潇洒离去 ☆`; + } else { + sysDiv.innerHTML = `☆ ${user.username} 离开了聊天室 ☆`; + } container.appendChild(sysDiv); scrollToBottom(); }); @@ -441,6 +487,15 @@ return; } + // 如果发言对象是 AI 小助手,走专用机器人 API + const toUser = formData.get('to_user'); + if (toUser === 'AI小助手') { + contentInput.value = ''; + contentInput.focus(); + await sendToChatBot(content); + return; + } + submitBtn.disabled = true; try { @@ -931,4 +986,133 @@ fishingTimer = null; fishingReelTimeout = null; } + + // ── AI 聊天机器人 ────────────────────────────────── + let chatBotSending = false; + + /** + * 发送消息给 AI 机器人 + * 先在包厢窗口显示用户消息,再调用 API 获取回复 + */ + async function sendToChatBot(content) { + if (chatBotSending) { + alert('AI 正在思考中,请稍候...'); + return; + } + chatBotSending = true; + + const now = new Date(); + const timeStr = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0') + ':' + + now.getSeconds().toString().padStart(2, '0'); + + // 显示用户发送的消息 + const userDiv = document.createElement('div'); + userDiv.className = 'msg-line'; + userDiv.innerHTML = `${window.chatContext.username}` + + `对🤖AI小助手说:` + + `${escapeHtml(content)}` + + ` (${timeStr})`; + container2.appendChild(userDiv); + + // 显示"思考中"提示 + const thinkDiv = document.createElement('div'); + thinkDiv.className = 'msg-line'; + thinkDiv.innerHTML = '🤖 AI小助手 正在思考中...'; + container2.appendChild(thinkDiv); + if (autoScroll) container2.scrollTop = container2.scrollHeight; + + try { + const res = await fetch(window.chatContext.chatBotUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute( + 'content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + message: content, + room_id: window.chatContext.roomId + }) + }); + + const data = await res.json(); + + // 移除"思考中"提示 + thinkDiv.remove(); + + const replyTime = new Date(); + const replyTimeStr = replyTime.getHours().toString().padStart(2, '0') + ':' + + replyTime.getMinutes().toString().padStart(2, '0') + ':' + + replyTime.getSeconds().toString().padStart(2, '0'); + + if (res.ok && data.status === 'success') { + // 显示机器人回复 + const botDiv = document.createElement('div'); + botDiv.className = 'msg-line'; + botDiv.style.background = '#f0fdf4'; + botDiv.style.borderLeft = '3px solid #22c55e'; + botDiv.style.padding = '4px 8px'; + botDiv.style.margin = '2px 0'; + botDiv.style.borderRadius = '4px'; + botDiv.innerHTML = `🤖 AI小助手` + + `对${window.chatContext.username}说:` + + `${escapeHtml(data.reply)}` + + ` (${replyTimeStr})` + + ` [${data.provider}]`; + container2.appendChild(botDiv); + } else { + // 显示错误信息 + const errDiv = document.createElement('div'); + errDiv.className = 'msg-line'; + errDiv.innerHTML = `🤖【AI小助手】${data.message || '回复失败,请稍后重试'}` + + ` (${replyTimeStr})`; + container2.appendChild(errDiv); + } + } catch (e) { + thinkDiv.remove(); + const errDiv = document.createElement('div'); + errDiv.className = 'msg-line'; + errDiv.innerHTML = '🤖【AI小助手】网络连接错误,请稍后重试'; + container2.appendChild(errDiv); + } + + chatBotSending = false; + if (autoScroll) container2.scrollTop = container2.scrollHeight; + } + + /** + * 清除与 AI 小助手的对话上下文 + */ + async function clearChatBotContext() { + try { + const res = await fetch(window.chatContext.chatBotClearUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute( + 'content'), + 'Accept': 'application/json' + } + }); + const data = await res.json(); + + const sysDiv = document.createElement('div'); + sysDiv.className = 'msg-line'; + sysDiv.innerHTML = '🤖【系统】' + (data.message || '对话已重置') + ''; + container2.appendChild(sysDiv); + if (autoScroll) container2.scrollTop = container2.scrollHeight; + } catch (e) { + alert('清除失败:' + e.message); + } + } + + /** + * HTML 转义函数,防止 XSS + */ + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } diff --git a/routes/web.php b/routes/web.php index a0f87ba..5ae13c1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { // ---- 钓鱼小游戏(复刻原版 diaoyu/ 功能)---- Route::post('/room/{id}/fish/cast', [FishingController::class, 'cast'])->name('fishing.cast'); Route::post('/room/{id}/fish/reel', [FishingController::class, 'reel'])->name('fishing.reel'); + + // ---- AI 聊天机器人 ---- + Route::post('/chatbot/chat', [ChatBotController::class, 'chat'])->name('chatbot.chat'); + Route::post('/chatbot/clear', [ChatBotController::class, 'clearContext'])->name('chatbot.clear'); }); // 强力特权层中间件:同时验证 chat.auth 登录态 和 chat.level:super 特权(superlevel 由 sysparam 配置) @@ -106,4 +111,19 @@ Route::middleware(['chat.auth', 'chat.level:super'])->prefix('admin')->name('adm Route::put('/autoact/{id}', [\App\Http\Controllers\Admin\AutoactController::class, 'update'])->name('autoact.update'); Route::post('/autoact/{id}/toggle', [\App\Http\Controllers\Admin\AutoactController::class, 'toggle'])->name('autoact.toggle'); Route::delete('/autoact/{id}', [\App\Http\Controllers\Admin\AutoactController::class, 'destroy'])->name('autoact.destroy'); + + // VIP 会员等级管理 + Route::get('/vip', [\App\Http\Controllers\Admin\VipController::class, 'index'])->name('vip.index'); + Route::post('/vip', [\App\Http\Controllers\Admin\VipController::class, 'store'])->name('vip.store'); + Route::put('/vip/{id}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update'); + Route::delete('/vip/{id}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy'); + + // AI 厂商配置管理 + Route::get('/ai-providers', [\App\Http\Controllers\Admin\AiProviderController::class, 'index'])->name('ai-providers.index'); + Route::post('/ai-providers', [\App\Http\Controllers\Admin\AiProviderController::class, 'store'])->name('ai-providers.store'); + Route::put('/ai-providers/{id}', [\App\Http\Controllers\Admin\AiProviderController::class, 'update'])->name('ai-providers.update'); + Route::post('/ai-providers/{id}/toggle', [\App\Http\Controllers\Admin\AiProviderController::class, 'toggleEnabled'])->name('ai-providers.toggle'); + Route::post('/ai-providers/{id}/default', [\App\Http\Controllers\Admin\AiProviderController::class, 'setDefault'])->name('ai-providers.default'); + Route::post('/ai-providers/toggle-chatbot', [\App\Http\Controllers\Admin\AiProviderController::class, 'toggleChatBot'])->name('ai-providers.toggle-chatbot'); + Route::delete('/ai-providers/{id}', [\App\Http\Controllers\Admin\AiProviderController::class, 'destroy'])->name('ai-providers.destroy'); });